diff --git a/.changeset/fix-remote-space-file-completion.md b/.changeset/fix-remote-space-file-completion.md new file mode 100644 index 0000000..c534de7 --- /dev/null +++ b/.changeset/fix-remote-space-file-completion.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Keep all-file prompt suggestions active while typing file names with spaces, and include git-tracked/untracked matches when broad all-file scans miss them. diff --git a/src/client/src/components/PromptEditor.ts b/src/client/src/components/PromptEditor.ts index 81d0054..569c18e 100644 --- a/src/client/src/components/PromptEditor.ts +++ b/src/client/src/components/PromptEditor.ts @@ -8,6 +8,7 @@ import { customElement, property, query, state } from "lit/decorators.js"; import { api, type FileSuggestion, type SessionStatus, type SlashCommand } from "../api"; import { inputModeForDraft } from "../inputModes"; import { machineSessionKey } from "../machineKeys"; +import { detectPromptCompletionTrigger, fileCompletionInsertText, type PromptCompletionTrigger } from "../promptCompletions"; import { clearDraft, loadDraft, saveDraft } from "../promptDraftStorage"; import { promptEditorStyles, type CompletionItem } from "./shared"; import "./AutocompleteMenu"; @@ -193,7 +194,7 @@ export class PromptEditor extends LitElement { this.completions = files .slice(0, 12) .map((file) => { - const insertText = fileInsertText(file.path, trigger.quoted === true, file.path.endsWith("/") ? trigger.allPrefix : undefined); + const insertText = fileCompletionInsertText(file.path, trigger.quoted === true, file.path.endsWith("/") ? trigger.allPrefix : undefined); return { kind: "file", replaceFrom: trigger.from, @@ -206,30 +207,8 @@ export class PromptEditor extends LitElement { } } - 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); - if (quotedTrigger !== undefined) return quotedTrigger; - - 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 - 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(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; 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 - 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; + private currentTrigger(): PromptCompletionTrigger | undefined { + return detectPromptCompletionTrigger(this.draft, this.editor?.state.selection.main.head ?? this.draft.length); } private moveCompletion(delta: number): boolean { @@ -301,12 +280,6 @@ function draftStorageKey(machineId: unknown, sessionId: unknown): string | undef return machineSessionKey(machineId, sessionId); } -function fileInsertText(path: string, quoted: boolean, allPrefix?: "@ " | "!@"): string { - const prefix = allPrefix ?? "@"; - if (!quoted && !path.includes(" ")) return `${prefix}${path}`; - return `${prefix}"${path}"`; -} - function emptySlashCommands(): SlashCommand[] { return []; } diff --git a/src/client/src/inputModes.test.ts b/src/client/src/inputModes.test.ts index 228daa4..5f14055 100644 --- a/src/client/src/inputModes.test.ts +++ b/src/client/src/inputModes.test.ts @@ -17,6 +17,7 @@ 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 @ A FILE")).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" }); diff --git a/src/client/src/inputModes.ts b/src/client/src/inputModes.ts index 38c0453..ed59118 100644 --- a/src/client/src/inputModes.ts +++ b/src/client/src/inputModes.ts @@ -1,3 +1,5 @@ +import { detectPromptCompletionTrigger } from "./promptCompletions"; + export type InputMode = | { kind: "normal" } | { kind: "command" } @@ -9,7 +11,7 @@ export function inputModeForDraft(draft: string): InputMode { 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" }; + if (detectPromptCompletionTrigger(draft)?.kind === "file") return { kind: "file" }; return { kind: "normal" }; } @@ -22,13 +24,3 @@ function currentToken(draft: string): string { return draft.slice(tokenStart); } -function isFileCompletionContext(draft: string): boolean { - const token = currentToken(draft); - 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("@ ") || prefix.endsWith("!@"); -} diff --git a/src/client/src/promptCompletions.test.ts b/src/client/src/promptCompletions.test.ts new file mode 100644 index 0000000..59bb8ed --- /dev/null +++ b/src/client/src/promptCompletions.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { detectPromptCompletionTrigger, fileCompletionInsertText } from "./promptCompletions"; + +describe("detectPromptCompletionTrigger", () => { + it("keeps all-file suggestions active when an @ space query contains spaces", () => { + expect(detectPromptCompletionTrigger("open @ A FILE")).toEqual({ + kind: "file", + query: "A FILE", + from: 5, + to: 13, + fileScope: "all", + allPrefix: "@ ", + }); + }); + + it("keeps !@ all-file suggestions active when the query contains spaces", () => { + expect(detectPromptCompletionTrigger("open !@A FILE")).toEqual({ + kind: "file", + query: "A FILE", + from: 5, + to: 13, + fileScope: "all", + allPrefix: "!@", + }); + }); + + it("detects quoted all-file and tracked-file queries", () => { + expect(detectPromptCompletionTrigger("open @ \"A F")).toEqual({ + kind: "file", + query: "A F", + from: 5, + to: 11, + fileScope: "all", + allPrefix: "@ ", + quoted: true, + }); + expect(detectPromptCompletionTrigger("open @\"src/main")).toEqual({ + kind: "file", + query: "src/main", + from: 5, + to: 15, + fileScope: "tracked", + quoted: true, + }); + }); + + it("detects normal tracked file and leading slash command queries", () => { + expect(detectPromptCompletionTrigger("open @src/main")).toEqual({ + kind: "file", + query: "src/main", + from: 5, + to: 14, + fileScope: "tracked", + }); + expect(detectPromptCompletionTrigger("/model")).toEqual({ kind: "command", query: "model", from: 0, to: 6 }); + }); +}); + +describe("fileCompletionInsertText", () => { + it("quotes completed file paths that contain spaces", () => { + expect(fileCompletionInsertText("A FILE", false)).toBe('@"A FILE"'); + }); + + it("preserves all-file prefixes for directories so completion can continue in that scope", () => { + expect(fileCompletionInsertText("dir with space/", false, "@ ")).toBe('@ "dir with space/"'); + expect(fileCompletionInsertText("vendor/", false, "!@")).toBe("!@vendor/"); + }); +}); diff --git a/src/client/src/promptCompletions.ts b/src/client/src/promptCompletions.ts new file mode 100644 index 0000000..1a0fc7b --- /dev/null +++ b/src/client/src/promptCompletions.ts @@ -0,0 +1,62 @@ +export type PromptCompletionTrigger = + | { kind: "command"; query: string; from: number; to: number } + | { kind: "file"; query: string; from: number; to: number; fileScope?: "tracked" | "all" | undefined; allPrefix?: "@ " | "!@" | undefined; quoted?: boolean }; + +export function detectPromptCompletionTrigger(draft: string, cursor = draft.length): PromptCompletionTrigger | undefined { + const beforeCursor = draft.slice(0, cursor); + const quotedTrigger = currentQuotedTrigger(beforeCursor, cursor); + if (quotedTrigger !== undefined) return quotedTrigger; + + const allFileTrigger = currentUnquotedAllFileTrigger(beforeCursor, cursor); + if (allFileTrigger !== undefined) return allFileTrigger; + + 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 - 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(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; +} + +export function fileCompletionInsertText(path: string, quoted: boolean, allPrefix?: "@ " | "!@"): string { + const prefix = allPrefix ?? "@"; + if (!quoted && !path.includes(" ")) return `${prefix}${path}`; + return `${prefix}"${path}"`; +} + +function currentQuotedTrigger(beforeCursor: string, cursor: number): PromptCompletionTrigger | 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 - 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; +} + +function currentUnquotedAllFileTrigger(beforeCursor: string, cursor: number): PromptCompletionTrigger | undefined { + const lineStart = beforeCursor.lastIndexOf("\n") + 1; + const line = beforeCursor.slice(lineStart); + const atSpaceIndex = lastTokenBoundarySequence(line, "@ "); + const bangAtIndex = lastTokenBoundarySequence(line, "!@"); + const prefixStartInLine = Math.max(atSpaceIndex, bangAtIndex); + if (prefixStartInLine === -1) return undefined; + + const allPrefix: "@ " | "!@" = prefixStartInLine === bangAtIndex ? "!@" : "@ "; + const from = lineStart + prefixStartInLine; + const queryStart = from + allPrefix.length; + return { kind: "file", query: beforeCursor.slice(queryStart), from, to: cursor, fileScope: "all", allPrefix }; +} + +function lastTokenBoundarySequence(text: string, sequence: string): number { + for (let index = text.lastIndexOf(sequence); index >= 0; index = text.lastIndexOf(sequence, index - 1)) { + if (index === 0 || isWhitespace(text[index - 1])) return index; + } + return -1; +} + +function isWhitespace(value: string | undefined): boolean { + return value === " " || value === "\t"; +} diff --git a/src/server/workspaces/fileSuggestions.test.ts b/src/server/workspaces/fileSuggestions.test.ts index be02b20..32040a5 100644 --- a/src/server/workspaces/fileSuggestions.test.ts +++ b/src/server/workspaces/fileSuggestions.test.ts @@ -22,7 +22,7 @@ describe("file suggestions", () => { 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" }); + if (file === "git" && args.join(" ") === "ls-files -z") return Promise.resolve({ stdout: "src/app.ts\0README.md\0" }); return Promise.reject(new Error(`unexpected command: ${file} ${args.join(" ")}`)); }, }; @@ -32,7 +32,7 @@ describe("file suggestions", () => { { path: "README.md", kind: "tracked" }, { path: "src/app.ts", kind: "tracked" }, ]); - expect(calls).toEqual([{ file: "git", args: ["ls-files"] }]); + expect(calls).toEqual([{ file: "git", args: ["ls-files", "-z"] }]); }); it("asks ripgrep for hidden and ignored files in all-file scope", async () => { @@ -40,6 +40,7 @@ describe("file suggestions", () => { const deps: FileSuggestionDependencies = { execFile: (file, args) => { calls.push({ file, args }); + if (file === "git") return Promise.reject(new Error("not a git repository")); return Promise.resolve({ stdout: "node_modules/pkg/index.js\nsrc/app.ts\n" }); }, }; @@ -48,10 +49,57 @@ describe("file suggestions", () => { { path: "node_modules/pkg/", kind: "other" }, { path: "node_modules/pkg/index.js", kind: "other" }, ]); - expect(calls).toEqual([{ file: "rg", args: ["--files", "--hidden", "--no-ignore"] }]); + expect(calls).toEqual([ + { file: "git", args: ["ls-files", "-z"] }, + { file: "git", args: ["ls-files", "--others", "--exclude-standard", "-z"] }, + { file: "rg", args: ["--files", "--hidden", "--no-ignore", "--glob", "!.git", "--glob", "!.git/**"] }, + ]); }); - it("falls back to a bounded filesystem scan without directory exclusions when git and rg are unavailable", async () => { + it("keeps git untracked files in all-file scope when the broad scan misses them", async () => { + const deps: FileSuggestionDependencies = { + execFile: (file, args) => { + if (file === "git" && args.join(" ") === "ls-files -z") return Promise.resolve({ stdout: "src/app.ts\0" }); + if (file === "git" && args.join(" ") === "ls-files --others --exclude-standard -z") return Promise.resolve({ stdout: "MD PRojects here.md\0" }); + if (file === "rg") return Promise.resolve({ stdout: "src/app.ts\n" }); + return Promise.reject(new Error(`unexpected command: ${file} ${args.join(" ")}`)); + }, + }; + + await expect(listFileSuggestions("/repo", "MD PRojects", { scope: "all" }, deps)).resolves.toEqual([ + { path: "MD PRojects here.md", kind: "untracked" }, + ]); + }); + + it("ranks basename matches before deeper incidental path matches", async () => { + const deps: FileSuggestionDependencies = { + execFile: (file, args) => { + if (file === "git" && args.join(" ") === "ls-files -z") return Promise.resolve({ stdout: "klingit-go/cli/cmd/dev/main.go\0MD PRojects here.md\0" }); + if (file === "git" && args.join(" ") === "ls-files --others --exclude-standard -z") return Promise.resolve({ stdout: "" }); + if (file === "rg") return Promise.resolve({ stdout: "" }); + return Promise.reject(new Error(`unexpected command: ${file} ${args.join(" ")}`)); + }, + }; + + const suggestions = await listFileSuggestions("/repo", "MD", { scope: "all" }, deps); + + expect(suggestions[0]).toEqual({ path: "MD PRojects here.md", kind: "tracked" }); + }); + + it("preserves git filenames without trimming whitespace", async () => { + const deps: FileSuggestionDependencies = { + execFile: (file, args) => { + if (file === "git" && args.join(" ") === "ls-files -z") return Promise.resolve({ stdout: " leading.md\0" }); + return Promise.reject(new Error(`unexpected command: ${file} ${args.join(" ")}`)); + }, + }; + + await expect(listFileSuggestions("/repo", " leading", { scope: "tracked" }, deps)).resolves.toEqual([ + { path: " leading.md", kind: "tracked" }, + ]); + }); + + it("falls back to a bounded filesystem scan without non-git 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 }); diff --git a/src/server/workspaces/fileSuggestions.ts b/src/server/workspaces/fileSuggestions.ts index 67af66c..54dd516 100644 --- a/src/server/workspaces/fileSuggestions.ts +++ b/src/server/workspaces/fileSuggestions.ts @@ -8,6 +8,7 @@ import type { ClientFileSuggestion } from "../types.js"; const execFileAsync = promisify(execFile); const commandMaxBuffer = 1024 * 1024 * 8; const maxFilesystemFallbackPaths = 20_000; +const maxFileSuggestions = 80; interface ExecFileOptions { cwd: string; @@ -30,11 +31,10 @@ export async function listFileSuggestions(cwd: string, query = "", options: File const normalizedQuery = normalizeFileQuery(query); const exec = deps.execFile ?? execFileAsync; const files = await listFilesForScope(cwd, options.scope, exec); - return files - .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); + return rankFileSuggestions( + files.filter((file) => options.kind === undefined || file.kind === options.kind), + normalizedQuery, + ).slice(0, maxFileSuggestions); } export async function listPathSuggestions(cwd: string, prefix = ""): Promise { @@ -61,31 +61,39 @@ export async function listPathSuggestions(cwd: string, prefix = ""): Promise): Promise { - if (scope === "all") return listPlainFiles(cwd, exec, true); + if (scope === "all") return listAllFiles(cwd, exec); 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"); + return withDirectories(nulRecords(await git(cwd, ["ls-files", "-z"], exec)), "tracked"); } async function listGitFiles(cwd: string, exec: NonNullable): Promise { const [tracked, untracked] = await Promise.all([ - git(cwd, ["ls-files"], exec), - git(cwd, ["ls-files", "--others", "--exclude-standard"], exec), + git(cwd, ["ls-files", "-z"], exec), + git(cwd, ["ls-files", "--others", "--exclude-standard", "-z"], exec), ]); return [ - ...withDirectories(lines(tracked), "tracked"), - ...withDirectories(lines(untracked), "untracked"), + ...withDirectories(nulRecords(tracked), "tracked"), + ...withDirectories(nulRecords(untracked), "untracked"), ]; } +async function listAllFiles(cwd: string, exec: NonNullable): Promise { + const [gitFiles, plainFiles] = await Promise.all([ + listGitFiles(cwd, exec).catch((): ClientFileSuggestion[] => []), + listPlainFiles(cwd, exec, true), + ]); + return mergeSuggestions(gitFiles, plainFiles); +} + async function listPlainFiles(cwd: string, exec: NonNullable, includeIgnored: boolean): Promise { try { - const args = includeIgnored ? ["--files", "--hidden", "--no-ignore"] : ["--files"]; + const args = includeIgnored ? ["--files", "--hidden", "--no-ignore", "--glob", "!.git", "--glob", "!.git/**"] : ["--files"]; const { stdout } = await exec("rg", args, { cwd, maxBuffer: commandMaxBuffer }); - return withDirectories(lines(stdout), "other"); + return withDirectories(textLines(stdout), "other"); } catch { return withDirectories(await filesystemFiles(cwd), "other"); } @@ -113,6 +121,7 @@ async function collectFilesystemFiles(cwd: string, relativeDirectory: string, pa if (paths.length >= maxFilesystemFallbackPaths) return; const relativePath = relativeDirectory === "" ? entry.name : `${relativeDirectory}/${entry.name}`; if (entry.isDirectory()) { + if (entry.name === ".git") continue; await collectFilesystemFiles(cwd, relativePath, paths, true); continue; } @@ -135,11 +144,88 @@ async function git(cwd: string, args: string[], exec: NonNullable line.trim()).filter(Boolean); +function rankFileSuggestions(files: ClientFileSuggestion[], normalizedQuery: string): ClientFileSuggestion[] { + if (normalizedQuery === "") return [...files].sort(compareFileSuggestions); + return files + .map((file) => ({ file, score: fileSuggestionScore(file.path, normalizedQuery) })) + .filter(({ score }) => score > 0) + .sort((a, b) => b.score - a.score || kindRank(a.file.kind) - kindRank(b.file.kind) || pathDepth(a.file.path) - pathDepth(b.file.path) || compareFileSuggestions(a.file, b.file)) + .map(({ file }) => file); +} + +function fileSuggestionScore(path: string, normalizedQuery: string): number { + const normalizedPath = normalizeSuggestionPathForSearch(path); + const name = displayBasename(normalizedPath); + if (normalizedPath === normalizedQuery) return 1000; + if (name === normalizedQuery) return 980; + if (name.startsWith(normalizedQuery)) return 900; + if (normalizedPath.startsWith(normalizedQuery)) return 850; + if (name.includes(normalizedQuery)) return 750; + if (normalizedPath.includes(normalizedQuery)) return 650; + + const tokens = normalizedQuery.split(/\s+/u).filter(Boolean); + if (tokens.length > 1 && tokens.every((token) => normalizedPath.includes(token))) { + return 550 + tokens.filter((token) => name.includes(token)).length * 25; + } + + return isSubsequence(normalizedQuery, normalizedPath) ? 200 : 0; +} + +function normalizeSuggestionPathForSearch(path: string): string { + const lower = path.toLowerCase(); + return lower.endsWith("/") ? lower.slice(0, -1) : lower; +} + +function displayBasename(path: string): string { + return path.split("/").filter(Boolean).at(-1) ?? path; +} + +function isSubsequence(needle: string, haystack: string): boolean { + let haystackIndex = 0; + for (const char of needle) { + haystackIndex = haystack.indexOf(char, haystackIndex); + if (haystackIndex === -1) return false; + haystackIndex += char.length; + } + return true; +} + +function compareFileSuggestions(a: ClientFileSuggestion, b: ClientFileSuggestion): number { + return Number(!a.path.endsWith("/")) - Number(!b.path.endsWith("/")) || a.path.localeCompare(b.path); +} + +function kindRank(kind: ClientFileSuggestion["kind"]): number { + switch (kind) { + case "tracked": return 0; + case "untracked": return 1; + case "other": return 2; + } +} + +function pathDepth(path: string): number { + return path.split("/").filter(Boolean).length; +} + +function textLines(text: string): string[] { + return text.split("\n").map((line) => line.endsWith("\r") ? line.slice(0, -1) : line).filter((line) => line !== ""); +} + +function nulRecords(text: string): string[] { + return text.split("\0").filter((record) => record !== ""); +} + +function mergeSuggestions(primary: ClientFileSuggestion[], secondary: ClientFileSuggestion[]): ClientFileSuggestion[] { + const seen = new Set(); + const merged: ClientFileSuggestion[] = []; + for (const suggestion of [...primary, ...secondary]) { + if (seen.has(suggestion.path)) continue; + seen.add(suggestion.path); + merged.push(suggestion); + } + return merged; } function withDirectories(paths: string[], kind: ClientFileSuggestion["kind"]): ClientFileSuggestion[] {