From 7de118ffe8ab3851baa6e9470b0a7fb6ba58e102 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Thu, 29 Oct 2026 09:28:43 +0100 Subject: [PATCH 1/3] =?UTF-8?q?feat(tree):=20Search=20in=20Folder=E2=80=A6?= =?UTF-8?q?=20context=20menu=20item?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- DESIGN.md | 3 ++ README.md | 3 +- src/client/app.ts | 4 ++ src/client/search.ts | 12 ++++++ src/client/tree.ts | 7 ++- test/client.search.folder.test.ts | 71 +++++++++++++++++++++++++++++++ 6 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 test/client.search.folder.test.ts diff --git a/DESIGN.md b/DESIGN.md index bb4a9ad..77291fe 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -400,6 +400,9 @@ ab (whole word) and .* (regex), with the same labels and `aria-pressed`. Matchin rules as `codeview.ts`, so a hit in the project is a hit in the file. - There are optional include and exclude globs, comma-separated, matched with `Bun.Glob`. - Deferred: replace (never; the app is read-only), multiline regex, search history. +- **Search in Folder…** (folder context menu in the Explorer) switches to Search and sets the + include field to `/**`, re-running any current query. Glob metacharacters in the folder + name are backslash-escaped, and commas (the list separator) become `?`, so the scope stays literal. **Engine (performance engineer).** Matching runs in the server, in a Bun `Worker`, using sync I/O into a buffer that is reused. diff --git a/README.md b/README.md index 62246ec..b0e1d5c 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,8 @@ signed build provenance; see [how to verify](https://codeviewer.21no.de/#verify) - **Search in files:** ⌘⇧F opens a sidebar search over the whole project: literal, whole-word or regex, case toggle, include/exclude globs, and `.gitignore` respected unless you opt in to ignored files. Results stream in grouped by file, open at the exact match, and offer a Refresh when files - change on disk. + change on disk. Right-click a folder in the Explorer and pick "Search in Folder…" to scope the + search to it. - **Git (read-only):** - Explorer status letters and colors (M A U D R C), with folders rolled up and ignored files dimmed. - Colored tabs. diff --git a/src/client/app.ts b/src/client/app.ts index f7ee8a5..477e41c 100644 --- a/src/client/app.ts +++ b/src/client/app.ts @@ -167,6 +167,10 @@ class App { // Sidebar this.tree = new FileTree(this.info.name); this.tree.onOpen = (p, pin) => void this.openFile(p, { pin, focus: pin }); + this.tree.onSearchIn = (dir) => { + this.showSearch(); + this.search.searchIn(dir); + }; this.search = new SearchView(); this.search.onOpen = (path, line, col, len) => void this.openFile(path, { line, col, len, focus: false }); const sideTab = (v: SideView, label: string) => { diff --git a/src/client/search.ts b/src/client/search.ts index baf4114..8732a53 100644 --- a/src/client/search.ts +++ b/src/client/search.ts @@ -29,6 +29,11 @@ function safe(s: string): string { ); } +/** An include glob for everything under `dir`: Bun.Glob metacharacters escaped, commas (the list separator) widened to `?`. */ +export function folderGlob(dir: string): string { + return `${dir.replace(/[\\*?[\]{}!]/g, "\\$&").replace(/,/g, "?")}/**`; +} + const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`; /** Project-wide content search panel: query, option toggles, results grouped by file. */ @@ -212,6 +217,13 @@ export class SearchView { this.staleBar.hidden = true; } + /** Scopes the search to one folder (via the include field) and re-runs any current query. */ + searchIn(dir: string): void { + this.include.value = folderGlob(dir); + this.focus(); + if (this.eligible()) this.schedule(true); + } + focus() { this.input.focus(); (this.input as unknown as { select?: () => void }).select?.(); diff --git a/src/client/tree.ts b/src/client/tree.ts index 5f37b5c..e42bef2 100644 --- a/src/client/tree.ts +++ b/src/client/tree.ts @@ -32,6 +32,7 @@ export class FileTree { private changedDirs = new Set(); private frame = 0; onOpen: (path: string, pin: boolean) => void = () => {}; + onSearchIn: (dir: string) => void = () => {}; git: GitState | null = null; constructor(rootName: string) { @@ -192,7 +193,11 @@ export class FileTree { this.selected = n.path; this.schedule(); if (n.type === "dir") { - showContextMenu(e, [{ label: n.expanded ? "Collapse" : "Expand", run: () => void this.toggle(n) }, collapse]); + showContextMenu(e, [ + { label: n.expanded ? "Collapse" : "Expand", run: () => void this.toggle(n) }, + { label: "Search in Folder…", run: () => this.onSearchIn(n.path) }, + collapse, + ]); } else if (n.type === "file") { showContextMenu(e, [{ label: "Open in New Tab", run: () => this.onOpen(n.path, true) }]); } diff --git a/test/client.search.folder.test.ts b/test/client.search.folder.test.ts new file mode 100644 index 0000000..8de8703 --- /dev/null +++ b/test/client.search.folder.test.ts @@ -0,0 +1,71 @@ +import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test"; +import { FakeEvent, installDom, mockFetch, uninstallDom, type FakeElement } from "./helpers/fakedom.ts"; + +let S: typeof import("../src/client/search.ts"); +beforeAll(async () => { + installDom(); + S = await import("../src/client/search.ts"); +}); +afterAll(() => uninstallDom()); + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +const done = () => JSON.stringify({ t: "done", files: 0, matches: 0, skipped: 0, truncated: false, ms: 1 }) + "\n"; + +describe("folderGlob", () => { + test("matches everything under the folder and nothing beside it", () => { + const g = new Bun.Glob(S.folderGlob("src/client")); + expect(g.match("src/client/a.ts")).toBe(true); + expect(g.match("src/client/term/b.ts")).toBe(true); + expect(g.match("src/server/a.ts")).toBe(false); + expect(g.match("src/clientx/a.ts")).toBe(false); + }); + + test("glob metacharacters in folder names are literal", () => { + for (const dir of ["a[b]", "a{x,y}", "a*b", "q?", "!neg", "back\\slash", "pages/[id]"]) { + const g = new Bun.Glob(S.folderGlob(dir)); + expect(g.match(`${dir}/f.ts`)).toBe(true); + } + expect(new Bun.Glob(S.folderGlob("a*b")).match("aZb/f.ts")).toBe(false); + expect(new Bun.Glob(S.folderGlob("pages/[id]")).match("pages/i/f.ts")).toBe(false); + }); + + test("a comma never splits the include list", () => { + const g = S.folderGlob("a,b"); + expect(g).not.toContain(","); + expect(new Bun.Glob(g).match("a,b/f.ts")).toBe(true); + }); +}); + +describe("SearchView.searchIn", () => { + let restore = () => {}; + afterEach(() => restore()); + + const make = () => { + const r = mockFetch(() => new Response(done())); + restore = r.restore; + const v = new S.SearchView({ debounce: 0 }); + const root = v.el as unknown as FakeElement; + return { v, calls: r.calls, input: root.querySelector(".sv-query")!, include: root.querySelector(".sv-include")! }; + }; + + test("sets the include field and focuses the query without searching when it is empty", async () => { + const { v, calls, input, include } = make(); + v.searchIn("src/client"); + await sleep(0); + expect(include.value).toBe("src/client/**"); + expect(input.focused).toBe(true); + expect(calls.length).toBe(0); + }); + + test("re-runs a current query scoped to the folder", async () => { + const { v, calls, input } = make(); + input.value = "foo"; + input.dispatchEvent(new FakeEvent("input")); + await sleep(0); + v.searchIn("lib"); + await sleep(0); + const url = new URL(calls.at(-1)!.url, "http://x"); + expect(url.searchParams.get("q")).toBe("foo"); + expect(url.searchParams.get("include")).toBe("lib/**"); + }); +}); From 970a54a8808b446e55a81cfb3c387c6c252231cc Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Thu, 29 Oct 2026 09:34:45 +0100 Subject: [PATCH 2/3] fix(search): make Search in Folder scope literal, ignored-aware and single-request --- DESIGN.md | 9 +- src/client/app.ts | 6 +- src/client/search.ts | 31 +++++- src/client/tree.ts | 4 +- src/server/search.ts | 21 +++- test/client.search.folder.scope.test.ts | 121 ++++++++++++++++++++++++ test/client.search.folder.test.ts | 8 +- 7 files changed, 181 insertions(+), 19 deletions(-) create mode 100644 test/client.search.folder.scope.test.ts diff --git a/DESIGN.md b/DESIGN.md index 77291fe..9d4b4bf 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -401,8 +401,13 @@ rules as `codeview.ts`, so a hit in the project is a hit in the file. - There are optional include and exclude globs, comma-separated, matched with `Bun.Glob`. - Deferred: replace (never; the app is read-only), multiline regex, search history. - **Search in Folder…** (folder context menu in the Explorer) switches to Search and sets the - include field to `/**`, re-running any current query. Glob metacharacters in the folder - name are backslash-escaped, and commas (the list separator) become `?`, so the scope stays literal. + include field to `/**`, re-running any current query. Glob metacharacters, commas and + whitespace in the folder name are backslash-escaped, so the scope stays literal: `parseGlobs` + treats `\,` as a literal comma (not a separator) and the client's trim cannot eat edge whitespace. + The scope is set before the view is revealed, so exactly one request is made. A git-ignored folder + turns "Include ignored files" on. An existing exclude is kept (user-authored filter), so an exclude + that covers the folder yields no results. A scope longer than the server's 1,024-character cap shows + a message instead of an invalid-query error. **Engine (performance engineer).** Matching runs in the server, in a Bun `Worker`, using sync I/O into a buffer that is reused. diff --git a/src/client/app.ts b/src/client/app.ts index 477e41c..7253ed1 100644 --- a/src/client/app.ts +++ b/src/client/app.ts @@ -167,11 +167,9 @@ class App { // Sidebar this.tree = new FileTree(this.info.name); this.tree.onOpen = (p, pin) => void this.openFile(p, { pin, focus: pin }); - this.tree.onSearchIn = (dir) => { - this.showSearch(); - this.search.searchIn(dir); - }; this.search = new SearchView(); + this.tree.onSearchIn = (dir, ignored) => this.search.searchIn(dir, ignored); + this.search.onReveal = () => this.showSearch(); this.search.onOpen = (path, line, col, len) => void this.openFile(path, { line, col, len, focus: false }); const sideTab = (v: SideView, label: string) => { const b = h("button", { class: "side-tab", type: "button", onclick: () => this.setSideView(v) }, label); diff --git a/src/client/search.ts b/src/client/search.ts index 8732a53..1ffd9e4 100644 --- a/src/client/search.ts +++ b/src/client/search.ts @@ -29,17 +29,22 @@ function safe(s: string): string { ); } -/** An include glob for everything under `dir`: Bun.Glob metacharacters escaped, commas (the list separator) widened to `?`. */ +/** An include glob for everything under `dir`: Bun.Glob metacharacters, commas (the list separator) and whitespace (trimmed from list entries) backslash-escaped, so the scope is literal. */ export function folderGlob(dir: string): string { - return `${dir.replace(/[\\*?[\]{}!]/g, "\\$&").replace(/,/g, "?")}/**`; + return `${dir.replace(/[\\*?[\]{}!,\s]/g, "\\$&")}/**`; } +/** Longest include list the server accepts (src/server/search.ts MAX_GLOB_CHARS). */ +const MAX_INCLUDE = 1024; + const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`; /** Project-wide content search panel: query, option toggles, results grouped by file. */ export class SearchView { readonly el: HTMLElement; onOpen: (path: string, line: number, col: number, len: number) => void = () => {}; + /** Makes the view visible; searchIn calls it once the scope is set, before searching. */ + onReveal: () => void = () => {}; private input: HTMLInputElement; private include: HTMLInputElement; private exclude: HTMLInputElement; @@ -218,9 +223,27 @@ export class SearchView { } /** Scopes the search to one folder (via the include field) and re-runs any current query. */ - searchIn(dir: string): void { - this.include.value = folderGlob(dir); + searchIn(dir: string, ignored = false): void { + const glob = folderGlob(dir); + this.include.value = glob; + if (ignored && !this.on.ignored) { + // A git-ignored folder has no results unless ignored files are searched too. + this.on.ignored = true; + this.toggles.get("ignored")?.setAttribute("aria-pressed", "true"); + } + // The search below replaces stale results, so revealing the view must not start its own. + this.clearStale(); + this.onReveal(); this.focus(); + if (glob.length > MAX_INCLUDE) { + this.cancel(); + this.started = true; + this.groups = []; + this.done = null; + this.error = "Folder path is too long to search in as a folder"; + this.input.classList.remove("invalid"); + return this.render(); + } if (this.eligible()) this.schedule(true); } diff --git a/src/client/tree.ts b/src/client/tree.ts index e42bef2..f6736d4 100644 --- a/src/client/tree.ts +++ b/src/client/tree.ts @@ -32,7 +32,7 @@ export class FileTree { private changedDirs = new Set(); private frame = 0; onOpen: (path: string, pin: boolean) => void = () => {}; - onSearchIn: (dir: string) => void = () => {}; + onSearchIn: (dir: string, ignored: boolean) => void = () => {}; git: GitState | null = null; constructor(rootName: string) { @@ -195,7 +195,7 @@ export class FileTree { if (n.type === "dir") { showContextMenu(e, [ { label: n.expanded ? "Collapse" : "Expand", run: () => void this.toggle(n) }, - { label: "Search in Folder…", run: () => this.onSearchIn(n.path) }, + { label: "Search in Folder…", run: () => this.onSearchIn(n.path, !!this.git?.repo && this.git.dir(n.path) === "I") }, collapse, ]); } else if (n.type === "file") { diff --git a/src/server/search.ts b/src/server/search.ts index fc27ea2..554bae7 100644 --- a/src/server/search.ts +++ b/src/server/search.ts @@ -192,12 +192,23 @@ export function createWorkerScanner(deadlineMs: number = SEARCH_DEADLINE_MS): Sc // ───────────── SL03 scaffolding: candidate list (stubs, filled in by implement) ───────────── -/** Splits a comma-separated glob list: trimmed, empty entries dropped. */ +/** Splits a comma-separated glob list: trimmed, empty entries dropped; `\,` is a literal comma, other escapes are kept for Bun.Glob. */ export function parseGlobs(s: string | null | undefined): string[] { - return (s ?? "") - .split(",") - .map((x) => x.trim()) - .filter(Boolean); + const out: string[] = []; + let cur = ""; + const str = s ?? ""; + for (let i = 0; i < str.length; i++) { + const c = str[i]!; + if (c === "\\" && i + 1 < str.length) { + const n = str[++i]!; + cur += n === "," ? "," : c + n; + } else if (c === ",") { + out.push(cur); + cur = ""; + } else cur += c; + } + out.push(cur); + return out.map((x) => x.trim()).filter(Boolean); } export const MAX_GLOB_CHARS = 1024; diff --git a/test/client.search.folder.scope.test.ts b/test/client.search.folder.scope.test.ts new file mode 100644 index 0000000..8f21fa3 --- /dev/null +++ b/test/client.search.folder.scope.test.ts @@ -0,0 +1,121 @@ +import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test"; +import { filterByGlobs, parseGlobs } from "../src/server/search.ts"; +import { FakeEvent, installDom, mockFetch, uninstallDom, type FakeElement } from "./helpers/fakedom.ts"; + +let S: typeof import("../src/client/search.ts"); +beforeAll(async () => { + installDom(); + S = await import("../src/client/search.ts"); +}); +afterAll(() => uninstallDom()); + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +const done = () => JSON.stringify({ t: "done", files: 0, matches: 0, skipped: 0, truncated: false, ms: 1 }) + "\n"; + +/** What the server would use for an include value sent by the client: client trim, then parseGlobs. */ +const serverGlobs = (include: string) => parseGlobs(include.trim()); + +describe("folder scope round trip (client folderGlob -> server parseGlobs)", () => { + test("an escaped comma is not a separator and is unescaped for Bun.Glob", () => { + expect(parseGlobs("a\\,b/**,c/**")).toEqual(["a,b/**", "c/**"]); + expect(parseGlobs("a\\,b")).toEqual(["a,b"]); + expect(parseGlobs("x\\\\,y")).toEqual(["x\\\\", "y"]); // escaped backslash, then a real separator + }); + + test("a comma in the folder name scopes literally, not to siblings", () => { + const files = ["a,b/f.ts", "a_b/f.ts", "a-b/f.ts", "a,b/deep/g.ts"]; + expect(filterByGlobs(files, serverGlobs(S.folderGlob("a,b")), [])).toEqual(["a,b/f.ts", "a,b/deep/g.ts"]); + }); + + test("commas next to backslashes and at the edges stay literal", () => { + for (const dir of ["a\\,b", ",lead", "trail,", "x,,y", "p/q,r/s"]) { + const files = [`${dir}/f.ts`, `${dir.replace(/,/g, "_")}/f.ts`, `${dir}x/f.ts`]; + expect(filterByGlobs(files, serverGlobs(S.folderGlob(dir)), [])).toEqual([`${dir}/f.ts`]); + } + }); + + test("leading and trailing whitespace in folder names survives trimming", () => { + for (const dir of [" a", "a ", "\ta", "a\t", " a", " a /b ", "x/ y", "x/y "]) { + const files = [...new Set([`${dir}/f.ts`, `${dir.trim()}/f.ts`])]; + const globs = serverGlobs(S.folderGlob(dir)); + expect(globs.length).toBe(1); + expect(filterByGlobs(files, globs, [])).toEqual([`${dir}/f.ts`]); + } + }); +}); + +describe("SearchView.searchIn requests", () => { + let restore = () => {}; + afterEach(() => restore()); + + const make = (handler?: (url: string) => Response) => { + const r = mockFetch((url) => handler?.(url) ?? new Response(done())); + restore = r.restore; + const v = new S.SearchView({ debounce: 0 }); + const root = v.el as unknown as FakeElement; + const input = root.querySelector(".sv-query")!; + const type = async (q: string) => { + input.value = q; + input.dispatchEvent(new FakeEvent("input")); + await sleep(0); + }; + return { v, root, calls: r.calls, input, type, ignored: root.querySelector(".sv-wide")! }; + }; + const params = (url: string) => new URL(url, "http://x").searchParams; + + test("a git-ignored target folder turns 'Include ignored files' on and the search sends ignored=1", async () => { + const { v, calls, type, ignored } = make(); + await type("foo"); + calls.length = 0; + v.searchIn("dist", true); + await sleep(0); + expect(ignored.getAttribute("aria-pressed")).toBe("true"); + expect(calls.length).toBe(1); + expect(params(calls[0]!.url).get("ignored")).toBe("1"); + expect(params(calls[0]!.url).get("include")).toBe("dist/**"); + }); + + test("an ignored target also enables the toggle when no search runs yet", async () => { + const { v, calls, ignored } = make(); + v.searchIn("build", true); + await sleep(0); + expect(ignored.getAttribute("aria-pressed")).toBe("true"); + expect(calls.length).toBe(0); + }); + + test("revealing the view cannot trigger a search with the old scope: exactly one scoped request", async () => { + const { v, calls, type, input } = make(); + await type("foo"); + v.markStale(["x.ts"], true); // results went stale while the view was hidden + calls.length = 0; + const seen: string[] = []; + let reveals = 0; + v.onReveal = () => { + reveals++; + seen.push((v.el as unknown as FakeElement).querySelector(".sv-include")!.value); + v.onShown(); // what App.showSearch -> setSideView("search") does + }; + v.searchIn("lib"); + await sleep(0); + expect(reveals).toBe(1); + expect(seen).toEqual(["lib/**"]); // the scope is already set when the view is revealed + expect(calls.length).toBe(1); + expect(params(calls[0]!.url).get("include")).toBe("lib/**"); + }); + + test("a scope too long for the server is reported without marking the query invalid", async () => { + const tooLong = (url: string) => + (params(url).get("include") ?? "").length > 1024 + ? new Response(JSON.stringify({ error: "Invalid include/exclude globs" }), { status: 400 }) + : new Response(done()); + const { v, root, calls, input, type } = make(tooLong); + await type("foo"); + v.searchIn(`${"d".repeat(200)}/`.repeat(6) + "x"); + await sleep(5); + const results = root.querySelector(".sv-results")!; + const sent = calls.filter((c) => (params(c.url).get("include") ?? "").length > 1024); + expect(input.classList.contains("invalid")).toBe(false); + expect(sent.length === 0 || /too long|folder/i.test(results.textContent)).toBe(true); + expect(results.textContent).not.toContain("Invalid include/exclude globs"); + }); +}); diff --git a/test/client.search.folder.test.ts b/test/client.search.folder.test.ts index 8de8703..d9a6a83 100644 --- a/test/client.search.folder.test.ts +++ b/test/client.search.folder.test.ts @@ -1,4 +1,5 @@ import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test"; +import { parseGlobs } from "../src/server/search.ts"; import { FakeEvent, installDom, mockFetch, uninstallDom, type FakeElement } from "./helpers/fakedom.ts"; let S: typeof import("../src/client/search.ts"); @@ -31,8 +32,11 @@ describe("folderGlob", () => { test("a comma never splits the include list", () => { const g = S.folderGlob("a,b"); - expect(g).not.toContain(","); - expect(new Bun.Glob(g).match("a,b/f.ts")).toBe(true); + expect(g).toBe("a\\,b/**"); + expect(parseGlobs(g)).toEqual(["a,b/**"]); + const glob = new Bun.Glob(parseGlobs(g)[0]!); + expect(glob.match("a,b/f.ts")).toBe(true); + expect(glob.match("a_b/f.ts")).toBe(false); }); }); From c10cbc7f918b0f7bce1925c4bdeebb159e9e23ae Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Thu, 29 Oct 2026 09:42:19 +0100 Subject: [PATCH 3/3] Release 1.4.0 Search in Folder: right-click a folder in the Explorer to scope search in files to it. The scope is literal (glob metacharacters, commas and edge whitespace escaped; parseGlobs accepts `\,`), turns on ignored files for a .gitignore'd folder, and issues a single scoped request. Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 47ce08e..a137913 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codeviewer", - "version": "1.3.0", + "version": "1.4.0", "description": "A fast, read-only code viewer with a real built-in terminal and live git indicators. Zero dependencies.", "license": "MIT", "author": "21no.de",