diff --git a/.changeset/file-suggestions-without-rg.md b/.changeset/file-suggestions-without-rg.md new file mode 100644 index 0000000..d166d1a --- /dev/null +++ b/.changeset/file-suggestions-without-rg.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Keep chat file mention suggestions working on installations that do not have ripgrep available, add an all-file `@` mention mode, stop hiding directories in the file explorer, and report optional ripgrep availability in `pi-web doctor`. diff --git a/docs/faq.html b/docs/faq.html index f4c9a4b..240b8e9 100644 --- a/docs/faq.html +++ b/docs/faq.html @@ -121,8 +121,9 @@

What does pi-web doctor check?

It checks whether the service shell and native service environment can find Node 22+, npm, Pi, and the Pi - Web binaries. It also prints installed and running PI WEB versions when available, and reports user service - lingering when relevant for server-style installs. + Web binaries. It also prints installed and running PI WEB versions when available, reports optional ripgrep + availability for faster all-file @-mention suggestions, uses a bounded filesystem fallback when + ripgrep is unavailable, and reports user service lingering when relevant for server-style installs.

If something works in your terminal but fails in doctor, treat that as a login-shell PATH mismatch and diff --git a/src/cli.ts b/src/cli.ts index 745a422..ca28bfa 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -931,16 +931,44 @@ function runChecks(checks: Check[]): boolean { const ok = result.status === 0; failed ||= !ok; console.log(`${ok ? "✓" : "✗"} ${label}`); - const output = (result.stdout || result.stderr).trim(); - if (output !== "") { - const lines = output.split("\n"); - for (const line of lines.slice(0, 3)) console.log(` ${line}`); - if (lines.length > 3) console.log(" ..."); - } + printCheckOutput(result.stdout || result.stderr); } return !failed; } +function printCheckOutput(output: string): void { + const trimmed = output.trim(); + if (trimmed === "") return; + const lines = trimmed.split("\n"); + for (const line of lines.slice(0, 3)) console.log(` ${line}`); + if (lines.length > 3) console.log(" ..."); +} + +function optionalDoctorChecks(): Check[] { + const shell = serviceShellLabel(); + const backend = currentServiceBackend(); + const checks: Check[] = [[`${shell} can find optional ripgrep (rg)`, serviceShellCommand(commandCheck("rg"))]]; + if (backend?.kind === "systemd") checks.push([`systemd user ${shell} can find optional ripgrep (rg)`, systemdUserServiceShellCommand(commandCheck("rg"))]); + return checks; +} + +function printOptionalDoctorChecks(): void { + let missingOptionalTool = false; + for (const [label, command] of optionalDoctorChecks()) { + const [bin, ...args] = command; + if (bin === undefined) continue; + const result = capture(bin, args); + const ok = result.status === 0; + missingOptionalTool ||= !ok; + console.log(`${ok ? "✓" : "!"} ${label}`); + printCheckOutput(result.stdout || result.stderr); + } + if (missingOptionalTool) { + console.log(" Install ripgrep, or make rg visible to the service shell, for faster all-file @ suggestions."); + console.log(" PI WEB falls back to a bounded filesystem scan when rg is unavailable."); + } +} + function printPathSetupAdvice(): void { const shell = detectServiceShell(); console.log("\nPATH setup advice:"); @@ -969,6 +997,7 @@ async function doctor(): Promise { await printPiWebVersionReport(); console.log("\nDoctor checks:"); const ok = runChecks(doctorChecks()); + printOptionalDoctorChecks(); const nodePtySpawnHelperOk = printNodePtyDarwinSpawnHelperCheck(); if (supportsSystemdUserServices()) { diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index 0026218..0c0f9b6 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -133,8 +133,20 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } +export interface FileSuggestionQueryOptions { + kind?: FileSuggestion["kind"] | undefined; + mode?: "file" | "path" | undefined; + scope?: "tracked" | "all" | undefined; +} + export const filesApi = { - files: (cwd: string, query: string, kind?: FileSuggestion["kind"], mode?: "file" | "path") => request(`/api/files?cwd=${encodeURIComponent(cwd)}&q=${encodeURIComponent(query)}${kind !== undefined ? `&kind=${encodeURIComponent(kind)}` : ""}${mode !== undefined ? `&mode=${encodeURIComponent(mode)}` : ""}`, arrayOf(parseFileSuggestion)), + files: (cwd: string, query: string, options: FileSuggestionQueryOptions = {}) => { + const params = new URLSearchParams({ cwd, q: query }); + if (options.kind !== undefined) params.set("kind", options.kind); + if (options.mode !== undefined) params.set("mode", options.mode); + if (options.scope !== undefined) params.set("scope", options.scope); + return request(`/api/files?${params.toString()}`, arrayOf(parseFileSuggestion)); + }, }; export const gitApi = { diff --git a/src/client/src/components/PromptEditor.ts b/src/client/src/components/PromptEditor.ts index 78f2581..947f56f 100644 --- a/src/client/src/components/PromptEditor.ts +++ b/src/client/src/components/PromptEditor.ts @@ -110,7 +110,7 @@ export class PromptEditor extends LitElement { syntaxHighlighting(defaultHighlightStyle, { fallback: true }), EditorView.lineWrapping, EditorView.contentAttributes.of((view) => inputAssistanceContentAttributes(view.state.sliceDoc(0, view.state.selection.main.head))), - placeholder("Message pi... Use / for commands, @ for files"), + placeholder("Message pi... Use / for commands, @ for tracked files, @ space for all files"), this.editableCompartment.of(EditorView.editable.of(!this.disabled)), this.readOnlyCompartment.of(EditorState.readOnly.of(this.disabled)), EditorView.updateListener.of((update) => { @@ -182,12 +182,12 @@ export class PromptEditor extends LitElement { ...(command.description === undefined ? {} : { description: command.description }), })); } else if (trigger.kind === "file" && this.cwd !== undefined && this.cwd !== "") { - const files = await api.files(this.cwd, trigger.query, trigger.fileKind, trigger.fileMode).catch(emptyFileSuggestions); + const files = await api.files(this.cwd, trigger.query, { scope: trigger.fileScope }).catch(emptyFileSuggestions); if (version !== this.requestVersion) return; this.completions = files .slice(0, 12) .map((file) => { - const insertText = fileInsertText(file.path, trigger.fileMode === "path", trigger.quoted === true); + const insertText = fileInsertText(file.path, trigger.quoted === true, file.path.endsWith("/") ? trigger.allPrefix : undefined); return { kind: "file", replaceFrom: trigger.from, @@ -200,7 +200,7 @@ export class PromptEditor extends LitElement { } } - private currentTrigger(): { kind: "command" | "file"; query: string; from: number; to: number; fileKind?: FileSuggestion["kind"]; fileMode?: "file" | "path"; quoted?: boolean } | undefined { + private currentTrigger(): { kind: "command" | "file"; query: string; from: number; to: number; fileScope?: "tracked" | "all" | undefined; allPrefix?: "@ " | "!@" | undefined; quoted?: boolean } | undefined { const cursor = this.editor?.state.selection.main.head ?? this.draft.length; const beforeCursor = this.draft.slice(0, cursor); const quotedTrigger = this.currentQuotedTrigger(beforeCursor, cursor); @@ -209,18 +209,20 @@ export class PromptEditor extends LitElement { const tokenStart = Math.max(beforeCursor.lastIndexOf(" "), beforeCursor.lastIndexOf("\n")) + 1; const token = beforeCursor.slice(tokenStart); const beforeToken = beforeCursor.slice(0, tokenStart); - if (beforeToken.endsWith("@ ")) return { kind: "file", query: token, from: tokenStart, to: cursor, fileMode: "path" }; + if (beforeToken.endsWith("@ ")) return { kind: "file", query: token, from: tokenStart - 2, to: cursor, fileScope: "all", allPrefix: "@ " }; if (token.startsWith("/") && tokenStart === 0) return { kind: "command", query: token.slice(1), from: tokenStart, to: cursor }; - if (token.startsWith("@")) return { kind: "file", query: token.slice(1), from: tokenStart, to: cursor }; + if (token.startsWith("!@")) return { kind: "file", query: token.slice(2), from: tokenStart, to: cursor, fileScope: "all", allPrefix: "!@" }; + if (token.startsWith("@")) return { kind: "file", query: token.slice(1), from: tokenStart, to: cursor, fileScope: "tracked" }; return undefined; } - private currentQuotedTrigger(beforeCursor: string, cursor: number): { kind: "file"; query: string; from: number; to: number; fileMode?: "file" | "path"; quoted: true } | undefined { + private currentQuotedTrigger(beforeCursor: string, cursor: number): { kind: "file"; query: string; from: number; to: number; fileScope?: "tracked" | "all" | undefined; allPrefix?: "@ " | "!@" | undefined; quoted: true } | undefined { const quoteStart = beforeCursor.lastIndexOf("\""); if (quoteStart === -1) return undefined; const prefix = beforeCursor.slice(0, quoteStart); - if (prefix.endsWith("@")) return { kind: "file", query: beforeCursor.slice(quoteStart + 1), from: prefix.length - 1, to: cursor, quoted: true }; - if (prefix.endsWith("@ ")) return { kind: "file", query: beforeCursor.slice(quoteStart + 1), from: quoteStart, to: cursor, fileMode: "path", quoted: true }; + if (prefix.endsWith("!@")) return { kind: "file", query: beforeCursor.slice(quoteStart + 1), from: prefix.length - 2, to: cursor, fileScope: "all", allPrefix: "!@", quoted: true }; + if (prefix.endsWith("@")) return { kind: "file", query: beforeCursor.slice(quoteStart + 1), from: prefix.length - 1, to: cursor, fileScope: "tracked", quoted: true }; + if (prefix.endsWith("@ ")) return { kind: "file", query: beforeCursor.slice(quoteStart + 1), from: prefix.length - 2, to: cursor, fileScope: "all", allPrefix: "@ ", quoted: true }; return undefined; } @@ -286,8 +288,8 @@ export class PromptEditor extends LitElement { static override styles = promptEditorStyles; } -function fileInsertText(path: string, pathMode: boolean, quoted: boolean): string { - const prefix = pathMode ? "" : "@"; +function fileInsertText(path: string, quoted: boolean, allPrefix?: "@ " | "!@"): string { + const prefix = allPrefix ?? "@"; if (!quoted && !path.includes(" ")) return `${prefix}${path}`; return `${prefix}"${path}"`; } diff --git a/src/client/src/inputModes.test.ts b/src/client/src/inputModes.test.ts index 717da29..228daa4 100644 --- a/src/client/src/inputModes.test.ts +++ b/src/client/src/inputModes.test.ts @@ -17,7 +17,10 @@ describe("inputModeForDraft", () => { it("detects file completion contexts", () => { expect(inputModeForDraft("open @src/main.ts")).toEqual({ kind: "file" }); expect(inputModeForDraft("open @ ")).toEqual({ kind: "file" }); + expect(inputModeForDraft("open !@vendor/file.ts")).toEqual({ kind: "file" }); + expect(inputModeForDraft("!@vendor/file.ts")).toEqual({ kind: "file" }); expect(inputModeForDraft("open @ \"src/main.ts")).toEqual({ kind: "file" }); + expect(inputModeForDraft("open !@\"vendor/file.ts")).toEqual({ kind: "file" }); expect(inputModeForDraft("open \"src/main.ts")).toEqual({ kind: "normal" }); }); }); diff --git a/src/client/src/inputModes.ts b/src/client/src/inputModes.ts index 680683b..38c0453 100644 --- a/src/client/src/inputModes.ts +++ b/src/client/src/inputModes.ts @@ -6,6 +6,7 @@ export type InputMode = export function inputModeForDraft(draft: string): InputMode { const trimmed = draft.trimStart(); + if (trimmed.startsWith("!@")) return { kind: "file" }; if (trimmed.startsWith("!")) return { kind: "shell", excludeFromContext: trimmed.startsWith("!!") }; if (currentToken(draft).startsWith("/")) return { kind: "command" }; if (isFileCompletionContext(draft)) return { kind: "file" }; @@ -23,11 +24,11 @@ function currentToken(draft: string): string { function isFileCompletionContext(draft: string): boolean { const token = currentToken(draft); - if (token.startsWith("@")) return true; + if (token.startsWith("@") || token.startsWith("!@")) return true; const tokenStart = draft.length - token.length; if (draft.slice(0, tokenStart).endsWith("@ ")) return true; const quoteStart = draft.lastIndexOf("\""); if (quoteStart === -1) return false; const prefix = draft.slice(0, quoteStart); - return prefix.endsWith("@") || prefix.endsWith("@ "); + return prefix.endsWith("@") || prefix.endsWith("@ ") || prefix.endsWith("!@"); } diff --git a/src/server/app.ts b/src/server/app.ts index 88ce5a9..99ee439 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -84,11 +84,11 @@ export async function buildApp(deps: AppDependencies = {}): Promise("/api/files", async (request, reply) => { + app.get<{ Querystring: { cwd?: string; q?: string; kind?: "tracked" | "untracked" | "other"; mode?: "file" | "path"; scope?: "tracked" | "all" } }>("/api/files", async (request, reply) => { if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" }); try { if (request.query.mode === "path") return await listPathSuggestions(request.query.cwd, request.query.q ?? ""); - return await listFileSuggestions(request.query.cwd, request.query.q ?? "", request.query.kind); + return await listFileSuggestions(request.query.cwd, request.query.q ?? "", { kind: request.query.kind, scope: request.query.scope }); } catch (error) { return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); } diff --git a/src/server/workspaces/fileSuggestions.test.ts b/src/server/workspaces/fileSuggestions.test.ts new file mode 100644 index 0000000..be02b20 --- /dev/null +++ b/src/server/workspaces/fileSuggestions.test.ts @@ -0,0 +1,75 @@ +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { listFileSuggestions, type FileSuggestionDependencies } from "./fileSuggestions"; + +const temporaryRoots: string[] = []; + +async function tempWorkspace(): Promise { + const root = await mkdtemp(join(tmpdir(), "pi-web-files-")); + temporaryRoots.push(root); + return root; +} + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe("file suggestions", () => { + it("uses tracked git files for tracked-scope suggestions", async () => { + const calls: { file: string; args: string[] }[] = []; + const deps: FileSuggestionDependencies = { + execFile: (file, args) => { + calls.push({ file, args }); + if (file === "git" && args.join(" ") === "ls-files") return Promise.resolve({ stdout: "src/app.ts\nREADME.md\n" }); + return Promise.reject(new Error(`unexpected command: ${file} ${args.join(" ")}`)); + }, + }; + + await expect(listFileSuggestions("/repo", "", { scope: "tracked" }, deps)).resolves.toEqual([ + { path: "src/", kind: "tracked" }, + { path: "README.md", kind: "tracked" }, + { path: "src/app.ts", kind: "tracked" }, + ]); + expect(calls).toEqual([{ file: "git", args: ["ls-files"] }]); + }); + + it("asks ripgrep for hidden and ignored files in all-file scope", async () => { + const calls: { file: string; args: string[] }[] = []; + const deps: FileSuggestionDependencies = { + execFile: (file, args) => { + calls.push({ file, args }); + return Promise.resolve({ stdout: "node_modules/pkg/index.js\nsrc/app.ts\n" }); + }, + }; + + await expect(listFileSuggestions("/repo", "pkg", { scope: "all" }, deps)).resolves.toEqual([ + { path: "node_modules/pkg/", kind: "other" }, + { path: "node_modules/pkg/index.js", kind: "other" }, + ]); + expect(calls).toEqual([{ file: "rg", args: ["--files", "--hidden", "--no-ignore"] }]); + }); + + it("falls back to a bounded filesystem scan without directory exclusions when git and rg are unavailable", async () => { + const root = await tempWorkspace(); + await mkdir(join(root, "src"), { recursive: true }); + await mkdir(join(root, "node_modules", "pkg"), { recursive: true }); + await writeFile(join(root, "README.md"), "hello"); + await writeFile(join(root, "src", "app.ts"), "export {};\n"); + await writeFile(join(root, "node_modules", "pkg", "index.js"), "module.exports = {};\n"); + + const deps: FileSuggestionDependencies = { + execFile: (file) => Promise.reject(Object.assign(new Error(`spawn ${file} ENOENT`), { code: "ENOENT" })), + }; + + await expect(listFileSuggestions(root, "", { scope: "all" }, deps)).resolves.toEqual([ + { path: "node_modules/", kind: "other" }, + { path: "node_modules/pkg/", kind: "other" }, + { path: "src/", kind: "other" }, + { path: "node_modules/pkg/index.js", kind: "other" }, + { path: "README.md", kind: "other" }, + { path: "src/app.ts", kind: "other" }, + ]); + }); +}); diff --git a/src/server/workspaces/fileSuggestions.ts b/src/server/workspaces/fileSuggestions.ts index 6147fa8..2f544b3 100644 --- a/src/server/workspaces/fileSuggestions.ts +++ b/src/server/workspaces/fileSuggestions.ts @@ -5,13 +5,32 @@ import { promisify } from "node:util"; import type { ClientFileSuggestion } from "../types.js"; const execFileAsync = promisify(execFile); +const commandMaxBuffer = 1024 * 1024 * 8; +const maxFilesystemFallbackPaths = 20_000; -export async function listFileSuggestions(cwd: string, query = "", kind?: ClientFileSuggestion["kind"]): Promise { - const normalizedQuery = query.replace(/^@/, "").toLowerCase(); - const files = await listGitFiles(cwd).catch(() => listPlainFiles(cwd)); +interface ExecFileOptions { + cwd: string; + maxBuffer: number; +} + +export type FileSuggestionScope = "tracked" | "all"; + +export interface FileSuggestionOptions { + kind?: ClientFileSuggestion["kind"] | undefined; + scope?: FileSuggestionScope | undefined; +} + +export interface FileSuggestionDependencies { + execFile?: (file: string, args: string[], options: ExecFileOptions) => Promise<{ stdout: string }>; +} + +export async function listFileSuggestions(cwd: string, query = "", options: FileSuggestionOptions = {}, deps: FileSuggestionDependencies = {}): Promise { + const normalizedQuery = normalizeFileQuery(query); + const exec = deps.execFile ?? execFileAsync; + const files = await listFilesForScope(cwd, options.scope, exec); return files - .filter((file) => !kind || file.kind === kind) - .filter((file) => !normalizedQuery || file.path.toLowerCase().includes(normalizedQuery)) + .filter((file) => options.kind === undefined || file.kind === options.kind) + .filter((file) => normalizedQuery === "" || file.path.toLowerCase().includes(normalizedQuery)) .sort((a, b) => Number(!a.path.endsWith("/")) - Number(!b.path.endsWith("/")) || a.path.localeCompare(b.path)) .slice(0, 80); } @@ -39,10 +58,20 @@ export async function listPathSuggestions(cwd: string, prefix = ""): Promise { +async function listFilesForScope(cwd: string, scope: FileSuggestionScope | undefined, exec: NonNullable): Promise { + if (scope === "all") return listPlainFiles(cwd, exec, true); + if (scope === "tracked") return listTrackedFiles(cwd, exec).catch(() => listPlainFiles(cwd, exec, true)); + return listGitFiles(cwd, exec).catch(() => listPlainFiles(cwd, exec, false)); +} + +async function listTrackedFiles(cwd: string, exec: NonNullable): Promise { + return withDirectories(lines(await git(cwd, ["ls-files"], exec)), "tracked"); +} + +async function listGitFiles(cwd: string, exec: NonNullable): Promise { const [tracked, untracked] = await Promise.all([ - git(cwd, ["ls-files"]), - git(cwd, ["ls-files", "--others", "--exclude-standard"]), + git(cwd, ["ls-files"], exec), + git(cwd, ["ls-files", "--others", "--exclude-standard"], exec), ]); return [ ...withDirectories(lines(tracked), "tracked"), @@ -50,16 +79,63 @@ async function listGitFiles(cwd: string): Promise { ]; } -async function listPlainFiles(cwd: string): Promise { - const { stdout } = await execFileAsync("rg", ["--files"], { cwd, maxBuffer: 1024 * 1024 * 8 }); - return withDirectories(lines(stdout), "other"); +async function listPlainFiles(cwd: string, exec: NonNullable, includeIgnored: boolean): Promise { + try { + const args = includeIgnored ? ["--files", "--hidden", "--no-ignore"] : ["--files"]; + const { stdout } = await exec("rg", args, { cwd, maxBuffer: commandMaxBuffer }); + return withDirectories(lines(stdout), "other"); + } catch { + return withDirectories(await filesystemFiles(cwd), "other"); + } } -async function git(cwd: string, args: string[]): Promise { - const { stdout } = await execFileAsync("git", args, { cwd, maxBuffer: 1024 * 1024 * 8 }); +async function filesystemFiles(cwd: string): Promise { + const paths: string[] = []; + await collectFilesystemFiles(cwd, "", paths, false); + return paths; +} + +async function collectFilesystemFiles(cwd: string, relativeDirectory: string, paths: string[], optionalDirectory: boolean): Promise { + if (paths.length >= maxFilesystemFallbackPaths) return; + const absoluteDirectory = relativeDirectory === "" ? cwd : join(cwd, relativeDirectory); + let entries; + try { + entries = await readdir(absoluteDirectory, { withFileTypes: true }); + } catch (error) { + if (optionalDirectory) return; + throw error; + } + + entries.sort((a, b) => Number(!a.isDirectory()) - Number(!b.isDirectory()) || a.name.localeCompare(b.name)); + for (const entry of entries) { + if (paths.length >= maxFilesystemFallbackPaths) return; + const relativePath = relativeDirectory === "" ? entry.name : `${relativeDirectory}/${entry.name}`; + if (entry.isDirectory()) { + await collectFilesystemFiles(cwd, relativePath, paths, true); + continue; + } + if (entry.isFile() || await isSymlinkedFile(cwd, relativePath, entry.isSymbolicLink())) paths.push(relativePath); + } +} + +async function isSymlinkedFile(cwd: string, relativePath: string, symbolicLink: boolean): Promise { + if (!symbolicLink) return false; + try { + return (await stat(join(cwd, relativePath))).isFile(); + } catch { + return false; + } +} + +async function git(cwd: string, args: string[], exec: NonNullable): Promise { + const { stdout } = await exec("git", args, { cwd, maxBuffer: commandMaxBuffer }); return stdout; } +function normalizeFileQuery(query: string): string { + return query.replace(/^!@/, "").replace(/^@\s?/, "").toLowerCase(); +} + function lines(text: string): string[] { return text.split("\n").map((line) => line.trim()).filter(Boolean); } diff --git a/src/server/workspaces/fileTreeService.test.ts b/src/server/workspaces/fileTreeService.test.ts index f5d9f18..b0b5710 100644 --- a/src/server/workspaces/fileTreeService.test.ts +++ b/src/server/workspaces/fileTreeService.test.ts @@ -17,7 +17,7 @@ afterEach(async () => { }); describe("listWorkspaceTree", () => { - it("lists visible entries with directories first, sorted by name", async () => { + it("lists entries with directories first, sorted by name", async () => { const root = await tempWorkspace(); await mkdir(join(root, "z-dir")); await mkdir(join(root, "a-dir")); @@ -32,7 +32,9 @@ describe("listWorkspaceTree", () => { expect(tree.path).toBe(""); expect(tree.truncated).toBe(false); expect(tree.entries.map((entry) => [entry.name, entry.type])).toEqual([ + [".git", "directory"], ["a-dir", "directory"], + ["node_modules", "directory"], ["z-dir", "directory"], ["a.txt", "file"], ["b.txt", "file"], diff --git a/src/server/workspaces/fileTreeService.ts b/src/server/workspaces/fileTreeService.ts index 6e62bde..fcd7ff2 100644 --- a/src/server/workspaces/fileTreeService.ts +++ b/src/server/workspaces/fileTreeService.ts @@ -11,11 +11,11 @@ export async function listWorkspaceTree(rootPath: string, path: string | undefin if (!stat.isDirectory()) throw new Error("Path is not a directory"); const dirents = await readdir(target, { withFileTypes: true }); - const visible = dirents.filter((entry) => entry.name !== ".git" && entry.name !== "node_modules").sort((a, b) => { + const sorted = dirents.sort((a, b) => { if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1; return a.name.localeCompare(b.name); }); - const selected = visible.slice(0, MAX_ENTRIES); + const selected = sorted.slice(0, MAX_ENTRIES); const entries = await Promise.all(selected.map(async (entry): Promise => { const absolute = join(target, entry.name); const childRelative = relativePath === "" ? entry.name : `${relativePath}/${entry.name}`; @@ -24,5 +24,5 @@ export async function listWorkspaceTree(rootPath: string, path: string | undefin return { name: entry.name, path: childRelative, type, size: childStat.size, modifiedAt: childStat.mtime.toISOString() }; })); - return { path: relativePath, entries, scannedAt: new Date().toISOString(), truncated: visible.length > selected.length }; + return { path: relativePath, entries, scannedAt: new Date().toISOString(), truncated: sorted.length > selected.length }; }