From a5a18893c580c52b2dc7d0ba6e2c131a1b1be7f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 21:09:35 +0000 Subject: [PATCH 1/2] Build the explorer (Task 3.3, Req 11.2) Adds the explorer built-in: a lazy-loading directory tree over workspace.fs.readdir/watch, create/rename/delete via showInputBox/ showQuickPick, real .gitignore-aware visibility (batched git check-ignore when available, a glob-matcher fallback otherwise), explorer.showHidden, the explorerFocus context key, and ctrl+shift+e. Along the way: FileSystem gains delete/rename/mkdir; tecode.ui.Tree gains controlled expansion, keyboard nav, and focus tracking; the shared ignore-aware walk now also backs command-palette's ctrl+p. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WELSsojQQL1cTAR5iUUsTK --- bun.lock | 5 + packages/api/src/namespaces.ts | 21 + packages/builtin/command-palette/index.ts | 25 +- .../builtin/explorer/ExplorerView.test.tsx | 182 ++++++ packages/builtin/explorer/ExplorerView.tsx | 129 ++++ packages/builtin/explorer/index.test.ts | 6 - packages/builtin/explorer/index.test.tsx | 602 ++++++++++++++++++ packages/builtin/explorer/index.ts | 335 +++++++++- packages/builtin/explorer/manifest.ts | 100 +++ packages/builtin/explorer/store.test.ts | 270 ++++++++ packages/builtin/explorer/store.ts | 322 ++++++++++ packages/builtin/index.ts | 18 +- packages/builtin/package.json | 7 +- packages/builtin/shared/gitRunner.test.ts | 112 ++++ packages/builtin/shared/gitRunner.ts | 137 ++++ .../builtin/shared/gitignoreMatcher.test.ts | 104 +++ packages/builtin/shared/gitignoreMatcher.ts | 180 ++++++ packages/builtin/shared/ignore.test.ts | 255 +++++++- packages/builtin/shared/ignore.ts | 216 ++++++- packages/builtin/shared/index.ts | 26 +- packages/builtin/shared/walkFiles.test.ts | 15 +- packages/builtin/shared/walkFiles.ts | 47 +- packages/builtin/tsconfig.json | 2 +- packages/cli/src/main.integration.test.ts | 15 +- packages/cli/src/themesPreFirstFrame.test.ts | 1 + packages/core/src/buffer/fileSystem.test.ts | 80 +++ packages/core/src/buffer/fileSystem.ts | 33 +- packages/core/src/ui/components.test.tsx | 305 +++++++++ packages/core/src/ui/components.tsx | 301 +++++++-- packages/core/src/ui/focus.tsx | 20 +- 30 files changed, 3722 insertions(+), 149 deletions(-) create mode 100644 packages/builtin/explorer/ExplorerView.test.tsx create mode 100644 packages/builtin/explorer/ExplorerView.tsx delete mode 100644 packages/builtin/explorer/index.test.ts create mode 100644 packages/builtin/explorer/index.test.tsx create mode 100644 packages/builtin/explorer/manifest.ts create mode 100644 packages/builtin/explorer/store.test.ts create mode 100644 packages/builtin/explorer/store.ts create mode 100644 packages/builtin/shared/gitRunner.test.ts create mode 100644 packages/builtin/shared/gitRunner.ts create mode 100644 packages/builtin/shared/gitignoreMatcher.test.ts create mode 100644 packages/builtin/shared/gitignoreMatcher.ts diff --git a/bun.lock b/bun.lock index f61f9cf..4804ab1 100644 --- a/bun.lock +++ b/bun.lock @@ -20,7 +20,12 @@ "name": "@tecode/builtin", "version": "0.1.0", "dependencies": { + "@opentui/react": "^0.1.107", "@tecode/api": "workspace:*", + "react": "^19.0.0", + }, + "devDependencies": { + "@types/react": "^19.0.0", }, }, "packages/cli": { diff --git a/packages/api/src/namespaces.ts b/packages/api/src/namespaces.ts index 17a39e6..c381844 100644 --- a/packages/api/src/namespaces.ts +++ b/packages/api/src/namespaces.ts @@ -102,6 +102,27 @@ export interface FileSystem { /** Watch a file or directory for changes. Returns a {@link Disposable} * that stops the watch. */ watch(uri: Uri, listener: Listener): Disposable; + /** + * Delete the file or (empty or non-empty) directory at `uri` (Task 3.3, + * Req 11.2 — the explorer's delete command). Rejects on failure (does + * not exist, permission denied) — same never-silently-swallows contract + * as {@link read}/{@link write}; the caller (the explorer built-in) + * surfaces the rejection via `window.showMessage(..., "error")`. + */ + delete(uri: Uri): Promise; + /** + * Rename/move the file or directory at `oldUri` to `newUri` (Task 3.3, + * Req 11.2 — the explorer's rename command). Rejects on failure + * (`oldUri` missing, `newUri` already exists, permission denied) — + * same contract as {@link delete}. + */ + rename(oldUri: Uri, newUri: Uri): Promise; + /** + * Create a new, empty directory at `uri` (Task 3.3, Req 11.2 — the + * explorer's "New Folder" command). Rejects on failure (already exists, + * parent missing, permission denied) — same contract as {@link delete}. + */ + mkdir(uri: Uri): Promise; } /** diff --git a/packages/builtin/command-palette/index.ts b/packages/builtin/command-palette/index.ts index d680e74..51383c2 100644 --- a/packages/builtin/command-palette/index.ts +++ b/packages/builtin/command-palette/index.ts @@ -48,9 +48,14 @@ * `api.workspace.fs.readdir` (Task 3.2's plan: "verify what * `api.workspace.fs` actually exposes" — `@tecode/api`'s `FileSystem. * readdir(uri): Promise`, `namespaces.ts`) rooted at - * `api.workspace.rootUri`, with `createDefaultIgnorer()`'s interim stub - * (`../shared/ignore.ts`, superseded by Task 3.3's real explorer ignore - * logic) and a `maxResults` cap (`QUICK_OPEN_MAX_RESULTS`) that stops the + * `api.workspace.rootUri`, with `../shared/ignore.ts`'s REAL, Task + * 3.3-built `IgnoreChecker` (`createIgnoreChecker({ readFile: + * api.workspace.fs.read, gitRunner: createBunGitRunner() })` — batched + * `git check-ignore` when `git` is available, the root `.gitignore`'s glob + * matcher otherwise; this is the exact "one ignore-aware walk `ctrl+p` and + * the explorer both use" Task 3.3's issue calls for, replacing Task 3.2's + * interim `createDefaultIgnorer()` stub) and a `maxResults` cap + * (`QUICK_OPEN_MAX_RESULTS`) that stops the * traversal outright once hit rather than walking the whole tree first * (code review finding, "bounded workspace scan" — see `../shared/ * walkFiles.ts`'s "Bounded scans"); a cap-truncated walk surfaces a @@ -86,7 +91,7 @@ */ import type { CommandDescriptor, ExtensionContext, QuickPickItem } from "@tecode/api"; -import { createDefaultIgnorer, filterByWhen, fuzzyMatch, walkFiles } from "../shared"; +import { createBunGitRunner, createIgnoreChecker, filterByWhen, fuzzyMatch, walkFiles } from "../shared"; import { QUICK_OPEN_COMMAND_ID, SHOW_COMMANDS_COMMAND_ID } from "./manifest"; /** Cap on how many files {@link registerQuickOpen}'s workspace walk collects @@ -142,6 +147,16 @@ function registerShowCommands(ctx: ExtensionContext): void { /** Registers `workbench.action.quickOpen` (this module's TSDoc). */ function registerQuickOpen(ctx: ExtensionContext): void { const { api } = ctx; + // Built ONCE, when the extension activates, not per keystroke/invocation: + // `IgnoreChecker`'s own `git`-availability check and root-`.gitignore` + // parse are each cached internally per instance (`ignore.ts`'s TSDoc), so + // reusing this one instance across every `ctrl+p` in the session avoids + // re-spawning `git --version` and re-reading `.gitignore` on every open. + const ignore = createIgnoreChecker({ + readFile: (uri) => api.workspace.fs.read(uri), + gitRunner: createBunGitRunner(), + }); + ctx.subscriptions.push( api.commands.register(QUICK_OPEN_COMMAND_ID, async () => { const rootUri = api.workspace.rootUri; @@ -152,7 +167,7 @@ function registerQuickOpen(ctx: ExtensionContext): void { const { files, truncated } = await walkFiles(rootUri, { readdir: (uri) => api.workspace.fs.readdir(uri), - ignore: createDefaultIgnorer(), + ignore, maxResults: QUICK_OPEN_MAX_RESULTS, }); if (files.length === 0) { diff --git a/packages/builtin/explorer/ExplorerView.test.tsx b/packages/builtin/explorer/ExplorerView.test.tsx new file mode 100644 index 0000000..670f3d8 --- /dev/null +++ b/packages/builtin/explorer/ExplorerView.test.tsx @@ -0,0 +1,182 @@ +/** + * Tests for {@link ExplorerView} (Task 3.3, Req 11.2) — a fake `Tree` + * component stands in for `@tecode/core`'s real one (this built-in has no + * compile-time or runtime dependency on it, `ExplorerView.tsx`'s TSDoc), + * proving the PROP WIRING (`nodes`/`selectedId`/`expandedIds`/ + * `focusContextKey`/`onSelect`/`onToggle`/`onActivate`) rather than + * `tecode.ui.Tree`'s own rendering (already covered by `@tecode/core`'s + * `components.test.tsx`). + */ + +import { describe, expect, test } from "bun:test"; +import { act, type ReactNode } from "react"; +import { testRender } from "@opentui/react/test-utils"; +import type { Tecode, Uri } from "@tecode/api"; +import { createIgnoreChecker } from "../shared"; +import { createExplorerStore } from "./store"; +import { EXPLORER_FOCUS_CONTEXT_KEY, ExplorerView } from "./ExplorerView"; + +/** A minimal fake `tecode.ui.Tree` — captures the last props it was + * rendered with (for assertion) and renders each node's label as text, + * enough to prove `ExplorerView` passes the right data through without + * reimplementing the real `Tree`'s rendering/keyboard logic. */ +function createFakeTree(): { Tree: Tecode["ui"]["Tree"]; lastProps: () => Record | undefined } { + let captured: Record | undefined; + const Tree = ((rawProps: Record) => { + captured = rawProps; + const nodes = (rawProps["nodes"] as Array<{ id: string; label: string }> | undefined) ?? []; + return {nodes.map((n) => {n.label})} as unknown as ReactNode; + }) as unknown as Tecode["ui"]["Tree"]; + return { Tree, lastProps: () => captured }; +} + +type FakeTree = { [name: string]: FakeTree | null }; +const ROOT: Uri = "file:///workspace/"; + +function createStore(tree: FakeTree, rootUri: Uri | undefined) { + return createExplorerStore(rootUri, { + readdir: async (uri) => { + const relative = uri.replace(ROOT, "").replace(/\/$/, ""); + const segments = relative.length > 0 ? relative.split("/") : []; + let node: FakeTree = tree; + for (const segment of segments) { + const next = node[decodeURIComponent(segment)]; + if (next === null || next === undefined) throw new Error("ENOENT"); + node = next; + } + return Object.entries(node).map(([name, value]) => ({ + name, + type: (value === null ? "file" : "directory") as "file" | "directory", + })); + }, + ignore: createIgnoreChecker(), + showMessage: () => {}, + showHidden: false, + }); +} + +describe("ExplorerView (Task 3.3, Req 11.2)", () => { + test("shows 'No folder is open.' when the store has no rootUri", async () => { + const store = createStore({}, undefined); + const { Tree } = createFakeTree(); + const { renderOnce, captureCharFrame } = await testRender( + {}} />, + { width: 30, height: 5 }, + ); + await renderOnce(); + expect(captureCharFrame()).toContain("No folder is open."); + }); + + test("shows '(empty)' before the root has loaded", async () => { + const store = createStore({}, ROOT); + const { Tree } = createFakeTree(); + const { renderOnce, captureCharFrame } = await testRender( + {}} />, + { width: 30, height: 5 }, + ); + await renderOnce(); + expect(captureCharFrame()).toContain("(empty)"); + }); + + test("renders the store's nodes once loaded, and re-renders on store changes", async () => { + const store = createStore({ "a.ts": null }, ROOT); + const { Tree } = createFakeTree(); + const { renderOnce, captureCharFrame } = await testRender( + {}} />, + { width: 30, height: 5 }, + ); + await renderOnce(); + expect(captureCharFrame()).toContain("(empty)"); + + await act(async () => { + await store.reload(ROOT); + }); + await renderOnce(); + expect(captureCharFrame()).toContain("a.ts"); + }); + + test("passes selectedId, expandedIds, and focusContextKey through to Tree", async () => { + const store = createStore({ src: { "a.ts": null } }, ROOT); + await store.reload(ROOT); + store.setSelectedId("file:///workspace/src" as Uri); + store.toggle("file:///workspace/src" as Uri, true); + await new Promise((r) => setTimeout(r, 10)); // let the toggle's own reload settle + + const { Tree, lastProps } = createFakeTree(); + const { renderOnce } = await testRender( + {}} />, + { width: 30, height: 5 }, + ); + await renderOnce(); + + expect(lastProps()?.["selectedId"]).toBe("file:///workspace/src"); + expect(lastProps()?.["expandedIds"]).toEqual(["file:///workspace/src"]); + expect(lastProps()?.["focusContextKey"]).toBe(EXPLORER_FOCUS_CONTEXT_KEY); + }); + + test("onSelect from Tree updates the store's selection", async () => { + const store = createStore({ "a.ts": null }, ROOT); + await store.reload(ROOT); + const { Tree, lastProps } = createFakeTree(); + const { renderOnce } = await testRender( + {}} />, + { width: 30, height: 5 }, + ); + await renderOnce(); + + act(() => { + (lastProps()?.["onSelect"] as (id: string) => void)("file:///workspace/a.ts"); + }); + expect(store.getSelectedId()).toBe("file:///workspace/a.ts"); + }); + + test("onToggle from Tree toggles the store's expansion", async () => { + const store = createStore({ src: { "a.ts": null } }, ROOT); + await store.reload(ROOT); + const { Tree, lastProps } = createFakeTree(); + const { renderOnce } = await testRender( + {}} />, + { width: 30, height: 5 }, + ); + await renderOnce(); + + act(() => { + (lastProps()?.["onToggle"] as (id: string, expanding: boolean) => void)("file:///workspace/src", true); + }); + expect(store.getExpandedIds()).toEqual(["file:///workspace/src"]); + }); + + test("onActivate on a FILE node calls onOpenFile with its uri", async () => { + const store = createStore({ "a.ts": null }, ROOT); + await store.reload(ROOT); + const opened: string[] = []; + const { Tree, lastProps } = createFakeTree(); + const { renderOnce } = await testRender( + opened.push(uri)} />, + { width: 30, height: 5 }, + ); + await renderOnce(); + + act(() => { + (lastProps()?.["onActivate"] as (id: string) => void)("file:///workspace/a.ts"); + }); + expect(opened).toEqual(["file:///workspace/a.ts"]); + }); + + test("onActivate on a DIRECTORY node does NOT call onOpenFile", async () => { + const store = createStore({ src: { "a.ts": null } }, ROOT); + await store.reload(ROOT); + const opened: string[] = []; + const { Tree, lastProps } = createFakeTree(); + const { renderOnce } = await testRender( + opened.push(uri)} />, + { width: 30, height: 5 }, + ); + await renderOnce(); + + act(() => { + (lastProps()?.["onActivate"] as (id: string) => void)("file:///workspace/src"); + }); + expect(opened).toEqual([]); + }); +}); diff --git a/packages/builtin/explorer/ExplorerView.tsx b/packages/builtin/explorer/ExplorerView.tsx new file mode 100644 index 0000000..bee4a41 --- /dev/null +++ b/packages/builtin/explorer/ExplorerView.tsx @@ -0,0 +1,129 @@ +/** + * `ExplorerView` — the React component `index.ts` registers into + * `"sidebar.view"` under `manifest.ts`'s `EXPLORER_VIEW_ID` (Task 3.3, Req + * 11.2; design.md §13's `explorer` design). A thin render of an + * {@link ExplorerStore} over `tecode.ui.Tree`, with + * `focusContextKey="explorerFocus"` (Req 4.6, 11.2) — everything else + * (loading, mutation, `fs.watch`) lives in the store/`index.ts`, not here. + * + * **Bridging `tecode.ui.Tree`'s `ComponentType` into a real JSX element**: + * `@tecode/api`'s `UiNamespace.Tree` is typed as the bare, React-free + * `ComponentType = (props: Record) => unknown` + * (`namespaces.ts`'s TSDoc) — this built-in has no dependency on + * `@tecode/core`'s REAL `Tree` implementation, only on the interface. The + * exact same cast `@tecode/core`'s OWN `components.tsx`'s + * `RegisteredView` uses (`const Component = props.component as unknown as + * (p: Record) => ReactNode`) is applied once here, at + * module scope, so every render just writes plain, ergonomic JSX + * (``) rather than re-casting inline. + * + * **Why THIS built-in needs `react`/`@opentui/react` as real + * dependencies** (checked against `eslint.config.mjs`: only importing + * `@tecode/core` is blocked, not `react`/`@opentui/react` themselves) — + * `packages/builtin`'s `package.json`/`tsconfig.json` were extended for + * this task (Task 3.3's plan: "builtin's package.json may need react") + * since no other built-in has registered a `tecode.ui` view before this + * one; the root `tsconfig.json`'s `jsx: "react-jsx"` / + * `jsxImportSource: "@opentui/react"` already applies repo-wide, so this + * package just needed `**\/*.tsx` added to its own `tsconfig.json`'s + * `include` to be picked up at all. + */ + +import { useEffect, useReducer, type ReactNode } from "react"; +import type { ComponentType, Tecode } from "@tecode/api"; +import type { ExplorerStore } from "./store"; + +/** The loose shape `tecode.ui.Tree` actually renders (this module's + * TSDoc) — duck-typed against `@tecode/core`'s real `TreeProps`, never + * imported (the layering rule). */ +type TreeComponentProps = Record & { + nodes?: unknown[]; + selectedId?: string; + expandedIds?: string[]; + onSelect?: (id: string) => void; + onToggle?: (id: string, expanding: boolean) => void; + onActivate?: (id: string) => void; + focusContextKey?: string; +}; + +/** Props for {@link ExplorerView}. */ +export interface ExplorerViewProps { + store: ExplorerStore; + /** `tecode.ui.Tree` itself (`ctx.api.ui.Tree`) — injected rather than + * imported so this component has zero compile-time dependency on + * `@tecode/core` (matches every other `packages/builtin/**` module's + * "only `@tecode/api`" discipline). */ + Tree: Tecode["ui"]["Tree"]; + /** Called when the user activates (Enter, or a mouse click on) a FILE + * node — `index.ts` wires this to `workbench.action.files.openUri`. Not + * called for a directory node (Tree's own `return`/click toggles its + * expansion instead — see `components.tsx`'s `onActivate` TSDoc). */ + onOpenFile: (uri: string) => void; +} + +/** `explorerFocus` (Req 4.6, 11.2) — `tecode.ui.Tree`'s own + * `focusContextKey` prop reports into it via `@tecode/core`'s + * `useFocusTracking` (`components.tsx`), which is what a `when: + * "explorerFocus"` keybinding elsewhere would gate on. Exported so + * `index.ts`/tests reference the same string. */ +export const EXPLORER_FOCUS_CONTEXT_KEY = "explorerFocus"; + +/** + * Renders `store`'s current tree state (Task 3.3). Subscribes to `store. + * onDidChange` and force-re-renders on every mutation — the same + * "subscribe + force-render, with an unconditional extra render right + * after subscribing to close the subscribe-after-render race" shape + * `@tecode/core`'s `ui/modalOverlay.tsx`'s `ModalOverlay` already uses for + * its own external store (that module's TSDoc explains the race in full). + */ +export function ExplorerView(props: ExplorerViewProps): ReactNode { + const { store } = props; + const TreeComponent = props.Tree as unknown as (p: TreeComponentProps) => ReactNode; + + const [, forceRender] = useReducer((n: number) => n + 1, 0); + useEffect(() => { + const sub = store.onDidChange(() => forceRender()); + forceRender(); + return () => sub.dispose(); + }, [store]); + + const rootUri = store.getRootUri(); + if (!rootUri) { + return {"No folder is open."}; + } + + const nodes = store.getNodes(); + if (nodes.length === 0) { + return {"(empty)"}; + } + + return ( + store.setSelectedId(id)} + onToggle={(id, expanding) => store.toggle(id, expanding)} + onActivate={(id) => { + if (store.isDirectory(id)) return; // Tree's own Enter/click already toggled it. + props.onOpenFile(id); + }} + /> + ); +} + +/** + * Wrap {@link ExplorerView} as a plain `tecode.ui.registerView`-compatible + * {@link ComponentType} (`(props: Record) => unknown`, + * `@tecode/api`'s `namespaces.ts`) — `index.ts` stays a `.ts` file with no + * JSX of its own; this is the one place that bridges `ExplorerViewProps` + * (a real, narrow prop type) into the loose shape `registerView` expects, + * closing over `props` (built once, in `index.ts`'s `activate(ctx)`) so + * every render always sees the SAME `store`/`Tree`/`onOpenFile` regardless + * of whatever the caller (`shell.tsx`'s `Sidebar`, which passes none for a + * `sidebar.view`) hands it as its own component props. + */ +export function createExplorerViewComponent(props: ExplorerViewProps): ComponentType { + return () => ; +} diff --git a/packages/builtin/explorer/index.test.ts b/packages/builtin/explorer/index.test.ts deleted file mode 100644 index 581209a..0000000 --- a/packages/builtin/explorer/index.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { expect, test } from "bun:test"; -import { EXPLORER_PLACEHOLDER } from "./index"; - -test("placeholder", () => { - expect(EXPLORER_PLACEHOLDER).toBe(true); -}); diff --git a/packages/builtin/explorer/index.test.tsx b/packages/builtin/explorer/index.test.tsx new file mode 100644 index 0000000..bdfe45a --- /dev/null +++ b/packages/builtin/explorer/index.test.tsx @@ -0,0 +1,602 @@ +/** + * Integration tests for `explorer`'s `activate(ctx)` (Task 3.3, Req 11.2) + * — replaces the Task 3.2-era placeholder. A minimal fake `Tecode` (local + * to this file, `@tecode/api` types only, matching `command-palette/ + * index.test.ts`'s `createFakeApi` house convention) stands in for the + * real core, EXCEPT `workspace.fs`, which is backed by REAL `node:fs/ + * promises` calls against a real temp directory (this task's completion + * requirement: "create/rename/delete against a temp dir") — `packages/ + * builtin/**` may not import `@tecode/core` even in its own tests (this + * package's other `index.test.ts` files' own precedent), so this is a + * small, local reimplementation of `@tecode/api`'s `FileSystem` contract + * over real `node:fs`/`node:url`, not `@tecode/core`'s own + * `buffer/fileSystem.ts`. + * + * **A `.tsx` file, not `.ts`**: several tests need a currently-SELECTED + * node (rename/delete operate on `store.getSelectedId()`) — the only way + * to set that from outside `activate`'s own closure is through the + * registered view's `tecode.ui.Tree`'s `onSelect` prop, so this suite's + * fake `ui.Tree` captures its own props (mirrors `ExplorerView.test.tsx`'s + * `createFakeTree`) and `mountView`/`select` below actually mount the + * registered component with `@opentui/react/test-utils`'s `testRender` to + * reach it. + */ + +import { afterEach, describe, expect, test } from "bun:test"; +import { watch as nodeWatchFs, statSync } from "node:fs"; +import { + mkdir as nodeMkdir, + mkdtemp, + readFile as nodeReadFile, + readdir as nodeReaddir, + rename as nodeRename, + rm, + stat as nodeStat, + writeFile as nodeWriteFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { act, type ReactNode } from "react"; +import { testRender } from "@opentui/react/test-utils"; +import type { + CommandHandler, + ComponentType, + ConfigChangeEvent, + DirEntry, + Disposable, + ExtensionContext, + FileChangeEvent, + FileType, + InputBoxOptions, + Listener, + MessageKind, + QuickPickItem, + QuickPickOptions, + Tecode, + Uri, +} from "@tecode/api"; +import { activate } from "./index"; +import { + EXPLORER_DELETE_COMMAND_ID, + EXPLORER_FOCUS_COMMAND_ID, + EXPLORER_NEW_FILE_COMMAND_ID, + EXPLORER_NEW_FOLDER_COMMAND_ID, + EXPLORER_RENAME_COMMAND_ID, + EXPLORER_SHOW_HIDDEN_CONFIG_KEY, + EXPLORER_VIEW_ID, +} from "./manifest"; + +function uriToPath(uri: Uri): string { + return fileURLToPath(uri); +} +function pathToUri(path: string): Uri { + return pathToFileURL(path).href; +} + +/** Waits for `predicate` to become true, polling — real `fs.watch` + * delivery is not synchronous (matches `@tecode/core`'s `fileSystem. + * test.ts`'s own `waitFor`). */ +async function waitFor(predicate: () => boolean | Promise, timeoutMs = 5000): Promise { + const start = Date.now(); + while (!(await predicate())) { + if (Date.now() - start > timeoutMs) throw new Error("waitFor: timed out"); + await new Promise((resolve) => setTimeout(resolve, 20)); + } +} + +function classify(entry: { isDirectory(): boolean; isFile(): boolean }): FileType { + if (entry.isDirectory()) return "directory"; + if (entry.isFile()) return "file"; + return "unknown"; +} + +/** A REAL-filesystem-backed `FileSystem` (this module's TSDoc) — every + * method is a thin `node:fs/promises` pass-through, mirroring (but not + * importing) `@tecode/core`'s own `buffer/fileSystem.ts`. */ +function createRealFs(): Tecode["workspace"]["fs"] { + return { + async read(uri) { + return nodeReadFile(uriToPath(uri)); + }, + async write(uri, content) { + await nodeWriteFile(uriToPath(uri), content); + }, + async stat(uri) { + const s = await nodeStat(uriToPath(uri)); + return { type: classify(s), size: s.size, mtime: s.mtimeMs, ctime: s.ctimeMs }; + }, + async readdir(uri) { + const entries = await nodeReaddir(uriToPath(uri), { withFileTypes: true }); + return entries.map((entry): DirEntry => ({ name: entry.name, type: classify(entry) })); + }, + watch(uri, listener: Listener): Disposable { + const path = uriToPath(uri); + let isDirectory = false; + try { + isDirectory = statSync(path).isDirectory(); + } catch { + // Matches `fileSystem.ts`'s own fallback. + } + let disposed = false; + let watcher: ReturnType; + try { + watcher = nodeWatchFs(path, (eventType, filename) => { + if (disposed) return; + const name = typeof filename === "string" ? filename : undefined; + const affectedPath = isDirectory && name ? join(path, name) : path; + listener({ type: eventType === "change" ? "changed" : "created", uri: pathToUri(affectedPath) }); + }); + } catch { + // Matches the real `FileSystem.watch`'s contract (`fileSystem. + // ts`'s TSDoc): a setup failure (e.g. the path does not exist) + // degrades to a no-op disposable rather than throwing. + return { dispose() {} }; + } + return { + dispose() { + if (disposed) return; + disposed = true; + watcher.close(); + }, + }; + }, + async delete(uri) { + await rm(uriToPath(uri), { recursive: true }); + }, + async rename(oldUri, newUri) { + await nodeRename(uriToPath(oldUri), uriToPath(newUri)); + }, + async mkdir(uri) { + await nodeMkdir(uriToPath(uri)); + }, + }; +} + +/** A minimal fake `Tecode` (this module's TSDoc) — real `fs` (above), a + * capturing fake `ui.Tree` (this module's TSDoc's "A `.tsx` file"), and an + * in-memory fake for everything else `explorer`'s `activate` touches. */ +function createFakeApi(rootUri: Uri | undefined) { + const commandHandlers = new Map(); + const registeredViews = new Map(); + const messages: Array<{ message: string; kind?: MessageKind }> = []; + const configValues = new Map([[EXPLORER_SHOW_HIDDEN_CONFIG_KEY, false]]); + const configListeners = new Set>(); + let nextInputValue: string | undefined; + let nextPick: QuickPickItem | undefined; + let lastQuickPickOptions: QuickPickOptions | undefined; + let lastValidateInput: ((value: string) => string | undefined) | undefined; + let lastTreeProps: Record | undefined; + + const commands: Tecode["commands"] = { + register(id, handler) { + commandHandlers.set(id, handler); + return { dispose: () => commandHandlers.delete(id) }; + }, + async execute(id, ...args) { + const handler = commandHandlers.get(id); + if (!handler) return undefined; + return handler(...args); + }, + list: () => [], + }; + + // Renders each node's label as plain text (unlike a "capture-only" fake + // that returns `null`) so tests that poll `captureCharFrame()` for a + // filename (the watch-driven-refresh and `showHidden` suites below) can + // actually observe the tree's current contents, not just its props. + const Tree = ((rawProps: Record) => { + lastTreeProps = rawProps; + const nodes = (rawProps["nodes"] as Array<{ id: string; label: string }> | undefined) ?? []; + return ( + + {nodes.map((n) => ( + {n.label} + ))} + + ); + }) as unknown as Tecode["ui"]["Tree"]; + + const api: Tecode = { + commands, + workspace: { + rootUri, + fs: createRealFs(), + openDocument: async () => { + throw new Error("not implemented in this fake"); + }, + documents: [], + onDidOpen: () => ({ dispose() {} }), + onDidClose: () => ({ dispose() {} }), + onDidSave: () => ({ dispose() {} }), + save: async () => {}, + } as unknown as Tecode["workspace"], + window: { + activeEditor: undefined, + showMessage(message: string, kind?: MessageKind) { + messages.push({ message, kind }); + }, + async showQuickPick(items: QuickPickItem[], options?: QuickPickOptions) { + lastQuickPickOptions = options; + void items; + return nextPick; + }, + async showInputBox(options?: InputBoxOptions) { + lastValidateInput = options?.validateInput; + return nextInputValue; + }, + setStatusBarItem: () => ({ dispose() {} }), + } as unknown as Tecode["window"], + editor: undefined as never, + ui: { + registerView: (_slot, id, component) => { + if (component) registeredViews.set(id, component); + return { dispose: () => registeredViews.delete(id) }; + }, + useTheme: undefined as never, + List: undefined as never, + Tree, + Input: undefined as never, + Tabs: undefined as never, + }, + config: { + get: (key: string) => configValues.get(key) as T | undefined, + onDidChange: (listener: Listener) => { + configListeners.add(listener); + return { dispose: () => configListeners.delete(listener) }; + }, + }, + context: { + get: () => undefined, + set: () => {}, + }, + languages: undefined as never, + themes: undefined as never, + }; + + return { + api, + getMessages: () => messages, + getRegisteredView: () => registeredViews.get(EXPLORER_VIEW_ID), + setNextInputValue: (value: string | undefined) => (nextInputValue = value), + setNextPick: (pick: QuickPickItem | undefined) => (nextPick = pick), + getLastQuickPickOptions: () => lastQuickPickOptions, + getLastValidateInput: () => lastValidateInput, + getLastTreeProps: () => lastTreeProps, + setConfig: (key: string, value: unknown) => { + configValues.set(key, value); + for (const listener of configListeners) listener({ affectsConfiguration: (k) => k === key }); + }, + }; +} + +function createFixture(rootUri: Uri | undefined) { + const fake = createFakeApi(rootUri); + const subscriptions: Disposable[] = []; + const ctx: ExtensionContext = { + api: fake.api, + extensionUri: rootUri ?? ("file:///nowhere/" as Uri), + subscriptions, + storagePath: "/tmp/tecode-explorer-test-storage", + }; + activate(ctx); + return { + ...fake, + dispose: () => { + for (const sub of subscriptions.reverse()) sub.dispose(); + }, + }; +} + +/** Mount the registered view once (so its `tecode.ui.Tree` props get + * captured — this module's TSDoc) and select `uri` through the captured + * `onSelect` callback. The mount is disposed immediately after — selection + * itself lives in `explorer`'s own store closure, not in this render, so + * it survives the unmount (`store.ts`'s TSDoc). */ +async function selectViaTree( + fixture: ReturnType, + uri: string, +): Promise { + const Component = fixture.getRegisteredView() as unknown as (props: Record) => ReactNode; + const { renderOnce } = await testRender(, { width: 30, height: 10 }); + await renderOnce(); + const onSelect = fixture.getLastTreeProps()?.["onSelect"] as ((id: string) => void) | undefined; + act(() => onSelect?.(uri)); +} + +describe("explorer activate() (Task 3.3, Req 11.2)", () => { + let dir: string | undefined; + + afterEach(async () => { + if (dir) await rm(dir, { recursive: true, force: true }); + dir = undefined; + }); + + test("registers the sidebar view under EXPLORER_VIEW_ID", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-explorer-")); + const fixture = createFixture(pathToUri(dir)); + expect(fixture.getRegisteredView()).toBeDefined(); + fixture.dispose(); + }); + + test("explorer.focus delegates to workbench.view.explorer", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-explorer-")); + const fixture = createFixture(pathToUri(dir)); + let focusedViewCalled = false; + fixture.api.commands.register(`workbench.view.${EXPLORER_VIEW_ID}`, () => { + focusedViewCalled = true; + }); + await fixture.api.commands.execute(EXPLORER_FOCUS_COMMAND_ID); + expect(focusedViewCalled).toBe(true); + fixture.dispose(); + }); + + describe("explorer.newFile", () => { + test("creates an empty file at the workspace root and surfaces no error", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-explorer-")); + const fixture = createFixture(pathToUri(dir)); + fixture.setNextInputValue("new-file.ts"); + + await fixture.api.commands.execute(EXPLORER_NEW_FILE_COMMAND_ID); + await waitFor(() => { + try { + statSync(join(dir!, "new-file.ts")); + return true; + } catch { + return false; + } + }); + + expect(fixture.getMessages().filter((m) => m.kind === "error")).toEqual([]); + fixture.dispose(); + }); + + test("cancelling the input box (undefined) creates nothing", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-explorer-")); + const fixture = createFixture(pathToUri(dir)); + fixture.setNextInputValue(undefined); + + await fixture.api.commands.execute(EXPLORER_NEW_FILE_COMMAND_ID); + + const entries = await nodeReaddir(dir); + expect(entries).toEqual([]); + fixture.dispose(); + }); + + test("validateInput rejects an empty name and accepts a normal one", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-explorer-")); + const fixture = createFixture(pathToUri(dir)); + fixture.setNextInputValue(undefined); + + await fixture.api.commands.execute(EXPLORER_NEW_FILE_COMMAND_ID); + + expect(fixture.getLastValidateInput()?.("")).toBeDefined(); + expect(fixture.getLastValidateInput()?.("ok.ts")).toBeUndefined(); + fixture.dispose(); + }); + + test("validateInput rejects a name already present at the target directory", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-explorer-")); + await nodeWriteFile(join(dir, "existing.ts"), ""); + const fixture = createFixture(pathToUri(dir)); + await waitFor(async () => (await nodeReaddir(dir!)).length > 0); + await new Promise((r) => setTimeout(r, 50)); // let the store's own async reload settle + + fixture.setNextInputValue(undefined); + await fixture.api.commands.execute(EXPLORER_NEW_FILE_COMMAND_ID); + + expect(fixture.getLastValidateInput()?.("existing.ts")).toBeDefined(); + fixture.dispose(); + }); + + test("a write failure (nonexistent target directory) surfaces via showMessage error", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-explorer-")); + // rootUri points somewhere that does not exist on disk at all — + // `resolveTargetDirectory()` still resolves to it (nothing + // selected), so the real `fs.write` call itself fails. + const missingRoot = pathToUri(join(dir, "does-not-exist")); + const fixture = createFixture(missingRoot); + fixture.setNextInputValue("file.ts"); + + await fixture.api.commands.execute(EXPLORER_NEW_FILE_COMMAND_ID); + + expect(fixture.getMessages().some((m) => m.kind === "error")).toBe(true); + fixture.dispose(); + }); + + test("no folder open (rootUri undefined) shows an info message instead of crashing", async () => { + const fixture = createFixture(undefined); + fixture.setNextInputValue("file.ts"); + + await fixture.api.commands.execute(EXPLORER_NEW_FILE_COMMAND_ID); + + expect(fixture.getMessages().some((m) => m.kind === "info")).toBe(true); + fixture.dispose(); + }); + }); + + test("explorer.newFolder creates a real directory", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-explorer-")); + const fixture = createFixture(pathToUri(dir)); + fixture.setNextInputValue("new-dir"); + + await fixture.api.commands.execute(EXPLORER_NEW_FOLDER_COMMAND_ID); + await waitFor(() => { + try { + return statSync(join(dir!, "new-dir")).isDirectory(); + } catch { + return false; + } + }); + fixture.dispose(); + }); + + describe("explorer.rename", () => { + test("no selection shows an info message", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-explorer-")); + const fixture = createFixture(pathToUri(dir)); + await fixture.api.commands.execute(EXPLORER_RENAME_COMMAND_ID); + expect(fixture.getMessages().some((m) => m.kind === "info")).toBe(true); + fixture.dispose(); + }); + + test("renames a real file on disk and updates the selection to the new uri", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-explorer-")); + await nodeWriteFile(join(dir, "old.ts"), "content"); + const fixture = createFixture(pathToUri(dir)); + await waitFor(async () => (await nodeReaddir(dir!)).length > 0); + await new Promise((r) => setTimeout(r, 50)); + + await selectViaTree(fixture, pathToUri(join(dir, "old.ts"))); + fixture.setNextInputValue("new.ts"); + + await fixture.api.commands.execute(EXPLORER_RENAME_COMMAND_ID); + await waitFor(() => { + try { + statSync(join(dir!, "new.ts")); + return true; + } catch { + return false; + } + }); + + expect(fixture.getMessages().filter((m) => m.kind === "error")).toEqual([]); + const remaining = await nodeReaddir(dir); + expect(remaining).toEqual(["new.ts"]); + fixture.dispose(); + }); + + test("a rename failure (the file vanished underneath) surfaces via showMessage error", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-explorer-")); + const filePath = join(dir, "vanishing.ts"); + await nodeWriteFile(filePath, ""); + const fixture = createFixture(pathToUri(dir)); + await waitFor(async () => (await nodeReaddir(dir!)).length > 0); + await new Promise((r) => setTimeout(r, 50)); + + await selectViaTree(fixture, pathToUri(filePath)); + await rm(filePath); // the store's cache is now stale — the real rename call must fail + fixture.setNextInputValue("renamed.ts"); + + await fixture.api.commands.execute(EXPLORER_RENAME_COMMAND_ID); + + expect(fixture.getMessages().some((m) => m.kind === "error")).toBe(true); + fixture.dispose(); + }); + }); + + describe("explorer.delete", () => { + test("no selection shows an info message", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-explorer-")); + const fixture = createFixture(pathToUri(dir)); + await fixture.api.commands.execute(EXPLORER_DELETE_COMMAND_ID); + expect(fixture.getMessages().some((m) => m.kind === "info")).toBe(true); + fixture.dispose(); + }); + + test("confirming deletes the real file on disk", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-explorer-")); + const filePath = join(dir, "doomed.ts"); + await nodeWriteFile(filePath, ""); + const fixture = createFixture(pathToUri(dir)); + await waitFor(async () => (await nodeReaddir(dir!)).length > 0); + await new Promise((r) => setTimeout(r, 50)); + + await selectViaTree(fixture, pathToUri(filePath)); + fixture.setNextPick({ label: "Delete", description: "confirm" }); + + await fixture.api.commands.execute(EXPLORER_DELETE_COMMAND_ID); + await waitFor(async () => !(await nodeReaddir(dir!)).includes("doomed.ts")); + + expect(fixture.getLastQuickPickOptions()?.placeHolder).toContain("doomed.ts"); + fixture.dispose(); + }); + + test("cancelling leaves the file untouched", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-explorer-")); + const filePath = join(dir, "safe.ts"); + await nodeWriteFile(filePath, ""); + const fixture = createFixture(pathToUri(dir)); + await waitFor(async () => (await nodeReaddir(dir!)).length > 0); + await new Promise((r) => setTimeout(r, 50)); + + await selectViaTree(fixture, pathToUri(filePath)); + fixture.setNextPick({ label: "Cancel", description: "cancel" }); + + await fixture.api.commands.execute(EXPLORER_DELETE_COMMAND_ID); + + const entries = await nodeReaddir(dir); + expect(entries).toContain("safe.ts"); + fixture.dispose(); + }); + + test("a delete failure (already gone) surfaces via showMessage error", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-explorer-")); + const filePath = join(dir, "already-gone.ts"); + await nodeWriteFile(filePath, ""); + const fixture = createFixture(pathToUri(dir)); + await waitFor(async () => (await nodeReaddir(dir!)).length > 0); + await new Promise((r) => setTimeout(r, 50)); + + await selectViaTree(fixture, pathToUri(filePath)); + await rm(filePath); // stale cache, as above + fixture.setNextPick({ label: "Delete", description: "confirm" }); + + await fixture.api.commands.execute(EXPLORER_DELETE_COMMAND_ID); + + expect(fixture.getMessages().some((m) => m.kind === "error")).toBe(true); + fixture.dispose(); + }); + }); + + describe("watch-driven refresh (Task 3.3, Req 11.2)", () => { + test("an externally created file is picked up and shown without any command being run", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-explorer-")); + const fixture = createFixture(pathToUri(dir)); + + await nodeWriteFile(join(dir, "external.ts"), ""); + + const Component = fixture.getRegisteredView() as unknown as (props: Record) => ReactNode; + const { renderOnce, captureCharFrame } = await testRender(, { width: 30, height: 10 }); + await waitFor(async () => { + await act(async () => { + await renderOnce(); + }); + return captureCharFrame().includes("external.ts"); + }); + + expect(fixture.getMessages().filter((m) => m.kind === "error")).toEqual([]); + fixture.dispose(); + }); + }); + + describe("explorer.showHidden (Req 9.5)", () => { + test("toggling the setting live reveals dotfiles without a restart", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-explorer-")); + await nodeWriteFile(join(dir, ".env"), ""); + await nodeWriteFile(join(dir, "visible.ts"), ""); + const fixture = createFixture(pathToUri(dir)); + + const Component = fixture.getRegisteredView() as unknown as (props: Record) => ReactNode; + const { renderOnce, captureCharFrame } = await testRender(, { width: 30, height: 10 }); + await waitFor(async () => { + await act(async () => { + await renderOnce(); + }); + return captureCharFrame().includes("visible.ts"); + }); + expect(captureCharFrame()).not.toContain(".env"); + + act(() => fixture.setConfig(EXPLORER_SHOW_HIDDEN_CONFIG_KEY, true)); + await waitFor(async () => { + await act(async () => { + await renderOnce(); + }); + return captureCharFrame().includes(".env"); + }); + + expect(fixture.getMessages().filter((m) => m.kind === "error")).toEqual([]); + fixture.dispose(); + }); + }); +}); diff --git a/packages/builtin/explorer/index.ts b/packages/builtin/explorer/index.ts index 75d55ce..3296c3c 100644 --- a/packages/builtin/explorer/index.ts +++ b/packages/builtin/explorer/index.ts @@ -1,2 +1,333 @@ -// Placeholder for the explorer built-in extension. -export const EXPLORER_PLACEHOLDER = true; +/** + * `explorer`'s `activate(ctx)`/`deactivate()` (Task 3.3, Req 11.2; + * design.md §13's `explorer` design). Builds one {@link ExplorerStore} per + * activation (`./store.ts`), registers `ExplorerView` into `"sidebar.view"` + * (`./ExplorerView.tsx`), subscribes `workspace.fs.watch` to keep the tree + * live, and implements the five commands `manifest.ts` declares + * (`focus`/`newFile`/`newFolder`/`rename`/`delete`) over `showInputBox`/ + * `showQuickPick`. Only imports `@tecode/api` plus this package's own + * local `./store`/`./ExplorerView`/`../shared` files (the ESLint layering + * rule) — every read/write goes through `ctx.api`. + * + * **`workspace.fs.watch`, one subscription per LOADED directory**: `Req + * 11.2`'s "tree view over `workspace.fs.readdir` + `watch`" — `@tecode/ + * core`'s real `FileSystem.watch` (`buffer/fileSystem.ts`) watches exactly + * one path non-recursively (`node:fs.watch` with no `recursive` option), + * so keeping the WHOLE visible tree live means one `watch(dirUri, ...)` + * call per directory the store has ever loaded (the root, up front, plus + * every directory the user expands), not a single recursive watch on the + * root. Each watch's listener simply reloads THAT SAME directory + * (`store.reload(dirUri)`) on any event — the watch is already scoped to + * exactly that directory's own direct children, so the event's own + * `type`/`uri` fields need no further inspection. Watches are created + * once per directory and never torn down on collapse (an accepted MVP + * simplification: a very deep, very widely-expanded-then-collapsed + * session accumulates one live watcher per directory ever visited) — all + * of them still land in `ctx.subscriptions`, so they are cleaned up + * together on deactivation regardless. + * + * **Never throws, out of this module** (design.md §14): every command + * handler either delegates to `ExplorerStore` methods (already + * never-throwing, `store.ts`'s TSDoc) or wraps its own `workspace.fs.*` + * call in a `try`/`catch` that reports via `window.showMessage(..., + * "error")` — matching Req 11.2's "create/rename/delete with input-box + * prompts and error surfacing" and design.md §14's "File save I/O error -> + * status-bar error" row for the same class of failure. + */ + +import type { ExtensionContext, QuickPickItem, Uri } from "@tecode/api"; +import { createBunGitRunner, createIgnoreChecker, joinChildUri } from "../shared"; +import { createExplorerViewComponent } from "./ExplorerView"; +import { createExplorerStore, type ExplorerStore } from "./store"; +import { + EXPLORER_DELETE_COMMAND_ID, + EXPLORER_FOCUS_COMMAND_ID, + EXPLORER_NEW_FILE_COMMAND_ID, + EXPLORER_NEW_FOLDER_COMMAND_ID, + EXPLORER_RENAME_COMMAND_ID, + EXPLORER_SHOW_HIDDEN_CONFIG_KEY, + EXPLORER_VIEW_ID, +} from "./manifest"; + +/** The privileged bridge command `@tecode/core`'s `ui/openFileCommand.ts` + * registers directly on the core `CommandRegistry` (matches + * `command-palette/index.ts`'s own documented duplication — `packages/ + * builtin` may never import `@tecode/core`, so this string must stay in + * sync with `@tecode/core`'s `OPEN_FILE_COMMAND_ID` by hand). */ +const OPEN_FILE_COMMAND_ID = "workbench.action.files.openUri"; + +/** `workbench.view.` is auto-registered by `@tecode/core`'s + * `shell.tsx`'s `Shell` for every sidebar/activity-bar pair (Req 6.2) — + * `explorer.focus`'s handler is a one-line delegate to it, which both + * shows+activates the explorer sidebar AND (VS Code-style) toggles it + * shut again on a repeat invocation while already active/visible + * (`shell.tsx`'s `selectSidebarView` TSDoc). Actually moving OpenTUI + * keyboard focus into the tree happens separately, inside `ExplorerView` + * mounting fresh every time the sidebar becomes visible again (`shell. + * tsx`'s `Sidebar` unmounts its content entirely while hidden) — `Tree`'s + * own `focused` prop below drives that. */ +const FOCUS_SIDEBAR_VIEW_COMMAND_ID = `workbench.view.${EXPLORER_VIEW_ID}`; + +/** Render a caught `unknown` as a message string without risking a second + * throw (matches `fileSystem.ts`'s/`walkFiles.ts`'s callers' own + * `describeError`). */ +function describeError(err: unknown): string { + try { + if (err instanceof Error) return err.message; + return String(err); + } catch { + return "Unknown error"; + } +} + +/** Validates a new file/folder name (Req 11.2's "create... with input-box + * prompts"): non-empty, no path separator, and not already used by a + * sibling already listed in `dirUri` (a best-effort check against + * whatever the store last loaded — the real `write`/`mkdir` call is still + * the final authority, so a race with an out-of-band change is simply + * reported as an error at that point instead). `currentName`, when given + * (renaming), exempts that one name from the collision check — renaming a + * file to its own current name is otherwise indistinguishable from "name + * already taken". */ +function validateEntryName(value: string, siblingNames: readonly string[], currentName?: string): string | undefined { + const trimmed = value.trim(); + if (trimmed.length === 0) return "Name cannot be empty."; + if (trimmed.includes("/")) return "Name cannot contain \"/\"."; + if (trimmed !== currentName && siblingNames.includes(trimmed)) { + return `"${trimmed}" already exists here.`; + } + return undefined; +} + +/** Registers `explorer.focus` (this module's TSDoc). */ +function registerFocusCommand(ctx: ExtensionContext): void { + const { api } = ctx; + ctx.subscriptions.push( + api.commands.register(EXPLORER_FOCUS_COMMAND_ID, async () => { + await api.commands.execute(FOCUS_SIDEBAR_VIEW_COMMAND_ID); + }), + ); +} + +/** Registers `explorer.newFile`/`newFolder` (Req 11.2). Both share the + * same "resolve target directory -> prompt -> validate -> `fs.write`/ + * `mkdir` -> reload -> select" shape, parameterized only by `kind`. */ +function registerCreateCommands(ctx: ExtensionContext, store: ExplorerStore): void { + const { api } = ctx; + + function registerCreate(commandId: string, kind: "file" | "folder", prompt: string): void { + ctx.subscriptions.push( + api.commands.register(commandId, async () => { + const dirUri = store.resolveTargetDirectory(); + if (!dirUri) { + api.window.showMessage("No folder is open.", "info"); + return; + } + + const name = await api.window.showInputBox({ + prompt, + validateInput: (value) => validateEntryName(value, siblingNamesOf(store, dirUri)), + }); + if (!name) return; + + const uri = joinChildUri(dirUri, name.trim()); + try { + if (kind === "file") await api.workspace.fs.write(uri, new Uint8Array()); + else await api.workspace.fs.mkdir(uri); + } catch (cause) { + api.window.showMessage( + `Could not create ${kind === "file" ? "file" : "folder"}: ${describeError(cause)}`, + "error", + ); + return; + } + + await store.reload(dirUri); + store.setSelectedId(uri); + }), + ); + } + + registerCreate(EXPLORER_NEW_FILE_COMMAND_ID, "file", "New file name"); + registerCreate(EXPLORER_NEW_FOLDER_COMMAND_ID, "folder", "New folder name"); +} + +/** The currently-loaded sibling names of `dirUri` (this module's + * `validateEntryName`'s TSDoc) — reads `store.getNodes()` for the root, or + * the already-expanded subtree otherwise; a directory whose children were + * never loaded (not the target of any prior `reload`) simply has no known + * siblings yet, which just means the collision check has nothing to catch + * (the real `fs.write`/`mkdir` call still reports a genuine collision as + * an error). */ +function siblingNamesOf(store: ExplorerStore, dirUri: Uri): string[] { + function search(nodes: ReturnType): string[] | undefined { + for (const node of nodes) { + if (node.id === dirUri) { + return (node.children ?? []).map((c) => c.label); + } + if (node.children) { + const found = search(node.children); + if (found) return found; + } + } + return undefined; + } + if (store.getRootUri() === dirUri) return store.getNodes().map((n) => n.label); + return search(store.getNodes()) ?? []; +} + +/** Registers `explorer.rename` (Req 11.2). */ +function registerRenameCommand(ctx: ExtensionContext, store: ExplorerStore): void { + const { api } = ctx; + ctx.subscriptions.push( + api.commands.register(EXPLORER_RENAME_COMMAND_ID, async () => { + const uri = store.getSelectedId(); + if (!uri) { + api.window.showMessage("Select a file or folder to rename.", "info"); + return; + } + const parent = store.getParent(uri); + const currentName = store.getName(uri); + if (!parent || currentName === undefined) { + api.window.showMessage("Could not determine the selected item's location.", "error"); + return; + } + + const newName = await api.window.showInputBox({ + prompt: "New name", + value: currentName, + validateInput: (value) => validateEntryName(value, siblingNamesOf(store, parent), currentName), + }); + if (!newName || newName.trim() === currentName) return; + + const newUri = joinChildUri(parent, newName.trim()); + try { + await api.workspace.fs.rename(uri, newUri); + } catch (cause) { + api.window.showMessage(`Could not rename: ${describeError(cause)}`, "error"); + return; + } + + await store.reload(parent); + store.setSelectedId(newUri); + }), + ); +} + +/** Registers `explorer.delete` (Req 11.2's "delete... with... error + * surfacing"), confirming via `showQuickPick` (Task 3.3's plan: "delete + * confirms via `showQuickPick` Delete/Cancel"). */ +function registerDeleteCommand(ctx: ExtensionContext, store: ExplorerStore): void { + const { api } = ctx; + ctx.subscriptions.push( + api.commands.register(EXPLORER_DELETE_COMMAND_ID, async () => { + const uri = store.getSelectedId(); + if (!uri) { + api.window.showMessage("Select a file or folder to delete.", "info"); + return; + } + const name = store.getName(uri) ?? uri; + const parent = store.getParent(uri); + + const items: QuickPickItem[] = [ + { label: "Delete", description: "confirm" }, + { label: "Cancel", description: "cancel" }, + ]; + const picked = await api.window.showQuickPick(items, { + placeHolder: `Delete "${name}"?`, + }); + if (picked?.label !== "Delete") return; + + try { + await api.workspace.fs.delete(uri); + } catch (cause) { + api.window.showMessage(`Could not delete "${name}": ${describeError(cause)}`, "error"); + return; + } + + if (store.getSelectedId() === uri) store.setSelectedId(undefined); + if (parent) await store.reload(parent); + }), + ); +} + +/** Sets up one `workspace.fs.watch` subscription per directory the store + * loads, for the lifetime of this activation (this module's TSDoc). */ +function wireWatch(ctx: ExtensionContext, store: ExplorerStore): void { + const { api } = ctx; + const watched = new Set(); + + function watchIfNew(dirUri: Uri): void { + if (watched.has(dirUri)) return; + watched.add(dirUri); + ctx.subscriptions.push(api.workspace.fs.watch(dirUri, () => void store.reload(dirUri))); + } + + const rootUri = store.getRootUri(); + if (rootUri) watchIfNew(rootUri); + + // A directory only becomes watchable once the store has actually loaded + // it (the first successful `reload`) — `onDidChange` fires on every + // mutation, so this scans every currently-known loaded directory after + // each one; already-watched directories are skipped instantly via + // `watched`, so this stays cheap even on a large, long-lived tree. + ctx.subscriptions.push( + store.onDidChange(() => { + for (const id of store.getExpandedIds()) { + if (store.isDirectory(id)) watchIfNew(id); + } + }), + ); +} + +export function activate(ctx: ExtensionContext): void { + const { api } = ctx; + const rootUri = api.workspace.rootUri; + + const ignore = createIgnoreChecker({ + readFile: (uri) => api.workspace.fs.read(uri), + gitRunner: createBunGitRunner(), + }); + + const store = createExplorerStore(rootUri, { + readdir: (uri) => api.workspace.fs.readdir(uri), + ignore, + showMessage: (message, kind) => api.window.showMessage(message, kind), + showHidden: api.config.get(EXPLORER_SHOW_HIDDEN_CONFIG_KEY) ?? false, + }); + + // Req 9.5's `explorer.showHidden`, live (Task 3.3's "showHidden toggle + // reflects without restart") — matches `editor-core/index.ts`'s + // `editor.tabSize`/`editor.insertSpaces` live-reload precedent. + ctx.subscriptions.push( + api.config.onDidChange((event) => { + if (!event.affectsConfiguration(EXPLORER_SHOW_HIDDEN_CONFIG_KEY)) return; + store.setShowHidden(api.config.get(EXPLORER_SHOW_HIDDEN_CONFIG_KEY) ?? false); + }), + ); + + if (rootUri) void store.reload(rootUri); + wireWatch(ctx, store); + + ctx.subscriptions.push( + api.ui.registerView( + "sidebar.view", + EXPLORER_VIEW_ID, + createExplorerViewComponent({ + store, + Tree: api.ui.Tree, + onOpenFile: (uri) => void api.commands.execute(OPEN_FILE_COMMAND_ID, uri), + }), + ), + ); + + registerFocusCommand(ctx); + registerCreateCommands(ctx, store); + registerRenameCommand(ctx, store); + registerDeleteCommand(ctx, store); +} + +export function deactivate(): void { + // Nothing beyond `ctx.subscriptions` (disposed by the host, Req 2.6) — + // this extension owns no other resources. +} diff --git a/packages/builtin/explorer/manifest.ts b/packages/builtin/explorer/manifest.ts new file mode 100644 index 0000000..3d31e8c --- /dev/null +++ b/packages/builtin/explorer/manifest.ts @@ -0,0 +1,100 @@ +/** + * `explorer`'s manifest (Task 3.3, Req 11.2; design.md §13's `explorer` + * design): declares the sidebar view `index.ts` populates, the file- + * operation commands it implements, `ctrl+shift+e`'s focus keybinding, and + * `explorer.showHidden`'s configuration schema. Read and validated by the + * host WITHOUT executing `index.ts` (Req 2.2) — pure data, `export default + * {...} satisfies Manifest` (follows `command-palette/manifest.ts`'s + * precedent). + * + * **`activationEvents: ["onCommand:explorer.focus"]`, not `"onStartup"`** + * (design.md §12's "extension loading deferred... activate lazily per + * their activation events"): the explorer's `views`/`commands`/ + * `keybindings`/`configuration` contributions are all registered at + * manifest-registration time regardless of activation (`@tecode/core`'s + * `host/registration.ts`'s `registerExtension` — every one of those four + * kinds is pushed into its registry unconditionally, before any + * extension's `index.ts` ever runs), so the activity-bar icon, the + * "Focus on Explorer" palette entry, `ctrl+shift+e`, and `explorer. + * showHidden`'s default are all present from the very first frame either + * way. Only the actual `ExplorerView` React component (and its + * `workspace.fs.readdir`/`watch` wiring) needs `activate(ctx)` to have + * run — and that happens lazily, triggered by WHICHEVER of two paths + * happens first: `ctrl+shift+e` resolving to `explorer.focus` (a lazy + * command's `commands.execute` re-dispatches after activating its owner, + * `commands/registry.ts`), or the user clicking the activity-bar icon + * directly (`ui/slotRegistry.ts`'s `requestActivation`, `shell.tsx`'s + * `Sidebar`) — `onCommand:explorer.focus` only names the FIRST of those + * two as this manifest's own declared trigger; the second path activates + * the same way any lazy `sidebar.view` entry does, independently of + * `activationEvents` (`slotRegistry.ts`'s TSDoc's "Lazy views from + * manifests"). + * + * **No keybinding for Enter/creation/rename/deletion, deliberately** + * (Task 3.3's plan: "avoid double-handling"): `tecode.ui.Tree` + * (`@tecode/core`'s `components.tsx`) already handles `up`/`down`/`left`/ + * `right`/`return` itself, directly on its own focused root node + * (`components.tsx`'s TSDoc's "Keyboard nav while focused") — a core-level + * `when: "explorerFocus"` keybinding for `return` would race Tree's own + * `onKeyDown` handling for the exact same keystroke with no well-defined + * winner. `explorer.newFile`/`newFolder`/`rename`/`delete` are reachable + * via `ctrl+shift+p` (the command palette lists every registered command) + * with no dedicated keybinding of their own in this MVP — Req 11.2 asks + * for the CAPABILITY (create/rename/delete with prompts), not a specific + * keyboard shortcut for each. + */ + +import type { Manifest } from "@tecode/api"; + +/** The sidebar view id `index.ts` registers `ExplorerView` under, and the + * activity-bar/sidebar pairing id (Req 6.2) — also `workbench.view. + * explorer`'s auto-registered target (`@tecode/core`'s `shell.tsx`'s + * `Sidebar`'s TSDoc). Exported so `index.ts` and tests reference the same + * id. */ +export const EXPLORER_VIEW_ID = "explorer"; + +/** `ctrl+shift+e` — focuses (and, VS Code-style, toggles) the explorer + * sidebar (Req 11.2). */ +export const EXPLORER_FOCUS_COMMAND_ID = "explorer.focus"; +/** Creates a new file (Req 11.2's "create... with input-box prompts"). */ +export const EXPLORER_NEW_FILE_COMMAND_ID = "explorer.newFile"; +/** Creates a new folder (Req 11.2). */ +export const EXPLORER_NEW_FOLDER_COMMAND_ID = "explorer.newFolder"; +/** Renames the selected file or folder (Req 11.2). */ +export const EXPLORER_RENAME_COMMAND_ID = "explorer.rename"; +/** Deletes the selected file or folder, after a confirm prompt (Req + * 11.2). */ +export const EXPLORER_DELETE_COMMAND_ID = "explorer.delete"; + +/** Req 9.5's MVP setting: shows dotfiles and `.gitignore`-ignored entries + * when `true` (`../shared/ignore.ts`'s `showHidden` bypass). Exported so + * `index.ts` and tests reference the same key. */ +export const EXPLORER_SHOW_HIDDEN_CONFIG_KEY = "explorer.showHidden"; + +export default { + id: "tecode.explorer", + version: "0.1.0", + apiVersion: "1.0", + activationEvents: [`onCommand:${EXPLORER_FOCUS_COMMAND_ID}`], + contributes: { + views: [{ id: EXPLORER_VIEW_ID, title: "Explorer", slot: "sidebar", icon: "📁" }], + commands: [ + { id: EXPLORER_FOCUS_COMMAND_ID, title: "Focus on Explorer", category: "View" }, + { id: EXPLORER_NEW_FILE_COMMAND_ID, title: "New File", category: "File" }, + { id: EXPLORER_NEW_FOLDER_COMMAND_ID, title: "New Folder", category: "File" }, + { id: EXPLORER_RENAME_COMMAND_ID, title: "Rename", category: "File" }, + { id: EXPLORER_DELETE_COMMAND_ID, title: "Delete", category: "File" }, + ], + keybindings: [{ key: "ctrl+shift+e", command: EXPLORER_FOCUS_COMMAND_ID }], + configuration: { + title: "Explorer", + properties: { + [EXPLORER_SHOW_HIDDEN_CONFIG_KEY]: { + type: "boolean", + default: false, + description: "Show hidden (dot-prefixed) and .gitignore-ignored files in the explorer.", + }, + }, + }, + }, +} satisfies Manifest; diff --git a/packages/builtin/explorer/store.test.ts b/packages/builtin/explorer/store.test.ts new file mode 100644 index 0000000..ee87921 --- /dev/null +++ b/packages/builtin/explorer/store.test.ts @@ -0,0 +1,270 @@ +/** + * Tests for {@link createExplorerStore} (Task 3.3, Req 11.2) — local fakes + * only (no mock libraries, house convention): a fake `readdir` over an + * in-memory tree (mirrors `../shared/walkFiles.test.ts`'s `FakeTree`), and + * a real {@link createIgnoreChecker} with no `git`/`.gitignore` + * dependencies (deterministic: only dotfile/always-ignored-dir hiding + * applies, exactly like `ignore.test.ts`'s "no dependencies at all" suite). + */ + +import { describe, expect, test } from "bun:test"; +import type { DirEntry, MessageKind, Uri } from "@tecode/api"; +import { createIgnoreChecker } from "../shared"; +import { createExplorerStore, type ExplorerStore } from "./store"; + +const ROOT: Uri = "file:///workspace/"; + +type FakeTree = { [name: string]: FakeTree | null }; + +function createFakeReaddir(tree: FakeTree): (uri: Uri) => Promise { + return async (uri: Uri): Promise => { + const relative = uri.replace(ROOT, "").replace(/\/$/, ""); + const segments = relative.length > 0 ? relative.split("/").map(decodeURIComponent) : []; + let node: FakeTree = tree; + for (const segment of segments) { + const next = node[segment]; + if (next === null || next === undefined) throw new Error(`ENOENT: ${uri}`); + node = next; + } + return Object.entries(node).map(([name, value]) => ({ + name, + type: value === null ? "file" : "directory", + })); + }; +} + +function createStore( + tree: FakeTree, + overrides: { rootUri?: Uri | undefined; showHidden?: boolean } = {}, +): { store: ExplorerStore; messages: Array<{ message: string; kind?: MessageKind }> } { + const messages: Array<{ message: string; kind?: MessageKind }> = []; + const store = createExplorerStore("rootUri" in overrides ? overrides.rootUri : ROOT, { + readdir: createFakeReaddir(tree), + ignore: createIgnoreChecker(), + showMessage: (message, kind) => messages.push({ message, kind }), + showHidden: overrides.showHidden ?? false, + }); + return { store, messages }; +} + +async function waitForChange(store: ExplorerStore): Promise { + await new Promise((resolve) => { + const sub = store.onDidChange(() => { + sub.dispose(); + resolve(); + }); + }); +} + +describe("createExplorerStore (Task 3.3, Req 11.2)", () => { + test("no rootUri degrades to a permanently empty tree, never throws", () => { + const { store } = createStore({}, { rootUri: undefined }); + expect(store.getRootUri()).toBeUndefined(); + expect(store.getNodes()).toEqual([]); + expect(store.resolveTargetDirectory()).toBeUndefined(); + expect(() => store.toggle("file:///x" as Uri, true)).not.toThrow(); + expect(() => store.setSelectedId("file:///x" as Uri)).not.toThrow(); + }); + + test("getNodes() is empty before the root has ever loaded", () => { + const { store } = createStore({ "a.ts": null }); + expect(store.getNodes()).toEqual([]); + }); + + test("reload(rootUri) populates top-level nodes, sorted, with directories marked hasChildren", async () => { + const { store } = createStore({ + "b.ts": null, + "a.ts": null, + src: { "index.ts": null }, + }); + await store.reload(ROOT); + expect(store.getNodes()).toEqual([ + { id: "file:///workspace/a.ts", label: "a.ts", hasChildren: false, children: undefined }, + { id: "file:///workspace/b.ts", label: "b.ts", hasChildren: false, children: undefined }, + { id: "file:///workspace/src", label: "src", hasChildren: true, children: undefined }, + ]); + }); + + test("dotfiles and node_modules are hidden by default (real IgnoreChecker, no git/.gitignore)", async () => { + const { store } = createStore({ + ".git": { HEAD: null }, + node_modules: { pkg: null }, + "keep.ts": null, + }); + await store.reload(ROOT); + expect(store.getNodes().map((n) => n.label)).toEqual(["keep.ts"]); + }); + + test("a readdir failure reports via showMessage and leaves prior children untouched", async () => { + let shouldFail = false; + const tree: FakeTree = { "a.ts": null }; + const baseReaddir = createFakeReaddir(tree); + const messages: Array<{ message: string; kind?: MessageKind }> = []; + const store = createExplorerStore(ROOT, { + readdir: async (uri) => { + if (shouldFail) throw new Error("permission denied"); + return baseReaddir(uri); + }, + ignore: createIgnoreChecker(), + showMessage: (message, kind) => messages.push({ message, kind }), + showHidden: false, + }); + + await store.reload(ROOT); + expect(store.getNodes().map((n) => n.label)).toEqual(["a.ts"]); + + shouldFail = true; + await store.reload(ROOT); + + expect(store.getNodes().map((n) => n.label)).toEqual(["a.ts"]); // unchanged + expect(messages.some((m) => m.kind === "error" && /permission denied/.test(m.message))).toBe(true); + }); + + describe("toggle (expand/collapse)", () => { + test("expanding a directory for the first time loads its children lazily", async () => { + const { store } = createStore({ src: { "a.ts": null, "b.ts": null } }); + await store.reload(ROOT); + expect(store.getExpandedIds()).toEqual([]); + + const changed = waitForChange(store); + store.toggle("file:///workspace/src" as Uri, true); + await changed; + + expect(store.getExpandedIds()).toEqual(["file:///workspace/src"]); + expect(store.getNodes()[0]?.children?.map((c) => c.label)).toEqual(["a.ts", "b.ts"]); + }); + + test("collapsing keeps the cached children (a re-expand is instant, no reload)", async () => { + const { store } = createStore({ src: { "a.ts": null } }); + await store.reload(ROOT); + await new Promise((resolve) => { + const sub = store.onDidChange(() => { + sub.dispose(); + resolve(); + }); + store.toggle("file:///workspace/src" as Uri, true); + }); + + store.toggle("file:///workspace/src" as Uri, false); + expect(store.getExpandedIds()).toEqual([]); + // Collapsed nodes render no children array (Tree hides them), but the + // directory itself is still known/marked hasChildren. + expect(store.getNodes()[0]).toEqual({ + id: "file:///workspace/src", + label: "src", + hasChildren: true, + children: undefined, + }); + + store.toggle("file:///workspace/src" as Uri, true); + // Instant — no intervening readdir needed, so no async wait here. + expect(store.getNodes()[0]?.children?.map((c) => c.label)).toEqual(["a.ts"]); + }); + + test("toggling a uri that is not a known directory is a no-op", () => { + const { store } = createStore({ "a.ts": null }); + expect(() => store.toggle("file:///workspace/a.ts" as Uri, true)).not.toThrow(); + expect(store.getExpandedIds()).toEqual([]); + }); + }); + + describe("selection", () => { + test("setSelectedId/getSelectedId round-trip and notify on change", async () => { + const { store } = createStore({ "a.ts": null }); + const changed = waitForChange(store); + store.setSelectedId("file:///workspace/a.ts" as Uri); + await changed; + expect(store.getSelectedId()).toBe("file:///workspace/a.ts" as Uri); + }); + + test("setting the same id again does not fire onDidChange", async () => { + const { store } = createStore({ "a.ts": null }); + store.setSelectedId("file:///workspace/a.ts" as Uri); + let fired = false; + const sub = store.onDidChange(() => (fired = true)); + store.setSelectedId("file:///workspace/a.ts" as Uri); + sub.dispose(); + expect(fired).toBe(false); + }); + }); + + describe("resolveTargetDirectory", () => { + test("no selection resolves to the root", async () => { + const { store } = createStore({ "a.ts": null }); + await store.reload(ROOT); + expect(store.resolveTargetDirectory()).toBe(ROOT); + }); + + test("a selected directory resolves to itself", async () => { + const { store } = createStore({ src: { "a.ts": null } }); + await store.reload(ROOT); + store.setSelectedId("file:///workspace/src" as Uri); + expect(store.resolveTargetDirectory()).toBe("file:///workspace/src"); + }); + + test("a selected file resolves to its parent directory", async () => { + const { store } = createStore({ src: { "a.ts": null } }); + await store.reload(ROOT); + await new Promise((resolve) => { + const sub = store.onDidChange(() => { + sub.dispose(); + resolve(); + }); + store.toggle("file:///workspace/src" as Uri, true); + }); + store.setSelectedId("file:///workspace/src/a.ts" as Uri); + expect(store.resolveTargetDirectory()).toBe("file:///workspace/src"); + }); + }); + + describe("getName / getParent / isDirectory", () => { + test("report known children's metadata after a reload", async () => { + const { store } = createStore({ src: { "a.ts": null } }); + await store.reload(ROOT); + expect(store.getName("file:///workspace/src" as Uri)).toBe("src"); + expect(store.getParent("file:///workspace/src" as Uri)).toBe(ROOT); + expect(store.isDirectory("file:///workspace/src" as Uri)).toBe(true); + expect(store.isDirectory("file:///workspace/does-not-exist" as Uri)).toBe(false); + }); + + test("an unknown uri reports undefined name/parent", () => { + const { store } = createStore({}); + expect(store.getName("file:///workspace/ghost" as Uri)).toBeUndefined(); + expect(store.getParent("file:///workspace/ghost" as Uri)).toBeUndefined(); + }); + }); + + describe("showHidden", () => { + test("setShowHidden(true) reloads every already-loaded directory and reveals hidden entries", async () => { + const { store } = createStore({ ".env": null, "keep.ts": null }); + await store.reload(ROOT); + expect(store.getNodes().map((n) => n.label)).toEqual(["keep.ts"]); + + const changed = waitForChange(store); + store.setShowHidden(true); + await changed; + // The flag flips synchronously; the actual reveal lands once the + // triggered reload resolves — poll briefly rather than assume one + // `onDidChange` tick is enough (reload() fires its OWN change too). + const start = Date.now(); + while (store.getNodes().length < 2 && Date.now() - start < 2000) { + await new Promise((r) => setTimeout(r, 5)); + } + expect(store.getNodes().map((n) => n.label).sort()).toEqual([".env", "keep.ts"]); + }); + + test("setShowHidden with the same value is a no-op", () => { + const { store } = createStore({ "a.ts": null }, { showHidden: false }); + let fired = false; + const sub = store.onDidChange(() => (fired = true)); + store.setShowHidden(false); + sub.dispose(); + expect(fired).toBe(false); + }); + + test("getShowHidden reflects the constructed initial value", () => { + const { store } = createStore({}, { showHidden: true }); + expect(store.getShowHidden()).toBe(true); + }); + }); +}); diff --git a/packages/builtin/explorer/store.ts b/packages/builtin/explorer/store.ts new file mode 100644 index 0000000..5374bb7 --- /dev/null +++ b/packages/builtin/explorer/store.ts @@ -0,0 +1,322 @@ +/** + * `ExplorerStore` — the explorer's tree state, kept as a plain, UI- + * framework-free object (Task 3.3, Req 11.2; design.md §13's `explorer` + * design: "tree state from `tecode.workspace.fs.readdir` + `watch`"). + * `index.ts`'s `activate(ctx)` builds one instance per activation and + * `ExplorerView.tsx` renders straight off it; both read/write the SAME + * store, so a command (`explorer.newFile`, a `fs.watch` reload, ...) and + * the rendered tree always agree. + * + * **`ExplorerTreeNode`, not `@tecode/core`'s `TreeNode`**: `packages/ + * builtin/**` may never import `@tecode/core` (the ESLint layering rule), + * and `tecode.ui.Tree`'s node shape is not part of `@tecode/api` either + * (`namespaces.ts`'s `UiNamespace.Tree` is the bare, React-free + * `ComponentType`). {@link ExplorerTreeNode} below is this module's own + * LOCAL declaration of the exact same shape `@tecode/core`'s + * `components.tsx`'s `TreeNode` documents (`id`/`label`/`children`/ + * `hasChildren`) — duck-typed compatibility, not a real import, is all + * `ExplorerView.tsx` needs to hand {@link getNodes}' result straight to + * `` as a `Record[]`. + * + * **Lazy per-directory loading, mirroring `walkFiles.ts`'s shape but NOT + * reusing it directly**: `walkFiles` eagerly recurses the WHOLE tree for + * `ctrl+p`'s candidate list; the explorer instead loads exactly one + * directory's children at a time — the root, up front, and any other + * directory only once the user actually expands it ({@link + * ExplorerStore.toggle}) — since an always-visible sidebar tree walking an + * entire large workspace up front would be needlessly expensive. Both this + * module and `walkFiles.ts` still share the SAME `../shared/ignore.ts` + * `IgnoreChecker` and `../shared/walkFiles.ts`'s `joinChildUri` for the one + * join operation each directory listing needs (Task 3.3's "one + * ignore-aware walk `ctrl+p` and the explorer both use"). + * + * **`onDidChange` fires on every mutation** (this module's TSDoc): a + * directory finishing its `readdir`, an expand/collapse, a selection + * change, or a `showHidden` flip. `ExplorerView.tsx` subscribes once and + * force-re-renders — the same "subscribe + force-render" shape + * `@tecode/core`'s `ui/shell.tsx`'s `useSlotViews`/`useOpenDocuments` + * already use for an external store (`keymap/context.ts`'s + * `createContextService` for the underlying emitter shape this module + * copies). + * + * **Never throws**: every method that can fail internally (a `readdir` + * rejecting) reports through {@link ExplorerStoreDeps.showMessage} rather + * than rejecting/throwing back to its caller (design.md §14's "a partial + * workspace scan degrades gracefully" convention, `walkFiles.ts`'s own + * "an unreadable directory is skipped" precedent) — the affected directory + * simply renders with whatever it last successfully loaded (empty, the + * first time). + */ + +import type { DirEntry, Disposable, Event, Listener, MessageKind, Uri } from "@tecode/api"; +import { joinChildUri, type IgnoreChecker } from "../shared"; + +/** The exact node shape `tecode.ui.Tree` expects (this module's TSDoc) — + * duck-typed, not imported. */ +export interface ExplorerTreeNode { + id: string; + label: string; + children?: ExplorerTreeNode[]; + hasChildren?: boolean; +} + +/** One directory's cached, already-ignore-filtered children. */ +interface ExplorerChild { + uri: Uri; + name: string; + isDirectory: boolean; +} + +/** Dependencies for {@link createExplorerStore}. */ +export interface ExplorerStoreDeps { + /** Matches `@tecode/api`'s `FileSystem.readdir` exactly — pass + * `api.workspace.fs.readdir` directly. */ + readdir(uri: Uri): Promise; + /** The real `.gitignore`-aware visibility helper (`../shared/ignore.ts`, + * Task 3.3) — batched per directory, exactly matching what one `readdir` + * call here produces. */ + ignore: IgnoreChecker; + /** Surfaces a `readdir` failure (design.md §14) — pass + * `api.window.showMessage` directly. */ + showMessage(message: string, kind?: MessageKind): void; + /** Req 9.5's `explorer.showHidden` initial value — `index.ts` reads + * `api.config.get` once up front and passes the result here; later + * changes go through {@link ExplorerStore.setShowHidden}. */ + showHidden: boolean; +} + +/** Render a caught `unknown` as a message string without risking a second + * throw (matches `fileSystem.ts`'s/`registry.ts`'s `describeError`). */ +function describeError(err: unknown): string { + try { + if (err instanceof Error) return err.message; + return String(err); + } catch { + return "Unknown error"; + } +} + +/** The explorer's tree state (this module's TSDoc). */ +export interface ExplorerStore { + /** The workspace root this store was built for — `undefined` degrades + * to an always-empty tree (Task 3.3's plan: "`rootUri` undefined -> + * empty degrade"), never a crash. */ + getRootUri(): Uri | undefined; + getShowHidden(): boolean; + /** Flips `explorer.showHidden` and reloads every directory ALREADY + * loaded (root, plus every expanded directory) so the change is visible + * immediately — Task 3.3's "showHidden toggle reflects without + * restart". */ + setShowHidden(value: boolean): void; + getSelectedId(): Uri | undefined; + setSelectedId(id: Uri | undefined): void; + /** Every currently-expanded directory's uri, as plain strings (`tecode. + * ui.Tree`'s `expandedIds` prop wants `string[]`). */ + getExpandedIds(): string[]; + /** The root's children, built depth-first from whatever has been loaded + * and is currently expanded (this module's TSDoc) — ready to pass + * straight to ``. `getRootUri()` being `undefined`, + * or the root not having finished its initial load yet, both report + * `[]` (a loading/empty tree, never a crash). */ + getNodes(): ExplorerTreeNode[]; + /** Whether `uri` is a known directory (loaded as some OTHER directory's + * child at some point) — `undefined` (never seen) is treated as "not a + * directory" by every caller that needs a yes/no answer (e.g. + * `resolveTargetDirectory`), which is the safe default for an unknown + * id. */ + isDirectory(uri: Uri): boolean; + /** `uri`'s own display name (its `readdir` entry name), if known. */ + getName(uri: Uri): string | undefined; + /** `uri`'s parent DIRECTORY uri, if known (every child learned via a + * `reload` call is recorded against the directory it came from). The + * root itself has no recorded parent. */ + getParent(uri: Uri): Uri | undefined; + /** + * Expand or collapse directory `uri` (Task 3.3's keyboard-nav-driven + * `onToggle`, or a mouse click): `expanding: true` loads its children + * on the FIRST expand (subsequent re-expands reuse the cache — a + * `fs.watch`-triggered {@link reload} is what keeps it fresh, not a + * reload on every re-expand); `false` just collapses without discarding + * the cached children (a re-expand is instant). A no-op for a `uri` + * this store does not know is a directory. + */ + toggle(uri: Uri, expanding: boolean): void; + /** + * (Re)load one directory's children from `workspace.fs.readdir` — + * called for the root once up front, for a directory the first time it + * expands, and again whenever a `fs.watch` subscription reports a + * change under it. Never throws (this module's TSDoc); a `readdir` + * failure reports via `showMessage` and leaves that directory's + * children exactly as they were before the call. + */ + reload(uri: Uri): Promise; + /** + * Where a create command (`explorer.newFile`/`newFolder`) should place + * the new entry, and delete/rename's error surfaces read the CURRENT + * selection to resolve it: the selected directory itself, the selected + * file's PARENT directory, or the root when nothing is selected/known. + * `undefined` only when `getRootUri()` is itself `undefined` (Task + * 3.3's plan: "no folder open" degrade). + */ + resolveTargetDirectory(): Uri | undefined; + /** Fires after every mutation (this module's TSDoc). */ + onDidChange: Event; +} + +/** + * Build an {@link ExplorerStore} rooted at `rootUri` (Task 3.3, Req 11.2). + * `rootUri: undefined` (no folder open) is a fully supported, permanently + * empty store — every method degrades gracefully rather than assuming a + * root exists. + */ +export function createExplorerStore(rootUri: Uri | undefined, deps: ExplorerStoreDeps): ExplorerStore { + const childrenByDir = new Map(); + const relativeDirByUri = new Map(); + const parentByUri = new Map(); + const directoryUris = new Set(); + const expanded = new Set(); + const listeners = new Set>(); + + let showHidden = deps.showHidden; + let selectedId: Uri | undefined; + + if (rootUri) relativeDirByUri.set(rootUri, ""); + + function fireChange(): void { + for (const listener of Array.from(listeners)) { + try { + listener(undefined); + } catch { + // Isolate listener failures (matches `keymap/context.ts`'s + // `createContextService`'s own `set()`). + } + } + } + + function onDidChange(listener: Listener): Disposable { + listeners.add(listener); + let disposed = false; + return { + dispose() { + if (disposed) return; + disposed = true; + listeners.delete(listener); + }, + }; + } + + async function reload(dirUri: Uri): Promise { + if (!rootUri) return; + const relativeDir = relativeDirByUri.get(dirUri) ?? ""; + + let entries: DirEntry[]; + try { + entries = await deps.readdir(dirUri); + } catch (cause) { + deps.showMessage(`Could not read directory: ${describeError(cause)}`, "error"); + return; + } + + const sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name)); + let visible: DirEntry[]; + try { + visible = await deps.ignore.filterEntries({ + rootUri, + dirUri, + relativeDir, + entries: sorted, + showHidden, + }); + } catch (cause) { + // `IgnoreChecker.filterEntries` is documented never to throw + // (`ignore.ts`'s TSDoc); guarded anyway (this codebase's + // "guard even a documented never-throw dependency" convention). + deps.showMessage(`Could not filter directory listing: ${describeError(cause)}`, "error"); + visible = sorted; + } + + const children: ExplorerChild[] = visible.map((entry) => { + const childUri = joinChildUri(dirUri, entry.name); + const isDirectory = entry.type === "directory"; + relativeDirByUri.set(childUri, relativeDir.length > 0 ? `${relativeDir}/${entry.name}` : entry.name); + parentByUri.set(childUri, dirUri); + if (isDirectory) directoryUris.add(childUri); + return { uri: childUri, name: entry.name, isDirectory }; + }); + + childrenByDir.set(dirUri, children); + fireChange(); + } + + function buildNodes(dirUri: Uri): ExplorerTreeNode[] | undefined { + const children = childrenByDir.get(dirUri); + if (!children) return undefined; + return children.map((child) => ({ + id: child.uri, + label: child.name, + hasChildren: child.isDirectory, + children: child.isDirectory && expanded.has(child.uri) ? buildNodes(child.uri) : undefined, + })); + } + + function toggle(uri: Uri, expanding: boolean): void { + if (!directoryUris.has(uri)) return; + if (expanding) { + expanded.add(uri); + if (!childrenByDir.has(uri)) { + void reload(uri); + return; // reload() itself fires the change once loaded. + } + } else { + expanded.delete(uri); + } + fireChange(); + } + + function setShowHidden(value: boolean): void { + if (showHidden === value) return; + showHidden = value; + // Reload everything already loaded (root + every expanded directory) + // so the change is visible without a restart (this module's TSDoc). + const loadedDirs = Array.from(childrenByDir.keys()); + fireChange(); // reflect the flag itself immediately; reloads land as they resolve. + for (const dirUri of loadedDirs) void reload(dirUri); + } + + function setSelectedId(id: Uri | undefined): void { + if (selectedId === id) return; + selectedId = id; + fireChange(); + } + + function resolveTargetDirectory(): Uri | undefined { + if (!rootUri) return undefined; + if (selectedId && directoryUris.has(selectedId)) return selectedId; + if (selectedId) { + const parent = parentByUri.get(selectedId); + if (parent) return parent; + } + return rootUri; + } + + return { + getRootUri: () => rootUri, + getShowHidden: () => showHidden, + setShowHidden, + getSelectedId: () => selectedId, + setSelectedId, + getExpandedIds: () => Array.from(expanded), + getNodes: () => (rootUri ? (buildNodes(rootUri) ?? []) : []), + isDirectory: (uri) => directoryUris.has(uri), + getName: (uri) => { + const parent = parentByUri.get(uri); + if (!parent) return undefined; + return childrenByDir.get(parent)?.find((c) => c.uri === uri)?.name; + }, + getParent: (uri) => parentByUri.get(uri), + toggle, + reload, + resolveTargetDirectory, + onDidChange, + }; +} diff --git a/packages/builtin/index.ts b/packages/builtin/index.ts index 32a369c..89c6b41 100644 --- a/packages/builtin/index.ts +++ b/packages/builtin/index.ts @@ -23,12 +23,14 @@ * * `command-palette` (Task 3.2, Req 11.3) is the fourth built-in wired in * here — command search (`ctrl+shift+p`) and fuzzy file quick-open - * (`ctrl+p`), both thin wrappers over `tecode.window.showQuickPick`. Every - * remaining `packages/builtin/*` package (`explorer`, `keybindings-editor`, - * `statusbar`) is still a placeholder with no `manifest.ts` — each is its - * own later task (tasks.md's Phase 3/4 built-in tasks). `themes-default` - * (Task 2.7, Req 11.4) and `languages-basic` (Task 2.9, Req 8.4) are the - * second and third. + * (`ctrl+p`), both thin wrappers over `tecode.window.showQuickPick`. + * `explorer` (Task 3.3, Req 11.2) is the fifth — a directory tree over + * `workspace.fs.readdir`/`watch`, create/rename/delete, `.gitignore`-aware + * visibility, and `ctrl+shift+e`. Every remaining `packages/builtin/*` + * package (`keybindings-editor`, `statusbar`) is still a placeholder with + * no `manifest.ts` — each is its own later task (tasks.md's Phase 3/4 + * built-in tasks). `themes-default` (Task 2.7, Req 11.4) and + * `languages-basic` (Task 2.9, Req 8.4) are the second and third. * * **`builtinThemeAssets`** (Task 2.7, design.md §3): the embedded-JSON * counterpart to `builtinModules` above, for a built-in's @@ -48,6 +50,8 @@ import * as commandPaletteModule from "./command-palette/index"; import commandPaletteManifest from "./command-palette/manifest"; import * as editorCoreModule from "./editor-core/index"; import editorCoreManifest from "./editor-core/manifest"; +import * as explorerModule from "./explorer/index"; +import explorerManifest from "./explorer/manifest"; import * as themesDefaultModule from "./themes-default/index"; import themesDefaultManifest, { DARK_MODERN_THEME_ID, @@ -87,6 +91,7 @@ export const builtinManifests: Manifest[] = [ themesDefaultManifest, languagesBasicManifest, commandPaletteManifest, + explorerManifest, ]; /** Every built-in extension's real implementation module, keyed by @@ -98,6 +103,7 @@ export const builtinModules: Record = { [themesDefaultManifest.id]: themesDefaultModule, [languagesBasicManifest.id]: languagesBasicModule, [commandPaletteManifest.id]: commandPaletteModule, + [explorerManifest.id]: explorerModule, }; /** Every built-in extension's embedded theme JSON assets, keyed by the diff --git a/packages/builtin/package.json b/packages/builtin/package.json index fccb5f7..850e5de 100644 --- a/packages/builtin/package.json +++ b/packages/builtin/package.json @@ -6,6 +6,11 @@ "main": "index.ts", "types": "index.ts", "dependencies": { - "@tecode/api": "workspace:*" + "@tecode/api": "workspace:*", + "@opentui/react": "^0.1.107", + "react": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0" } } diff --git a/packages/builtin/shared/gitRunner.test.ts b/packages/builtin/shared/gitRunner.test.ts new file mode 100644 index 0000000..d78003b --- /dev/null +++ b/packages/builtin/shared/gitRunner.test.ts @@ -0,0 +1,112 @@ +/** + * Tests for {@link createBunGitRunner} (Task 3.3, Req 11.2) — the real + * `Bun.spawn`-backed `GitRunner` implementation. `ignore.ts`'s own tests + * stub `GitRunner` entirely (this task's "stub both ways" completion + * requirement); this suite instead proves the REAL implementation actually + * talks to a real `git` CLI correctly, against a real temp git repository — + * skipped outright when this environment has no `git` binary at all + * (`isAvailable()` reporting `false` is itself part of what's asserted). + */ + +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createBunGitRunner, uriToGitPath } from "./gitRunner"; + +async function initRepo(dir: string): Promise { + const proc = Bun.spawn(["git", "init", "-q"], { cwd: dir, stdout: "ignore", stderr: "ignore" }); + await proc.exited; +} + +describe("createBunGitRunner (Task 3.3, Req 11.2)", () => { + let dir: string | undefined; + + afterEach(async () => { + if (dir) await rm(dir, { recursive: true, force: true }); + dir = undefined; + }); + + test("isAvailable() reports true when the git CLI is installed", async () => { + const runner = createBunGitRunner(); + expect(await runner.isAvailable()).toBe(true); + }); + + test("isAvailable() caches its result — a second call does not re-spawn", async () => { + const runner = createBunGitRunner(); + const first = await runner.isAvailable(); + const second = await runner.isAvailable(); + expect(first).toBe(true); + expect(second).toBe(true); + }); + + test("checkIgnore reports paths matched by the repo's real .gitignore, echoed back exactly as given", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-gitrunner-")); + await initRepo(dir); + await writeFile(join(dir, ".gitignore"), "*.log\n"); + const keptPath = join(dir, "keep.ts"); + const ignoredPath = join(dir, "debug.log"); + await writeFile(keptPath, "export {};\n"); + await writeFile(ignoredPath, "log line\n"); + + const runner = createBunGitRunner(); + const ignored = await runner.checkIgnore(dir, [keptPath, ignoredPath]); + + expect(ignored.has(ignoredPath)).toBe(true); + expect(ignored.has(keptPath)).toBe(false); + }); + + test("checkIgnore respects a nested .gitignore too (real git resolves the full chain)", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-gitrunner-")); + await initRepo(dir); + await mkdir(join(dir, "src")); + await writeFile(join(dir, "src", ".gitignore"), "generated.ts\n"); + const nestedIgnoredPath = join(dir, "src", "generated.ts"); + const nestedKeptPath = join(dir, "src", "index.ts"); + await writeFile(nestedIgnoredPath, "// generated\n"); + await writeFile(nestedKeptPath, "export {};\n"); + + const runner = createBunGitRunner(); + const ignored = await runner.checkIgnore(dir, [nestedIgnoredPath, nestedKeptPath]); + + expect(ignored.has(nestedIgnoredPath)).toBe(true); + expect(ignored.has(nestedKeptPath)).toBe(false); + }); + + test("checkIgnore returns an empty set when nothing is ignored (git's exit 1 is not an error here)", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-gitrunner-")); + await initRepo(dir); + const filePath = join(dir, "a.ts"); + await writeFile(filePath, "export {};\n"); + + const runner = createBunGitRunner(); + const ignored = await runner.checkIgnore(dir, [filePath]); + + expect(ignored.size).toBe(0); + }); + + test("checkIgnore against a non-repository directory degrades to an empty set rather than throwing", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-gitrunner-not-a-repo-")); + const filePath = join(dir, "a.ts"); + await writeFile(filePath, "export {};\n"); + + const runner = createBunGitRunner(); + await expect(runner.checkIgnore(dir, [filePath])).resolves.toEqual(new Set()); + }); + + test("checkIgnore with an empty path list never spawns and resolves to an empty set", async () => { + const runner = createBunGitRunner(); + const ignored = await runner.checkIgnore("/nonexistent", []); + expect(ignored.size).toBe(0); + }); +}); + +describe("uriToGitPath (Task 3.3, Req 11.2)", () => { + test("converts a file:// URI to a real filesystem path", () => { + expect(uriToGitPath("file:///workspace/src")).toBe("/workspace/src"); + }); + + test("falls back to the raw string for an unparseable URI rather than throwing", () => { + expect(uriToGitPath("not-a-uri" as never)).toBe("not-a-uri"); + }); +}); diff --git a/packages/builtin/shared/gitRunner.ts b/packages/builtin/shared/gitRunner.ts new file mode 100644 index 0000000..3355d5c --- /dev/null +++ b/packages/builtin/shared/gitRunner.ts @@ -0,0 +1,137 @@ +/** + * `GitRunner` — the injectable seam over the real `git` CLI (Task 3.3, Req + * 11.2; design.md §13: "if `git` CLI exists (checked once with + * `Bun.spawn(["git","--version"])`), visibility uses `git check-ignore + * --stdin` batched per directory"). `ignore.ts` is the sole consumer: + * detects whether `git` is usable at all ({@link GitRunner.isAvailable}, + * cached — `git --version` runs at most once per {@link GitRunner} + * instance) and, when it is, batches every directory's candidate entries + * into one `git check-ignore --stdin` call ({@link GitRunner.checkIgnore}) + * rather than spawning a process per entry. + * + * **Why a real filesystem PATH, not a `Uri`, and why that's allowed here**: + * `packages/builtin/**`'s usual "never reach a real filesystem directly" + * discipline (`walkFiles.ts`'s TSDoc) is about FILE I/O — reading/writing + * bytes through `node:fs`, which the `tecode.workspace.fs` API exists + * specifically to mediate. Running `git` as a subprocess is a different + * kind of operation entirely (no file content is read or written by this + * module), and `@tecode/api` exposes no "run a subprocess" namespace to + * route it through — {@link createBunGitRunner} is that seam's one + * necessary exception, exactly like `walkFiles.ts`'s own use of the global + * `URL` constructor for the one path-join it needs. Converting a `file://` + * `Uri` to a real path for `git`'s `cwd`/stdin arguments uses `node:url`'s + * `fileURLToPath` directly — a pure string transform, not filesystem I/O — + * rather than duplicating `@tecode/core`'s `buffer/uri.ts` (off-limits by + * the layering rule) or reaching for `node:fs`. + * + * **ESLint layering** (verified against this repo's `eslint.config.mjs`): + * the rule blocks only `import` (static or dynamic) of `@tecode/core` — + * `Bun.spawn` (a global, no import at all) and `node:url` (a Node/Bun + * builtin, not `@tecode/core`) are both unrestricted, so this default + * implementation lives directly in `packages/builtin/shared/` rather than + * needing to be pushed into `@tecode/core` and injected from there. + */ + +import { fileURLToPath } from "node:url"; +import type { Uri } from "@tecode/api"; + +/** Batches `git check-ignore` for one directory's worth of candidate + * entries at a time (design.md §13's "batched per directory") — the seam + * `ignore.ts` depends on, injectable so tests can stub it either way + * ("git present" / "git absent → glob fallback", this task's completion + * requirement) without spawning a real process. */ +export interface GitRunner { + /** + * Whether the `git` CLI is usable at all — `git --version` exits `0`. + * Checked at most ONCE per {@link GitRunner} instance (design.md §13); + * every subsequent call resolves from the cached result. Never rejects: + * a spawn failure (git not installed, `PATH` issue, anything else) is + * treated as "unavailable", the same "degrade gracefully" contract + * every other host-boundary check in this codebase follows. + */ + isAvailable(): Promise; + /** + * Run `git check-ignore --stdin` once for every path in `absolutePaths` + * (design.md §13's "batched per directory" — one call per directory + * being filtered, not one per entry), with `cwd` anchoring the + * invocation inside the repository (any directory inside the repo + * works; `git` resolves the repository root itself). Returns the SUBSET + * of `absolutePaths` (by exact string) that `git` reports as ignored — + * `git check-ignore --stdin` echoes back a matched path in EXACTLY the + * form it was given on stdin, so absolute paths in yield absolute paths + * out, making an exact-string `Set` lookup safe. Never rejects: any + * failure (git disappears mid-session, a non-repository `cwd`, anything + * else) resolves to an empty set — "nothing is reported ignored by git" + * — rather than throwing, so a transient git failure degrades to + * showing everything rather than crashing the caller. + */ + checkIgnore(cwd: string, absolutePaths: readonly string[]): Promise>; +} + +/** Convert a `file://...` {@link Uri} to a real filesystem path for + * {@link GitRunner}'s `cwd`/path arguments (this module's TSDoc's "Why a + * real filesystem PATH"). Never throws: an unparseable `Uri` (should not + * happen for anything `workspace.fs` itself handed back) falls back to the + * raw string — `git` simply reports it as not ignored rather than this + * module crashing over it. */ +export function uriToGitPath(uri: Uri): string { + try { + return fileURLToPath(uri); + } catch { + return uri; + } +} + +/** + * The real {@link GitRunner}, over `Bun.spawn` (design.md §13's own + * `Bun.spawn(["git","--version"])`). The default implementation `ignore.ts` + * uses when no `GitRunner` is injected. + */ +export function createBunGitRunner(): GitRunner { + let cachedAvailable: Promise | undefined; + + async function checkVersion(): Promise { + try { + const proc = Bun.spawn(["git", "--version"], { stdout: "ignore", stderr: "ignore" }); + const exitCode = await proc.exited; + return exitCode === 0; + } catch { + return false; + } + } + + function isAvailable(): Promise { + if (!cachedAvailable) cachedAvailable = checkVersion(); + return cachedAvailable; + } + + async function checkIgnore(cwd: string, absolutePaths: readonly string[]): Promise> { + if (absolutePaths.length === 0) return new Set(); + try { + const proc = Bun.spawn(["git", "check-ignore", "--stdin"], { + cwd, + stdin: "pipe", + stdout: "pipe", + stderr: "ignore", + }); + const stdin = proc.stdin; + stdin.write(`${absolutePaths.join("\n")}\n`); + stdin.end(); + const output = await new Response(proc.stdout).text(); + // `git check-ignore --stdin` exits 1 when NONE of the inputs are + // ignored, and >1 on a genuine error — neither is a reason to + // discard whatever stdout it already produced (exit 1 with empty + // stdout is the common, entirely expected "nothing ignored" case). + await proc.exited; + const matched = output + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + return new Set(matched); + } catch { + return new Set(); + } + } + + return { isAvailable, checkIgnore }; +} diff --git a/packages/builtin/shared/gitignoreMatcher.test.ts b/packages/builtin/shared/gitignoreMatcher.test.ts new file mode 100644 index 0000000..e5d5784 --- /dev/null +++ b/packages/builtin/shared/gitignoreMatcher.test.ts @@ -0,0 +1,104 @@ +/** + * Tests for {@link parseGitignore} (Task 3.3, Req 11.2) — the glob-fallback + * `.gitignore` matcher `ignore.ts` uses when no `git` CLI is available. + * Full pattern coverage (anchors, `**`, negation, dir patterns) is this + * task's own completion requirement. + */ + +import { describe, expect, test } from "bun:test"; +import { parseGitignore } from "./gitignoreMatcher"; + +describe("parseGitignore (Task 3.3, Req 11.2)", () => { + test("a bare pattern with no slash matches the basename at any depth", () => { + const matcher = parseGitignore("*.log"); + expect(matcher.isIgnored("debug.log", false)).toBe(true); + expect(matcher.isIgnored("nested/deep/debug.log", false)).toBe(true); + expect(matcher.isIgnored("debug.txt", false)).toBe(false); + }); + + test("a leading-slash pattern is anchored to the root only", () => { + const matcher = parseGitignore("/build"); + expect(matcher.isIgnored("build", true)).toBe(true); + expect(matcher.isIgnored("nested/build", true)).toBe(false); + }); + + test("a pattern with a slash in the middle is anchored to the root, no leading slash needed", () => { + const matcher = parseGitignore("src/generated"); + expect(matcher.isIgnored("src/generated", true)).toBe(true); + expect(matcher.isIgnored("other/src/generated", true)).toBe(false); + }); + + test("trailing slash makes a pattern directory-only", () => { + const matcher = parseGitignore("dist/"); + expect(matcher.isIgnored("dist", true)).toBe(true); + expect(matcher.isIgnored("dist", false)).toBe(false); + }); + + test("* does not cross a path separator", () => { + const matcher = parseGitignore("/src/*.ts"); + expect(matcher.isIgnored("src/index.ts", false)).toBe(true); + expect(matcher.isIgnored("src/nested/index.ts", false)).toBe(false); + }); + + test("** matches across path separators", () => { + const matcher = parseGitignore("**/*.log"); + expect(matcher.isIgnored("a/b/c/debug.log", false)).toBe(true); + expect(matcher.isIgnored("debug.log", false)).toBe(true); + }); + + test("a trailing /** matches everything inside a directory but not the directory itself", () => { + const matcher = parseGitignore("build/**"); + expect(matcher.isIgnored("build/output.js", false)).toBe(true); + expect(matcher.isIgnored("build/nested/output.js", false)).toBe(true); + expect(matcher.isIgnored("build", true)).toBe(false); + }); + + test("a /**/ in the middle matches zero or more intervening directories", () => { + const matcher = parseGitignore("a/**/b"); + expect(matcher.isIgnored("a/b", false)).toBe(true); + expect(matcher.isIgnored("a/x/b", false)).toBe(true); + expect(matcher.isIgnored("a/x/y/b", false)).toBe(true); + expect(matcher.isIgnored("a/c", false)).toBe(false); + }); + + test("negation un-ignores a later, more specific match", () => { + const matcher = parseGitignore("*.log\n!important.log"); + expect(matcher.isIgnored("debug.log", false)).toBe(true); + expect(matcher.isIgnored("important.log", false)).toBe(false); + }); + + test("a later plain pattern re-ignores after an earlier negation (last match wins)", () => { + const matcher = parseGitignore("!*.log\n*.log"); + expect(matcher.isIgnored("debug.log", false)).toBe(true); + }); + + test("comments and blank lines are ignored", () => { + const matcher = parseGitignore("# a comment\n\n*.log\n \n"); + expect(matcher.isIgnored("debug.log", false)).toBe(true); + expect(matcher.isIgnored("# a comment", false)).toBe(false); + }); + + test("an escaped leading # is treated as a literal pattern character, not a comment", () => { + const matcher = parseGitignore("\\#important"); + expect(matcher.isIgnored("#important", false)).toBe(true); + }); + + test("no patterns at all (empty file) ignores nothing", () => { + const matcher = parseGitignore(""); + expect(matcher.isIgnored("anything.ts", false)).toBe(false); + }); + + test("multiple independent patterns all apply", () => { + const matcher = parseGitignore("node_modules/\n*.log\n/dist"); + expect(matcher.isIgnored("node_modules", true)).toBe(true); + expect(matcher.isIgnored("debug.log", false)).toBe(true); + expect(matcher.isIgnored("dist", true)).toBe(true); + expect(matcher.isIgnored("src/index.ts", false)).toBe(false); + }); + + test("a pathological line (lone '!' or '/') does not throw and matches nothing", () => { + expect(() => parseGitignore("!\n/\n*.log")).not.toThrow(); + const matcher = parseGitignore("!\n/\n*.log"); + expect(matcher.isIgnored("debug.log", false)).toBe(true); + }); +}); diff --git a/packages/builtin/shared/gitignoreMatcher.ts b/packages/builtin/shared/gitignoreMatcher.ts new file mode 100644 index 0000000..ed92501 --- /dev/null +++ b/packages/builtin/shared/gitignoreMatcher.ts @@ -0,0 +1,180 @@ +/** + * A minimal `.gitignore` glob matcher (Task 3.3, Req 11.2; design.md §13's + * "otherwise a minimal `.gitignore` glob matcher handles the common + * patterns"): the glob-fallback half of the explorer's `.gitignore`-aware + * visibility, used whenever the `git` CLI is unavailable ({@link + * ../gitRunner.ts}'s `GitRunner.isAvailable` reports `false`) — `ignore.ts` + * is the module that actually picks between this and the git-backed path. + * + * **Scope, matching this task's plan**: supports `*` (any run of characters + * except `/`), `**` (any run of characters, `/` included), `?` is + * deliberately NOT supported (not asked for by this task's plan, and + * `.gitignore` files overwhelmingly use `*`/`**`, not `?`), `!` negation + * (a later matching pattern overrides an earlier one, per real `.gitignore` + * semantics), trailing-`/` directory-only patterns, and anchoring — a + * pattern containing a `/` anywhere other than a trailing position (i.e. a + * leading `/`, or a `/` in the middle) is anchored to the root; a pattern + * with no other `/` matches the basename at ANY depth (equivalent to + * prefixing it with a leading `**` + `/`), exactly like real `.gitignore`. Character + * classes (`[abc]`) are not supported — outside this task's stated scope. + * + * **Single root `.gitignore` only** (this module's caller, `ignore.ts`'s + * TSDoc): real `git` respects a whole CHAIN of `.gitignore` files (one per + * directory, plus global excludes) — this glob fallback only ever sees ONE + * file's content (the workspace root's `.gitignore`, if any), with every + * candidate path normalized ROOT-RELATIVE before matching (this task's + * plan: "paths normalized root-relative before matching"). A documented + * MVP simplification: covers the overwhelmingly common case (a single + * top-level `.gitignore`) without emulating git's full nested-file + * resolution; whenever the real `git` CLI is available, {@link + * ../gitRunner.ts}'s batched `git check-ignore` is used instead and this + * limitation does not apply at all. + */ + +/** One compiled `.gitignore` pattern (this module's TSDoc). */ +interface CompiledPattern { + /** `true` for a `!`-prefixed pattern — a later match against this + * pattern UN-ignores a path an earlier pattern ignored. */ + negate: boolean; + /** `true` for a trailing-`/` pattern — only ever matches a directory. */ + dirOnly: boolean; + regex: RegExp; +} + +/** Escape every regex metacharacter in `segment` EXCEPT the glob + * wildcards this module itself interprets (`*`, handled by the caller + * before this ever runs) — used on whatever literal text remains between + * wildcards. */ +function escapeRegexLiteral(segment: string): string { + return segment.replace(/[.+^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Compile one `.gitignore` GLOB (the pattern text with `!`/trailing-`/` + * already stripped by {@link compileGitignoreLine}) into a `RegExp` that + * matches a root-relative path (this module's TSDoc's "Scope"). + * `anchored` decides whether the compiled regex is anchored to the START + * of the path (a `/`-containing pattern) or may match starting at any + * path-segment boundary (a bare basename pattern, effectively `**\/pattern`). + */ +function compileGlobToRegex(pattern: string, anchored: boolean): RegExp { + // Walk the pattern left to right, translating each `**` / `*` / literal + // run in turn — simpler and less error-prone than one giant `.replace` + // chain operating on overlapping wildcard forms. + let body = ""; + let i = 0; + while (i < pattern.length) { + if (pattern.startsWith("**/", i)) { + body += "(?:.*/)?"; + i += 3; + } else if (pattern.startsWith("**", i)) { + body += ".*"; + i += 2; + } else if (pattern[i] === "*") { + body += "[^/]*"; + i += 1; + } else { + // Consume the longest literal run up to the next wildcard so + // `escapeRegexLiteral` sees whole chunks rather than one character + // calls (cosmetic; behaves identically either way). + let j = i; + while (j < pattern.length && pattern[j] !== "*") j++; + body += escapeRegexLiteral(pattern.slice(i, j)); + i = j; + } + } + const prefix = anchored ? "^" : "^(?:.*/)?"; + return new RegExp(`${prefix}${body}$`); +} + +/** + * Compile one non-blank, non-comment `.gitignore` line (this module's + * TSDoc). Returns `undefined` for a line that, once trimmed, is empty (a + * blank line, or a lone `!`/`/` with nothing left to match). + */ +function compileGitignoreLine(rawLine: string): CompiledPattern | undefined { + let line = rawLine; + // A line ending in whitespace is trimmed UNLESS that whitespace is + // backslash-escaped (real `.gitignore` semantics) — this module only + // handles the common, non-escaped case: trim trailing unescaped spaces. + line = line.replace(/(? false }; + +/** + * Parse `.gitignore` file content (Task 3.3, Req 11.2) into a reusable + * {@link GitignoreMatcher}. Comment lines (`#`, unless escaped as `\#`) and + * blank lines are skipped, per real `.gitignore` syntax. Never throws — a + * malformed line simply compiles to nothing rather than aborting the whole + * file (this codebase's "a partial/bad input degrades gracefully" + * convention, e.g. `walkFiles.ts`'s unreadable-directory handling). + */ +export function parseGitignore(content: string): GitignoreMatcher { + const patterns: CompiledPattern[] = []; + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine; + if (line.trim().length === 0) continue; + if (line.startsWith("#")) continue; + try { + const compiled = compileGitignoreLine(line.startsWith("\\#") ? line.slice(1) : line); + if (compiled) patterns.push(compiled); + } catch { + // A pathological line (this module's TSDoc): skip it, keep parsing + // the rest of the file. + } + } + if (patterns.length === 0) return EMPTY_MATCHER; + + function isIgnored(relativePath: string, isDirectory: boolean): boolean { + let ignored = false; + for (const pattern of patterns) { + if (pattern.dirOnly && !isDirectory) continue; + if (pattern.regex.test(relativePath)) { + ignored = !pattern.negate; + } + } + return ignored; + } + + return { isIgnored }; +} diff --git a/packages/builtin/shared/ignore.test.ts b/packages/builtin/shared/ignore.test.ts index d978ad9..542b094 100644 --- a/packages/builtin/shared/ignore.test.ts +++ b/packages/builtin/shared/ignore.test.ts @@ -1,31 +1,244 @@ /** - * Tests for {@link createDefaultIgnorer} (Task 3.2, Req 11.3) — the interim - * ignore stub `walkFiles.ts` uses by default (see `ignore.ts`'s TSDoc for - * why this is intentionally minimal ahead of Task 3.3's real one). + * Tests for {@link createIgnoreChecker} (Task 3.3, Req 11.2) — stubs + * {@link GitRunner} BOTH ways (git present / absent → glob fallback, this + * task's completion requirement) with local fakes, never a real `git` + * subprocess (this suite's house convention: no mock libraries, local + * fakes only). */ import { describe, expect, test } from "bun:test"; -import { createDefaultIgnorer } from "./ignore"; - -describe("createDefaultIgnorer (Task 3.2, Req 11.3)", () => { - test("excludes .git and node_modules directories", () => { - const ignore = createDefaultIgnorer(); - expect(ignore(".git", true)).toBe(true); - expect(ignore("node_modules", true)).toBe(true); - expect(ignore(".hg", true)).toBe(true); - expect(ignore(".svn", true)).toBe(true); +import type { DirEntry, Uri } from "@tecode/api"; +import { createIgnoreChecker, type FilterEntriesOptions } from "./ignore"; +import type { GitRunner } from "./gitRunner"; + +const ROOT: Uri = "file:///workspace/"; + +function entry(name: string, type: DirEntry["type"] = "file"): DirEntry { + return { name, type }; +} + +function names(entries: DirEntry[]): string[] { + return entries.map((e) => e.name); +} + +/** A fake `GitRunner` that reports `git` unavailable — the glob-fallback + * path. */ +function createUnavailableGitRunner(): GitRunner { + return { + isAvailable: async () => false, + checkIgnore: async () => { + throw new Error("must not be called when git is unavailable"); + }, + }; +} + +/** A fake `GitRunner` that reports `git` available and ignores every + * absolute path whose basename is in `ignoredBasenames` — enough to prove + * the git path is actually exercised (batched call included) without a + * real subprocess. */ +function createFakeGitRunner( + ignoredBasenames: ReadonlySet, +): GitRunner & { calls: Array<{ cwd: string; paths: readonly string[] }> } { + const calls: Array<{ cwd: string; paths: readonly string[] }> = []; + return { + calls, + isAvailable: async () => true, + checkIgnore: async (cwd, paths) => { + calls.push({ cwd, paths }); + const ignored = paths.filter((p) => ignoredBasenames.has(p.split("/").pop() ?? "")); + return new Set(ignored); + }, + }; +} + +function baseOptions(entries: DirEntry[], overrides: Partial = {}): FilterEntriesOptions { + return { + rootUri: ROOT, + dirUri: ROOT, + relativeDir: "", + entries, + ...overrides, + }; +} + +describe("createIgnoreChecker (Task 3.3, Req 11.2)", () => { + describe("with no dependencies at all", () => { + test("still hides dotfiles and the always-ignored VCS/dependency directory names", async () => { + const checker = createIgnoreChecker(); + const visible = await checker.filterEntries( + baseOptions([ + entry(".git", "directory"), + entry(".env"), + entry("node_modules", "directory"), + entry("src", "directory"), + entry("index.ts"), + ]), + ); + expect(names(visible)).toEqual(["src", "index.ts"]); + }); + + test("never excludes a FILE named like an always-ignored directory (dirs-only rule)", async () => { + const checker = createIgnoreChecker(); + const visible = await checker.filterEntries(baseOptions([entry("node_modules", "file")])); + expect(names(visible)).toEqual(["node_modules"]); + }); }); - test("does not exclude an ordinary source directory or file", () => { - const ignore = createDefaultIgnorer(); - expect(ignore("src", true)).toBe(false); - expect(ignore("packages", true)).toBe(false); - expect(ignore("index.ts", false)).toBe(false); + describe("showHidden bypass (Req 9.5)", () => { + test("bypasses dotfile hiding AND the always-ignored directory names", async () => { + const checker = createIgnoreChecker(); + const visible = await checker.filterEntries( + baseOptions([entry(".git", "directory"), entry("node_modules", "directory"), entry("src", "directory")], { + showHidden: true, + }), + ); + expect(names(visible)).toEqual([".git", "node_modules", "src"]); + }); + + test("bypasses git check-ignore too", async () => { + const gitRunner = createFakeGitRunner(new Set(["ignored.ts"])); + const checker = createIgnoreChecker({ gitRunner }); + const visible = await checker.filterEntries( + baseOptions([entry("ignored.ts"), entry("kept.ts")], { showHidden: true }), + ); + expect(names(visible)).toEqual(["ignored.ts", "kept.ts"]); + expect(gitRunner.calls).toEqual([]); + }); + + test("re-evaluated fresh on every call — no restart needed to take effect", async () => { + const checker = createIgnoreChecker(); + const entries = [entry(".env")]; + expect(names(await checker.filterEntries(baseOptions(entries, { showHidden: false })))).toEqual([]); + expect(names(await checker.filterEntries(baseOptions(entries, { showHidden: true })))).toEqual([".env"]); + expect(names(await checker.filterEntries(baseOptions(entries, { showHidden: false })))).toEqual([]); + }); }); - test("never excludes a FILE named like an ignored directory (dirs-only rule)", () => { - const ignore = createDefaultIgnorer(); - expect(ignore(".git", false)).toBe(false); - expect(ignore("node_modules", false)).toBe(false); + describe("git available (batched check-ignore)", () => { + test("filters out entries git reports ignored, keeps the rest", async () => { + const gitRunner = createFakeGitRunner(new Set(["dist"])); + const checker = createIgnoreChecker({ gitRunner }); + const visible = await checker.filterEntries( + baseOptions([entry("dist", "directory"), entry("src", "directory"), entry("index.ts")]), + ); + expect(names(visible)).toEqual(["src", "index.ts"]); + }); + + test("batches the whole directory's candidates into ONE checkIgnore call", async () => { + const gitRunner = createFakeGitRunner(new Set()); + const checker = createIgnoreChecker({ gitRunner }); + await checker.filterEntries(baseOptions([entry("a.ts"), entry("b.ts"), entry("c.ts")])); + expect(gitRunner.calls).toHaveLength(1); + expect(gitRunner.calls[0]?.paths).toHaveLength(3); + }); + + test("never calls readFile (.gitignore content) when git is available", async () => { + let readFileCalled = false; + const gitRunner = createFakeGitRunner(new Set()); + const checker = createIgnoreChecker({ + gitRunner, + readFile: async () => { + readFileCalled = true; + return new TextEncoder().encode(""); + }, + }); + await checker.filterEntries(baseOptions([entry("a.ts")])); + expect(readFileCalled).toBe(false); + }); + + test("dotfiles and always-ignored directory names are excluded before git is even consulted", async () => { + const gitRunner = createFakeGitRunner(new Set()); + const checker = createIgnoreChecker({ gitRunner }); + await checker.filterEntries(baseOptions([entry(".git", "directory"), entry("src", "directory")])); + expect(gitRunner.calls).toHaveLength(1); + expect(gitRunner.calls[0]?.paths).toEqual(["/workspace/src"]); + }); + + test("a checkIgnore failure degrades to 'nothing further ignored' rather than throwing", async () => { + const gitRunner: GitRunner = { + isAvailable: async () => true, + checkIgnore: async () => { + throw new Error("git exploded"); + }, + }; + const checker = createIgnoreChecker({ gitRunner }); + const visible = await checker.filterEntries(baseOptions([entry("a.ts")])); + expect(names(visible)).toEqual(["a.ts"]); + }); + + test("an isAvailable failure falls back to the glob path rather than throwing", async () => { + const gitRunner: GitRunner = { + isAvailable: async () => { + throw new Error("spawn failed"); + }, + checkIgnore: async () => new Set(), + }; + const checker = createIgnoreChecker({ + gitRunner, + readFile: async () => new TextEncoder().encode("*.log"), + }); + const visible = await checker.filterEntries(baseOptions([entry("debug.log"), entry("keep.ts")])); + expect(names(visible)).toEqual(["keep.ts"]); + }); + }); + + describe("git unavailable (glob fallback over the root .gitignore)", () => { + test("applies the root .gitignore's patterns", async () => { + const checker = createIgnoreChecker({ + gitRunner: createUnavailableGitRunner(), + readFile: async () => new TextEncoder().encode("*.log\n/dist"), + }); + const visible = await checker.filterEntries( + baseOptions([entry("debug.log"), entry("dist", "directory"), entry("src", "directory")]), + ); + expect(names(visible)).toEqual(["src"]); + }); + + test("no readFile dependency at all: nothing further is ignored beyond dotfiles/always-ignored", async () => { + const checker = createIgnoreChecker({ gitRunner: createUnavailableGitRunner() }); + const visible = await checker.filterEntries(baseOptions([entry("a.ts"), entry("b.log")])); + expect(names(visible)).toEqual(["a.ts", "b.log"]); + }); + + test("a readFile rejection (no .gitignore file) degrades to 'nothing further ignored'", async () => { + const checker = createIgnoreChecker({ + gitRunner: createUnavailableGitRunner(), + readFile: async () => { + throw new Error("ENOENT"); + }, + }); + const visible = await checker.filterEntries(baseOptions([entry("a.ts")])); + expect(names(visible)).toEqual(["a.ts"]); + }); + + test("paths are normalized root-relative before matching a nested directory", async () => { + const checker = createIgnoreChecker({ + gitRunner: createUnavailableGitRunner(), + readFile: async () => new TextEncoder().encode("/src/*.generated.ts"), + }); + const visible = await checker.filterEntries( + baseOptions([entry("a.generated.ts"), entry("b.ts")], { + dirUri: "file:///workspace/src/", + relativeDir: "src", + }), + ); + expect(names(visible)).toEqual(["b.ts"]); + }); + + test("the same root .gitignore is only read once across multiple directories (cached per checker)", async () => { + let readCount = 0; + const checker = createIgnoreChecker({ + gitRunner: createUnavailableGitRunner(), + readFile: async () => { + readCount += 1; + return new TextEncoder().encode("*.log"); + }, + }); + await checker.filterEntries(baseOptions([entry("a.ts")])); + await checker.filterEntries( + baseOptions([entry("b.ts")], { dirUri: "file:///workspace/src/", relativeDir: "src" }), + ); + expect(readCount).toBe(1); + }); }); }); diff --git a/packages/builtin/shared/ignore.ts b/packages/builtin/shared/ignore.ts index c762e8a..5010de7 100644 --- a/packages/builtin/shared/ignore.ts +++ b/packages/builtin/shared/ignore.ts @@ -1,46 +1,200 @@ /** - * An interim ignore predicate for `walkFiles.ts` (Task 3.2, Req 11.3): file - * quick-open needs SOME notion of "don't walk into this directory" before - * offering a real one, but the real `.gitignore`-aware logic — batched - * `git check-ignore` when the `git` CLI exists, a minimal glob matcher - * otherwise, plus the `explorer.showHidden` setting — is Task 3.3's job - * (design.md §13's `explorer` design, tasks.md's Task 3.3), shared with the - * explorer built-in once it lands. + * The real `.gitignore`-aware visibility helper (Task 3.3, Req 11.2; + * design.md §13's `explorer` design) that replaces this module's earlier + * interim stub (Task 3.2's deliberately dumb `createDefaultIgnorer`, kept + * only in this file's git history now). Shared by `walkFiles.ts` (the + * command-palette's `ctrl+p` file quick-open) AND the explorer built-in's + * own directory listings — the exact "one ignore-aware walk the whole + * codebase shares" this task's issue calls for. * - * **Deliberately dumb for now** (this task's plan): excludes only the - * handful of directory names that are *always* noise for a source-code - * quick-open regardless of any `.gitignore` content — version-control - * metadata and the one dependency-manager directory this monorepo itself - * uses. Nothing else is excluded; a real `dist`/`build`/`.env` etc. is only - * ever filtered once Task 3.3's real ignore logic replaces this. + * **Decision order per directory** (design.md §13): for one `readdir` + * batch, + * 1. `showHidden: true` bypasses EVERYTHING below — every entry is + * visible, unconditionally (Req 9.5's `explorer.showHidden`). + * 2. Otherwise, dotfile hiding (any entry whose name starts with `.`) and + * a small always-ignored set of version-control/dependency directory + * names ({@link ALWAYS_IGNORED_DIR_NAMES} — carried over from Task + * 3.2's interim stub, since these are noise regardless of what a + * project's own `.gitignore` says) are applied first. + * 3. Whatever survives step 2 is then checked against `.gitignore` + * content: batched `git check-ignore --stdin` (`gitRunner.ts`) when the + * `git` CLI is available, or {@link parseGitignore}'s glob matcher over + * the WORKSPACE ROOT's `.gitignore` file otherwise (its own module's + * TSDoc documents the "single root file only" simplification the glob + * path makes — the git path has no such limitation, since `git + * check-ignore` itself resolves the real, full chain of `.gitignore` + * files). * - * **Swappable by design**: {@link Ignorer} is a plain function type, and - * `walkFiles`'s `deps.ignore` is optional — passing a different - * implementation (e.g. Task 3.3's real one) requires no change to - * `walkFiles.ts` itself, just a different value at the call site. + * **`readFile`, not a raw path** ({@link IgnoreCheckerDeps.readFile}): the + * glob fallback needs the root `.gitignore`'s CONTENT, which — per this + * package's "never reach a real filesystem directly" discipline + * (`walkFiles.ts`'s TSDoc) — must come through `tecode.workspace.fs.read`'s + * exact signature, so a caller (the explorer built-in, `walkFiles.ts`'s own + * default) can pass `api.workspace.fs.read` directly with no adapter, the + * same pattern `WalkFilesDeps.readdir` already established. */ -/** Whether a directory entry named `name` should be skipped entirely - * (never descended into, never listed) — checked once per entry by - * `walkFiles.ts`. `isDirectory` is provided so a future real - * implementation (Task 3.3) can apply directory-only or file-and-directory - * rules differently; this interim default only ever ignores directories. */ -export type Ignorer = (name: string, isDirectory: boolean) => boolean; +import type { DirEntry, Uri } from "@tecode/api"; +import { type GitignoreMatcher, parseGitignore } from "./gitignoreMatcher"; +import type { GitRunner } from "./gitRunner"; +import { uriToGitPath } from "./gitRunner"; -/** Directory names this interim ignorer always excludes (this module's - * TSDoc) — version-control metadata directories and `node_modules`. */ -const DEFAULT_IGNORED_DIR_NAMES: ReadonlySet = new Set([ +/** Directory names ALWAYS excluded regardless of `.gitignore` content or + * git availability (this module's TSDoc's "always-ignored set") — carried + * over unchanged from Task 3.2's interim `createDefaultIgnorer`. Bypassed + * entirely by `showHidden: true`, same as every other rule here. */ +const ALWAYS_IGNORED_DIR_NAMES: ReadonlySet = new Set([ ".git", ".hg", ".svn", "node_modules", ]); +/** Dependencies for {@link createIgnoreChecker}. Both optional: an + * {@link createIgnoreChecker} with neither still applies dotfile hiding and + * {@link ALWAYS_IGNORED_DIR_NAMES} (this module's steps 1-2), it just never + * has any `.gitignore` content or `git` to additionally consult (step 3 + * always reports "nothing further ignored"). */ +export interface IgnoreCheckerDeps { + /** Reads a file's bytes — matches `@tecode/api`'s exact `FileSystem. + * read(uri): Promise` signature (this module's TSDoc), used + * ONLY to load the workspace root's `.gitignore` for the glob fallback + * (never called when `gitRunner` reports `git` available for a given + * directory). Omitted: the glob fallback has no `.gitignore` content to + * apply (equivalent to an empty/absent `.gitignore`). */ + readFile?: (uri: Uri) => Promise; + /** Batched `git check-ignore` (`gitRunner.ts`). Omitted: always uses the + * glob fallback, as if `git` were unavailable. */ + gitRunner?: GitRunner; +} + +/** One `filterEntries` call's worth of context (this module's TSDoc) — one + * directory's already-`readdir`'d entries, batched together exactly like + * `git check-ignore --stdin` wants (design.md §13). */ +export interface FilterEntriesOptions { + /** The workspace root — the glob fallback's `.gitignore` is read from + * here (this module's TSDoc); unused on the git path. */ + rootUri: Uri; + /** The directory `entries` came from — `git check-ignore`'s `cwd` and + * the base every entry's absolute path is built from. */ + dirUri: Uri; + /** `dirUri`'s path relative to `rootUri`, `/`-joined, no leading/trailing + * slash, `""` for the root itself — used to build each entry's + * ROOT-RELATIVE path for the glob fallback (this task's plan: "paths + * normalized root-relative before matching"). Unused on the git path + * (which works in absolute paths). */ + relativeDir: string; + entries: readonly DirEntry[]; + /** Req 9.5's `explorer.showHidden` — bypasses EVERYTHING (this module's + * TSDoc's step 1). Defaults to `false`. Read fresh on every call (not + * cached), so a caller re-invoking this after the setting changes gets + * the new behavior immediately with no restart (Task 3.3's "showHidden + * toggle reflects without restart"). */ + showHidden?: boolean; +} + +/** The real ignore-aware visibility helper (this module's TSDoc). */ +export interface IgnoreChecker { + /** Filter one `readdir` batch down to what should actually be visible + * (this module's TSDoc's 3-step decision order). Preserves `entries`' + * relative order. Never throws: a `gitRunner`/`readFile` failure + * degrades to "nothing further ignored" for that call (this module's + * TSDoc's per-dependency fallback), never an exception out of this + * method. */ + filterEntries(options: FilterEntriesOptions): Promise; +} + +/** Join `.gitignore` onto `rootUri` (mirrors `walkFiles.ts`'s own + * `joinChildUri`, duplicated locally rather than imported so this module + * has no dependency on `walkFiles.ts` — the dependency runs the other way, + * `walkFiles.ts` depends on THIS module). No percent-encoding needed: the + * literal filename `.gitignore` has no characters `encodeURIComponent` + * would ever touch. */ +function rootGitignoreUri(rootUri: Uri): Uri { + const base = rootUri.endsWith("/") ? rootUri : `${rootUri}/`; + return `${base}.gitignore`; +} + /** - * Build the interim default {@link Ignorer} (this module's TSDoc): - * excludes {@link DEFAULT_IGNORED_DIR_NAMES} directories, includes - * everything else. + * Build an {@link IgnoreChecker} (Task 3.3, Req 11.2). Neither dependency + * is required — see {@link IgnoreCheckerDeps}'s TSDoc for what an + * omitted one degrades to. */ -export function createDefaultIgnorer(): Ignorer { - return (name, isDirectory) => isDirectory && DEFAULT_IGNORED_DIR_NAMES.has(name); +export function createIgnoreChecker(deps: IgnoreCheckerDeps = {}): IgnoreChecker { + // Cached per workspace root actually seen (a single `IgnoreChecker` + // instance is expected to serve exactly one workspace root for its + // whole lifetime — `walkFiles.ts`'s one call, or the explorer's one + // session — but keying by `rootUri` costs nothing and avoids any + // surprise if that ever changes). Loaded lazily, at most once per root: + // re-reading the SAME `.gitignore` on every directory visited during one + // walk/session would be wasteful for no correctness benefit within a + // single walk; a `.gitignore` edited mid-session is a documented, + // acceptable MVP limitation (this module's TSDoc does not promise live + // `.gitignore`-content reloading, only live `showHidden` reloading). + const gitignoreCache = new Map>(); + + async function loadGitignoreMatcher(rootUri: Uri): Promise { + const cached = gitignoreCache.get(rootUri); + if (cached) return cached; + const loaded = (async () => { + if (!deps.readFile) return parseGitignore(""); + try { + const bytes = await deps.readFile(rootGitignoreUri(rootUri)); + return parseGitignore(new TextDecoder().decode(bytes)); + } catch { + // No `.gitignore` file, or unreadable — treat as an empty one + // (this module's TSDoc's per-dependency fallback). + return parseGitignore(""); + } + })(); + gitignoreCache.set(rootUri, loaded); + return loaded; + } + + async function isGitAvailable(): Promise { + if (!deps.gitRunner) return false; + try { + return await deps.gitRunner.isAvailable(); + } catch { + // Documented never-throw on GitRunner, guarded anyway (matches this + // codebase's "guard even a documented never-throw dependency" + // convention, e.g. `slotRegistry.ts`'s `requestActivation`). + return false; + } + } + + async function filterEntries(options: FilterEntriesOptions): Promise { + const { rootUri, dirUri, relativeDir, entries, showHidden } = options; + if (showHidden) return [...entries]; + + const candidates = entries.filter((entry) => { + if (entry.name.startsWith(".")) return false; + if (entry.type === "directory" && ALWAYS_IGNORED_DIR_NAMES.has(entry.name)) return false; + return true; + }); + if (candidates.length === 0) return []; + + if (await isGitAvailable()) { + // `fileURLToPath` preserves a directory URL's trailing slash (e.g. + // `"file:///workspace/"` -> `"/workspace/"`) — stripped here so the + // join below never produces a doubled `//` in front of `entry.name`. + const dirPath = uriToGitPath(dirUri).replace(/\/+$/, ""); + const absolutePaths = candidates.map((entry) => `${dirPath}/${entry.name}`); + let ignored: ReadonlySet; + try { + ignored = await deps.gitRunner!.checkIgnore(dirPath, absolutePaths); + } catch { + ignored = new Set(); + } + return candidates.filter((_, index) => !ignored.has(absolutePaths[index]!)); + } + + const matcher = await loadGitignoreMatcher(rootUri); + return candidates.filter((entry) => { + const relativePath = relativeDir.length > 0 ? `${relativeDir}/${entry.name}` : entry.name; + return !matcher.isIgnored(relativePath, entry.type === "directory"); + }); + } + + return { filterEntries }; } diff --git a/packages/builtin/shared/index.ts b/packages/builtin/shared/index.ts index b4483d2..9a6dc9b 100644 --- a/packages/builtin/shared/index.ts +++ b/packages/builtin/shared/index.ts @@ -1,14 +1,22 @@ /** - * Pure utilities shared across built-in extensions (Task 3.2, Req 11.3), - * imported for the first time by `command-palette` — `explorer` (Task 3.3) - * is expected to reuse `ignore.ts`'s real successor and `walkFiles.ts`'s - * traversal shape once it lands. Everything here imports only - * `@tecode/api` types (plus platform globals like `URL`) — never - * `@tecode/core` — the same ESLint layering rule every `packages/builtin/**` - * file is already held to (`eslint.config.mjs`). + * Pure utilities shared across built-in extensions (Task 3.2/3.3, Req + * 11.2, 11.3) — `command-palette`'s `ctrl+p` file quick-open and the + * `explorer` built-in both consume `ignore.ts`'s real `.gitignore`-aware + * `IgnoreChecker` and (`command-palette` only) `walkFiles.ts`'s recursive + * traversal shape. Everything here imports only `@tecode/api` types (plus + * platform globals like `URL`/`Bun.spawn` and Node/Bun builtins like + * `node:url`) — never `@tecode/core` — the same ESLint layering rule every + * `packages/builtin/**` file is already held to (`eslint.config.mjs`). */ export { fuzzyMatch, type FuzzyMatchResult } from "./fuzzyMatch"; export { evaluateWhen, filterByWhen, type WhenContextGetter } from "./whenFilter"; -export { createDefaultIgnorer, type Ignorer } from "./ignore"; -export { walkFiles, type WalkedFile, type WalkFilesDeps, type WalkFilesResult } from "./walkFiles"; +export { + createIgnoreChecker, + type FilterEntriesOptions, + type IgnoreChecker, + type IgnoreCheckerDeps, +} from "./ignore"; +export { parseGitignore, type GitignoreMatcher } from "./gitignoreMatcher"; +export { createBunGitRunner, uriToGitPath, type GitRunner } from "./gitRunner"; +export { joinChildUri, walkFiles, type WalkedFile, type WalkFilesDeps, type WalkFilesResult } from "./walkFiles"; diff --git a/packages/builtin/shared/walkFiles.test.ts b/packages/builtin/shared/walkFiles.test.ts index c93efb7..9f3bcaf 100644 --- a/packages/builtin/shared/walkFiles.test.ts +++ b/packages/builtin/shared/walkFiles.test.ts @@ -70,7 +70,7 @@ describe("walkFiles (Task 3.2, Req 11.3)", () => { expect(first).toEqual(second); }); - test("excludes .git and node_modules by default (ignore.ts's interim stub)", async () => { + test("excludes .git and node_modules by default (ignore.ts's real ignore-aware default)", async () => { const tree: FakeTree = { ".git": { HEAD: null }, node_modules: { "some-pkg": { "index.js": null } }, @@ -80,18 +80,27 @@ describe("walkFiles (Task 3.2, Req 11.3)", () => { expect(files.map((f) => f.relativePath)).toEqual(["src/index.ts"]); }); - test("a custom ignore predicate overrides the default (swappable interface)", async () => { + test("a custom IgnoreChecker overrides the default (swappable interface, Task 3.3)", async () => { const tree: FakeTree = { "keep.ts": null, "skip.ts": null, }; const { files } = await walkFiles(ROOT, { readdir: createFakeReaddir(tree), - ignore: (name) => name === "skip.ts", + ignore: { filterEntries: async ({ entries }) => entries.filter((e) => e.name !== "skip.ts") }, }); expect(files.map((f) => f.relativePath)).toEqual(["keep.ts"]); }); + test("showHidden bypasses the default ignore logic entirely (Req 9.5)", async () => { + const tree: FakeTree = { + ".git": { HEAD: null }, + "visible.ts": null, + }; + const { files } = await walkFiles(ROOT, { readdir: createFakeReaddir(tree), showHidden: true }); + expect(files.map((f) => f.relativePath).sort()).toEqual([".git/HEAD", "visible.ts"]); + }); + test("an unreadable directory is skipped rather than throwing", async () => { const deps: WalkFilesDeps = { readdir: async (uri) => { diff --git a/packages/builtin/shared/walkFiles.ts b/packages/builtin/shared/walkFiles.ts index 6bbf14c..d12dbfc 100644 --- a/packages/builtin/shared/walkFiles.ts +++ b/packages/builtin/shared/walkFiles.ts @@ -19,7 +19,9 @@ * one join operation this module needs — a child name onto a directory * URI — using the platform-global `URL` constructor rather than * `node:url`'s `pathToFileURL`/`fileURLToPath` round-trip, so this module - * never has to convert a URI to a filesystem path at all. + * never has to convert a URI to a filesystem path at all. Exported (Task + * 3.3) so the explorer built-in reuses the exact same join logic for its + * own create/rename URI-building rather than duplicating it. * * **Deterministic ordering** (this task's plan): each directory's entries * are sorted by name before recursing/collecting, so two walks over the @@ -49,10 +51,22 @@ * can surface that to the user instead of silently showing a partial list * with no indication it's incomplete. Omitting `maxResults` walks the whole * tree exactly as before (unchanged behavior). + * + * **Real `.gitignore`-aware ignore logic (Task 3.3, Req 11.2)**: {@link + * WalkFilesDeps.ignore} now takes `ignore.ts`'s real {@link IgnoreChecker} + * (batched per directory, git-or-glob, `showHidden`-bypassable) rather than + * Task 3.2's interim per-entry `Ignorer` stub — the exact "one ignore-aware + * walk `ctrl+p` and the explorer both use" this task's issue calls for. + * Each directory's sorted entries are handed to {@link IgnoreChecker. + * filterEntries} as one batch (matching `git check-ignore --stdin`'s own + * batched-per-directory design, `gitRunner.ts`), and only the SURVIVING + * entries are recursed into/collected — an ignored directory is never + * `readdir`'d at all, the same "don't even look inside an ignored + * directory" behavior the interim stub already had. */ import type { DirEntry, FileType, Uri } from "@tecode/api"; -import { createDefaultIgnorer, type Ignorer } from "./ignore"; +import { createIgnoreChecker, type IgnoreChecker } from "./ignore"; /** One file found by {@link walkFiles}. */ export interface WalkedFile { @@ -71,9 +85,14 @@ export interface WalkedFile { * passed directly (this module's TSDoc). */ export interface WalkFilesDeps { readdir(uri: Uri): Promise; - /** Defaults to {@link createDefaultIgnorer}'s interim stub (`ignore.ts`'s - * TSDoc) when omitted. */ - ignore?: Ignorer; + /** The real ignore-aware visibility helper (Task 3.3, `ignore.ts`'s + * TSDoc) — defaults to {@link createIgnoreChecker}'s no-dependencies + * form (dotfile hiding + the always-ignored VCS/dependency directory + * names, no `.gitignore`/`git` consultation) when omitted. */ + ignore?: IgnoreChecker; + /** Req 9.5's `explorer.showHidden` — bypasses {@link ignore} entirely + * for this walk (`ignore.ts`'s TSDoc). Defaults to `false`. */ + showHidden?: boolean; /** Stop collecting once this many files have been found, abandoning the * traversal outright rather than walking everything and truncating after * (this module's TSDoc's "Bounded scans"). Omit for an unbounded walk @@ -97,7 +116,7 @@ export interface WalkFilesResult { * missing. `name` is percent-encoded so a literal `#`/`?`/`%`/etc. in a * real filename round-trips as one path segment rather than being parsed * as a fragment/query/escape by `URL`. */ -function joinChildUri(dirUri: Uri, name: string): Uri { +export function joinChildUri(dirUri: Uri, name: string): Uri { const base = dirUri.endsWith("/") ? dirUri : `${dirUri}/`; return new URL(encodeURIComponent(name), base).href; } @@ -114,8 +133,8 @@ const DIRECTORY_TYPE: FileType = "directory"; * {@link WalkFilesResult.truncated}. */ export async function walkFiles(rootUri: Uri, deps: WalkFilesDeps): Promise { - const ignore = deps.ignore ?? createDefaultIgnorer(); - const { maxResults } = deps; + const ignore = deps.ignore ?? createIgnoreChecker(); + const { maxResults, showHidden } = deps; const results: WalkedFile[] = []; let truncated = false; @@ -137,15 +156,21 @@ export async function walkFiles(rootUri: Uri, deps: WalkFilesDeps): Promise a.name.localeCompare(b.name)); - for (const entry of sorted) { + const visible = await ignore.filterEntries({ + rootUri, + dirUri, + relativeDir: relativePrefix, + entries: sorted, + showHidden, + }); + + for (const entry of visible) { if (capReached()) { truncated = true; return; } const isDirectory = entry.type === DIRECTORY_TYPE; - if (ignore(entry.name, isDirectory)) continue; - const relativePath = relativePrefix.length > 0 ? `${relativePrefix}/${entry.name}` : entry.name; const childUri = joinChildUri(dirUri, entry.name); diff --git a/packages/builtin/tsconfig.json b/packages/builtin/tsconfig.json index 9807382..214a321 100644 --- a/packages/builtin/tsconfig.json +++ b/packages/builtin/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "../../tsconfig.json", - "include": ["**/*.ts"] + "include": ["**/*.ts", "**/*.tsx"] } diff --git a/packages/cli/src/main.integration.test.ts b/packages/cli/src/main.integration.test.ts index b302058..1ce8b80 100644 --- a/packages/cli/src/main.integration.test.ts +++ b/packages/cli/src/main.integration.test.ts @@ -124,14 +124,17 @@ test("headless startup renders the shell before any extension's index.ts loads, expect(firstFrameMs).toBeGreaterThanOrEqual(0); expect(firstFrameMs).toBeLessThan(1_000); - // 5, not 1: the workspace fixture extension AND the real `@tecode/ + // 6, not 1: the workspace fixture extension AND the real `@tecode/ // builtin` `editor-core` (Task 2.3, `onStartup`) AND `themes-default` // (Task 2.7) AND `languages-basic` (Task 2.9) AND `command-palette` - // (Task 3.2, `onStartup`) — `themes-default`/`languages-basic` are - // pure-contribution extensions with no `onStartup` — they still count - // as LOADED/registered, just never activated — all load during this - // real (no `builtins` override) subprocess run. - expect(headlessExit?.["loaded"]).toBe(5); + // (Task 3.2, `onStartup`) AND `explorer` (Task 3.3, + // `onCommand:explorer.focus`) — `themes-default`/`languages-basic`/ + // `explorer` are never ACTIVATED by a headless run with no keystrokes + // (no `onStartup` for the first two; `explorer.focus` is never + // executed here), but every manifest still counts as LOADED/registered + // regardless of activation — all load during this real (no `builtins` + // override) subprocess run. + expect(headlessExit?.["loaded"]).toBe(6); expect(headlessExit?.["skipped"]).toBe(0); } finally { await rm(homeDir, { recursive: true, force: true }); diff --git a/packages/cli/src/themesPreFirstFrame.test.ts b/packages/cli/src/themesPreFirstFrame.test.ts index 6acaee0..5a4cc9d 100644 --- a/packages/cli/src/themesPreFirstFrame.test.ts +++ b/packages/cli/src/themesPreFirstFrame.test.ts @@ -118,6 +118,7 @@ test("Dark Modern is active before renderShell would be called, with zero extens expect(loadedIds).toEqual([ "tecode.command-palette", "tecode.editor-core", + "tecode.explorer", "tecode.languages-basic", "tecode.themes-default", ]); diff --git a/packages/core/src/buffer/fileSystem.test.ts b/packages/core/src/buffer/fileSystem.test.ts index bb740e4..21e207d 100644 --- a/packages/core/src/buffer/fileSystem.test.ts +++ b/packages/core/src/buffer/fileSystem.test.ts @@ -82,6 +82,86 @@ describe("createFileSystem", () => { await expect(fs.read(pathToUri(join(dir, "missing.txt")))).rejects.toThrow(); }); + describe("delete/rename/mkdir (Task 3.3, Req 11.2)", () => { + test("delete removes a file", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-fs-")); + const filePath = join(dir, "doomed.txt"); + await nodeWriteFile(filePath, "bye", "utf8"); + const fs = createFileSystem(); + + await fs.delete(pathToUri(filePath)); + + await expect(fs.stat(pathToUri(filePath))).rejects.toThrow(); + }); + + test("delete removes a non-empty directory recursively", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-fs-")); + const subDir = join(dir, "sub"); + await mkdir(subDir); + await nodeWriteFile(join(subDir, "child.txt"), "x", "utf8"); + const fs = createFileSystem(); + + await fs.delete(pathToUri(subDir)); + + await expect(fs.stat(pathToUri(subDir))).rejects.toThrow(); + }); + + test("delete rejects for a path that does not exist", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-fs-")); + const fs = createFileSystem(); + + await expect(fs.delete(pathToUri(join(dir, "missing.txt")))).rejects.toThrow(); + }); + + test("rename moves a file to a new name", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-fs-")); + const oldPath = join(dir, "old.txt"); + const newPath = join(dir, "new.txt"); + await nodeWriteFile(oldPath, "content", "utf8"); + const fs = createFileSystem(); + + await fs.rename(pathToUri(oldPath), pathToUri(newPath)); + + await expect(fs.stat(pathToUri(oldPath))).rejects.toThrow(); + expect(new TextDecoder().decode(await fs.read(pathToUri(newPath)))).toBe("content"); + }); + + test("rename rejects when the source does not exist", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-fs-")); + const fs = createFileSystem(); + + await expect( + fs.rename(pathToUri(join(dir, "missing.txt")), pathToUri(join(dir, "new.txt"))), + ).rejects.toThrow(); + }); + + test("mkdir creates a new empty directory", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-fs-")); + const fs = createFileSystem(); + const newDir = join(dir, "created"); + + await fs.mkdir(pathToUri(newDir)); + + const stat = await fs.stat(pathToUri(newDir)); + expect(stat.type).toBe("directory"); + }); + + test("mkdir rejects when the directory already exists", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-fs-")); + await mkdir(join(dir, "exists")); + const fs = createFileSystem(); + + await expect(fs.mkdir(pathToUri(join(dir, "exists")))).rejects.toThrow(); + }); + + test("mkdir rejects when the parent directory does not exist", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-fs-")); + const fs = createFileSystem(); + + await expect(fs.mkdir(pathToUri(join(dir, "missing-parent", "child")))).rejects.toThrow(); + }); + }); + describe("watch — real fs.watch integration (design.md §16)", () => { test("reports a 'changed' event when a watched file is modified", async () => { dir = await mkdtemp(join(tmpdir(), "tecode-fs-watch-")); diff --git a/packages/core/src/buffer/fileSystem.ts b/packages/core/src/buffer/fileSystem.ts index ad98b04..f6413f8 100644 --- a/packages/core/src/buffer/fileSystem.ts +++ b/packages/core/src/buffer/fileSystem.ts @@ -129,6 +129,37 @@ export function createFileSystem(deps: FileSystemDeps = {}): FileSystem { }; } + /** + * `Req 10.1's delete/rename/mkdir` (Task 3.3, Req 11.2): thin + * `node:fs/promises` pass-throughs, matching `read`/`write`/`stat`'s own + * "reject on failure, preserve the original error" contract — no + * try/catch here, exactly like every other method above; a caller that + * needs a never-throwing surface (the explorer built-in) wraps these + * itself and reports via `window.showMessage(..., "error")` (design.md + * §14). + */ + async function deleteEntry(uri: Uri): Promise { + // `recursive: true` lets this delete a non-empty directory too (Req + // 11.2's "delete" — the explorer does not require an empty-directory + // precondition); `force: false` (the default) so a missing path still + // rejects rather than silently no-op'ing. + await nodeFs.rm(uriToPath(uri), { recursive: true }); + } + + async function rename(oldUri: Uri, newUri: Uri): Promise { + await nodeFs.rename(uriToPath(oldUri), uriToPath(newUri)); + } + + async function mkdir(uri: Uri): Promise { + // No `recursive: true`: Req 11.2's "New Folder" always creates one + // folder inside an already-visible (and therefore already-existing) + // directory — surfacing a missing-parent failure here, rather than + // silently creating intermediate directories, matches `write`'s own + // choice to let a missing-parent `ENOENT` propagate rather than paper + // over it. + await nodeFs.mkdir(uriToPath(uri)); + } + async function readdir(uri: Uri): Promise { const path = uriToPath(uri); const entries = await nodeFs.readdir(path, { withFileTypes: true }); @@ -243,5 +274,5 @@ export function createFileSystem(deps: FileSystemDeps = {}): FileSystem { }; } - return { read, write, stat, readdir, watch }; + return { read, write, stat, readdir, watch, delete: deleteEntry, rename, mkdir }; } diff --git a/packages/core/src/ui/components.test.tsx b/packages/core/src/ui/components.test.tsx index 4b69dbe..108a158 100644 --- a/packages/core/src/ui/components.test.tsx +++ b/packages/core/src/ui/components.test.tsx @@ -7,10 +7,20 @@ import { describe, expect, test } from "bun:test"; import { act, useEffect, useState } from "react"; +import type { KeyEvent } from "@opentui/core"; import { testRender } from "@opentui/react/test-utils"; import type { ComponentType } from "@tecode/api"; +import { createContextService } from "../keymap/context"; +import { ContextFocusTracker, type FocusableNode } from "./focus"; import { List, RegisteredView, Tabs, Tree, type ListItem, type TabItem, type TreeNode } from "./components"; +/** A minimal `KeyEvent`-shaped object for driving {@link Tree}'s `onKeyDown` + * directly (this suite's "keyboard nav" tests) — only `name` matters to + * `Tree`'s handler, so every other `KeyEvent` field is a harmless dummy. */ +function keyEvent(name: string): KeyEvent { + return { name, ctrl: false, shift: false, option: false, meta: false, sequence: "" } as KeyEvent; +} + describe("List (tecode.ui.List)", () => { test("renders every item's label", async () => { const items: ListItem[] = [ @@ -56,6 +66,301 @@ describe("Tree (tecode.ui.Tree)", () => { expect(frame).toContain("src"); expect(frame).toContain("index.ts"); }); + + describe("hasChildren override (Task 3.3, Req 11.2 — lazy-loaded directories)", () => { + test("shows the expand arrow for a node with hasChildren:true but no children array yet", async () => { + const nodes: TreeNode[] = [{ id: "dir", label: "src", hasChildren: true }]; + const { renderOnce, captureCharFrame } = await testRender(, { + width: 30, + height: 4, + }); + await renderOnce(); + expect(captureCharFrame()).toContain("▸ src"); + }); + + test("expanding a hasChildren:true node with no children yet reveals nothing (not yet loaded) without crashing", async () => { + const nodes: TreeNode[] = [{ id: "dir", label: "src", hasChildren: true }]; + const { renderOnce, captureCharFrame } = await testRender( + , + { width: 30, height: 4 }, + ); + await renderOnce(); + expect(captureCharFrame()).toContain("▾ src"); + }); + }); + + describe("controlled expansion (Task 3.3, Req 11.2)", () => { + test("expandedIds drives visibility instead of internal state", async () => { + const nodes: TreeNode[] = [ + { id: "root", label: "src", children: [{ id: "child", label: "index.ts" }] }, + ]; + const { renderOnce, captureCharFrame } = await testRender( + , + { width: 30, height: 6 }, + ); + await renderOnce(); + expect(captureCharFrame()).toContain("index.ts"); + }); + + test("uncontrolled mode still calls onToggle alongside managing its own state (keyboard-driven)", async () => { + const nodes: TreeNode[] = [ + { id: "root", label: "src", children: [{ id: "child", label: "index.ts" }] }, + ]; + const toggles: Array<{ id: string; expanding: boolean }> = []; + let captured: FocusableNode | null = null; + const { renderOnce, captureCharFrame } = await testRender( + toggles.push({ id, expanding })} + treeRef={(node: FocusableNode | null) => (captured = node)} + />, + { width: 30, height: 6 }, + ); + await renderOnce(); + expect(captured).not.toBeNull(); + + await act(() => { + (captured as unknown as { onKeyDown?: (key: KeyEvent) => void }).onKeyDown?.(keyEvent("right")); + }); + await renderOnce(); + + expect(toggles).toEqual([{ id: "root", expanding: true }]); + expect(captureCharFrame()).toContain("index.ts"); + }); + }); + + describe("keyboard nav while focused (Task 3.3, Req 11.2)", () => { + const NODES: TreeNode[] = [ + { + id: "src", + label: "src", + children: [ + { id: "a.ts", label: "a.ts" }, + { id: "b.ts", label: "b.ts" }, + ], + }, + { id: "readme", label: "README.md" }, + ]; + + /** + * Renders `` behind a small stateful harness that feeds `onSelect` + * back into `selectedId` (a real caller — the explorer built-in included + * — always does this; a test driving MULTIPLE sequential key presses and + * asserting each one's effect on the NEXT press needs the same + * round-trip, or `selectedId` would stay frozen at whatever the test + * passed in initially). `initial.onSelect`/`onToggle`/`onActivate` are + * still invoked (for assertions) alongside the harness's own bookkeeping. + */ + async function renderTree(initial: { + selectedId?: string; + expandedIds?: string[]; + onSelect?: (id: string) => void; + onToggle?: (id: string, expanding: boolean) => void; + onActivate?: (id: string) => void; + }): Promise<{ press: (name: string) => Promise }> { + let captured: FocusableNode | null = null; + + function Harness(): ReturnType { + const [selectedId, setSelectedId] = useState(initial.selectedId); + return ( + (captured = node)} + onSelect={(id: string) => { + setSelectedId(id); + initial.onSelect?.(id); + }} + onToggle={initial.onToggle} + onActivate={initial.onActivate} + /> + ); + } + + const { renderOnce } = await testRender(, { width: 30, height: 10 }); + await renderOnce(); + return { + press: async (name: string) => { + await act(() => { + (captured as unknown as { onKeyDown?: (key: KeyEvent) => void })?.onKeyDown?.(keyEvent(name)); + }); + await renderOnce(); + }, + }; + } + + test("down/up move selection across visible top-level nodes", async () => { + const selected: string[] = []; + const { press } = await renderTree({ onSelect: (id) => selected.push(id) }); + + await press("down"); + expect(selected).toEqual(["src"]); + + await press("down"); // src has children but is collapsed -> next visible is "readme" + expect(selected).toEqual(["src", "readme"]); + + await press("up"); + expect(selected).toEqual(["src", "readme", "src"]); + }); + + test("down walks into an expanded branch's children in visible order", async () => { + const selected: string[] = []; + const { press } = await renderTree({ + selectedId: "src", + expandedIds: ["src"], + onSelect: (id) => selected.push(id), + }); + + await press("down"); + expect(selected).toEqual(["a.ts"]); + }); + + test("right expands a collapsed branch without moving selection", async () => { + const toggles: Array<{ id: string; expanding: boolean }> = []; + const { press } = await renderTree({ + selectedId: "src", + expandedIds: [], + onToggle: (id, expanding) => toggles.push({ id, expanding }), + }); + + await press("right"); + expect(toggles).toEqual([{ id: "src", expanding: true }]); + }); + + test("right on an already-expanded branch moves selection to its first child", async () => { + const selected: string[] = []; + const { press } = await renderTree({ + selectedId: "src", + expandedIds: ["src"], + onSelect: (id) => selected.push(id), + }); + + await press("right"); + expect(selected).toEqual(["a.ts"]); + }); + + test("right on a leaf node is a no-op", async () => { + const selected: string[] = []; + const toggles: unknown[] = []; + const { press } = await renderTree({ + selectedId: "readme", + onSelect: (id) => selected.push(id), + onToggle: (id, expanding) => toggles.push({ id, expanding }), + }); + + await press("right"); + expect(selected).toEqual([]); + expect(toggles).toEqual([]); + }); + + test("left collapses an expanded branch without moving selection", async () => { + const toggles: Array<{ id: string; expanding: boolean }> = []; + const { press } = await renderTree({ + selectedId: "src", + expandedIds: ["src"], + onToggle: (id, expanding) => toggles.push({ id, expanding }), + }); + + await press("left"); + expect(toggles).toEqual([{ id: "src", expanding: false }]); + }); + + test("left on a child node moves selection to its parent", async () => { + const selected: string[] = []; + const { press } = await renderTree({ + selectedId: "a.ts", + expandedIds: ["src"], + onSelect: (id) => selected.push(id), + }); + + await press("left"); + expect(selected).toEqual(["src"]); + }); + + test("left on a top-level leaf with no parent is a no-op", async () => { + const selected: string[] = []; + const { press } = await renderTree({ selectedId: "readme", onSelect: (id) => selected.push(id) }); + + await press("left"); + expect(selected).toEqual([]); + }); + + test("return activates the selected leaf node", async () => { + const activated: string[] = []; + const { press } = await renderTree({ selectedId: "readme", onActivate: (id) => activated.push(id) }); + + await press("return"); + expect(activated).toEqual(["readme"]); + }); + + test("return on a branch node both toggles it and activates it", async () => { + const activated: string[] = []; + const toggles: Array<{ id: string; expanding: boolean }> = []; + const { press } = await renderTree({ + selectedId: "src", + expandedIds: [], + onActivate: (id) => activated.push(id), + onToggle: (id, expanding) => toggles.push({ id, expanding }), + }); + + await press("return"); + expect(toggles).toEqual([{ id: "src", expanding: true }]); + expect(activated).toEqual(["src"]); + }); + + test("an unrecognized key is a no-op", async () => { + const selected: string[] = []; + const { press } = await renderTree({ selectedId: "readme", onSelect: (id) => selected.push(id) }); + + await press("a"); + expect(selected).toEqual([]); + }); + + test("down with no current selection lands on the first visible node", async () => { + const selected: string[] = []; + const { press } = await renderTree({ onSelect: (id) => selected.push(id) }); + + await press("down"); + expect(selected).toEqual(["src"]); + }); + }); + + describe("focusContextKey (Task 3.3, Req 4.6, 11.2)", () => { + test("reports focus gain/loss on the root box to the given context key", async () => { + const context = createContextService(); + let captured: FocusableNode | null = null; + const { renderOnce } = await testRender( + + (captured = n)} /> + , + { width: 20, height: 5 }, + ); + await renderOnce(); + + expect(context.get("explorerFocus")).toBeUndefined(); + (captured as unknown as { focus: () => void }).focus(); + expect(context.get("explorerFocus")).toBe(true); + (captured as unknown as { blur: () => void }).blur(); + expect(context.get("explorerFocus")).toBe(false); + }); + + test("omitting focusContextKey reports nothing (backward compatible)", async () => { + const context = createContextService(); + let captured: FocusableNode | null = null; + const { renderOnce } = await testRender( + + (captured = n)} /> + , + { width: 20, height: 5 }, + ); + await renderOnce(); + + (captured as unknown as { focus: () => void }).focus(); + expect(context.get("explorerFocus")).toBeUndefined(); + }); + }); }); describe("Tabs (tecode.ui.Tabs)", () => { diff --git a/packages/core/src/ui/components.tsx b/packages/core/src/ui/components.tsx index 2e61b87..73a5778 100644 --- a/packages/core/src/ui/components.tsx +++ b/packages/core/src/ui/components.tsx @@ -23,10 +23,11 @@ * `unknown`). */ -import { useEffect, useRef, useState, type ReactNode } from "react"; -import type { SelectOption, TabSelectOption, TabSelectRenderable } from "@opentui/core"; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import type { KeyEvent, SelectOption, TabSelectOption, TabSelectRenderable } from "@opentui/core"; import type { ComponentType } from "@tecode/api"; import type { FocusableNode } from "./focus"; +import { useFocusTracking } from "./focus"; import { toColorInput, useTheme } from "./theme"; /* ------------------------------------------------------------------ */ @@ -126,6 +127,19 @@ export interface TreeNode { id: string; label: string; children?: TreeNode[]; + /** + * Explicit override for whether this node shows the expand/collapse + * affordance and participates in `right`/`left`-arrow nav as a branch + * (this module's TSDoc's "keyboard nav"), independent of `children`'s + * current length (Task 3.3, Req 11.2). The explorer's directory nodes + * are known to BE directories — and so must show the expand arrow — + * before their children have ever been loaded via `workspace.fs. + * readdir` (`children` stays `undefined` until the first expand). + * Omitted (the default): falls back to `(children?.length ?? 0) > 0`, + * Task 1.14's original, still-correct behavior for a fully-eager caller + * that always has every node's full `children` array up front. + */ + hasChildren?: boolean; } /** {@link Tree}'s props. */ @@ -133,57 +147,260 @@ export interface TreeProps { nodes?: TreeNode[]; selectedId?: string; onSelect?: (id: string) => void; - /** Node ids expanded by default (uncontrolled after mount — this is a - * minimal MVP component, not a fully controlled tree). */ + /** + * Controlled expansion (Task 3.3, Req 11.2): when given, expand/collapse + * state is driven entirely by the caller (paired with {@link onToggle}) + * instead of Tree's own internal state. The explorer built-in needs this + * — its tree nodes are populated lazily via `workspace.fs.readdir` as + * each directory is expanded for the first time, so it must observe + * every expand/collapse itself rather than let Tree silently manage that + * state internally. Omitting this prop keeps the original UNCONTROLLED + * behavior ({@link defaultExpanded} seeds internal state once at mount; + * toggling thereafter only ever updates that internal state) — existing + * callers (Task 1.14) are unaffected. + */ + expandedIds?: string[]; + /** + * Called whenever a node with children is toggled — Enter/Return, + * `left`/`right` (this module's TSDoc's "keyboard nav"), or a mouse + * click — with `expanding` reporting the state the node is MOVING TO + * (`true` = about to expand). Fires alongside Tree's own internal + * uncontrolled toggle too (not just in controlled mode), so a caller + * that only wants to observe expansion (e.g. to lazily load a + * directory's children) without taking over the state entirely can pass + * this without also passing {@link expandedIds}. + */ + onToggle?: (id: string, expanding: boolean) => void; + /** Node ids expanded by default (uncontrolled mode only — ignored once + * {@link expandedIds} is given). */ defaultExpanded?: string[]; + /** + * Called when a node is "activated" — pressing Enter/Return while it is + * the selected node (Task 3.3) — distinct from {@link onSelect} (which + * also fires on a plain highlight move via the arrow keys or a mouse + * click over a branch node). The explorer opens a file only on this + * callback, not on every selection change. Fires for both leaf and + * branch nodes; a branch node's own Enter ALSO toggles its expansion + * (see this module's TSDoc's "keyboard nav") independently of whatever + * this callback does. + */ + onActivate?: (id: string) => void; + /** + * When set, focus gain/loss on Tree's own root box is reported to this + * context key via `useFocusTracking` (Task 3.3, Req 4.6, 11.2) — e.g. + * the explorer's `"explorerFocus"`, so a `when: "explorerFocus"` + * keybinding can gate on it. Omitted (the default): no focus reporting, + * matching Task 1.14's original behavior. + */ + focusContextKey?: string; + /** Ref callback onto the underlying OpenTUI `` node — an escape + * hatch mirroring {@link InputProps.inputRef}'s own TSDoc (same + * rationale: not part of `tecode.ui.Tree`'s public extension-facing + * contract, since `ComponentType`'s props are plain `Record` — an extension that never sets this key is unaffected). + * This module's own tests use it to invoke `onKeyDown` directly against + * the real renderable, the same "capture the real node, call its methods + * directly" style `focus.test.tsx` uses for `.focus()`/`.blur()`, rather + * than depend on `testRender`'s mouse/keyboard simulation reproducing a + * real focus transition end-to-end (`shell.test.tsx`'s documented + * "Coverage gap" precedent for why that is its own, separate concern). + */ + treeRef?: (node: FocusableNode | null) => void; + /** Declarative initial/imperative focus, forwarded straight to the root + * `` (`@opentui/react`'s own `focused` prop — see `findWidget.tsx`'s + * TSDoc for the one documented caveat: a `true` value on the very FIRST + * render focuses the node before `focusContextKey`'s own tracking ref has + * attached, so the underlying OpenTUI node still becomes genuinely + * keyboard-focused — real key events dispatch to it correctly — even + * though that specific initial transition is not itself reported to + * `focusContextKey`). */ + focused?: boolean; +} + +/** One node of {@link Tree}'s CURRENTLY VISIBLE (i.e. every ancestor is + * expanded) nodes, flattened depth-first in on-screen order — what + * {@link Tree}'s keyboard nav (this module's TSDoc) walks up/down over, and + * what its rendering loop iterates to lay out indentation without deep + * JSX recursion. */ +interface FlatTreeNode { + id: string; + label: string; + depth: number; + hasChildren: boolean; + parentId: string | undefined; +} + +/** Depth-first flatten of `nodes`, stopping recursion at any node not in + * `expanded` (this module's TSDoc). */ +function flattenVisibleNodes( + nodes: readonly TreeNode[] | undefined, + expanded: ReadonlySet, +): FlatTreeNode[] { + const result: FlatTreeNode[] = []; + function walk(list: readonly TreeNode[], depth: number, parentId: string | undefined): void { + for (const node of list) { + const hasChildren = node.hasChildren ?? (node.children?.length ?? 0) > 0; + result.push({ id: node.id, label: node.label, depth, hasChildren, parentId }); + if (hasChildren && expanded.has(node.id)) { + walk(node.children ?? [], depth + 1, node.id); + } + } + } + walk(nodes ?? [], 0, undefined); + return result; } -/** A minimal expand/collapse tree (`tecode.ui.Tree`, Req 10.1). No native - * OpenTUI tree renderable exists yet, so this composes ``/`` - * directly with hand-rolled indentation and local expand/collapse state. */ +/** + * A minimal expand/collapse tree (`tecode.ui.Tree`, Req 10.1, 11.2). No + * native OpenTUI tree renderable exists yet, so this composes ``/ + * `` directly over a depth-first-flattened, indentation-rendered node + * list (this module's TSDoc's {@link FlatTreeNode}). + * + * **Keyboard nav while focused** (Task 3.3, Req 11.2 — the explorer's core + * interaction): a `focusable` root `` with its own `onKeyDown` handler + * — the same "a focused OpenTUI node handles its own keys directly, via + * `RenderableOptions.onKeyDown`" mechanism `@opentui/core`'s own + * `SelectRenderable`/`TextareaRenderable` use internally (verified against + * this repo's vendored `@opentui/core@0.1.107` typings: `Renderable. + * onKeyDown`/`focusable`), rather than a core-level `when`-gated keybinding + * — Tree is a REUSABLE `tecode.ui` component any extension can mount, with + * no manifest of its own to declare a keybinding against, so it must own + * its navigation the same self-contained way OpenTUI's built-in focusable + * renderables do. `key.name` values (`"up"`/`"down"`/`"left"`/`"right"`/ + * `"return"`) match `@opentui/core`'s own parsed key names, the same names + * `keymap/keyEvent.ts`'s `keyEventToStroke` reads elsewhere in this + * codebase: + * - `up`/`down`: move the highlighted node to the previous/next VISIBLE + * node (this module's `flattenVisibleNodes`) and call `onSelect`. No + * current `selectedId` (or one no longer visible): lands on the first + * node. + * - `right`: on a collapsed branch, expands it (`toggle`); on an already- + * expanded branch, moves selection to its first child; a no-op on a leaf. + * - `left`: on an expanded branch, collapses it; otherwise (a leaf, or an + * already-collapsed branch) moves selection to the parent, if any. + * - `return`: calls {@link TreeProps.onActivate} for the selected node; + * ADDITIONALLY toggles a branch node's expansion (opening a file and + * expanding/collapsing a directory are both reasonable "Enter" outcomes, + * and are not mutually exclusive — the explorer's own `onActivate` + * decides what, if anything, "open" means for a directory id). + * + * Every other key passes through unhandled (no `preventDefault` call is + * available on `@opentui/core`'s `KeyEvent` shape here — this component + * simply does not act on it, the same "not our key, ignore it" discipline + * `editor/inputRouter.ts` documents for its own fallthrough scope). + */ export function Tree(rawProps: Record): ReactNode { const props = rawProps as TreeProps; const theme = useTheme(); - const [expanded, setExpanded] = useState>( + const isControlled = props.expandedIds !== undefined; + const [internalExpanded, setInternalExpanded] = useState>( () => new Set(props.defaultExpanded ?? []), ); + const expanded = useMemo( + () => (isControlled ? new Set(props.expandedIds ?? []) : internalExpanded), + [isControlled, props.expandedIds, internalExpanded], + ); + const focusRef = useFocusTracking(props.focusContextKey); + const rootRef = useCallback( + (node: FocusableNode | null) => { + focusRef(node); + props.treeRef?.(node); + }, + [focusRef, props.treeRef], + ); - function toggle(id: string): void { - setExpanded((prev: Set) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - } + const toggle = useCallback( + (id: string) => { + const expanding = !expanded.has(id); + if (!isControlled) { + setInternalExpanded((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + props.onToggle?.(id, expanding); + }, + [expanded, isControlled, props.onToggle], + ); - function renderNode(node: TreeNode, depth: number): ReactNode { - const hasChildren = (node.children?.length ?? 0) > 0; - const isExpanded = expanded.has(node.id); - const isSelected = props.selectedId === node.id; - const glyph = hasChildren ? (isExpanded ? "▾ " : "▸ ") : " "; - return ( - - { - if (hasChildren) toggle(node.id); - props.onSelect?.(node.id); - }} - > - {" ".repeat(depth) + glyph + node.label} - - {hasChildren && isExpanded - ? (node.children ?? []).map((child) => renderNode(child, depth + 1)) - : null} - - ); - } + const flat = useMemo(() => flattenVisibleNodes(props.nodes, expanded), [props.nodes, expanded]); - return {(props.nodes ?? []).map((n) => renderNode(n, 0))}; + const handleKeyDown = useCallback( + (key: KeyEvent) => { + if (flat.length === 0) return; + const currentIndex = props.selectedId ? flat.findIndex((n) => n.id === props.selectedId) : -1; + const current = currentIndex >= 0 ? flat[currentIndex] : undefined; + + switch (key.name) { + case "down": { + const next = flat[currentIndex === -1 ? 0 : Math.min(flat.length - 1, currentIndex + 1)]; + if (next) props.onSelect?.(next.id); + break; + } + case "up": { + const previous = flat[currentIndex === -1 ? 0 : Math.max(0, currentIndex - 1)]; + if (previous) props.onSelect?.(previous.id); + break; + } + case "right": { + if (!current) break; + if (current.hasChildren && !expanded.has(current.id)) { + toggle(current.id); + } else if (current.hasChildren) { + const child = flat[currentIndex + 1]; + if (child && child.parentId === current.id) props.onSelect?.(child.id); + } + break; + } + case "left": { + if (!current) break; + if (current.hasChildren && expanded.has(current.id)) { + toggle(current.id); + } else if (current.parentId !== undefined) { + props.onSelect?.(current.parentId); + } + break; + } + case "return": { + if (!current) break; + if (current.hasChildren) toggle(current.id); + props.onActivate?.(current.id); + break; + } + default: + break; + } + }, + [flat, props.selectedId, props.onSelect, props.onActivate, expanded, toggle], + ); + + return ( + + {flat.map((node) => { + const isExpanded = expanded.has(node.id); + const isSelected = props.selectedId === node.id; + const glyph = node.hasChildren ? (isExpanded ? "▾ " : "▸ ") : " "; + return ( + { + if (node.hasChildren) toggle(node.id); + else props.onActivate?.(node.id); + props.onSelect?.(node.id); + }} + > + {" ".repeat(node.depth) + glyph + node.label} + + ); + })} + + ); } /* ------------------------------------------------------------------ */ diff --git a/packages/core/src/ui/focus.tsx b/packages/core/src/ui/focus.tsx index 2ac823b..1a2c523 100644 --- a/packages/core/src/ui/focus.tsx +++ b/packages/core/src/ui/focus.tsx @@ -75,6 +75,18 @@ export function ContextFocusTracker(props: ContextFocusTrackerProps): ReactNode * discipline rather than requiring every isolated component test to wrap * itself in a provider it does not care about. * + * **`key: undefined`** (Task 3.3, `components.tsx`'s `Tree`'s optional + * `focusContextKey` prop): lets a component call this hook UNCONDITIONALLY + * (satisfying React's rules-of-hooks — a component cannot call a hook only + * when some prop happens to be set) even when it has no context key to + * report to for this particular instance. The returned ref callback still + * attaches/detaches its `FOCUSED`/`BLURRED` listeners exactly as normal (so + * a later prop change from `undefined` to a real key, or vice versa, is + * simply a different `key` value on the next render — this hook has no + * special-cased "key changed" branch beyond its existing `[context, key]` + * dependency array), it just never calls `context?.set(...)` while `key` + * is `undefined`. + * * **Detaching a still-focused node** (Req 11.1's find widget — the first * conditionally-mounted-only-while-focused consumer this codebase has): * every OTHER `useFocusTracking` consumer so far stays mounted for the @@ -89,7 +101,7 @@ export function ContextFocusTracker(props: ContextFocusTrackerProps): ReactNode * unmounting to `null`) — closing that gap without needing every caller to * remember to blur before unmounting. */ -export function useFocusTracking(key: string): (node: FocusEmitter | null) => void { +export function useFocusTracking(key: string | undefined): (node: FocusEmitter | null) => void { const context = useContext(FocusContextServiceContext); // Remembers the exact listener closures registered on the currently // attached node — `.off()` only removes a listener given the SAME @@ -114,17 +126,17 @@ export function useFocusTracking(key: string): (node: FocusEmitter | null) => vo attached.current = null; if (isFocusedRef.current) { isFocusedRef.current = false; - context?.set(key, false); + if (key !== undefined) context?.set(key, false); } } if (node) { const onFocused = () => { isFocusedRef.current = true; - context?.set(key, true); + if (key !== undefined) context?.set(key, true); }; const onBlurred = () => { isFocusedRef.current = false; - context?.set(key, false); + if (key !== undefined) context?.set(key, false); }; node.on(RenderableEvents.FOCUSED, onFocused); node.on(RenderableEvents.BLURRED, onBlurred); From 560f3a32be3313658b9ba6dc8e700519c70781fb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 21:27:16 +0000 Subject: [PATCH 2/2] Address explorer code review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes 9 review findings from the explorer build (Task 3.3, Req 11.2): - fileSystem.ts: rename() now probes the destination with lstat and rejects if it already exists, instead of silently replacing it (POSIX rename(2) semantics on Linux). - explorer/store.ts reload(): diffs old vs. new children on every reload; a removed or type-changed entry purges its own metadata and every cached descendant, and resets selection to the affected parent if the current selection was purged. - explorer/store.ts reload(): per-directory generation counter so an earlier-started reload of the same directory can no longer clobber a later one that resolves first. - explorer/index.ts: validateEntryName rejects "." and "..", and both the create and rename command handlers re-check right at the joinChildUri call site (not just via showInputBox's validateInput, which a programmatic command invocation can bypass) — closes a path traversal via joinChildUri's unescaped-dot encodeURIComponent behavior. - gitignoreMatcher.ts: escape "?" as a regex literal so patterns like "foo?.log" don't compile "?" into a quantifier. - gitRunner.test.ts: the real-git-CLI suite now checks isAvailable() once up front and skips itself on git-less CI images instead of asserting isAvailable() === true unconditionally. - gitRunner.ts checkIgnore(): use "git check-ignore -z --stdin" so paths are NUL-separated end to end, keeping paths with spaces or newlines matchable. - gitRunner.ts/ignore.ts: added GitRunner.isRepository() and use it to detect a non-repo workspace even when git itself is installed, so the .gitignore glob fallback still applies instead of silently disabling ignore filtering. - explorer/index.test.tsx: selectViaTree now destroys its temporary testRender mount, matching its own TSDoc. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WELSsojQQL1cTAR5iUUsTK --- packages/builtin/explorer/index.test.tsx | 72 +++++++++++- packages/builtin/explorer/index.ts | 64 +++++++++-- packages/builtin/explorer/store.test.ts | 107 ++++++++++++++++++ packages/builtin/explorer/store.ts | 84 +++++++++++++- packages/builtin/shared/gitRunner.test.ts | 25 +++- packages/builtin/shared/gitRunner.ts | 46 ++++++-- .../builtin/shared/gitignoreMatcher.test.ts | 7 ++ packages/builtin/shared/gitignoreMatcher.ts | 8 +- packages/builtin/shared/ignore.test.ts | 62 ++++++++++ packages/builtin/shared/ignore.ts | 47 ++++++-- packages/core/src/buffer/fileSystem.test.ts | 16 +++ packages/core/src/buffer/fileSystem.ts | 23 +++- 12 files changed, 525 insertions(+), 36 deletions(-) diff --git a/packages/builtin/explorer/index.test.tsx b/packages/builtin/explorer/index.test.tsx index bdfe45a..bf5fa5d 100644 --- a/packages/builtin/explorer/index.test.tsx +++ b/packages/builtin/explorer/index.test.tsx @@ -298,10 +298,11 @@ async function selectViaTree( uri: string, ): Promise { const Component = fixture.getRegisteredView() as unknown as (props: Record) => ReactNode; - const { renderOnce } = await testRender(, { width: 30, height: 10 }); + const { renderer, renderOnce } = await testRender(, { width: 30, height: 10 }); await renderOnce(); const onSelect = fixture.getLastTreeProps()?.["onSelect"] as ((id: string) => void) | undefined; act(() => onSelect?.(uri)); + renderer.destroy(); } describe("explorer activate() (Task 3.3, Req 11.2)", () => { @@ -413,6 +414,36 @@ describe("explorer activate() (Task 3.3, Req 11.2)", () => { expect(fixture.getMessages().some((m) => m.kind === "info")).toBe(true); fixture.dispose(); }); + + test("validateInput rejects '.' and '..'", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-explorer-")); + const fixture = createFixture(pathToUri(dir)); + fixture.setNextInputValue(undefined); + + await fixture.api.commands.execute(EXPLORER_NEW_FILE_COMMAND_ID); + + expect(fixture.getLastValidateInput()?.(".")).toBeDefined(); + expect(fixture.getLastValidateInput()?.("..")).toBeDefined(); + fixture.dispose(); + }); + + test("a '..' name is rejected even when it bypasses validateInput (a programmatic caller), never escaping the target directory", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-explorer-")); + const fixture = createFixture(pathToUri(dir)); + // The fake `showInputBox` above returns whatever was queued + // regardless of `validateInput` — exactly the "bypasses the input + // box's own validation" scenario the explicit re-check at the + // `joinChildUri` call site guards against. + fixture.setNextInputValue(".."); + + await fixture.api.commands.execute(EXPLORER_NEW_FILE_COMMAND_ID); + + expect(fixture.getMessages().some((m) => m.kind === "error")).toBe(true); + // Nothing was created — in particular, no `fs.write` call ever + // reached the (would-be escaped) parent directory. + expect(await nodeReaddir(dir)).toEqual([]); + fixture.dispose(); + }); }); test("explorer.newFolder creates a real directory", async () => { @@ -483,6 +514,45 @@ describe("explorer activate() (Task 3.3, Req 11.2)", () => { expect(fixture.getMessages().some((m) => m.kind === "error")).toBe(true); fixture.dispose(); }); + + test("validateInput rejects '.' and '..'", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-explorer-")); + await nodeWriteFile(join(dir, "old.ts"), "content"); + const fixture = createFixture(pathToUri(dir)); + await waitFor(async () => (await nodeReaddir(dir!)).length > 0); + await new Promise((r) => setTimeout(r, 50)); + + await selectViaTree(fixture, pathToUri(join(dir, "old.ts"))); + fixture.setNextInputValue(undefined); + await fixture.api.commands.execute(EXPLORER_RENAME_COMMAND_ID); + + expect(fixture.getLastValidateInput()?.(".")).toBeDefined(); + expect(fixture.getLastValidateInput()?.("..")).toBeDefined(); + fixture.dispose(); + }); + + test("a '..' name is rejected even when it bypasses validateInput, never escaping the parent directory", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-explorer-")); + await nodeWriteFile(join(dir, "old.ts"), "content"); + const fixture = createFixture(pathToUri(dir)); + await waitFor(async () => (await nodeReaddir(dir!)).length > 0); + await new Promise((r) => setTimeout(r, 50)); + + await selectViaTree(fixture, pathToUri(join(dir, "old.ts"))); + // The fake `showInputBox` returns whatever was queued regardless of + // `validateInput` — the "bypasses the input box's own validation" + // scenario the explicit re-check at the `joinChildUri` call site + // guards against. + fixture.setNextInputValue(".."); + + await fixture.api.commands.execute(EXPLORER_RENAME_COMMAND_ID); + + expect(fixture.getMessages().some((m) => m.kind === "error")).toBe(true); + // The original file is untouched — no rename call ever reached the + // (would-be escaped) grandparent directory. + expect(await nodeReaddir(dir)).toEqual(["old.ts"]); + fixture.dispose(); + }); }); describe("explorer.delete", () => { diff --git a/packages/builtin/explorer/index.ts b/packages/builtin/explorer/index.ts index 3296c3c..dcfd658 100644 --- a/packages/builtin/explorer/index.ts +++ b/packages/builtin/explorer/index.ts @@ -81,18 +81,34 @@ function describeError(err: unknown): string { } /** Validates a new file/folder name (Req 11.2's "create... with input-box - * prompts"): non-empty, no path separator, and not already used by a - * sibling already listed in `dirUri` (a best-effort check against - * whatever the store last loaded — the real `write`/`mkdir` call is still - * the final authority, so a race with an out-of-band change is simply - * reported as an error at that point instead). `currentName`, when given - * (renaming), exempts that one name from the collision check — renaming a - * file to its own current name is otherwise indistinguishable from "name - * already taken". */ + * prompts"): non-empty, no path separator, not `.`/`..`, and not already + * used by a sibling already listed in `dirUri` (a best-effort check + * against whatever the store last loaded — the real `write`/`mkdir` call + * is still the final authority, so a race with an out-of-band change is + * simply reported as an error at that point instead). `currentName`, when + * given (renaming), exempts that one name from the collision check — + * renaming a file to its own current name is otherwise indistinguishable + * from "name already taken". + * + * **`.`/`..` rejection (security, code review fix)**: {@link joinChildUri} + * builds the target `Uri` as `new URL(encodeURIComponent(name), dirUri)` — + * `encodeURIComponent` does NOT escape `.`, so an unvalidated `".."` + * resolves the `URL` constructor's own `..`-segment normalization to the + * PARENT of `dirUri`, escaping the directory the create/rename command + * thinks it's confined to (`"."` similarly collapses to `dirUri` itself, + * silently overwriting/renaming onto the directory rather than a real + * child of it). Both the create path and the rename path below call this + * function again, explicitly, right before their own `joinChildUri` call — + * `showInputBox`'s `validateInput` already runs this, but that only blocks + * accepting the UI prompt; a caller invoking `api.commands.execute` on + * these command ids programmatically bypasses the input box (and its + * validation) entirely, so the guard at the `joinChildUri` call site is + * the one that actually matters. */ function validateEntryName(value: string, siblingNames: readonly string[], currentName?: string): string | undefined { const trimmed = value.trim(); if (trimmed.length === 0) return "Name cannot be empty."; if (trimmed.includes("/")) return "Name cannot contain \"/\"."; + if (trimmed === "." || trimmed === "..") return `"${trimmed}" is not a valid name.`; if (trimmed !== currentName && siblingNames.includes(trimmed)) { return `"${trimmed}" already exists here.`; } @@ -124,13 +140,25 @@ function registerCreateCommands(ctx: ExtensionContext, store: ExplorerStore): vo return; } + const siblingNames = siblingNamesOf(store, dirUri); const name = await api.window.showInputBox({ prompt, - validateInput: (value) => validateEntryName(value, siblingNamesOf(store, dirUri)), + validateInput: (value) => validateEntryName(value, siblingNames), }); if (!name) return; - const uri = joinChildUri(dirUri, name.trim()); + const trimmedName = name.trim(); + // Explicit re-check right at the `joinChildUri` call site (this + // module's `validateEntryName` TSDoc's "code review fix") — + // `validateInput` above only gates the input box UI, not a + // programmatic `api.commands.execute` call. + const nameError = validateEntryName(trimmedName, siblingNames); + if (nameError) { + api.window.showMessage(nameError, "error"); + return; + } + + const uri = joinChildUri(dirUri, trimmedName); try { if (kind === "file") await api.workspace.fs.write(uri, new Uint8Array()); else await api.workspace.fs.mkdir(uri); @@ -193,14 +221,26 @@ function registerRenameCommand(ctx: ExtensionContext, store: ExplorerStore): voi return; } + const siblingNames = siblingNamesOf(store, parent); const newName = await api.window.showInputBox({ prompt: "New name", value: currentName, - validateInput: (value) => validateEntryName(value, siblingNamesOf(store, parent), currentName), + validateInput: (value) => validateEntryName(value, siblingNames, currentName), }); if (!newName || newName.trim() === currentName) return; - const newUri = joinChildUri(parent, newName.trim()); + const trimmedNewName = newName.trim(); + // Explicit re-check right at the `joinChildUri` call site — see + // `validateEntryName`'s TSDoc's "code review fix": `validateInput` + // only gates the input box UI, not a programmatic + // `api.commands.execute` call. + const nameError = validateEntryName(trimmedNewName, siblingNames, currentName); + if (nameError) { + api.window.showMessage(nameError, "error"); + return; + } + + const newUri = joinChildUri(parent, trimmedNewName); try { await api.workspace.fs.rename(uri, newUri); } catch (cause) { diff --git a/packages/builtin/explorer/store.test.ts b/packages/builtin/explorer/store.test.ts index ee87921..93a7e6c 100644 --- a/packages/builtin/explorer/store.test.ts +++ b/packages/builtin/explorer/store.test.ts @@ -234,6 +234,113 @@ describe("createExplorerStore (Task 3.3, Req 11.2)", () => { }); }); + describe("reload diffing against external changes (code review fix)", () => { + test("external delete of an expanded+selected subdirectory purges its metadata and resets selection", async () => { + const tree: FakeTree = { src: { nested: { "a.ts": null } } }; + const { store } = createStore(tree); + await store.reload(ROOT); + await new Promise((resolve) => { + const sub = store.onDidChange(() => { + sub.dispose(); + resolve(); + }); + store.toggle("file:///workspace/src" as Uri, true); + }); + await new Promise((resolve) => { + const sub = store.onDidChange(() => { + sub.dispose(); + resolve(); + }); + store.toggle("file:///workspace/src/nested" as Uri, true); + }); + store.setSelectedId("file:///workspace/src/nested" as Uri); + expect(store.isDirectory("file:///workspace/src/nested" as Uri)).toBe(true); + + // Externally delete "src/nested" (the selected, expanded directory) + // and reload its now-stale parent. + delete (tree.src as FakeTree).nested; + await store.reload("file:///workspace/src" as Uri); + + expect(store.isDirectory("file:///workspace/src/nested" as Uri)).toBe(false); + expect(store.getParent("file:///workspace/src/nested/a.ts" as Uri)).toBeUndefined(); + expect(store.getName("file:///workspace/src/nested" as Uri)).toBeUndefined(); + expect(store.getExpandedIds()).not.toContain("file:///workspace/src/nested"); + // Selection resets to the affected parent, not left dangling on a + // uri that no longer exists (a broken create target otherwise). + expect(store.getSelectedId()).toBe("file:///workspace/src" as Uri); + }); + + test("a file replaced by a same-named directory drops the stale file metadata", async () => { + const tree: FakeTree = { thing: null }; + const { store } = createStore(tree); + await store.reload(ROOT); + expect(store.isDirectory("file:///workspace/thing" as Uri)).toBe(false); + + tree.thing = { "child.ts": null }; + await store.reload(ROOT); + + expect(store.isDirectory("file:///workspace/thing" as Uri)).toBe(true); + const changed = waitForChange(store); + store.toggle("file:///workspace/thing" as Uri, true); + await changed; + expect(store.getNodes().find((n) => n.label === "thing")?.children?.map((c) => c.label)).toEqual([ + "child.ts", + ]); + }); + + test("a directory replaced by a same-named file drops its cached children and expanded state", async () => { + const tree: FakeTree = { thing: { "child.ts": null } }; + const { store } = createStore(tree); + await store.reload(ROOT); + await new Promise((resolve) => { + const sub = store.onDidChange(() => { + sub.dispose(); + resolve(); + }); + store.toggle("file:///workspace/thing" as Uri, true); + }); + expect(store.isDirectory("file:///workspace/thing" as Uri)).toBe(true); + expect(store.getExpandedIds()).toEqual(["file:///workspace/thing"]); + + tree.thing = null; + await store.reload(ROOT); + + expect(store.isDirectory("file:///workspace/thing" as Uri)).toBe(false); + expect(store.getExpandedIds()).toEqual([]); + expect(store.getParent("file:///workspace/thing/child.ts" as Uri)).toBeUndefined(); + }); + }); + + describe("reload staleness (concurrent reloads of the same directory)", () => { + test("a reload that started earlier but resolves later is discarded, not allowed to overwrite a fresher result", async () => { + let call = 0; + const releaseFirst: Array<() => void> = []; + const store = createExplorerStore(ROOT, { + readdir: async () => { + call += 1; + if (call === 1) { + await new Promise((resolve) => releaseFirst.push(resolve)); + return [{ name: "stale.ts", type: "file" }]; + } + return [{ name: "fresh.ts", type: "file" }]; + }, + ignore: createIgnoreChecker(), + showMessage: () => {}, + showHidden: false, + }); + + const firstReload = store.reload(ROOT); // starts first, blocks on releaseFirst + const secondReload = store.reload(ROOT); // starts second, resolves immediately + await secondReload; + expect(store.getNodes().map((n) => n.label)).toEqual(["fresh.ts"]); + + releaseFirst.shift()?.(); // let the stale (first-started) reload finish now + await firstReload; + + expect(store.getNodes().map((n) => n.label)).toEqual(["fresh.ts"]); + }); + }); + describe("showHidden", () => { test("setShowHidden(true) reloads every already-loaded directory and reveals hidden entries", async () => { const { store } = createStore({ ".env": null, "keep.ts": null }); diff --git a/packages/builtin/explorer/store.ts b/packages/builtin/explorer/store.ts index 5374bb7..b7c97b3 100644 --- a/packages/builtin/explorer/store.ts +++ b/packages/builtin/explorer/store.ts @@ -177,6 +177,16 @@ export function createExplorerStore(rootUri: Uri | undefined, deps: ExplorerStor const expanded = new Set(); const listeners = new Set>(); + // Per-directory reload generation counter (code review fix, Task 3.3, Req + // 11.2): bumped at the START of every `reload(dirUri)` call, BEFORE the + // first `await`. A reload only commits state/`fireChange()` once every + // one of its `await`s resolves AND its captured generation is still the + // latest recorded for that `dirUri` — a concurrent reload of the SAME + // directory (a `fs.watch` event racing a `setShowHidden` reload, e.g.) + // that started later always wins; whichever finishes with a stale + // generation is discarded rather than clobbering the newer result. + const reloadGenerations = new Map(); + let showHidden = deps.showHidden; let selectedId: Uri | undefined; @@ -205,17 +215,56 @@ export function createExplorerStore(rootUri: Uri | undefined, deps: ExplorerStor }; } + /** + * Recursively purge `uri`'s own bookkeeping AND every cached descendant's + * (code review fix: an externally-removed or type-changed directory + * entry must not leave stale `parentByUri`/`relativeDirByUri`/ + * `directoryUris`/`childrenByDir` entries for itself or anything cached + * underneath it — a stale `directoryUris` entry in particular is exactly + * what turned a deleted-but-still-"known-directory" uri into a broken + * create target). Returns whether the CURRENT selection was purged (this + * uri itself, or any descendant of it), so the caller can reset + * selection to the affected parent. + */ + function purgeSubtree(uri: Uri): boolean { + let purgedSelection = selectedId === uri; + if (directoryUris.has(uri)) { + const cachedChildren = childrenByDir.get(uri); + if (cachedChildren) { + for (const child of cachedChildren) { + if (purgeSubtree(child.uri)) purgedSelection = true; + } + } + childrenByDir.delete(uri); + directoryUris.delete(uri); + expanded.delete(uri); + } + relativeDirByUri.delete(uri); + parentByUri.delete(uri); + reloadGenerations.delete(uri); + return purgedSelection; + } + async function reload(dirUri: Uri): Promise { if (!rootUri) return; const relativeDir = relativeDirByUri.get(dirUri) ?? ""; + // See the `reloadGenerations` field comment above: this call only ever + // commits below if it is still the most recently STARTED reload for + // `dirUri` by the time it finishes. + const generation = (reloadGenerations.get(dirUri) ?? 0) + 1; + reloadGenerations.set(dirUri, generation); + const isStale = (): boolean => reloadGenerations.get(dirUri) !== generation; + let entries: DirEntry[]; try { entries = await deps.readdir(dirUri); } catch (cause) { + if (isStale()) return; deps.showMessage(`Could not read directory: ${describeError(cause)}`, "error"); return; } + if (isStale()) return; const sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name)); let visible: DirEntry[]; @@ -234,17 +283,42 @@ export function createExplorerStore(rootUri: Uri | undefined, deps: ExplorerStor deps.showMessage(`Could not filter directory listing: ${describeError(cause)}`, "error"); visible = sorted; } + if (isStale()) return; + + const newEntries = visible.map((entry) => ({ + uri: joinChildUri(dirUri, entry.name), + name: entry.name, + isDirectory: entry.type === "directory", + })); + const newIsDirectoryByUri = new Map(newEntries.map((entry) => [entry.uri, entry.isDirectory])); + + // Diff against whatever this directory last had cached (code review + // fix): a previous child missing from `newIsDirectoryByUri` entirely + // (deleted externally) or present with a DIFFERENT type (file<-> + // directory) both purge that uri's own metadata and — when it was + // itself a directory — every cached descendant's too. + const previousChildren = childrenByDir.get(dirUri) ?? []; + let selectionPurged = false; + for (const previous of previousChildren) { + const newIsDirectory = newIsDirectoryByUri.get(previous.uri); + if (newIsDirectory === undefined || newIsDirectory !== previous.isDirectory) { + if (purgeSubtree(previous.uri)) selectionPurged = true; + } + } - const children: ExplorerChild[] = visible.map((entry) => { - const childUri = joinChildUri(dirUri, entry.name); - const isDirectory = entry.type === "directory"; - relativeDirByUri.set(childUri, relativeDir.length > 0 ? `${relativeDir}/${entry.name}` : entry.name); + const children: ExplorerChild[] = newEntries.map(({ uri: childUri, name, isDirectory }) => { + relativeDirByUri.set(childUri, relativeDir.length > 0 ? `${relativeDir}/${name}` : name); parentByUri.set(childUri, dirUri); if (isDirectory) directoryUris.add(childUri); - return { uri: childUri, name: entry.name, isDirectory }; + return { uri: childUri, name, isDirectory }; }); childrenByDir.set(dirUri, children); + // The affected parent is `dirUri` itself (this is the directory whose + // listing just changed) — root when `dirUri` is the root (this + // module's TSDoc's "reset selection to the affected parent (or + // root)"). + if (selectionPurged) selectedId = dirUri; fireChange(); } diff --git a/packages/builtin/shared/gitRunner.test.ts b/packages/builtin/shared/gitRunner.test.ts index d78003b..98e756a 100644 --- a/packages/builtin/shared/gitRunner.test.ts +++ b/packages/builtin/shared/gitRunner.test.ts @@ -19,7 +19,15 @@ async function initRepo(dir: string): Promise { await proc.exited; } -describe("createBunGitRunner (Task 3.3, Req 11.2)", () => { +// This suite exercises the REAL `git` CLI (this module's own TSDoc above) +// rather than stubbing `GitRunner`, so it needs a real `git` binary on +// `PATH` — not guaranteed on every CI image. Decided once, top-level, +// against the very same `isAvailable()` this suite tests, and used to +// skip the whole suite rather than asserting `isAvailable() === true` +// unconditionally (which would fail outright on a git-less image). +const hasGit = await createBunGitRunner().isAvailable(); + +describe.skipIf(!hasGit)("createBunGitRunner (Task 3.3, Req 11.2)", () => { let dir: string | undefined; afterEach(async () => { @@ -99,6 +107,21 @@ describe("createBunGitRunner (Task 3.3, Req 11.2)", () => { const ignored = await runner.checkIgnore("/nonexistent", []); expect(ignored.size).toBe(0); }); + + test("isRepository reports true inside a git working tree", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-gitrunner-")); + await initRepo(dir); + + const runner = createBunGitRunner(); + expect(await runner.isRepository(dir)).toBe(true); + }); + + test("isRepository reports false for a directory that is not a git repository", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-gitrunner-not-a-repo-")); + + const runner = createBunGitRunner(); + expect(await runner.isRepository(dir)).toBe(false); + }); }); describe("uriToGitPath (Task 3.3, Req 11.2)", () => { diff --git a/packages/builtin/shared/gitRunner.ts b/packages/builtin/shared/gitRunner.ts index 3355d5c..512c0ad 100644 --- a/packages/builtin/shared/gitRunner.ts +++ b/packages/builtin/shared/gitRunner.ts @@ -66,6 +66,20 @@ export interface GitRunner { * showing everything rather than crashing the caller. */ checkIgnore(cwd: string, absolutePaths: readonly string[]): Promise>; + /** + * Whether `cwd` is inside a git working tree at all (`git rev-parse + * --is-inside-work-tree` exits `0` and prints `true`) — the seam + * `ignore.ts` needs to tell "git is installed but this workspace isn't a + * repo" apart from "git is installed and definitively found nothing + * ignored" (Task 3.3, Req 11.2 code review: `checkIgnore` against a + * non-repository `cwd` degrades to an empty set exactly like the + * "nothing ignored" case, which — left unchecked — silently disables the + * `.gitignore` glob fallback for every non-repo workspace). Never + * rejects: any failure (git disappears mid-session, spawn error) reports + * `false`, same "degrade gracefully" contract as {@link isAvailable} and + * {@link checkIgnore}. + */ + isRepository(cwd: string): Promise; } /** Convert a `file://...` {@link Uri} to a real filesystem path for @@ -108,14 +122,20 @@ export function createBunGitRunner(): GitRunner { async function checkIgnore(cwd: string, absolutePaths: readonly string[]): Promise> { if (absolutePaths.length === 0) return new Set(); try { - const proc = Bun.spawn(["git", "check-ignore", "--stdin"], { + // `-z`: paths in on stdin AND matches out on stdout are NUL-separated + // rather than newline-separated, with no trimming of either side — + // a path containing a space or an embedded newline stays intact and + // matchable (a plain `\n`-joined stdin/stdout would either misparse + // such a path into pieces or, with a naive `.trim()`, corrupt one + // that has meaningful leading/trailing whitespace in its name). + const proc = Bun.spawn(["git", "check-ignore", "-z", "--stdin"], { cwd, stdin: "pipe", stdout: "pipe", stderr: "ignore", }); const stdin = proc.stdin; - stdin.write(`${absolutePaths.join("\n")}\n`); + stdin.write(absolutePaths.map((path) => `${path}\0`).join("")); stdin.end(); const output = await new Response(proc.stdout).text(); // `git check-ignore --stdin` exits 1 when NONE of the inputs are @@ -123,15 +143,27 @@ export function createBunGitRunner(): GitRunner { // discard whatever stdout it already produced (exit 1 with empty // stdout is the common, entirely expected "nothing ignored" case). await proc.exited; - const matched = output - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0); + const matched = output.split("\0").filter((entry) => entry.length > 0); return new Set(matched); } catch { return new Set(); } } - return { isAvailable, checkIgnore }; + async function isRepository(cwd: string): Promise { + try { + const proc = Bun.spawn(["git", "rev-parse", "--is-inside-work-tree"], { + cwd, + stdout: "pipe", + stderr: "ignore", + }); + const output = await new Response(proc.stdout).text(); + const exitCode = await proc.exited; + return exitCode === 0 && output.trim() === "true"; + } catch { + return false; + } + } + + return { isAvailable, checkIgnore, isRepository }; } diff --git a/packages/builtin/shared/gitignoreMatcher.test.ts b/packages/builtin/shared/gitignoreMatcher.test.ts index e5d5784..ca43c01 100644 --- a/packages/builtin/shared/gitignoreMatcher.test.ts +++ b/packages/builtin/shared/gitignoreMatcher.test.ts @@ -40,6 +40,13 @@ describe("parseGitignore (Task 3.3, Req 11.2)", () => { expect(matcher.isIgnored("src/nested/index.ts", false)).toBe(false); }); + test("? is not a wildcard — it matches only a literal '?', never crossing to an unrelated character", () => { + const matcher = parseGitignore("foo?.log"); + expect(matcher.isIgnored("foo?.log", false)).toBe(true); + expect(matcher.isIgnored("fo.log", false)).toBe(false); + expect(matcher.isIgnored("fooX.log", false)).toBe(false); + }); + test("** matches across path separators", () => { const matcher = parseGitignore("**/*.log"); expect(matcher.isIgnored("a/b/c/debug.log", false)).toBe(true); diff --git a/packages/builtin/shared/gitignoreMatcher.ts b/packages/builtin/shared/gitignoreMatcher.ts index ed92501..9af895d 100644 --- a/packages/builtin/shared/gitignoreMatcher.ts +++ b/packages/builtin/shared/gitignoreMatcher.ts @@ -44,9 +44,13 @@ interface CompiledPattern { /** Escape every regex metacharacter in `segment` EXCEPT the glob * wildcards this module itself interprets (`*`, handled by the caller * before this ever runs) — used on whatever literal text remains between - * wildcards. */ + * wildcards. `?` is NOT one of this module's wildcards (this module's + * TSDoc's "Scope": "`?` is deliberately NOT supported"), so it must be + * escaped here too — left bare, it compiles to a regex "any one character" + * quantifier/atom instead of matching a literal `?` (e.g. `foo?.log` would + * wrongly match `fo.log`). */ function escapeRegexLiteral(segment: string): string { - return segment.replace(/[.+^${}()|[\]\\]/g, "\\$&"); + return segment.replace(/[.+^${}()|[\]\\?]/g, "\\$&"); } /** diff --git a/packages/builtin/shared/ignore.test.ts b/packages/builtin/shared/ignore.test.ts index 542b094..3af4432 100644 --- a/packages/builtin/shared/ignore.test.ts +++ b/packages/builtin/shared/ignore.test.ts @@ -29,6 +29,9 @@ function createUnavailableGitRunner(): GitRunner { checkIgnore: async () => { throw new Error("must not be called when git is unavailable"); }, + isRepository: async () => { + throw new Error("must not be called when git is unavailable"); + }, }; } @@ -43,6 +46,7 @@ function createFakeGitRunner( return { calls, isAvailable: async () => true, + isRepository: async () => true, checkIgnore: async (cwd, paths) => { calls.push({ cwd, paths }); const ignored = paths.filter((p) => ignoredBasenames.has(p.split("/").pop() ?? "")); @@ -157,6 +161,7 @@ describe("createIgnoreChecker (Task 3.3, Req 11.2)", () => { test("a checkIgnore failure degrades to 'nothing further ignored' rather than throwing", async () => { const gitRunner: GitRunner = { isAvailable: async () => true, + isRepository: async () => true, checkIgnore: async () => { throw new Error("git exploded"); }, @@ -171,6 +176,7 @@ describe("createIgnoreChecker (Task 3.3, Req 11.2)", () => { isAvailable: async () => { throw new Error("spawn failed"); }, + isRepository: async () => true, checkIgnore: async () => new Set(), }; const checker = createIgnoreChecker({ @@ -182,6 +188,62 @@ describe("createIgnoreChecker (Task 3.3, Req 11.2)", () => { }); }); + describe("git available but the workspace is not a repository", () => { + test("falls back to the glob path rather than silently disabling .gitignore filtering", async () => { + const gitRunner: GitRunner = { + isAvailable: async () => true, + isRepository: async () => false, + checkIgnore: async () => { + throw new Error("must not be called when the workspace is not a git repository"); + }, + }; + const checker = createIgnoreChecker({ + gitRunner, + readFile: async () => new TextEncoder().encode("*.log"), + }); + const visible = await checker.filterEntries( + baseOptions([entry("debug.log"), entry("keep.ts")]), + ); + expect(names(visible)).toEqual(["keep.ts"]); + }); + + test("isRepository is checked once per root and cached", async () => { + let repoChecks = 0; + const gitRunner: GitRunner = { + isAvailable: async () => true, + isRepository: async () => { + repoChecks += 1; + return false; + }, + checkIgnore: async () => new Set(), + }; + const checker = createIgnoreChecker({ gitRunner, readFile: async () => new TextEncoder().encode("") }); + await checker.filterEntries(baseOptions([entry("a.ts")])); + await checker.filterEntries( + baseOptions([entry("b.ts")], { dirUri: "file:///workspace/src/", relativeDir: "src" }), + ); + expect(repoChecks).toBe(1); + }); + + test("an isRepository failure also falls back to the glob path rather than throwing", async () => { + const gitRunner: GitRunner = { + isAvailable: async () => true, + isRepository: async () => { + throw new Error("spawn failed"); + }, + checkIgnore: async () => new Set(), + }; + const checker = createIgnoreChecker({ + gitRunner, + readFile: async () => new TextEncoder().encode("*.log"), + }); + const visible = await checker.filterEntries( + baseOptions([entry("debug.log"), entry("keep.ts")]), + ); + expect(names(visible)).toEqual(["keep.ts"]); + }); + }); + describe("git unavailable (glob fallback over the root .gitignore)", () => { test("applies the root .gitignore's patterns", async () => { const checker = createIgnoreChecker({ diff --git a/packages/builtin/shared/ignore.ts b/packages/builtin/shared/ignore.ts index 5010de7..51e88c7 100644 --- a/packages/builtin/shared/ignore.ts +++ b/packages/builtin/shared/ignore.ts @@ -18,12 +18,18 @@ * project's own `.gitignore` says) are applied first. * 3. Whatever survives step 2 is then checked against `.gitignore` * content: batched `git check-ignore --stdin` (`gitRunner.ts`) when the - * `git` CLI is available, or {@link parseGitignore}'s glob matcher over - * the WORKSPACE ROOT's `.gitignore` file otherwise (its own module's - * TSDoc documents the "single root file only" simplification the glob - * path makes — the git path has no such limitation, since `git - * check-ignore` itself resolves the real, full chain of `.gitignore` - * files). + * `git` CLI is available AND the workspace root is actually inside a + * git working tree ({@link GitRunner.isRepository}, checked once per + * root and cached — `git` installed but the workspace not a repo falls + * through to the glob path below exactly like "git unavailable", rather + * than silently disabling `.gitignore` filtering the way `checkIgnore` + * alone would: it degrades a non-repo `cwd` to an empty "nothing + * ignored" set indistinguishable from a real, git-confirmed empty + * result), or {@link parseGitignore}'s glob matcher over the WORKSPACE + * ROOT's `.gitignore` file otherwise (its own module's TSDoc documents + * the "single root file only" simplification the glob path makes — the + * git path has no such limitation, since `git check-ignore` itself + * resolves the real, full chain of `.gitignore` files). * * **`readFile`, not a raw path** ({@link IgnoreCheckerDeps.readFile}): the * glob fallback needs the root `.gitignore`'s CONTENT, which — per this @@ -133,6 +139,32 @@ export function createIgnoreChecker(deps: IgnoreCheckerDeps = {}): IgnoreChecker // `.gitignore`-content reloading, only live `showHidden` reloading). const gitignoreCache = new Map>(); + // Whether `rootUri` is actually inside a git working tree — cached per + // root exactly like `gitignoreCache` above, and checked only once `git` + // itself is known to be available. `git` installed but the workspace NOT + // a repo (this module's TSDoc's code-review fix: previously fell through + // to `checkIgnore`, which degrades a non-repo `cwd` to an empty set + // indistinguishable from "nothing ignored" — silently disabling the + // `.gitignore` glob fallback) now routes to the glob path instead, same + // as "git unavailable". + const repositoryCache = new Map>(); + + async function isRepository(rootPath: string): Promise { + const cached = repositoryCache.get(rootPath); + if (cached) return cached; + const checked = (async () => { + try { + return await deps.gitRunner!.isRepository(rootPath); + } catch { + // Documented never-throw on GitRunner, guarded anyway (matches + // `isGitAvailable`'s own guard just below). + return false; + } + })(); + repositoryCache.set(rootPath, checked); + return checked; + } + async function loadGitignoreMatcher(rootUri: Uri): Promise { const cached = gitignoreCache.get(rootUri); if (cached) return cached; @@ -174,7 +206,8 @@ export function createIgnoreChecker(deps: IgnoreCheckerDeps = {}): IgnoreChecker }); if (candidates.length === 0) return []; - if (await isGitAvailable()) { + const rootPath = uriToGitPath(rootUri).replace(/\/+$/, ""); + if ((await isGitAvailable()) && (await isRepository(rootPath))) { // `fileURLToPath` preserves a directory URL's trailing slash (e.g. // `"file:///workspace/"` -> `"/workspace/"`) — stripped here so the // join below never produces a doubled `//` in front of `entry.name`. diff --git a/packages/core/src/buffer/fileSystem.test.ts b/packages/core/src/buffer/fileSystem.test.ts index 21e207d..26ddc91 100644 --- a/packages/core/src/buffer/fileSystem.test.ts +++ b/packages/core/src/buffer/fileSystem.test.ts @@ -126,6 +126,22 @@ describe("createFileSystem", () => { expect(new TextDecoder().decode(await fs.read(pathToUri(newPath)))).toBe("content"); }); + test("rename rejects when the destination already exists, leaving it unchanged", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-fs-")); + const oldPath = join(dir, "old.txt"); + const newPath = join(dir, "new.txt"); + await nodeWriteFile(oldPath, "source content", "utf8"); + await nodeWriteFile(newPath, "destination content", "utf8"); + const fs = createFileSystem(); + + await expect(fs.rename(pathToUri(oldPath), pathToUri(newPath))).rejects.toThrow(); + + expect(new TextDecoder().decode(await fs.read(pathToUri(newPath)))).toBe( + "destination content", + ); + expect(new TextDecoder().decode(await fs.read(pathToUri(oldPath)))).toBe("source content"); + }); + test("rename rejects when the source does not exist", async () => { dir = await mkdtemp(join(tmpdir(), "tecode-fs-")); const fs = createFileSystem(); diff --git a/packages/core/src/buffer/fileSystem.ts b/packages/core/src/buffer/fileSystem.ts index f6413f8..6c529af 100644 --- a/packages/core/src/buffer/fileSystem.ts +++ b/packages/core/src/buffer/fileSystem.ts @@ -146,8 +146,29 @@ export function createFileSystem(deps: FileSystemDeps = {}): FileSystem { await nodeFs.rm(uriToPath(uri), { recursive: true }); } + /** + * `node:fs/promises.rename` silently replaces an existing destination on + * Linux/macOS (POSIX `rename(2)` semantics) — the opposite of the API + * contract's "rejects ... `newUri` already exists" ({@link + * FileSystem.rename}). There is no atomic POSIX rename-without-replace, + * so this probes the destination with `lstat` first and rejects before + * calling through; a concurrent create of `newUri` between that probe + * and the actual rename (TOCTOU) can still slip through and be + * overwritten — accepted for the MVP, matching this module's other + * best-effort races (see `watch`'s create/delete disambiguation above). + */ async function rename(oldUri: Uri, newUri: Uri): Promise { - await nodeFs.rename(uriToPath(oldUri), uriToPath(newUri)); + const destination = uriToPath(newUri); + let destinationExists = true; + try { + await nodeFs.lstat(destination); + } catch { + destinationExists = false; + } + if (destinationExists) { + throw new Error(`Cannot rename: "${newUri}" already exists.`); + } + await nodeFs.rename(uriToPath(oldUri), destination); } async function mkdir(uri: Uri): Promise {