diff --git a/DESIGN.md b/DESIGN.md index e91c7a8..6a43a8b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1020,9 +1020,9 @@ Notes: acknowledgements*, which are derivable, instead of *who is currently blocked in a wait*, which is not (see *Knowing whether work is moving*). - `pad search` is the one read that selects by **what was written** rather than by - position, and it is a CLI (and store) capability rather than an MCP tool for now — the - MCP surface stays append-only and small on purpose, and this can be added to it and to - the Web UI later from the same `store.Search`. Four decisions are load-bearing: + position. The CLI and the Web UI both spell it, from the one `store.Search`; it is + deliberately not an MCP tool, because that surface stays append-only and small — and + it can be added there later from the same call. Four decisions are load-bearing: - **No index.** It reads the pads it looks at. An index would be state living outside the pad files, and everything here derives from them precisely so that a person with `rm` or an editor cannot leave a stale second copy of the truth. `--project`, `--pad` @@ -1048,6 +1048,21 @@ Notes: contract here: a lone header reads as one result to anything counting lines (`… 2>/dev/null | wc -l`), which is how a script concludes "found it" about a word that is not there. Everything a person needs to read the silence is on stderr. + + A hit is **cut around its match, never from the front** (`SearchRequest.TextWidth`), and + carries the match's rune offsets. Agent prose arrives as one long line, so a window taken + from the start of it returned a row that did not contain the word being reported — a + wrong answer wearing the shape of a truncated one. The offsets are published rather than + recomputed per surface for the reason everything else here has one definition: a second + matcher disagrees, `--word` being spelled with Unicode classes that `\b` does not mean. + + **The Web UI is the same search asked three ways**, not three features: no scope is the + whole store, `project` is one project, `ref` is one pad — the last being the only form + that carries a password, exactly as in the CLI. Over HTTP the password comes from the + session that already unlocked the pad, so it never travels in a URL; `limit` gains a + ceiling the CLI does not need, because `--limit 0` at a terminal is a person waiting for + their own command while over HTTP it is a request to serialise the store; and the + question lives in the URL, so a search is a link. - The three `rules` commands read without `--set` and write with it, at one level each. Writing is behind an explicit flag rather than "an argument means write", so a mistyped read can never overwrite the rules with the word that was meant as a filter. diff --git a/README.md b/README.md index 274c9c4..b8f5b0e 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,17 @@ scratchpad pad search "retry budget" --oldest # where it was DECIDED, not last Search reads the pads it looks at — there is no index, because an index would be state living outside the pad files. Narrow with `--project`, `--pad` or `--exclude-pad`. +The Web UI has the same search, asked three ways: across the store, inside one project, +or inside one pad (a tab on the rail beside its transcript, so a hit jumps straight into +it). A protected pad answers only when it is the pad being searched and this browser +session has already unlocked it; otherwise it is skipped, and the summary line says how +many were — an empty result never quietly means "not searched". + +A result list is an answer about a moment, so it is never repainted under you: when a pad +moves, the page says the results have gone stale and leaves you to ask again. The +alternative reorders rows while you are reading them, since the default order is by pad +activity. + A task shared by two agents is `done` only when **both** are, so one finishing never hides the other's outstanding work. @@ -264,13 +275,13 @@ reach the UI may change those, so what decides reachability itself (`tcp`, `ui`, | **Turn rule** | Nobody posts twice in a row *in the conversation* — a clean, readable back-and-forth. Task events are exempt, so dispatching work never blocks on a reply. | | **Addressing** | `--to` and `--re` route a section; `--wake-for` decides what interrupts you. Reading stays universal. | | **Tasks** | Work tracked as append-only events, folded into a board — per owner, so a shared task never reads as finished early. | -| **Search** | Find a word across pads — bodies and titles. No index: the pads themselves are the only state. | +| **Search** | Find a word across pads — bodies and titles, in the CLI and in the Web UI. No index: the pads themselves are the only state. | | **House rules** | Standing instructions at store, project and pad level — acknowledged before posting, and re-asked whenever they change. | | **Never a dead end** | A pad warns as it fills, then continues into a successor that inherits its identity, rules and open tasks. | | **Append-only pad** | The pad file is the single source of truth. No external state, no database. | | **Zero setup** | The default store bootstraps itself on first use. | | **CLI + MCP** | One binary: work on pad files directly, or serve them as MCP tools. | -| **Web UI** | `scratchpad ui` — read pads as a chat and watch turns land live, in the browser. | +| **Web UI** | `scratchpad ui` — read pads as a chat, search them, and watch turns land live, in the browser. | | **Password-protect** | Optional per-pad password — the server generates it, stores only a hash. | | **Live config** | The marker is re-read as it changes — limits, wait and rules policy apply without a restart. | | **Transports** | Unix socket by default, `--stdio` for host-spawned, opt-in loopback TCP. | diff --git a/cmd/scratchpad/pad.go b/cmd/scratchpad/pad.go index 3ef61d5..6329cbb 100644 --- a/cmd/scratchpad/pad.go +++ b/cmd/scratchpad/pad.go @@ -444,6 +444,10 @@ func newPadReadCmd() *cobra.Command { // searchTextWidth caps how much of a matching line is printed. Agent prose runs to whole // paragraphs on one line, and a search is a way to FIND a section, not to read it — the // ellipsis says the line goes on and `pad read --section` is how to see the rest. +// +// The store takes this window AROUND the match. It used to be applied here, to the front +// of the line, which printed a row that did not contain the word being reported whenever +// a paragraph matched past its 140th character — the common case in a pad. const searchTextWidth = 140 func newPadSearchCmd() *cobra.Command { @@ -499,7 +503,7 @@ func newPadSearchCmd() *cobra.Command { Query: args[0], Project: project, Ref: ref, Password: password, ExcludePads: excludePad, Regexp: asRegexp, Word: word, CaseSensitive: matchCase, Author: author, Kind: kind, Before: beforeTS, After: afterTS, - Oldest: oldest, Limit: limit, + Oldest: oldest, Limit: limit, TextWidth: searchTextWidth, }) if err != nil { return err @@ -521,8 +525,7 @@ func newPadSearchCmd() *cobra.Command { if h.InTitle { where = "title" } - fmt.Fprintf(w, "%s\t§%d\t%s\t%s\t%s\n", h.Ref, h.Section, where, h.Author, - truncateRunes(h.Text, searchTextWidth)) + fmt.Fprintf(w, "%s\t§%d\t%s\t%s\t%s\n", h.Ref, h.Section, where, h.Author, h.Text) } if err := w.Flush(); err != nil { return err @@ -592,16 +595,6 @@ func parseWhen(s string) (int64, error) { return 0, fmt.Errorf("invalid time %q (want a date like 2026-07-01 or an age like 30d)", s) } -// truncateRunes shortens a line for the table without cutting a multi-byte character in -// half — the searches this exists for are frequently not ASCII. -func truncateRunes(s string, max int) string { - r := []rune(s) - if len(r) <= max { - return s - } - return string(r[:max]) + "…" -} - func newPadWaitCmd() *cobra.Command { var ( dir dirFlags diff --git a/internal/skills/SKILL.md b/internal/skills/SKILL.md index 3bae18e..5eeda97 100644 --- a/internal/skills/SKILL.md +++ b/internal/skills/SKILL.md @@ -336,6 +336,9 @@ ignores case unless you pass `--case-sensitive`. Protected pads are NOT searched unless you name one with `--pad` and its `--password` — anything left out is listed on stderr, so an empty result never quietly means "not searched". +A long line is cut AROUND its match, so the word you searched for is always in the +row you get back; a leading `…` means the line started earlier. + **Looking for where something was DECIDED? Pass `--oldest`.** The default order is newest pad first, which answers "what is being said about this" — and a term the team is arguing about today will fill every result with restatements while diff --git a/internal/skills/topics/usage.md b/internal/skills/topics/usage.md index fa8f8ff..ae7e822 100644 --- a/internal/skills/topics/usage.md +++ b/internal/skills/topics/usage.md @@ -171,6 +171,10 @@ reads on from what you found. Matching ignores case unless `--case-sensitive`; `--word` keeps "budget" from being answered by "budgeting", with boundaries that hold for non-ASCII words too. +The line in a hit is a WINDOW, cut around the match — agent prose arrives as one long +line, and a `…` at either end means it continues there. What you searched for is always +inside the window; if you need the rest, the section number is right there. + ### "Where was this decided?" is a different search Results are grouped by pad, most recently active first — which answers *what is being @@ -211,6 +215,11 @@ prints nothing at all — not even the header — the way `grep` is silent. So `pad search … 2>/dev/null | wc -l` counts hits and nothing else. The summary, the truncation notice and the list of pads left out are all on stderr. +A person has the same search in the Web UI (`scratchpad ui`): its own page for the store +and for one project, and a tab on a pad's rail for that pad alone. Same rules, including +which pads it will not read — the protected ones are skipped there too, and only the pad +being searched can be opened, by a session that has already unlocked it. + ## Tracking work A long pad is a poor way to learn where a team stands, so work is tracked separately — diff --git a/internal/store/search.go b/internal/store/search.go index b1186a0..23c5f34 100644 --- a/internal/store/search.go +++ b/internal/store/search.go @@ -10,6 +10,7 @@ import ( "regexp" "sort" "strings" + "unicode/utf8" "golang.org/x/sys/unix" @@ -69,6 +70,13 @@ type SearchRequest struct { // Limit caps the hits RETURNED, not the bytes read: a pad whose scan has begun is // finished, and only the pads after it are skipped. Result.Truncated says the cap bound. Limit int + + // TextWidth caps a hit's line in RUNES, 0 for the whole line. The cut is taken AROUND + // the match rather than from the start of the line, which is the only cut that works + // on the prose this searches: an agent writes a paragraph as one line, and a word + // matched at character 500 is not in the first 140 of them. Cutting from the front + // therefore produced a result row that did not contain the word it was reporting. + TextWidth int } // Hit is one matching line, addressed the way the rest of the tool addresses things: by @@ -89,6 +97,17 @@ type Hit struct { // index a person reads a long pad by, so a search that ignored them would miss the // most deliberate statement of what a section is about. Line is 0 for these. InTitle bool `json:"in_title,omitempty"` + + // MatchStart and MatchEnd locate the first match INSIDE Text, in runes, half-open. + // Both are -1 when the match could not be located there (see matcher.locate). + // + // They are published rather than left to each caller to recompute because + // recomputing means a SECOND matcher, and a second one disagrees: --word spells its + // boundaries with Unicode classes that \b does not mean, and case folding is on by + // default here and off in most regex dialects. A surface that highlighted its own + // idea of the match would draw a box around the wrong characters. + MatchStart int `json:"match_start"` + MatchEnd int `json:"match_end"` } // SearchResult is what a search found, plus what it did NOT look at. Skipped and @@ -112,6 +131,14 @@ type SearchResult struct { type matcher struct { lit []byte re *regexp.Regexp + + // group says the pattern was wrapped in a capturing group to carry --word's + // boundaries, so what MATCHED is submatch 1 rather than the whole match. The + // boundaries are consuming character classes (RE2 has no lookaround), and they were + // harmless while the result was only ever a boolean — the moment a caller highlights + // the range, reporting them as part of the match paints the space either side of the + // word. + group bool } func newMatcher(req SearchRequest) (matcher, error) { @@ -126,10 +153,12 @@ func newMatcher(req SearchRequest) (matcher, error) { if !req.Regexp { expr = regexp.QuoteMeta(q) } + group := false if req.Word { - // No lookaround in RE2, so the boundaries are consuming character classes. That is - // harmless here: the result is only ever used as a boolean. - expr = `(?:^|[^\p{L}\p{N}_])(?:` + expr + `)(?:[^\p{L}\p{N}_]|$)` + // The capturing group opens BEFORE any group the caller's own pattern contains, + // so submatch 1 is always this one — never theirs. + expr = `(?:^|[^\p{L}\p{N}_])((?:` + expr + `))(?:[^\p{L}\p{N}_]|$)` + group = true } if !req.CaseSensitive { expr = `(?i)` + expr @@ -138,7 +167,7 @@ func newMatcher(req SearchRequest) (matcher, error) { if err != nil { return matcher{}, fmt.Errorf("bad search pattern: %w", err) } - return matcher{re: re}, nil + return matcher{re: re, group: group}, nil } func (m matcher) match(b []byte) bool { @@ -148,6 +177,52 @@ func (m matcher) match(b []byte) bool { return bytes.Contains(b, m.lit) } +// find returns the byte range of the first match in b. It is kept apart from match +// because match is the hot path — every line of every pad goes through it — while this +// runs only for the lines that already matched. +func (m matcher) find(b []byte) (start, end int, ok bool) { + if m.re == nil { + i := bytes.Index(b, m.lit) + if i < 0 { + return 0, 0, false + } + return i, i + len(m.lit), true + } + loc := m.re.FindSubmatchIndex(b) + if loc == nil { + return 0, 0, false + } + if m.group && len(loc) >= 4 && loc[2] >= 0 { + return loc[2], loc[3], true + } + return loc[0], loc[1], true +} + +// locate returns the first match's RUNE range inside s, or (-1, -1). +// +// It runs against the CLEANED text rather than reusing an offset taken from the raw +// line, because cleanLine rewrites the line: a tab becomes four spaces, which moves +// every byte after it. Re-finding on the string the caller will actually display is +// what keeps the two in step. It can legitimately fail — a pattern that matches a tab +// no longer matches the spaces it became — and -1 then means "cannot say", not "no +// match": the hit stands, only the highlight is dropped. +func (m matcher) locate(s string) (int, int) { + bs, be, ok := m.find([]byte(s)) + if !ok { + return -1, -1 + } + return utf8.RuneCountInString(s[:bs]), utf8.RuneCountInString(s[:be]) +} + +// excerptOf turns a matching line into what a hit carries: the text to show and where +// the match sits in it. Locating and cutting are one step because they are one decision +// — the cut is taken around the match — and splitting them across the two call sites +// below is how a title hit and a body hit would end up measured differently. +func (m matcher) excerptOf(s string, width int) (string, int, int) { + start, end := m.locate(s) + return excerpt(s, start, end, width) +} + // padHits groups one pad's hits so the result can be ordered by the pad's last activity, // the way `pad list` orders pads — a search across a store is read newest conversation // first, not alphabetically. @@ -372,9 +447,11 @@ func (s *Store) searchPad(project, id string, req SearchRequest, m matcher) (pad if !selects(sec) || !m.match(line) { return } + text, ms, me := m.excerptOf(cleanLine(line), req.TextWidth) hits = append(hits, Hit{ Ref: ref, Section: sec.N, Author: sec.Author, Title: sec.Title, - Kind: sec.Kind, TS: sec.TS, Line: lineNo, Text: cleanLine(line), + Kind: sec.Kind, TS: sec.TS, Line: lineNo, Text: text, + MatchStart: ms, MatchEnd: me, }) }) if err != nil { @@ -394,9 +471,14 @@ func (s *Store) searchPad(project, id string, req SearchRequest, m matcher) (pad if !selects(&sec) || !m.match([]byte(sec.Title)) { continue } + // A title is not cut. It is one short line by rule, and it is the thing a person + // scans a result list by — trimming it to a window would hide the very statement + // that made the section worth returning. + text, ms, me := m.excerptOf(sec.Title, 0) hits = append(hits, Hit{ Ref: ref, Section: sec.N, Author: sec.Author, Title: sec.Title, - Kind: sec.Kind, TS: sec.TS, Text: sec.Title, InTitle: true, + Kind: sec.Kind, TS: sec.TS, Text: text, InTitle: true, + MatchStart: ms, MatchEnd: me, }) } sort.SliceStable(hits, func(i, j int) bool { @@ -413,3 +495,55 @@ func (s *Store) searchPad(project, id string, req SearchRequest, m matcher) (pad func cleanLine(line []byte) string { return strings.TrimRight(strings.ReplaceAll(string(line), "\t", " "), "\r") } + +// ellipsis marks an excerpt that starts or ends mid-line. One rune, so the offsets it +// shifts move by exactly one. +const ellipsis = "…" + +// excerpt cuts s down to width runes AROUND the match at [start, end), returning the +// text and the match's range within it. +// +// Centring on the match is the whole point: an agent's paragraph arrives as a single +// line, and the answer to "where was this said" is usually in the middle of it. A cut +// taken from the front returns a row that does not contain the word being reported, +// which reads as a wrong result rather than as a truncated one. +// +// A match that is itself wider than the window is shown from its start — cutting it in +// half would be the same failure one level down. start < 0 means the match could not be +// located, and the line is then cut from the front, since there is no better place. +func excerpt(s string, start, end, width int) (string, int, int) { + if width <= 0 { + return s, start, end + } + r := []rune(s) + if len(r) <= width { + return s, start, end + } + if start < 0 { + return string(r[:width]) + ellipsis, -1, -1 + } + + // Centre the window on the match, then push it back inside the line. The clamp order + // matters: pinning to the end first and to the start second keeps `from` at 0 for a + // line shorter than the window, which the length check above has already excluded but + // which a later change could reintroduce. + from := start - max(0, (width-(end-start))/2) + from = min(from, len(r)-width) + from = max(from, 0) + to := min(from+width, len(r)) + + out := string(r[from:to]) + ns, ne := start-from, min(end, to)-from + if from > 0 { + out = ellipsis + out + ns++ + ne++ + } + if to < len(r) { + out += ellipsis + } + // A match running past the window is reported only as far as the text goes; the + // range must never point outside the string a caller is about to highlight. + ne = min(ne, len([]rune(out))) + return out, ns, max(ns, ne) +} diff --git a/internal/store/search_test.go b/internal/store/search_test.go index e6cd9c5..5b2f826 100644 --- a/internal/store/search_test.go +++ b/internal/store/search_test.go @@ -425,3 +425,121 @@ func TestSearchExcludePad(t *testing.T) { t.Fatal("an unparsable --exclude-pad must be refused") } } + +// A match is REPORTED WHERE IT IS, and the cut is taken around it. Agent prose arrives +// as one long line, so a window applied to the front of it returned a row that did not +// contain the word being reported — a wrong answer wearing the shape of a truncated one. +func TestSearchExcerptIsCutAroundTheMatch(t *testing.T) { + s := testStore(t) + line := strings.Repeat("a", 500) + " needle " + strings.Repeat("b", 500) + ref := writePadAt(t, s, "projectx", "longline", "erp", "Long", + line+"\n", time.Date(2026, 3, 1, 9, 0, 0, 0, time.UTC)) + + res, err := s.Search(SearchRequest{Query: "needle", TextWidth: 60}) + if err != nil { + t.Fatal(err) + } + if len(res.Hits) != 1 { + t.Fatalf("want one hit in %s, got %+v", ref, res.Hits) + } + h := res.Hits[0] + if !strings.Contains(h.Text, "needle") { + t.Fatalf("the excerpt does not contain the word it reports: %q", h.Text) + } + if n := len([]rune(h.Text)); n > 62 { + t.Errorf("excerpt is %d runes, want the 60-rune window plus at most two ellipses", n) + } + // The offsets must point at the word inside the text that is actually returned. + r := []rune(h.Text) + if h.MatchStart < 0 || h.MatchEnd > len(r) || string(r[h.MatchStart:h.MatchEnd]) != "needle" { + t.Fatalf("offsets %d..%d do not select the match in %q", h.MatchStart, h.MatchEnd, h.Text) + } + // Cut on both sides, so both ellipses are there. + if !strings.HasPrefix(h.Text, "…") || !strings.HasSuffix(h.Text, "…") { + t.Errorf("a line cut at both ends must say so: %q", h.Text) + } +} + +// The offsets survive a multi-byte line: they are RUNE indices, and a Vietnamese pad is +// the ordinary case here rather than the exotic one. +func TestSearchOffsetsAreRunesNotBytes(t *testing.T) { + s := testStore(t) + writePadAt(t, s, "projectx", "nonascii", "erp", "Tiêu đề", + "phần điều tra về địa chỉ hai cấp\n", time.Date(2026, 3, 1, 9, 0, 0, 0, time.UTC)) + + res, err := s.Search(SearchRequest{Query: "địa chỉ"}) + if err != nil { + t.Fatal(err) + } + if len(res.Hits) != 1 { + t.Fatalf("want one hit, got %+v", res.Hits) + } + h := res.Hits[0] + r := []rune(h.Text) + if got := string(r[h.MatchStart:h.MatchEnd]); got != "địa chỉ" { + t.Fatalf("offsets %d..%d gave %q, want %q", h.MatchStart, h.MatchEnd, got, "địa chỉ") + } +} + +// --word's boundaries are consuming character classes (RE2 has no lookaround), so the +// reported range must come from the submatch. Reporting the whole match would hand every +// caller a range covering the space either side of the word. +func TestSearchWordOffsetsExcludeTheBoundaries(t *testing.T) { + s := testStore(t) + writePadAt(t, s, "projectx", "wordy", "erp", "Word", + "the cache is warm\n", time.Date(2026, 3, 1, 9, 0, 0, 0, time.UTC)) + + res, err := s.Search(SearchRequest{Query: "cache", Word: true}) + if err != nil { + t.Fatal(err) + } + if len(res.Hits) != 1 { + t.Fatalf("want one hit, got %+v", res.Hits) + } + h := res.Hits[0] + r := []rune(h.Text) + if got := string(r[h.MatchStart:h.MatchEnd]); got != "cache" { + t.Fatalf("--word reported %q as the match; the boundaries must not be part of it", got) + } +} + +// A title is never cut: it is one short line by rule and it is what a person scans the +// result list by. +func TestSearchTitleHitCarriesOffsetsAndIsNotCut(t *testing.T) { + s := testStore(t) + title := "Decision about the retry budget that runs on and on and on and on and on and on" + writePadAt(t, s, "projectx", "titled", "erp", title, + "body says nothing\n", time.Date(2026, 3, 1, 9, 0, 0, 0, time.UTC)) + + res, err := s.Search(SearchRequest{Query: "budget", TextWidth: 20}) + if err != nil { + t.Fatal(err) + } + if len(res.Hits) != 1 || !res.Hits[0].InTitle { + t.Fatalf("want one title hit, got %+v", res.Hits) + } + h := res.Hits[0] + if h.Text != title { + t.Errorf("a title must not be cut by TextWidth, got %q", h.Text) + } + r := []rune(h.Text) + if string(r[h.MatchStart:h.MatchEnd]) != "budget" { + t.Errorf("offsets %d..%d do not select the match in the title", h.MatchStart, h.MatchEnd) + } +} + +// TextWidth 0 is the whole line, which is what a caller with room asks for. +func TestSearchTextWidthZeroKeepsTheWholeLine(t *testing.T) { + s := testStore(t) + line := strings.Repeat("a", 300) + " needle" + writePadAt(t, s, "projectx", "whole", "erp", "Long", + line+"\n", time.Date(2026, 3, 1, 9, 0, 0, 0, time.UTC)) + + res, err := s.Search(SearchRequest{Query: "needle"}) + if err != nil { + t.Fatal(err) + } + if len(res.Hits) != 1 || res.Hits[0].Text != line { + t.Fatalf("TextWidth 0 must keep the line intact, got %+v", res.Hits) + } +} diff --git a/internal/webui/api.go b/internal/webui/api.go index 45c54c9..c970509 100644 --- a/internal/webui/api.go +++ b/internal/webui/api.go @@ -3,6 +3,7 @@ package webui import ( "encoding/json" "net/http" + "net/url" "strconv" "strings" "time" @@ -665,6 +666,138 @@ func (s *Server) handleSetPadRules(r *http.Request, sess *session) (any, error) return s.store.PadRules(ref, password) } +// Search bounds. The CLI's `--limit 0` means "no cap", which is safe at a terminal +// where the person waits for their own command; over HTTP it is a way to ask the server +// to serialise the whole store into one response, so this surface always has a ceiling. +const ( + defaultSearchLimit = 100 + maxSearchLimit = 500 + + // searchTextWidth is how much of a matching line travels, in runes. Wider than the + // CLI's 140 because a browser has room to wrap, still a window rather than the line: + // a hit is a place to go, not the section itself. + searchTextWidth = 320 +) + +// handleSearch is the one read here that selects by WHAT WAS WRITTEN. The three scopes a +// person asks for are not three endpoints — they are which of `project` and `ref` is set: +// neither is the whole store, `project` is one project, `ref` is one pad. +// +// A protected pad is reached exactly the way the CLI reaches one: by being NAMED, with +// its password. The difference is only where the password comes from — the session that +// already unlocked it, rather than a flag typed again. Without `ref` the store skips +// every protected pad and says which, and this hands that list straight to the UI: an +// empty result must never be readable as "the word is nowhere in the store". +func (s *Server) handleSearch(r *http.Request, sess *session) (any, error) { + q := r.URL.Query() + query := strings.TrimSpace(q.Get("q")) + if query == "" { + return nil, badInput("q is required: there is nothing to search for") + } + + limit, err := queryInt(q, "limit", defaultSearchLimit) + if err != nil { + return nil, err + } + if limit <= 0 { + return nil, badInput("limit must be a positive integer") + } + + before, err := queryInt64(q, "before", 0) + if err != nil { + return nil, err + } + after, err := queryInt64(q, "after", 0) + if err != nil { + return nil, err + } + + flags := map[string]bool{} + for _, name := range []string{"oldest", "regexp", "word", "case"} { + v, err := queryBool(q, name) + if err != nil { + return nil, err + } + flags[name] = v + } + + ref := q.Get("ref") + req := store.SearchRequest{ + Query: query, + Project: q.Get("project"), + Ref: ref, + // Only a NAMED pad carries a password, which is the store's rule and not a + // simplification made here: a store-wide search that read through every pad this + // session happens to have unlocked would be a way to read one noun at a time. + Password: sess.unlocked(ref), + ExcludePads: q["exclude"], + Author: q.Get("author"), + Kind: q.Get("kind"), + Before: before, + After: after, + Oldest: flags["oldest"], + Regexp: flags["regexp"], + Word: flags["word"], + CaseSensitive: flags["case"], + Limit: min(limit, maxSearchLimit), + TextWidth: searchTextWidth, + } + + res, err := s.store.Search(req) + if err != nil { + return nil, err + } + if res.Hits == nil { + res.Hits = []store.Hit{} + } + return res, nil +} + +// queryBool reads a flag written either way a URL spells one: `?word` on its own, or +// `?word=true`. A value that is neither is refused rather than read as false — a +// misspelled flag that silently turns a filter off returns a result to a question +// nobody asked. +func queryBool(q url.Values, name string) (bool, error) { + if !q.Has(name) { + return false, nil + } + v := q.Get(name) + if v == "" { + return true, nil + } + b, err := strconv.ParseBool(v) + if err != nil { + return false, badInput(name + " must be true or false") + } + return b, nil +} + +func queryInt(q url.Values, name string, def int) (int, error) { + if q.Get(name) == "" { + return def, nil + } + n, err := strconv.Atoi(q.Get(name)) + if err != nil { + return 0, badInput(name + " must be an integer") + } + return n, nil +} + +// queryInt64 reads a unix timestamp. Seconds, not "30d": the age spelling belongs to the +// CLI, where a person types the bound themselves — a browser knows the clock and can +// subtract, and a wire format that parses prose is one more place for the two surfaces +// to disagree about what "a month ago" means. +func queryInt64(q url.Values, name string, def int64) (int64, error) { + if q.Get(name) == "" { + return def, nil + } + n, err := strconv.ParseInt(q.Get(name), 10, 64) + if err != nil { + return 0, badInput(name + " must be a unix timestamp in seconds") + } + return n, nil +} + // badInput builds the store-shaped error for a malformed request parameter, so the // UI sees the same code vocabulary the CLI and MCP use. func badInput(msg string) error { diff --git a/internal/webui/assets/app.css b/internal/webui/assets/app.css index 021dcd2..4464a25 100644 --- a/internal/webui/assets/app.css +++ b/internal/webui/assets/app.css @@ -583,14 +583,26 @@ pad-outline, .outline-pop, pad-tasks, .pad__people { and what scrolls internally when the pad has more sections than fit. The panel inside cannot own that: sticking the panel to a container of its own height gives it nowhere to travel, and leaves the tabs — and the close button in them — to scroll away. */ +/* How far down the rail starts is the toolbar's HEIGHT, which is measured and published + as --pad-toolbar-h (see observeToolbarHeight in pages/pad.js) rather than assumed here. + The toolbar wraps to a second line on a narrow window — and again once it is stuck and + takes on the pad's title and menu — so the constant this used to be was wrong exactly + when it mattered: the bar then covered the rail's tab strip, and the way to switch + index vanished at the moment someone had scrolled far enough to want it. The fallback + keeps the old value for the instant before the first measurement lands. */ .pad__rail { display: flex; flex-direction: column; gap: .35rem; - position: sticky; top: 3.25rem; - min-height: 0; max-height: calc(100vh - 5rem); -} -.pad__rail > pad-outline, .pad__rail > pad-tasks { min-height: 0; flex: 1 1 auto; } -.pad__rail[data-rail="outline"] > pad-tasks { display: none; } -.pad__rail[data-rail="tasks"] > pad-outline { display: none; } + position: sticky; top: calc(var(--pad-toolbar-h, 3.25rem) + .5rem); + min-height: 0; max-height: calc(100vh - var(--pad-toolbar-h, 3.25rem) - 2.25rem); +} +.pad__rail > pad-outline, .pad__rail > pad-tasks, .pad__rail > pad-search { min-height: 0; flex: 1 1 auto; } +/* The rail holds three indexes of the same pad and shows one at a time. */ +.pad__rail[data-rail="outline"] > pad-tasks, +.pad__rail[data-rail="outline"] > pad-search, +.pad__rail[data-rail="tasks"] > pad-outline, +.pad__rail[data-rail="tasks"] > pad-search, +.pad__rail[data-rail="search"] > pad-outline, +.pad__rail[data-rail="search"] > pad-tasks { display: none; } .rail__tabs { display: flex; align-items: stretch; gap: .15rem; padding: 0 .25rem; } .rail__tab { @@ -793,3 +805,145 @@ button.chip:hover { color: var(--text); border-color: var(--text); } .stuck__row { display: flex; gap: .5rem; align-items: baseline; font-size: .82rem; padding: .2rem 0; } .stuck__what { color: var(--warning, #d29922); font-weight: 600; } .stuck__age { color: var(--muted); margin-left: auto; font-variant-numeric: tabular-nums; } + +/* ── search ────────────────────────────────────────────────────────────────── */ +.search__form { margin: .5rem 0 1rem; display: flex; flex-direction: column; gap: .6rem; } +/* The query and its button on one line: a search box that wraps away from its own + submit reads as two unrelated controls. */ +.search__row { display: flex; gap: .5rem; align-items: center; } +.search__row puredashboard-input { flex: 1; min-width: 12rem; } + +.search__filters { + display: flex; flex-wrap: wrap; gap: .6rem 1rem; align-items: flex-end; +} +.search__field { display: flex; flex-direction: column; gap: .2rem; } +.search__label { + font-size: .7rem; letter-spacing: .04em; + text-transform: uppercase; color: var(--muted); +} +.search__flags { display: flex; flex-wrap: wrap; gap: .5rem 1rem; align-items: center; } + +/* The line that says how to read the number. It stays put whether or not anything + matched — "0 of 25 read" and "0 of 25 read, 3 skipped" are different answers. */ +.search__summary { + margin: 0 0 .4rem; font-size: .85rem; color: var(--text); + font-variant-numeric: tabular-nums; +} +.search__skipped, .search__warn { margin: .2rem 0; font-size: .8rem; color: var(--muted); } +.search__warn { color: var(--warning, #d29922); } + +.hits { display: flex; flex-direction: column; gap: .9rem; } +.hits__group { display: flex; flex-direction: column; gap: .1rem; } +.hits__pad { + display: flex; gap: .5rem; align-items: baseline; + padding-bottom: .2rem; border-bottom: 1px solid var(--border); +} +/* Flat (oldest-first) has no per-pad heading, so its rows need the separation the + group heading was providing. */ +.hits--flat { gap: .25rem; } + +/* A hit is a link that behaves like a row. `display: block` rather than flex so the + excerpt inside it wraps as prose. */ +.hit { + display: block; padding: .35rem .5rem; border-radius: var(--radius, 6px); + color: inherit; text-decoration: none; border-left: 2px solid transparent; +} +.hit:hover { background: var(--panel-2, var(--panel)); border-left-color: var(--accent); } +.hit__meta { + display: flex; flex-wrap: wrap; gap: .5rem; align-items: baseline; + font-size: .75rem; color: var(--muted); +} +.hit__sec { font-variant-numeric: tabular-nums; color: var(--accent); } +.hit__where { font-variant-numeric: tabular-nums; } +.hit__author { font-weight: 600; color: var(--text); } +.hit__time { margin-left: auto; } +.hit__title { + display: block; font-size: .8rem; color: var(--muted); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +/* The matching line. It wraps rather than scrolling: the excerpt is already a window + the server cut, and a second horizontal scroller inside a list of them is unreadable. + `overflow-wrap` because an agent's line can be one long token (a path, a hash). */ +.hit__text { + display: block; font-size: .84rem; line-height: 1.45; + overflow-wrap: anywhere; +} +.hit__text mark { + background: color-mix(in srgb, var(--accent) 28%, transparent); + color: inherit; border-radius: 2px; padding: 0 .05em; +} + +/* ── search inside a pad (the rail's third index) ──────────────────────────── */ +.padsearch__head { display: flex; gap: .35rem; padding: .3rem 0 .5rem; } +.padsearch__input { + flex: 1; min-width: 0; font: inherit; font-size: .8rem; + padding: .25rem .45rem; color: var(--text); + background: var(--bg); border: 1px solid var(--border); + border-radius: var(--radius, 6px); +} +.padsearch__input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } +.padsearch__go { + background: transparent; border: 1px solid var(--border); color: var(--text); + border-radius: var(--radius, 6px); padding: .2rem .5rem; font-size: .78rem; cursor: pointer; +} +.padsearch__go:hover { background: var(--panel-2, var(--panel)); } +.padsearch__note { margin: .3rem 0; font-size: .76rem; color: var(--muted); } +.padsearch__note--bad { color: var(--warning, #d29922); } +/* Same shape as the other two panels: the component is the column, and only the LIST + inside it scrolls — so the query box stays put while the hits move under it. */ +pad-search { display: flex; flex-direction: column; overflow: hidden; } +.padsearch__list { display: flex; flex-direction: column; gap: .1rem; overflow-y: auto; flex: 1 1 auto; min-height: 0; } + +/* One hit in the rail. A button, not a link: it moves the transcript beside it rather + than navigating, which is the same thing an outline row does. */ +.padhit { + display: flex; flex-direction: column; gap: .1rem; width: 100%; + padding: .3rem .35rem; text-align: left; cursor: pointer; + background: transparent; border: 0; border-left: 2px solid transparent; + border-radius: var(--radius, 6px); color: inherit; font: inherit; +} +.padhit:hover { background: var(--panel-2, var(--panel)); border-left-color: var(--accent); } +.padhit__head { + display: flex; gap: .4rem; align-items: baseline; + font-size: .7rem; color: var(--muted); +} +.padhit__sec { color: var(--accent); font-variant-numeric: tabular-nums; } +.padhit__where { font-variant-numeric: tabular-nums; } +.padhit__when { margin-left: auto; } +.padhit__title { + font-size: .74rem; color: var(--muted); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +/* Three lines of the matching text, then it stops: the rail is an index, and the + section itself is one click away in the column next to it. */ +.padhit__text { + font-size: .78rem; line-height: 1.35; overflow-wrap: anywhere; + display: -webkit-box; -webkit-line-clamp: 3; line-clamp: 3; + -webkit-box-orient: vertical; overflow: hidden; +} +.padhit__text mark { + background: color-mix(in srgb, var(--accent) 28%, transparent); + color: inherit; border-radius: 2px; +} +/* The hit for the section the transcript is showing, marked the way the outline marks + its own row — same signal, whichever index you have open. */ +.padhit[aria-current="true"] { border-left-color: var(--accent); } +.padhit[aria-current="true"] .padhit__sec { font-weight: 700; } + +/* Results that the store has moved on from. It sits ABOVE the hits, because it changes + how to read every row below it; and it is a notice with a button rather than an + automatic refresh, because repainting a result list reorders rows under whoever is + reading them. */ +.search__stale { + margin: 0 0 .5rem; padding: .35rem .5rem; + font-size: .8rem; border-radius: var(--radius, 6px); + background: color-mix(in srgb, var(--accent) 10%, transparent); + display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; +} +.padsearch__note--stale { color: var(--accent); } +.padsearch__again { + background: transparent; border: 1px solid var(--border); color: inherit; + border-radius: var(--radius, 6px); padding: .05rem .35rem; + font: inherit; font-size: .74rem; cursor: pointer; +} +.padsearch__again:hover { background: var(--panel-2, var(--panel)); } diff --git a/internal/webui/assets/app.js b/internal/webui/assets/app.js index 88d8332..2990abe 100644 --- a/internal/webui/assets/app.js +++ b/internal/webui/assets/app.js @@ -182,6 +182,11 @@ const router = new Router({ routes: { "/": { title: "Overview", load: () => import("/pages/overview.js") }, "/pads": { title: "Pads", load: () => import("/pages/pads.js") }, + // The page narrows this to the query itself once it mounts — the router's title hook + // is handed the path PARAMS only, and the question here lives in the query string. + // Router order makes that safe: it writes the title before mounting, so the page's + // own write is the one that stands. + "/search": { title: "Search", load: () => import("/pages/search.js") }, "/pads/:ref": { title: (p) => p.ref, load: () => import("/pages/pad.js") }, "/projects": { title: "Projects", load: () => import("/pages/projects.js") }, "/projects/:name": { title: (p) => `Project ${p.name}`, load: () => import("/pages/project.js") }, diff --git a/internal/webui/assets/components/pad-search.js b/internal/webui/assets/components/pad-search.js new file mode 100644 index 0000000..1d70670 --- /dev/null +++ b/internal/webui/assets/components/pad-search.js @@ -0,0 +1,144 @@ +// — finding a line inside ONE pad, beside its transcript. +// +// The store-wide page answers "which pad talked about this". This answers the question +// you have once you are already in a pad and it runs to six hundred sections: "where in +// HERE was that said". They are the same store call with a ref attached, and deliberately +// not the same screen — the answer to this one is only useful next to the transcript it +// jumps into. +// +// It shares the rail with the outline and the task board because all three are INDEXES +// of the same pad: one by section, one by work, one by what was written. A fourth column +// would take the room the transcript needs. +// +// Like its two neighbours it owns no answer of its own — the page runs the search and +// hands the hits down — and it emits `pick` for a hit and `search` for a new query. What +// it DOES own is the text in its own box, which is why the query lives here as state: +// re-rendering the rail while someone is typing must not take their caret with it. + +import { Reactive, html, repeat } from "/vendor/puredashboard/reactive.js"; +import { safeText, safeInline, cutChars, relTime, absTime } from "/lib/fmt.js"; + +// A title is cut to the rail's width; the whole of it stays in `title=`. +const TITLE_CHARS = 46; + +class PadSearch extends Reactive { + static properties = { + hits: {}, // the hits for the query that has been RUN, from /api/search + query: {}, // the query those hits belong to — not what is in the box + state: {}, // "idle" | "loading" | "done" | "error" + error: {}, // the message, when state is "error" + active: {}, // the section the transcript is showing, so the row can be marked + truncated: {}, // the search stopped at its limit + stale: {}, // sections arrived after these hits were found + }; + + // `active` arrives on every scroll tick, which re-renders this component while someone + // may be typing in the box above. That is safe, and it is worth writing down WHY, + // because the obvious guess is wrong: it is not that the binding is careful, it is + // that a binding does not fire at all when its value has not changed — + // `AttrPart.setValue` compares against the last value it wrote and returns early. So + // `.value=${this.query}` writes only when the QUERY changes, not when the component + // re-renders. + // + // What does destroy an input is a change of TEMPLATE IDENTITY: `NodePart.commit` keeps + // the existing DOM only while `v.strings` is the same array. Hence the shape here — the + // input lives in render()'s own literal, which never varies, and only `#body()` swaps + // between templates below it. + + constructor() { + super(); + this.hits = []; + this.query = ""; + this.state = "idle"; + this.error = ""; + this.active = 0; + this.truncated = false; + this.stale = false; + } + + setup() { + // Enter runs it. There is no index behind this — the store reads the pad — so a + // search per keystroke would re-read the whole file ten times for one word. + this.on("keydown", ".padsearch__input", (e, el) => { + if (e.key === "Enter") this.emit("search", el.value.trim()); + // Escape clears the search rather than the box: leaving the hits on screen with + // an empty field is a rail describing a question that is no longer being asked. + if (e.key === "Escape") { el.value = ""; this.emit("search", ""); } + }); + this.on("click", ".padsearch__go", () => { + const box = this.querySelector(".padsearch__input"); + this.emit("search", (box?.value || "").trim()); + }); + this.on("click", ".padhit", (e, el) => this.emit("pick", Number(el.dataset.n))); + this.on("click", ".padsearch__again", () => this.emit("search", this.query || "")); + } + + render() { + const hits = this.hits || []; + return html` +
+ + +
+ ${this.#body(hits)} + `; + } + + #body(hits) { + if (this.state === "loading") return html`

Reading the pad…

`; + if (this.state === "error") { + return html`

${safeText(this.error || "That search could not be run")}

`; + } + if (this.state === "idle") { + return html`

Type a word to find it in this pad's sections.

`; + } + if (!hits.length) { + return html`

No line in this pad contains that.

`; + } + return html` +

${hits.length} match${hits.length === 1 ? "" : "es"}${this.truncated ? ", stopped at the limit" : ""}

+

The pad has moved on since this ran · +

+
+ ${repeat(hits, (h) => `${h.section}:${h.line}:${h.in_title ? "t" : "b"}`, (h) => this.#row(h))} +
+ `; + } + + #row(h) { + return html` + + `; + } + + // The match is painted where the SERVER found it. Re-running the pattern here would be + // a second matcher, and it would disagree: --word is spelled with Unicode classes that + // JavaScript's \b does not mean, and folding is on by default here and off there. + // + // safeInline rather than safeText, because the offsets were measured against the text + // as written and safeText collapses whitespace — every character after a double space + // would shift, and the highlight would land beside the word instead of on it. + #marked(h) { + const r = [...safeInline(h.text || "")]; + const { match_start: s, match_end: e } = h; + if (!(Number.isInteger(s) && s >= 0 && Number.isInteger(e) && e > s && e <= r.length)) { + return html`${r.join("")}`; + } + return html`${r.slice(0, s).join("")}${r.slice(s, e).join("")}${r.slice(e).join("")}`; + } +} + +PadSearch.define("pad-search"); + +export { PadSearch }; diff --git a/internal/webui/assets/lib/api.js b/internal/webui/assets/lib/api.js index fb1bc32..a2c714d 100644 --- a/internal/webui/assets/lib/api.js +++ b/internal/webui/assets/lib/api.js @@ -86,6 +86,39 @@ export const api = { stuck: (olderThanS) => request("/api/stuck" + (olderThanS != null ? `?older_than_s=${olderThanS}` : "")), + // Content search — the one read that selects by what was WRITTEN rather than by + // position. The three scopes a person asks for are not three calls: no scope is the + // whole store, `project` narrows to one project, `ref` to one pad. + // + // A protected pad answers only when named by `ref`, and only if this session has + // already unlocked it — the server takes the password from the session, so it never + // travels in a URL that ends up in history or in a link someone copies out. + // + // `before`/`after` are unix SECONDS. The CLI's "30d" spelling stays at the terminal: + // a browser knows the clock and can subtract, and a wire format that parses prose is + // one more place for the two surfaces to disagree about what "a month ago" means. + search: (query, { + project, ref, exclude, author, kind, before, after, + oldest, regexp, word, matchCase, limit, + } = {}) => { + const q = new URLSearchParams(); + q.set("q", query); + for (const [k, v] of Object.entries({ project, ref, author, kind })) { + if (v) q.set(k, v); + } + for (const [k, v] of Object.entries({ before, after, limit })) { + if (v != null && v !== "") q.set(k, String(v)); + } + // Flags travel only when ON. Sending `word=false` would work, but a URL a person + // copies out of the address bar is better read when it lists what was asked for + // rather than everything that was not. + for (const [k, v] of Object.entries({ oldest, regexp, word, case: matchCase })) { + if (v) q.set(k, "true"); + } + for (const r of exclude || []) q.append("exclude", r); + return request(`/api/search?${q.toString()}`); + }, + unlock: (ref, password) => request(`/api/pads/${encodeURIComponent(ref)}/unlock`, { method: "POST", diff --git a/internal/webui/assets/lib/fmt.js b/internal/webui/assets/lib/fmt.js index 21e4d37..d31c282 100644 --- a/internal/webui/assets/lib/fmt.js +++ b/internal/webui/assets/lib/fmt.js @@ -87,6 +87,21 @@ export function safeText(raw, { multiline = false } = {}) { .trim(); } +/** + * safeInline cleans agent text WITHOUT changing its length: every dangerous character + * becomes exactly one space, and runs of whitespace are left alone. + * + * That is the whole difference from safeText, and it exists for one caller — a search + * hit, which arrives with the match's position measured by the server. Collapsing + * whitespace would move every character after the first double space, and the highlight + * would then be painted over the wrong word. Length-preserving is the contract; a change + * here that drops or merges characters breaks the offsets silently. + * @param {string} raw + */ +export function safeInline(raw) { + return String(raw ?? "").replace(UNSAFE_TEXT, " "); +} + /** * cutChars shortens a string to `max` CHARACTERS — code points, not UTF-16 units, so * an emoji or a non-BMP character is never split in half and left as a lone surrogate. diff --git a/internal/webui/assets/lib/prefs.js b/internal/webui/assets/lib/prefs.js index 55a8b75..9b31539 100644 --- a/internal/webui/assets/lib/prefs.js +++ b/internal/webui/assets/lib/prefs.js @@ -88,15 +88,23 @@ export function outline() { return raw === null ? DEFAULTS.outline : raw !== "off"; } -// Which half of the rail is showing — the outline or the task board. It is a property -// of the reader, not of a pad, so it survives navigating between pads. +// Which third of the rail is showing — the outline, the task board or search. It is a +// property of the reader, not of a pad, so it survives navigating between pads. +// +// Anything unrecognised falls back to the outline rather than being stored as-is: this +// value comes out of localStorage, which an older build, another tab or a person with +// devtools can have written. +const RAILS = ["outline", "tasks", "search"]; + export function rail() { - return read(KEYS.rail) === "tasks" ? "tasks" : "outline"; + const raw = read(KEYS.rail); + return RAILS.includes(raw) ? raw : "outline"; } export function setRail(which) { - write(KEYS.rail, which === "tasks" ? "tasks" : "outline"); - announce("rail", which); + const v = RAILS.includes(which) ? which : "outline"; + write(KEYS.rail, v); + announce("rail", v); } export function setOutline(on) { diff --git a/internal/webui/assets/lib/sidebar.js b/internal/webui/assets/lib/sidebar.js index 99726a2..e3e2f05 100644 --- a/internal/webui/assets/lib/sidebar.js +++ b/internal/webui/assets/lib/sidebar.js @@ -95,6 +95,7 @@ export function initSidebar() { { label: "Overview", href: "#/" }, { label: "Pads", href: "#/pads" }, { label: "Projects", href: "#/projects" }, + { label: "Search", href: "#/search" }, ]; navBottom.items = [{ label: "Settings", href: "#/settings" }]; diff --git a/internal/webui/assets/pages/pad.js b/internal/webui/assets/pages/pad.js index 2b6b8ee..5ba4ad6 100644 --- a/internal/webui/assets/pages/pad.js +++ b/internal/webui/assets/pages/pad.js @@ -31,6 +31,7 @@ import { menu } from "/vendor/puredashboard/menu.js"; import "/components/pad-outline.js"; import "/components/pad-tasks.js"; +import "/components/pad-search.js"; import { rulesChip, showRules } from "/components/rules-dialog.js"; import { api } from "/lib/api.js"; @@ -98,6 +99,10 @@ export default function mount(outlet, ctx) { // both are indexes of the same pad, and a second rail would take the room the // transcript needs. A two-button strip switches between them. const taskPanel = el("pad-tasks"); + // The third index of the same pad: by what was written. It runs its own search + // against /api/search with this pad's ref, so a protected pad answers through the + // session that already unlocked it and no password is ever put in a URL. + const searchPanel = el("pad-search"); const railTabs = el("div", { class: "rail__tabs", role: "tablist" }); const railTab = (id, label) => { const b = el("button", { @@ -115,8 +120,8 @@ export default function mount(outlet, ctx) { title: "Hide the rail", "aria-label": "Hide the rail", onclick: () => setOutline(false), }); - railTabs.append(railTab("outline", "Outline"), railTab("tasks", "Tasks"), railClose); - const railBox = el("div", { class: "pad__rail" }, railTabs, outline, taskPanel); + railTabs.append(railTab("outline", "Outline"), railTab("tasks", "Tasks"), railTab("search", "Search"), railClose); + const railBox = el("div", { class: "pad__rail" }, railTabs, outline, taskPanel, searchPanel); // The way back. A rail that closes with a « and leaves nothing behind is a rail you // have to go looking for — the toolbar's toggle is across the page and reads like // its neighbours. This puts the opener exactly where the closer was. @@ -160,6 +165,26 @@ export default function mount(outlet, ctx) { setDocTitle(); loadTasks(); await loadLatest(); + await openDeepLink(); + } + + // A pad is also arrived at FROM a search, and a hit that cannot be followed is a + // result you have to go and find by hand. `?section=` says where to land, `?q=` says + // what was being looked for — so the rail opens on the same search rather than making + // the person type it a second time in the pad they were just sent to. + // + // It runs after the newest page has loaded, because jumpTo replaces that page: doing + // it first would fetch the history and then scroll away from it. + async function openDeepLink() { + const want = Number(ctx?.query?.section); + const q = (ctx?.query?.q || "").trim(); + if (q) { + setRail("search"); + void runPadSearch(q); + } + if (Number.isInteger(want) && want >= 1 && want <= pad.section_count) { + await jumpTo(want); + } } // The router can only title this page with the ref, because the ref is all the URL @@ -370,6 +395,7 @@ export default function mount(outlet, ctx) { // Only now are the bodies in the document and measurable against the viewport. observeLazy(); observeStuck(); + observeToolbarHeight(); // The bodies just changed height, so where the reading line falls has changed too. updateActive(); } @@ -515,6 +541,45 @@ export default function mount(outlet, ctx) { } } + // ── searching this pad ───────────────────────────────────────────────────── + // + // The same /api/search the store-wide page uses, narrowed to this ref. It is run here + // rather than in the component for the reason the task board is: the page owns what + // happens to the transcript, and a hit is only useful because it can jump into it. + let searchSeq = 0; + + async function runPadSearch(query) { + const seq = ++searchSeq; + searchPanel.query = query; + if (!query) { + searchPanel.hits = []; + searchPanel.state = "idle"; + searchPanel.stale = false; + return; + } + searchPanel.state = "loading"; + searchPanel.stale = false; + try { + const res = await api.search(query, { ref, limit: 200 }); + // Two searches in flight and the slower one landing last would show the answer to + // the question that was abandoned. + if (disposed || seq !== searchSeq) return; + searchPanel.hits = res.hits || []; + searchPanel.truncated = !!res.truncated; + searchPanel.state = "done"; + } catch (err) { + if (disposed || seq !== searchSeq) return; + searchPanel.hits = []; + searchPanel.error = err.message; + searchPanel.state = "error"; + } + } + + searchPanel.addEventListener("search", (e) => { void runPadSearch(String(e.detail || "")); }); + // A hit jumps the transcript to its section, exactly as an outline row does — and by + // the same route, so the two cannot land in different places. + searchPanel.addEventListener("pick", (e) => { void jumpTo(Number(e.detail)); }); + taskPanel.addEventListener("pick", (e) => { void selectTask(Number(e.detail) || 0); }); taskPanel.addEventListener("open-only", (e) => { tasksOpenOnly = !!e.detail; @@ -659,7 +724,10 @@ export default function mount(outlet, ctx) { : msgs.reduce((a, b) => (b.getBoundingClientRect().bottom > a.getBoundingClientRect().bottom ? b : a)); } const n = Number(pick.dataset.section); - if (n && n !== outline.active) outline.active = n; + // Both indexes of the pad track the reading line, so whichever tab is showing marks + // where you are. Safe to push on every tick: a Reactive re-render writes a binding + // only when its value changed, so the search box's text is untouched by this. + if (n && n !== outline.active) { outline.active = n; searchPanel.active = n; } } // At most one recomputation per frame, however many events arrive in it. @@ -937,6 +1005,35 @@ export default function mount(outlet, ctx) { stuckObserver.observe(sentinel); } + // ── how far down the rail has to start ───────────────────────────────────── + // + // The rail is sticky under the sticky toolbar, so it has to know how tall that bar + // is. It used to assume — a constant in the stylesheet — and the assumption is wrong + // whenever the toolbar WRAPS, which it does on a narrow window and again once it is + // stuck and takes on the pad's title and menu. The rail then started underneath the + // bar, and the bar covered its tab strip: the way to switch index disappeared exactly + // when someone had scrolled far enough to want it. + // + // So it is measured. The value is published as a CSS variable on the layout, and the + // stylesheet does the arithmetic — script decides the number, CSS decides the design. + let toolbarResize = null; + + function observeToolbarHeight() { + toolbarResize?.disconnect(); + toolbarResize = null; + if (!frame) return; + const write = () => { + // A toolbar that is not sticky scrolls away with the transcript and takes no room + // from the rail — the offset is then zero, not its height. + const h = prefs.stickyBar() ? frame.toolbar.getBoundingClientRect().height : 0; + layout.style.setProperty("--pad-toolbar-h", `${Math.round(h)}px`); + }; + write(); + if (typeof ResizeObserver !== "function") return; + toolbarResize = new ResizeObserver(write); + toolbarResize.observe(frame.toolbar); + } + // ── lazy markdown ────────────────────────────────────────────────────────── // // Parsing markdown is the expensive part of this page: a section can be thousands of @@ -1234,6 +1331,12 @@ export default function mount(outlet, ctx) { const added = pad.section_count - previous; if (added <= 0) { render(); return; } + // Whatever the rail is searching was found in the pad as it stood a moment ago. Say + // so instead of re-running it: a search here reads the whole pad file, and doing that + // unasked on every arriving section would turn a busy pad into a poll. It only + // matters if there are hits on screen to be wrong about. + if (searchPanel.state === "done") searchPanel.stale = true; + // Reading history: do NOT move the viewport. Offer the jump instead. if (!showingLatest) { pendingNew += added; @@ -1294,6 +1397,7 @@ export default function mount(outlet, ctx) { offPrefs(); // The lazy elements disconnect their own observers as they leave the DOM. stuckObserver?.disconnect(); + toolbarResize?.disconnect(); holdStop?.(); if (activeFrame) cancelAnimationFrame(activeFrame); sizeWatch?.disconnect(); diff --git a/internal/webui/assets/pages/project.js b/internal/webui/assets/pages/project.js index c10dc40..7593bd9 100644 --- a/internal/webui/assets/pages/project.js +++ b/internal/webui/assets/pages/project.js @@ -7,6 +7,7 @@ import { renderPadTable } from "/pages/pads.js"; import { rulesChip } from "/components/rules-dialog.js"; import { api } from "/lib/api.js"; +import { el } from "/lib/ui.js"; export default function mount(outlet, ctx) { const project = ctx.params.name; @@ -23,9 +24,18 @@ export default function mount(outlet, ctx) { // No subtitle: the default one counts the pads, which is a fact about this page. The // word "project" was neither — the heading IS the project's name, and the sidebar // already says where you are. + // Searching THIS project is a link, not a control: the search page owns the question, + // and arriving there with the project already filled in is the whole difference + // between "search" and "search here". A real anchor, so it opens in a tab like any + // other link on this page. + const searchLink = el("a", { + class: "ghost-btn", text: "Search this project", + href: `#/search?project=${encodeURIComponent(project)}`, + }); + return renderPadTable(outlet, { project, heading: project, - actions: [chip], + actions: [searchLink, chip], }); } diff --git a/internal/webui/assets/pages/search.js b/internal/webui/assets/pages/search.js new file mode 100644 index 0000000..3f792ae --- /dev/null +++ b/internal/webui/assets/pages/search.js @@ -0,0 +1,384 @@ +// Search — finding a pad by WHAT WAS SAID in it, across the store or inside one project. +// +// Every other view here selects by position: a pad, a section number, a task, a kind. +// This one reads the prose, which makes it the page a person arrives at with a word and +// no idea where it lives. +// +// Three things shape it. +// +// The QUESTION LIVES IN THE URL. A search is a hash away from being a link, and the +// thing worth sending a colleague is the question, not a screenshot of the answer. So +// submitting navigates rather than fetching in place: back works, reload works, and the +// address bar always describes what is on screen. +// +// It does NOT search as you type. There is no index — the store reads the pads it looks +// at — so ten keystrokes would be ten walks over every pad. Enter, or the button. +// +// WHAT WAS NOT SEARCHED IS PART OF THE ANSWER. A protected pad is skipped, and a result +// of nothing means "not here" only if you also know where it did not look. That count +// belongs on the summary line itself, not below it: a person who reads one line must not +// read the wrong answer. + +import "/vendor/puredashboard/input.js"; +import "/vendor/puredashboard/select.js"; +import "/vendor/puredashboard/checkbox.js"; +import "/vendor/puredashboard/tag.js"; +import "/vendor/puredashboard/result.js"; + +import { api } from "/lib/api.js"; +import { onPad } from "/lib/bus.js"; +import { el, pageHead, skeleton, errorView, setChildren, link } from "/lib/ui.js"; +import { relTime, absTime, cutChars, safeText, safeInline } from "/lib/fmt.js"; + +// How many hits one search returns. The server caps it harder (500); this is the number +// a person actually reads before narrowing the question instead of scrolling. +const LIMIT = 100; + +// The time presets. A search for a decision is usually "recently" or "ever", and a date +// picker asks for a precision nobody has when they are looking for a word. +const WINDOWS = [ + { value: "", label: "Any time" }, + { value: "7", label: "Last 7 days" }, + { value: "30", label: "Last 30 days" }, + { value: "90", label: "Last 90 days" }, +]; + +const KINDS = [ + { value: "", label: "All kinds" }, + { value: "message", label: "Messages" }, + { value: "task", label: "Task events" }, + { value: "rules", label: "Rules" }, + { value: "notice", label: "Notices" }, +]; + +// readQuery turns the URL into the search that is on screen. It is the ONE reader of the +// query string: the form is filled from what this returns, and so is the request, so the +// two can never describe different searches. +function readQuery(q = {}) { + return { + query: q.q || "", + project: q.project || "", + author: q.author || "", + kind: q.kind || "", + days: q.days || "", + oldest: q.oldest === "1", + regexp: q.regexp === "1", + word: q.word === "1", + matchCase: q.case === "1", + }; +} + +// writeQuery is readQuery's inverse, and only records what was ASKED for — an empty +// field leaves no trace in the URL, so a copied link reads as the question it was. +function writeQuery(s) { + const p = new URLSearchParams(); + if (s.query) p.set("q", s.query); + for (const k of ["project", "author", "kind", "days"]) { + if (s[k]) p.set(k, s[k]); + } + for (const [k, on] of [["oldest", s.oldest], ["regexp", s.regexp], ["word", s.word], ["case", s.matchCase]]) { + if (on) p.set(k, "1"); + } + return p.toString(); +} + +export default function mount(outlet, ctx) { + let disposed = false; + const state = readQuery(ctx?.query); + + // ── the form ─────────────────────────────────────────────────────────────── + // Built once and never rebuilt: it holds what the person typed, and a page that + // reconstructs its own controls loses the caret in the middle of a correction. + + const input = el("puredashboard-input", { + type: "search", placeholder: "Find a word or phrase in pad content", value: state.query, + }); + input.addEventListener("keydown", (e) => { if (e.key === "Enter") submit(); }); + + const projectSel = el("puredashboard-select", { placeholder: "All projects" }); + projectSel.value = state.project; + + const kindSel = el("puredashboard-select", { placeholder: "All kinds" }); + kindSel.options = KINDS.filter((k) => k.value).map((k) => ({ value: k.value, label: k.label })); + kindSel.value = state.kind; + + const whenSel = el("puredashboard-select", { placeholder: "Any time" }); + whenSel.options = WINDOWS.filter((w) => w.value).map((w) => ({ value: w.value, label: w.label })); + whenSel.value = state.days; + + const authorInput = el("puredashboard-input", { placeholder: "Any agent", value: state.author }); + authorInput.addEventListener("keydown", (e) => { if (e.key === "Enter") submit(); }); + + const flag = (name, label, title) => { + const box = el("puredashboard-checkbox", { title }); + box.label = label; + box.checked = state[name]; + return box; + }; + // "Oldest first" is not a sort preference dressed up as one — it answers a DIFFERENT + // question. The default surfaces what is being said about a word now; this surfaces + // where it was settled, which is almost always the first time anyone wrote it down. + const oldestBox = flag("oldest", "Oldest first", "Where a word was first written — usually where it was decided"); + const wordBox = flag("word", "Whole word", "Match whole words only"); + const caseBox = flag("matchCase", "Match case", "Match capitalisation exactly"); + const reBox = flag("regexp", "Regex", "Read the pattern as a regular expression"); + + // No class: the theme's default button is the page's primary action, exactly as the + // pad page's Unlock is. A second style here would make the two look like two ranks. + const goBtn = el("button", { type: "button", text: "Search", onclick: () => submit() }); + + const form = el("form", { class: "search__form", onsubmit: (e) => { e.preventDefault(); submit(); } }, + el("div", { class: "search__row" }, input, goBtn), + el("div", { class: "search__filters" }, + labelled("Project", projectSel), + labelled("Agent", authorInput), + labelled("Kind", kindSel), + labelled("When", whenSel), + el("div", { class: "search__flags" }, oldestBox, wordBox, caseBox, reBox), + ), + ); + + // An ordinary wrapping