|
|
@@ -0,0 +1,329 @@
|
|
|
+/**
|
|
|
+ * Host-workspace discovery for TUI `@file` completion. The index contains
|
|
|
+ * paths only: selected values remain ordinary prompt text and file contents
|
|
|
+ * stay behind the model-facing `read` tool.
|
|
|
+ *
|
|
|
+ * @module @deepseek-ai/dsh-tui/file-autocomplete
|
|
|
+ */
|
|
|
+
|
|
|
+import { readdir } from 'node:fs/promises'
|
|
|
+import { isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
|
+
|
|
|
+/** Default maximum file and directory candidates rendered for one query. */
|
|
|
+export const DEFAULT_FILE_SEARCH_MAX_RESULTS = 20
|
|
|
+/** Default maximum entries retained in one workspace search index. */
|
|
|
+export const DEFAULT_FILE_SEARCH_MAX_ENTRIES = 10_000
|
|
|
+/** Directory basenames omitted from traversal unless the deployment overrides them. */
|
|
|
+export const DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES = ['.git', 'node_modules'] as const
|
|
|
+
|
|
|
+/** Resolved limits and exclusions for one TUI workspace index. */
|
|
|
+export interface FileSearchConfig {
|
|
|
+ /** Maximum ranked candidates returned for one query. */
|
|
|
+ maxResults: number
|
|
|
+ /** Maximum indexed files and directories. */
|
|
|
+ maxEntries: number
|
|
|
+ /** Directory basenames never traversed or offered. */
|
|
|
+ excludedDirectories: readonly string[]
|
|
|
+}
|
|
|
+
|
|
|
+/** One path-only completion candidate inside the session cwd. */
|
|
|
+export interface FileSearchCandidate {
|
|
|
+ /** User-facing path accepted by the normal prompt and filesystem tools. */
|
|
|
+ path: string
|
|
|
+ /** Directories keep completion open; files finish the mention. */
|
|
|
+ kind: 'file' | 'directory'
|
|
|
+}
|
|
|
+
|
|
|
+/** Active `@` token ending at the editor cursor. */
|
|
|
+export interface ActiveAtToken {
|
|
|
+ /** Complete token replaced when the user accepts a completion. */
|
|
|
+ prefix: string
|
|
|
+ /** Path query after `@` or `@"`. */
|
|
|
+ query: string
|
|
|
+ /** Whether the user opened a quoted path. */
|
|
|
+ quoted: boolean
|
|
|
+}
|
|
|
+
|
|
|
+interface IndexedPath extends FileSearchCandidate {}
|
|
|
+
|
|
|
+interface RankedPath {
|
|
|
+ candidate: FileSearchCandidate
|
|
|
+ score: number
|
|
|
+}
|
|
|
+
|
|
|
+interface IndexGeneration {
|
|
|
+ controller: AbortController
|
|
|
+ promise: Promise<IndexedPath[]>
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Extract an `@path` or `@"path with spaces` token at the cursor. An `@`
|
|
|
+ * inside another token, such as an email address, is not a completion trigger.
|
|
|
+ * @param line - current editor line.
|
|
|
+ * @param cursorCol - cursor column within that line.
|
|
|
+ * @returns the active token, or `undefined` outside an `@` token.
|
|
|
+ */
|
|
|
+export function activeAtToken(line: string, cursorCol: number): ActiveAtToken | undefined {
|
|
|
+ const beforeCursor = line.slice(0, cursorCol)
|
|
|
+ const quoted = /(?:^|\s)(@"([^"]*))$/u.exec(beforeCursor)
|
|
|
+ if (quoted?.[1] !== undefined && quoted[2] !== undefined) {
|
|
|
+ return { prefix: quoted[1], query: quoted[2], quoted: true }
|
|
|
+ }
|
|
|
+ const plain = /(?:^|\s)(@([^\s]*))$/u.exec(beforeCursor)
|
|
|
+ if (plain?.[1] === undefined || plain[2] === undefined) return undefined
|
|
|
+ return { prefix: plain[1], query: plain[2], quoted: false }
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Format a selected path as prompt text. Whitespace uses Pi's quoted
|
|
|
+ * `@"path"` grammar; directories retain a trailing slash so completion can
|
|
|
+ * descend another level.
|
|
|
+ * @param candidate - selected file or directory.
|
|
|
+ * @param preserveQuote - retain an explicitly opened quote even when unnecessary.
|
|
|
+ * @returns the insertion value, or `undefined` for a path the editor grammar cannot represent safely.
|
|
|
+ */
|
|
|
+export function formatFileMention(
|
|
|
+ candidate: FileSearchCandidate,
|
|
|
+ preserveQuote: boolean,
|
|
|
+): string | undefined {
|
|
|
+ const path = candidate.kind === 'directory' ? `${candidate.path}/` : candidate.path
|
|
|
+ if (/[\u0000-\u001f\u007f-\u009f"]/u.test(path)) return undefined
|
|
|
+ const quoted = preserveQuote || /\s/u.test(path)
|
|
|
+ if (!quoted) return `@${path}`
|
|
|
+ return `@"${path}"`
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Cancellable, reusable fuzzy index rooted at one agent working directory.
|
|
|
+ * Directory-scoped queries list live state; bare fuzzy queries share one
|
|
|
+ * bounded traversal until the `@` interaction ends or a tool result invalidates it.
|
|
|
+ */
|
|
|
+export class WorkspaceFileSearch {
|
|
|
+ private readonly excludedDirectories: ReadonlySet<string>
|
|
|
+ private generation: IndexGeneration | undefined
|
|
|
+ private disposed = false
|
|
|
+
|
|
|
+ constructor(
|
|
|
+ private readonly root: string,
|
|
|
+ private readonly config: FileSearchConfig,
|
|
|
+ ) {
|
|
|
+ if (!Number.isSafeInteger(config.maxResults) || config.maxResults <= 0) {
|
|
|
+ throw new Error('file search maxResults must be a positive safe integer')
|
|
|
+ }
|
|
|
+ if (!Number.isSafeInteger(config.maxEntries) || config.maxEntries <= 0) {
|
|
|
+ throw new Error('file search maxEntries must be a positive safe integer')
|
|
|
+ }
|
|
|
+ if (config.excludedDirectories.some(name => name.length === 0 || name.includes('/') || name.includes('\\'))) {
|
|
|
+ throw new Error('file search excludedDirectories entries must be non-empty directory basenames')
|
|
|
+ }
|
|
|
+ this.excludedDirectories = new Set(config.excludedDirectories)
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Return ranked path candidates for the current token.
|
|
|
+ * @param rawQuery - path text following `@` or `@"`.
|
|
|
+ * @param signal - cancels this caller's wait without killing an index shared by a newer query.
|
|
|
+ * @returns at most `maxResults` deterministic candidates.
|
|
|
+ */
|
|
|
+ async list(rawQuery: string, signal: AbortSignal): Promise<FileSearchCandidate[]> {
|
|
|
+ signal.throwIfAborted()
|
|
|
+ if (this.disposed) return []
|
|
|
+ const query = rawQuery.replaceAll('\\', '/')
|
|
|
+ const slash = query.lastIndexOf('/')
|
|
|
+ if (query === '' || slash >= 0) {
|
|
|
+ const directory = slash < 0 ? '' : query.slice(0, slash + 1)
|
|
|
+ const fragment = slash < 0 ? '' : query.slice(slash + 1)
|
|
|
+ return this.listDirectory(directory, fragment, signal)
|
|
|
+ }
|
|
|
+ const indexed = await waitForPromise(this.ensureIndex(), signal)
|
|
|
+ return rankCandidates(
|
|
|
+ indexed.filter(candidate => visibleForGlobalQuery(candidate.path, query)),
|
|
|
+ query,
|
|
|
+ this.config.maxResults,
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Discard the current index so the next bare query observes a fresh tree. */
|
|
|
+ invalidate(): void {
|
|
|
+ this.generation?.controller.abort(new Error('file search index invalidated'))
|
|
|
+ this.generation = undefined
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Abort traversal and make later queries return no candidates. */
|
|
|
+ dispose(): void {
|
|
|
+ if (this.disposed) return
|
|
|
+ this.disposed = true
|
|
|
+ this.invalidate()
|
|
|
+ }
|
|
|
+
|
|
|
+ private ensureIndex(): Promise<IndexedPath[]> {
|
|
|
+ if (this.generation !== undefined) return this.generation.promise
|
|
|
+ const controller = new AbortController()
|
|
|
+ const generation = {
|
|
|
+ controller,
|
|
|
+ promise: Promise.resolve([] as IndexedPath[]),
|
|
|
+ } satisfies IndexGeneration
|
|
|
+ generation.promise = this.scanWorkspace(controller.signal).catch((error: unknown) => {
|
|
|
+ /* v8 ignore next -- every owned abort clears `generation` synchronously; this only protects an unexpected scan failure */
|
|
|
+ if (this.generation === generation) this.generation = undefined
|
|
|
+ throw error
|
|
|
+ })
|
|
|
+ this.generation = generation
|
|
|
+ return generation.promise
|
|
|
+ }
|
|
|
+
|
|
|
+ private async scanWorkspace(signal: AbortSignal): Promise<IndexedPath[]> {
|
|
|
+ const indexed: IndexedPath[] = []
|
|
|
+ const directories: { absolute: string; relative: string }[] = [{ absolute: this.root, relative: '' }]
|
|
|
+ for (let cursor = 0; cursor < directories.length && indexed.length < this.config.maxEntries; cursor += 1) {
|
|
|
+ signal.throwIfAborted()
|
|
|
+ const directory = directories[cursor]
|
|
|
+ /* v8 ignore next 3 -- cursor is bounded by this exact queue's length. */
|
|
|
+ if (directory === undefined) {
|
|
|
+ throw new Error('file search selected a missing directory')
|
|
|
+ }
|
|
|
+ const entries = await readDirectory(directory.absolute, signal)
|
|
|
+ for (const entry of entries) {
|
|
|
+ signal.throwIfAborted()
|
|
|
+ const path = directory.relative === '' ? entry.name : `${directory.relative}/${entry.name}`
|
|
|
+ if (entry.isDirectory()) {
|
|
|
+ if (this.excludedDirectories.has(entry.name)) continue
|
|
|
+ indexed.push({ path, kind: 'directory' })
|
|
|
+ directories.push({ absolute: join(directory.absolute, entry.name), relative: path })
|
|
|
+ } else if (entry.isFile()) {
|
|
|
+ indexed.push({ path, kind: 'file' })
|
|
|
+ }
|
|
|
+ if (indexed.length >= this.config.maxEntries) break
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return indexed
|
|
|
+ }
|
|
|
+
|
|
|
+ private async listDirectory(
|
|
|
+ displayDirectory: string,
|
|
|
+ fragment: string,
|
|
|
+ signal: AbortSignal,
|
|
|
+ ): Promise<FileSearchCandidate[]> {
|
|
|
+ if (displayDirectory.split('/').some(segment => this.excludedDirectories.has(segment))) return []
|
|
|
+ const absolute = resolveDisplayDirectory(this.root, displayDirectory)
|
|
|
+ if (absolute === undefined) return []
|
|
|
+ const entries = await readDirectory(absolute, signal)
|
|
|
+ const candidates: FileSearchCandidate[] = []
|
|
|
+ for (const entry of entries) {
|
|
|
+ if (entry.name.startsWith('.') && !fragment.startsWith('.')) continue
|
|
|
+ if (entry.isDirectory()) {
|
|
|
+ if (this.excludedDirectories.has(entry.name)) continue
|
|
|
+ candidates.push({ path: `${displayDirectory}${entry.name}`, kind: 'directory' })
|
|
|
+ } else if (entry.isFile()) {
|
|
|
+ candidates.push({ path: `${displayDirectory}${entry.name}`, kind: 'file' })
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return rankCandidates(candidates, fragment, this.config.maxResults)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function resolveDisplayDirectory(root: string, displayDirectory: string): string | undefined {
|
|
|
+ const resolvedRoot = resolve(root)
|
|
|
+ const absolute = resolve(resolvedRoot, displayDirectory === '' ? '.' : displayDirectory)
|
|
|
+ const fromRoot = relative(resolvedRoot, absolute)
|
|
|
+ if (fromRoot === '..' || fromRoot.startsWith(`..${sep}`)) return undefined
|
|
|
+ /* v8 ignore next -- only Windows can produce a cross-volume absolute relative path */
|
|
|
+ if (isAbsolute(fromRoot)) return undefined
|
|
|
+ return absolute
|
|
|
+}
|
|
|
+
|
|
|
+async function readDirectory(absolute: string, signal: AbortSignal) {
|
|
|
+ signal.throwIfAborted()
|
|
|
+ try {
|
|
|
+ const entries = await readdir(absolute, { withFileTypes: true })
|
|
|
+ signal.throwIfAborted()
|
|
|
+ return entries.sort((left, right) => compareText(left.name, right.name))
|
|
|
+ } catch (_error: unknown) {
|
|
|
+ signal.throwIfAborted()
|
|
|
+ // An unreadable/missing subtree contributes no candidates; other readable
|
|
|
+ // branches remain useful and autocomplete is advisory.
|
|
|
+ return []
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function visibleForGlobalQuery(path: string, query: string): boolean {
|
|
|
+ if (query.startsWith('.') || query.includes('/.')) return true
|
|
|
+ return !path.split('/').some(segment => segment.startsWith('.'))
|
|
|
+}
|
|
|
+
|
|
|
+function rankCandidates(
|
|
|
+ candidates: readonly FileSearchCandidate[],
|
|
|
+ query: string,
|
|
|
+ limit: number,
|
|
|
+): FileSearchCandidate[] {
|
|
|
+ const ranked: RankedPath[] = []
|
|
|
+ for (const candidate of candidates) {
|
|
|
+ const score = scoreCandidate(candidate, query)
|
|
|
+ if (score !== undefined) ranked.push({ candidate, score })
|
|
|
+ }
|
|
|
+ ranked.sort((left, right) =>
|
|
|
+ right.score - left.score
|
|
|
+ || kindRank(left.candidate.kind) - kindRank(right.candidate.kind)
|
|
|
+ || (query === '' ? 0 : left.candidate.path.length - right.candidate.path.length)
|
|
|
+ || compareText(left.candidate.path, right.candidate.path))
|
|
|
+ return ranked.slice(0, limit).map(entry => entry.candidate)
|
|
|
+}
|
|
|
+
|
|
|
+function scoreCandidate(candidate: FileSearchCandidate, query: string): number | undefined {
|
|
|
+ if (query === '') return 0
|
|
|
+ const path = candidate.path.toLowerCase()
|
|
|
+ const name = path.slice(path.lastIndexOf('/') + 1)
|
|
|
+ const needle = query.toLowerCase()
|
|
|
+ const directoryBonus = candidate.kind === 'directory' ? 25 : 0
|
|
|
+ if (name === needle) return 1_000 + directoryBonus
|
|
|
+ if (name.startsWith(needle)) return 900 + directoryBonus
|
|
|
+ if (name.includes(needle)) return 700 + directoryBonus
|
|
|
+ if (path.includes(needle)) return 500 + directoryBonus
|
|
|
+ const subsequence = subsequenceScore(path, needle)
|
|
|
+ return subsequence === undefined ? undefined : 300 + subsequence + directoryBonus
|
|
|
+}
|
|
|
+
|
|
|
+function subsequenceScore(target: string, query: string): number | undefined {
|
|
|
+ let targetIndex = 0
|
|
|
+ let gap = 0
|
|
|
+ for (const character of query) {
|
|
|
+ const found = target.indexOf(character, targetIndex)
|
|
|
+ if (found < 0) return undefined
|
|
|
+ gap += found - targetIndex
|
|
|
+ targetIndex = found + 1
|
|
|
+ }
|
|
|
+ return Math.max(0, 100 - gap)
|
|
|
+}
|
|
|
+
|
|
|
+function kindRank(kind: FileSearchCandidate['kind']): number {
|
|
|
+ return kind === 'directory' ? 0 : 1
|
|
|
+}
|
|
|
+
|
|
|
+function compareText(left: string, right: string): number {
|
|
|
+ /* v8 ignore next -- entries and candidates are unique; host enumeration
|
|
|
+ * order determines which comparison direction sort requests. */
|
|
|
+ return left < right ? -1 : left > right ? 1 : 0
|
|
|
+}
|
|
|
+
|
|
|
+function waitForPromise<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
|
+ /* v8 ignore next -- `list()` checks this signal immediately before its synchronous call into this helper */
|
|
|
+ if (signal.aborted) return Promise.reject(errorReason(signal.reason, 'file search aborted'))
|
|
|
+ return new Promise<T>((resolvePromise, rejectPromise) => {
|
|
|
+ const onAbort = (): void => { rejectPromise(errorReason(signal.reason, 'file search aborted')) }
|
|
|
+ signal.addEventListener('abort', onAbort, { once: true })
|
|
|
+ promise.then(
|
|
|
+ (value) => {
|
|
|
+ signal.removeEventListener('abort', onAbort)
|
|
|
+ resolvePromise(value)
|
|
|
+ },
|
|
|
+ (error: unknown) => {
|
|
|
+ signal.removeEventListener('abort', onAbort)
|
|
|
+ rejectPromise(errorReason(error, 'file search index failed'))
|
|
|
+ },
|
|
|
+ )
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function errorReason(reason: unknown, fallback: string): Error {
|
|
|
+ return reason instanceof Error ? reason : new Error(fallback, { cause: reason })
|
|
|
+}
|