From c2d3a149a067b8330422b0be2bb903b5896925d2 Mon Sep 17 00:00:00 2001 From: Kingsword Date: Tue, 27 Oct 2026 07:49:27 +0800 Subject: [PATCH] fix(runtime): search workspace files across directory levels --- README.md | 5 +- docs/DEVELOPMENT.md | 16 ++ scripts/check-runtime.ts | 2 + scripts/runtime-workspace-path-patches.ts | 66 ++++++++ scripts/sync-runtime.ts | 8 + src/runtime-config-bridge.ts | 1 + src/runtime-workspace-paths.ts | 101 ++++++++++++ test/fixtures/workspace-path-runtime.cjs | 57 +++++++ test/runtime-workspace-path-patches.test.ts | 48 ++++++ test/runtime-workspace-paths.test.ts | 161 ++++++++++++++++++++ test/runtime/workspace-paths.test.ts | 28 ++++ 11 files changed, 491 insertions(+), 2 deletions(-) create mode 100644 scripts/runtime-workspace-path-patches.ts create mode 100644 src/runtime-workspace-paths.ts create mode 100644 test/fixtures/workspace-path-runtime.cjs create mode 100644 test/runtime-workspace-path-patches.test.ts create mode 100644 test/runtime-workspace-paths.test.ts create mode 100644 test/runtime/workspace-paths.test.ts diff --git a/README.md b/README.md index 45f004f..a96af26 100644 --- a/README.md +++ b/README.md @@ -132,8 +132,9 @@ bytes. ## Features **Editor and input.** pi-tui differential rendering with a CJK-aware -multi-line editor; slash-command, unified `@` workspace/Plugin references and -`$` Skill completion; persisted prompt history through ZCode's history API; +multi-line editor; slash-command completion, unified `@` workspace/Plugin +references with recursive file search, and `$` Skill completion; +persisted prompt history through ZCode's history API; `--no-color` and `NO_COLOR` support. **Streaming and conversation.** Streamed assistant text from official ZCode diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 7086b7c..f540b88 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -92,6 +92,22 @@ patch plan as required verification points: Desktop 3.16.3 already supplies their behavior, so they report `already_present` and never rewrite the runtime. Synchronization still fails if a future upstream build drops the behavior. +## Workspace file completion + +The required `recursive-workspace-paths` runtime patch extends the native +`createWorkspacePathSuggestionProvider` through `src/runtime-workspace-paths.ts`. +An unqualified `@name` searches all workspace directory levels; empty tokens and +explicit paths retain the native directory completion. Searches use the runtime's +filesystem port and excluded-directory set, skip symlinks, and forward cancellation. +Only the best requested number of matches are retained, with exact names before +prefix and substring matches. Hidden entries require a query beginning with `.`; +this preserves the runtime's existing rules rather than adding `.gitignore` parsing. + +`bun test test/runtime-workspace-paths.test.ts test/runtime-workspace-path-patches.test.ts` +covers real file trees and patch compatibility. After syncing the built runtime, +`bun test test/runtime/workspace-paths.test.ts` checks the actual native provider +and filesystem adapter in Node, including the nested file case from #240. + ## TUI startup compatibility Recent upstream runtimes pass session metadata and slash commands through diff --git a/scripts/check-runtime.ts b/scripts/check-runtime.ts index d85aa88..a3b4414 100755 --- a/scripts/check-runtime.ts +++ b/scripts/check-runtime.ts @@ -23,6 +23,7 @@ import { hasRuntimeHttpNoContentGuard, hasRuntimeNetworkRetryGuard, hasRuntimeResolvedContextWindow, + hasRuntimeRecursiveWorkspacePaths, hasRuntimeSharedOAuth, hasRuntimeSqliteBusyTimeout, hasRuntimeSqliteWriteRecovery, @@ -103,6 +104,7 @@ if (patchRuntimeLoginModelDefaults(runtimeSource) !== runtimeSource || !hasRuntimeBigmodelOAuth(runtimeSource) || !hasRuntimeBrowserUrlOpening(runtimeSource) || !hasRuntimeConfigurationDiagnostics(runtimeSource) + || !hasRuntimeRecursiveWorkspacePaths(runtimeSource) || !hasRuntimeResolvedContextWindow(runtimeSource) || !hasRuntimeSharedOAuth(runtimeSource) || !hasRuntimeCliSettingsFile(runtimeSource) diff --git a/scripts/runtime-workspace-path-patches.ts b/scripts/runtime-workspace-path-patches.ts new file mode 100644 index 0000000..d106131 --- /dev/null +++ b/scripts/runtime-workspace-path-patches.ts @@ -0,0 +1,66 @@ +import { parseExpressionAt } from "acorn"; + +const identifier = "[A-Za-z_$][\\w$]*"; +const bridge = 'require(require("node:path").join(__dirname,"cli-config.cjs"))'; +const wrapper = `${bridge}.withRecursiveWorkspacePathSuggestions`; + +function incompatible(detail: string): never { + throw new Error(`ZCode runtime is incompatible with recursive workspace paths (${detail}).`); +} + +function unique(items: T[], detail: string): T { + if (items.length !== 1) incompatible(`${detail} missing or ambiguous`); + return items[0]!; +} + +function escape(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} + +function inspect(runtime: string) { + const name = unique([...runtime.matchAll(new RegExp( + `${identifier}\\((${identifier}),"createWorkspacePathSuggestionProvider"\\)`, "gu" + ))], "provider label")[1]!; + const declaration = unique([...runtime.matchAll(new RegExp(`function ${escape(name)}\\(`, "gu"))], "provider function"); + const fn = parseExpressionAt(runtime, declaration.index!, { ecmaVersion: "latest" }); + if (fn.type !== "FunctionExpression" || fn.async || fn.params.length !== 1 || fn.params[0]?.type !== "Identifier") { + incompatible("provider signature changed"); + } + const source = (node: { start: number; end: number }) => runtime.slice(node.start, node.end); + const [bindings, returned] = fn.body.body; + if (fn.body.body.length !== 2 || bindings?.type !== "VariableDeclaration" || bindings.declarations.length !== 2 + || returned?.type !== "ReturnStatement" || !returned.argument) incompatible("provider body changed"); + const [fileSystem, root] = bindings.declarations; + if (fileSystem?.id.type !== "Identifier" || !fileSystem.init || root?.id.type !== "Identifier" || !root.init) { + incompatible("provider bindings changed"); + } + const options = escape(fn.params[0].name); + if (!new RegExp(`^${options}\\.fileSystemPort\\?\\?${identifier}\\(\\)$`, "u").test(source(fileSystem.init)) + || !new RegExp(`^\\(0,${identifier}\\.resolve\\)\\(${options}\\.workspaceDirectory\\)$`, "u").test(source(root.init))) { + incompatible("filesystem or workspace binding changed"); + } + const argument = returned.argument; + const native = argument.type === "CallExpression" ? argument.arguments[0] : argument; + if (native?.type !== "ArrowFunctionExpression" || !native.async || native.params.length !== 1 + || native.params[0]?.type !== "Identifier") incompatible("native callback changed"); + const nativeSource = source(native); + const exclusions = unique([...nativeSource.matchAll(new RegExp( + `(${identifier})\\.has\\(${identifier}\\.name\\)`, "gu" + ))], "directory exclusions")[1]!; + const listing = new RegExp( + `${escape(fileSystem.id.name)}\\.listDirectory\\(\\{path:${identifier}\\},\\{signal:${escape(native.params[0].name)}\\.abortSignal\\}\\)`, "u" + ); + if (!listing.test(nativeSource)) incompatible("native directory listing changed"); + const replacement = `${wrapper}(${nativeSource},{workspaceDirectory:${root.id.name},fileSystemPort:${fileSystem.id.name},excludedDirectories:${exclusions}})`; + if (argument.type === "CallExpression" && source(argument) !== replacement) incompatible("recursive wrapper changed"); + return { start: argument.start, end: argument.end, replacement, patched: argument.type === "CallExpression" }; +} + +export function hasRuntimeRecursiveWorkspacePaths(runtime: string): boolean { + try { return inspect(runtime).patched; } catch { return false; } +} + +export function patchRuntimeRecursiveWorkspacePaths(runtime: string): string { + const { start, end, replacement, patched } = inspect(runtime); + return patched ? runtime : runtime.slice(0, start) + replacement + runtime.slice(end); +} diff --git a/scripts/sync-runtime.ts b/scripts/sync-runtime.ts index 1895148..9308ed7 100755 --- a/scripts/sync-runtime.ts +++ b/scripts/sync-runtime.ts @@ -27,6 +27,8 @@ export { hasRuntimeAppServerStandaloneAuth, patchRuntimeAppServerStandaloneAuth import { hasRuntimeConfigurationDiagnostics, patchRuntimeConfigurationDiagnostics } from "./runtime-config-diagnostic-patches.ts"; import { hasRuntimeResolvedContextWindow, patchRuntimeResolvedContextWindow } from "./runtime-context-window-patches.ts"; export { hasRuntimeResolvedContextWindow, patchRuntimeResolvedContextWindow } from "./runtime-context-window-patches.ts"; +import { hasRuntimeRecursiveWorkspacePaths, patchRuntimeRecursiveWorkspacePaths } from "./runtime-workspace-path-patches.ts"; +export { hasRuntimeRecursiveWorkspacePaths, patchRuntimeRecursiveWorkspacePaths } from "./runtime-workspace-path-patches.ts"; import { patchRuntimeWorkflowManagement } from "./runtime-workflow-patches.ts"; export { patchRuntimeWorkflowManagement } from "./runtime-workflow-patches.ts"; export { hasRuntimeConfigurationDiagnostics, patchRuntimeConfigurationDiagnostics } from "./runtime-config-diagnostic-patches.ts"; @@ -1829,6 +1831,12 @@ export const runtimePatchPlan: readonly RuntimePatchDefinition[] = [ apply: patchRuntimeResolvedContextWindow, verify: hasRuntimeResolvedContextWindow }, + { + id: "recursive-workspace-paths", + requirement: "required", + apply: patchRuntimeRecursiveWorkspacePaths, + verify: hasRuntimeRecursiveWorkspacePaths + }, { id: "tui-bridge", requirement: "required", diff --git a/src/runtime-config-bridge.ts b/src/runtime-config-bridge.ts index 6c7bd4f..30ad695 100644 --- a/src/runtime-config-bridge.ts +++ b/src/runtime-config-bridge.ts @@ -8,6 +8,7 @@ export { assertResumableSession, ensureSessionForTitle, readSessionResumeTarget export { readTuiRuntimeProjection, sendTuiBackgroundTaskMessage } from "./runtime-tui-bridge.ts"; export { createTuiWorkflowManagement, startTuiSavedWorkflow } from "./runtime-workflow-bridge.ts"; export { restoreTuiBackgroundTasks } from "./runtime-background-restore.ts"; +export { withRecursiveWorkspacePathSuggestions } from "./runtime-workspace-paths.ts"; export { installSqliteWriteRecovery, pruneSqliteUsage, sqliteRecoveryStats } from "./runtime-sqlite-recovery.ts"; export { appServerRegistryOptions } from "./app-server-auth.ts"; export { exchangeBigmodelOAuthCode } from "./bigmodel-oauth.ts"; diff --git a/src/runtime-workspace-paths.ts b/src/runtime-workspace-paths.ts new file mode 100644 index 0000000..a363ab7 --- /dev/null +++ b/src/runtime-workspace-paths.ts @@ -0,0 +1,101 @@ +import { isAbsolute, relative, sep } from "node:path"; + +interface PathSuggestion { + kind: "file" | "directory"; + path: string; +} + +interface SuggestionRequest { + token: string; + limit?: number; + abortSignal?: AbortSignal; +} + +interface SuggestionResult { + items: PathSuggestion[]; + truncated: boolean; +} + +type PathSuggestionProvider = (request: SuggestionRequest) => Promise; + +interface WorkspacePathOptions { + workspaceDirectory: string; + fileSystemPort: { + listDirectory(request: { path: string }, options?: { signal?: AbortSignal }): Promise<{ + entries: { name: string; path: string; kind: string }[]; + }>; + }; + excludedDirectories: ReadonlySet; +} + +interface RankedSuggestion { + item: PathSuggestion; + rank: number; + depth: number; +} + +function compare(first: RankedSuggestion, second: RankedSuggestion): number { + return first.rank - second.rank + || Number(first.item.kind === "file") - Number(second.item.kind === "file") + || first.depth - second.depth + || first.item.path.localeCompare(second.item.path); +} + +/** Extend the runtime's native path completion with workspace-wide name searches. */ +export function withRecursiveWorkspacePathSuggestions( + native: PathSuggestionProvider, + options: WorkspacePathOptions +): PathSuggestionProvider { + return async (request) => { + const empty: SuggestionResult = { items: [], truncated: false }; + if (request.abortSignal?.aborted) return empty; + const query = request.token.replace(/^@/u, ""); + // Keep directory browsing, token validation and explicit paths upstream-owned. + if (!query || query === ".." || /[\\/]/u.test(query)) return native(request); + + const normalizedQuery = query.toLowerCase(); + const includeHidden = query.startsWith("."); + const limit = Number.isFinite(request.limit) ? Math.max(1, Math.floor(request.limit!)) : 50; + const pending = [options.workspaceDirectory]; + const best: RankedSuggestion[] = []; + let matches = 0; + + while (pending.length > 0) { + if (request.abortSignal?.aborted) return empty; + // Bound concurrent filesystem work; cancellation stops scheduling new reads. + const listings = await Promise.all(pending.splice(-8).map(async (path) => { + try { + return (await options.fileSystemPort.listDirectory({ path }, { signal: request.abortSignal })).entries; + } catch { + // An unreadable or removed subdirectory must not hide other results. + return []; + } + })); + if (request.abortSignal?.aborted) return empty; + for (const entries of listings) for (const entry of entries) { + // The native adapter reports symlinks separately. Do not follow them. + if (entry.kind !== "file" && entry.kind !== "directory") continue; + if (entry.kind === "directory" && options.excludedDirectories.has(entry.name)) continue; + if (!includeHidden && entry.name.startsWith(".")) continue; + const path = relative(options.workspaceDirectory, entry.path); + if (!path || path === ".." || path.startsWith(`..${sep}`) || isAbsolute(path)) continue; + if (entry.kind === "directory") pending.push(entry.path); + + const name = entry.name.toLowerCase(); + if (!name.includes(normalizedQuery)) continue; + matches += 1; + const displayPath = path.split(sep).join("/"); + const candidate: RankedSuggestion = { + item: { kind: entry.kind, path: entry.kind === "directory" ? `${displayPath}/` : displayPath }, + rank: name === normalizedQuery ? 0 : name.startsWith(normalizedQuery) ? 1 : 2, + depth: displayPath.split("/").length + }; + const index = best.findIndex((item) => compare(candidate, item) < 0); + if (index >= 0) best.splice(index, 0, candidate); + else if (best.length < limit) best.push(candidate); + if (best.length > limit) best.pop(); + } + } + return { items: best.map(({ item }) => item), truncated: matches > limit }; + }; +} diff --git a/test/fixtures/workspace-path-runtime.cjs b/test/fixtures/workspace-path-runtime.cjs new file mode 100644 index 0000000..a29a3c4 --- /dev/null +++ b/test/fixtures/workspace-path-runtime.cjs @@ -0,0 +1,57 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const Module = require("node:module"); +const path = require("node:path"); + +// Expose the native provider from the shipped bundle without starting the CLI. +const file = path.resolve(__dirname, "../../vendor/zcode.cjs"); +let source = fs.readFileSync(file, "utf8"); +const label = /[A-Za-z_$][\w$]*\(([A-Za-z_$][\w$]*),"createWorkspacePathSuggestionProvider"\)/u.exec(source); +assert.ok(label, "Missing native workspace path provider"); +const init = [...source.slice(0, label.index).matchAll(/([A-Za-z_$][\w$]*)=[A-Za-z_$][\w$]*\(\(\)=>\{/gu)].at(-1)?.[1]; +assert.ok(init, "Missing native workspace path initialization"); +const main = /async function [A-Za-z_$][\w$]*\(\)\{let [A-Za-z_$][\w$]*=process\.argv\.slice\(2\);/u.exec(source); +assert.ok(main, "Missing native runtime test entry"); +source = source.replace(main[0], `${main[0]}module.exports=(${init}(),${label[1]});return;`); +const runtime = new Module(file, module); +runtime.filename = file; +runtime.paths = Module._nodeModulePaths(path.dirname(file)); +runtime._compile(source, file); +const createProvider = runtime.exports; +assert.equal(typeof createProvider, "function"); + +async function run() { + const workspace = process.cwd(); + for (const name of [ + "First_folder/one.py", "First_folder/two.py", "Second_folder/one.py", "Second_folder/three.py", + "docs/my file.md", "src/node_modules/one.py", ".git/one.py", ".hidden/one.py", "src/.one.py" + ]) { + await fs.promises.mkdir(path.dirname(path.join(workspace, name)), { recursive: true }); + await fs.promises.writeFile(path.join(workspace, name), ""); + } + const search = createProvider({ workspaceDirectory: workspace }); + const result = await search({ token: "@on" }); + assert.deepEqual(result, { + items: [ + { kind: "file", path: "First_folder/one.py" }, + { kind: "file", path: "Second_folder/one.py" }, + { kind: "directory", path: "Second_folder/" } + ], truncated: false + }); + assert.deepEqual(await search({ token: "@one.py", limit: 1 }), { + items: [{ kind: "file", path: "First_folder/one.py" }], truncated: true + }); + for (const token of ["@First_folder/on", "@First_folder\\on"]) { + assert.deepEqual(await search({ token }), { items: [{ kind: "file", path: "First_folder/one.py" }], truncated: false }); + } + assert.ok((await search({ token: "@" })).items.every(item => !item.path.replace(/\/$/u, "").includes("/"))); + assert.deepEqual((await search({ token: "@my f" })).items, [{ kind: "file", path: "docs/my file.md" }]); + assert.deepEqual((await search({ token: "@.one" })).items, [{ kind: "file", path: "src/.one.py" }]); + assert.deepEqual(await search({ token: "@../" }), { items: [], truncated: false }); + const controller = new AbortController(); + controller.abort(); + assert.deepEqual(await search({ token: "@on", abortSignal: controller.signal }), { items: [], truncated: false }); + process.stdout.write("Native workspace search passed: recursive files, limits, scoped paths, exclusions and cancellation.\n"); +} + +run().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/test/runtime-workspace-path-patches.test.ts b/test/runtime-workspace-path-patches.test.ts new file mode 100644 index 0000000..aec7b83 --- /dev/null +++ b/test/runtime-workspace-path-patches.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from "bun:test"; +import { + hasRuntimeRecursiveWorkspacePaths as has, + patchRuntimeRecursiveWorkspacePaths as patch +} from "../scripts/runtime-workspace-path-patches.ts"; +import { applyRuntimePatchPlan, runtimePatchPlan } from "../scripts/sync-runtime.ts"; + +const fixture = 'function provider(e){let fs=e.fileSystemPort??defaultFS(),root=(0,path.resolve)(e.workspaceDirectory);' + + 'return async request=>{let entries=(await fs.listDirectory({path:root},{signal:request.abortSignal})).entries;' + + 'return{items:entries.filter(entry=>!excluded.has(entry.name)),truncated:false}}}' + + 'label(provider,"createWorkspacePathSuggestionProvider");'; + +test("wraps the native callback idempotently and keeps its filesystem and exclusions", () => { + const patched = patch(fixture); + expect(has(fixture)).toBe(false); + expect(has(patched)).toBe(true); + expect(patch(patched)).toBe(patched); + expect(patched).toContain("{workspaceDirectory:root,fileSystemPort:fs,excludedDirectories:excluded}"); + expect(patched).toContain("entries.filter(entry=>!excluded.has(entry.name))"); + const renamed = fixture.replaceAll("provider", "$factory").replaceAll("root", "$root").replaceAll("fs", "$fs"); + expect(has(patch(renamed))).toBe(true); +}); + +test("rejects changed, missing and ambiguous native anchors and incomplete wrappers", () => { + for (const source of [ + "const runtime = {};", + `${fixture}label(other,"createWorkspacePathSuggestionProvider");`, + fixture.replace("function provider(e)", "function provider(e, other)"), + fixture.replace("e.fileSystemPort", "e.otherPort"), + fixture.replace(".listDirectory(", ".searchFiles("), + fixture.replace("excluded.has(entry.name)", "false"), + patch(fixture).replace("excludedDirectories:excluded", "excludedDirectories:other"), + patch(fixture).replace("withRecursiveWorkspacePathSuggestions", "wrongWrapper") + ]) { + expect(has(source)).toBe(false); + expect(() => patch(source)).toThrow("incompatible with recursive workspace paths"); + } +}); + +test("registers recursion as a required, verified runtime patch", () => { + const entry = runtimePatchPlan.find(item => item.id === "recursive-workspace-paths"); + expect(entry?.requirement).toBe("required"); + const first = applyRuntimePatchPlan(fixture, [entry!]); + expect(first.reports[0]?.status).toBe("applied"); + expect(applyRuntimePatchPlan(first.runtime, [entry!]).reports[0]?.status).toBe("already_present"); + expect(() => applyRuntimePatchPlan("incompatible runtime", [entry!])) + .toThrow("Required runtime patch recursive-workspace-paths failed"); +}); diff --git a/test/runtime-workspace-paths.test.ts b/test/runtime-workspace-paths.test.ts new file mode 100644 index 0000000..1362e90 --- /dev/null +++ b/test/runtime-workspace-paths.test.ts @@ -0,0 +1,161 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, readdir, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, relative } from "node:path"; +import { withRecursiveWorkspacePathSuggestions } from "../src/runtime-workspace-paths.ts"; +import { WorkspaceAutocompleteProvider } from "../packages/zcode-tui/src/workspace-autocomplete.ts"; + +const directories: string[] = []; +afterEach(async () => { + await Promise.all(directories.splice(0).map(path => rm(path, { recursive: true, force: true }))); +}); + +async function fixture(files: string[]) { + const root = await mkdtemp(join(tmpdir(), "zcode-workspace-paths-")); + directories.push(root); + for (const file of files) { + await mkdir(dirname(join(root, file)), { recursive: true }); + await writeFile(join(root, file), ""); + } + const reads: string[] = []; + const fileSystemPort = { + async listDirectory({ path }: { path: string }) { + reads.push(relative(root, path) || "."); + const entries = await readdir(path, { withFileTypes: true }); + return { entries: entries.map(entry => ({ + name: entry.name, path: join(path, entry.name), + kind: entry.isDirectory() ? "directory" : entry.isFile() ? "file" : "symlink" + })) }; + } + }; + const nativeRequests: unknown[] = []; + const options = { workspaceDirectory: root, fileSystemPort, excludedDirectories: new Set([".git", "node_modules"]) }; + const search = withRecursiveWorkspacePathSuggestions(async request => { + nativeRequests.push(request); + return { items: [], truncated: false }; + }, options); + return { root, search, reads, options, nativeRequests }; +} + +describe("recursive runtime workspace paths", () => { + test("finds the issue #240 files across directories and inserts the selected relative path", async () => { + const { root, search } = await fixture([ + "First_folder/one.py", "First_folder/two.py", "Second_folder/one.py", "Second_folder/three.py" + ]); + expect(await search({ token: "@on" })).toEqual({ + items: [ + { kind: "file", path: "First_folder/one.py" }, + { kind: "file", path: "Second_folder/one.py" }, + { kind: "directory", path: "Second_folder/" } + ], truncated: false + }); + const editor = new WorkspaceAutocompleteProvider([], root, search); + const input = "inspect @on"; + const suggestions = await editor.getSuggestions([input], 0, input.length, { signal: new AbortController().signal }); + expect(suggestions?.items[0]).toEqual({ value: "@First_folder/one.py", label: "one.py", description: "First_folder/one.py" }); + expect(editor.applyCompletion([input], 0, input.length, suggestions!.items[0]!, suggestions!.prefix).lines) + .toEqual(["inspect @First_folder/one.py "]); + }); + + test("ranks exact names before prefixes and substrings across the entire tree before limiting", async () => { + const { search } = await fixture(["z-last/stone.py", "z-last/one-extra.py", "a-first/deep/one.py", "one.py"]); + expect(await search({ token: "@ONE.PY", limit: 2 })).toEqual({ + items: [{ kind: "file", path: "one.py" }, { kind: "file", path: "a-first/deep/one.py" }], truncated: true + }); + expect((await search({ token: "@one", limit: 3 })).items.map(item => item.path)) + .toEqual(["one.py", "z-last/one-extra.py", "a-first/deep/one.py"]); + expect((await search({ token: "@missing" })).items).toEqual([]); + expect((await search({ token: "@one", limit: 0 })).items).toHaveLength(1); + expect((await search({ token: "@one", limit: Number.NaN })).items).toHaveLength(4); + }); + + test("keeps empty tokens and explicit paths on the native provider", async () => { + const { search, reads, nativeRequests } = await fixture(["src/one.py"]); + for (const token of ["@", "@src/", "@src/on", "@src\\on", "@../on", "@/tmp/on", "@C:\\tmp\\on", "@.."]) { + await search({ token, limit: 7 }); + } + expect(nativeRequests).toHaveLength(8); + expect(nativeRequests[2]).toEqual({ token: "@src/on", limit: 7 }); + expect(reads).toEqual([]); + }); + + test("searches beyond a full result list and multiple batches of directories", async () => { + const { search, reads } = await fixture([ + ...Array.from({ length: 65 }, (_, index) => `group-${index}/stone.ts`), + "a/deep/one.ts" + ]); + expect(await search({ token: "@one.ts", limit: 1 })).toEqual({ + items: [{ kind: "file", path: "a/deep/one.ts" }], truncated: true + }); + expect(reads).toHaveLength(68); + }); + + test("preserves hidden-directory rules, exclusions and symlink boundaries", async () => { + const { root, search, reads } = await fixture([ + "src/one.py", "src/.one.py", ".hidden/one.py", ".hidden/.one.py", "src/node_modules/one.py", ".git/.one.py" + ]); + await symlink(root, join(root, "cycle"), process.platform === "win32" ? "junction" : "dir"); + expect((await search({ token: "@one" })).items.map(item => item.path)).toEqual(["src/one.py"]); + expect(reads).not.toContain("cycle"); + expect(reads).not.toContain(".hidden"); + expect(reads).not.toContain(join("src", "node_modules")); + expect((await search({ token: "@.one" })).items.map(item => item.path)).toEqual([".hidden/.one.py", "src/.one.py"]); + expect(reads).not.toContain(".git"); + }); + + test("handles deep Unicode paths and quotes spaces through the actual TUI provider", async () => { + const deepPath = `${"nested/".repeat(40)}源码/my file.ts`; + const { root, search } = await fixture([deepPath]); + const editor = new WorkspaceAutocompleteProvider([], root, search); + const input = '@"my fi'; + const suggestions = await editor.getSuggestions([input], 0, input.length, { signal: new AbortController().signal }); + expect(suggestions?.items[0]?.value).toBe(`@"${deepPath}"`); + expect(editor.applyCompletion([input], 0, input.length, suggestions!.items[0]!, suggestions!.prefix).lines) + .toEqual([`@"${deepPath}" `]); + expect((await search({ token: "@源码" })).items[0]?.kind).toBe("directory"); + }); + + test("sees new and removed files on the next query", async () => { + const { root, search } = await fixture(["src/old.ts"]); + expect((await search({ token: "@new" })).items).toEqual([]); + await writeFile(join(root, "src/new.ts"), ""); + expect((await search({ token: "@new" })).items).toEqual([{ kind: "file", path: "src/new.ts" }]); + await rm(join(root, "src/new.ts")); + expect((await search({ token: "@new" })).items).toEqual([]); + }); + + test("skips failed directories and entries outside the workspace", async () => { + const { options, reads } = await fixture(["ok/one.py", "bad/one.py"]); + const listDirectory = options.fileSystemPort.listDirectory; + options.fileSystemPort.listDirectory = async request => { + if (request.path === join(options.workspaceDirectory, "bad")) throw new Error("EACCES"); + const result = await listDirectory(request); + result.entries.push({ name: "one.py", path: join(options.workspaceDirectory, "..", "one.py"), kind: "file" }); + return result; + }; + const search = withRecursiveWorkspacePathSuggestions(async () => { throw new Error("unexpected native query"); }, options); + expect((await search({ token: "@one" })).items).toEqual([{ kind: "file", path: "ok/one.py" }]); + expect(reads).toContain("ok"); + }); + + test("forwards cancellation and discards results without scheduling further directories", async () => { + const { options, reads } = await fixture(["src/deep/one.py"]); + const controller = new AbortController(); + const listDirectory = options.fileSystemPort.listDirectory; + let received: AbortSignal | undefined; + const search = withRecursiveWorkspacePathSuggestions(async () => ({ items: [], truncated: false }), { + ...options, + fileSystemPort: { async listDirectory(request, context) { + received = context?.signal; + const result = await listDirectory(request); + controller.abort(); + return result; + } } + }); + expect(await search({ token: "@one", abortSignal: controller.signal })).toEqual({ items: [], truncated: false }); + expect(received).toBe(controller.signal); + expect(reads).toEqual(["."]); + await search({ token: "@one", abortSignal: controller.signal }); + expect(reads).toEqual(["."]); + }); +}); diff --git a/test/runtime/workspace-paths.test.ts b/test/runtime/workspace-paths.test.ts new file mode 100644 index 0000000..f16b643 --- /dev/null +++ b/test/runtime/workspace-paths.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runtimeTestEnv } from "../fixtures/runtime-env.ts"; + +test("searches nested files with the extracted runtime's native provider and filesystem", async () => { + const root = join(import.meta.dir, "../.."); + const directory = await mkdtemp(join(tmpdir(), "zcode-runtime-workspace-paths-")); + try { + const child = Bun.spawn(["node", join(root, "test/fixtures/workspace-path-runtime.cjs")], { + cwd: directory, env: runtimeTestEnv(directory), stdin: "ignore", stdout: "pipe", stderr: "pipe" + }); + const timeout = setTimeout(() => child.kill("SIGKILL"), 15_000); + try { + const [code, stdout, stderr] = await Promise.all([ + child.exited, new Response(child.stdout).text(), new Response(child.stderr).text() + ]); + expect(code, stderr || stdout).toBe(0); + expect(stdout).toContain("Native workspace search passed"); + } finally { + clearTimeout(timeout); + if (child.exitCode === null) { child.kill("SIGKILL"); await child.exited; } + } + } finally { + await rm(directory, { recursive: true, force: true }); + } +}, 20_000);