Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,14 @@ 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 `<folder>/**`, 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.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/client/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,8 @@ class App {
this.tree = new FileTree(this.info.name);
this.tree.onOpen = (p, pin) => void this.openFile(p, { pin, focus: pin });
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);
Expand Down
35 changes: 35 additions & 0 deletions src/client/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,22 @@ function safe(s: string): string {
);
}

/** 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(/[\\*?[\]{}!,\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;
Expand Down Expand Up @@ -212,6 +222,31 @@ 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, 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);
}

focus() {
this.input.focus();
(this.input as unknown as { select?: () => void }).select?.();
Expand Down
7 changes: 6 additions & 1 deletion src/client/tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export class FileTree {
private changedDirs = new Set<string>();
private frame = 0;
onOpen: (path: string, pin: boolean) => void = () => {};
onSearchIn: (dir: string, ignored: boolean) => void = () => {};
git: GitState | null = null;

constructor(rootName: string) {
Expand Down Expand Up @@ -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, !!this.git?.repo && this.git.dir(n.path) === "I") },
collapse,
]);
} else if (n.type === "file") {
showContextMenu(e, [{ label: "Open in New Tab", run: () => this.onOpen(n.path, true) }]);
}
Expand Down
21 changes: 16 additions & 5 deletions src/server/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
121 changes: 121 additions & 0 deletions test/client.search.folder.scope.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
75 changes: 75 additions & 0 deletions test/client.search.folder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
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");
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).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);
});
});

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/**");
});
});
Loading