-
Notifications
You must be signed in to change notification settings - Fork 147
fix(tui): add readdir fallback for @-mention in non-git directories (#266) #268
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
f9c2003
0f3cc36
f565824
cdbd382
3da0d7f
d9eb875
e48823a
4950e9d
5b813e6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@moonshot-ai/kimi-code": patch | ||
| --- | ||
|
|
||
| `@` file completion now works in non-git directories. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -26,11 +26,14 @@ | |
| * | ||
| * When `fd` is available the inner pi-tui provider owns the `@` branch | ||
| * verbatim — its fd invocation respects `.gitignore` and is strictly | ||
| * better than anything we can cheaply reproduce in TS. We only kick in | ||
| * when `fd` is missing AND we're in a git repo. | ||
| * better than anything we can cheaply reproduce in TS. When `fd` is | ||
| * missing, we only fall back to our own recursive readdir when the | ||
| * work dir is not a git repository; inside a git repo we trust the | ||
| * `git ls-files` snapshot to honor `.gitignore`. | ||
| */ | ||
|
|
||
| import { basename } from 'node:path'; | ||
| import { existsSync, readdirSync, statSync } from 'node:fs'; | ||
| import { basename, join } from 'node:path'; | ||
|
|
||
| import { | ||
| CombinedAutocompleteProvider, | ||
|
|
@@ -46,13 +49,43 @@ import type { GitLsFilesCache, GitSnapshot } from '#/utils/git/git-ls-files'; | |
|
|
||
| const MAX_SUGGESTIONS_WHEN_QUERY = 50; | ||
| const MAX_SUGGESTIONS_WHEN_EMPTY = 15; | ||
| const READDIR_TTL_MS = 2000; | ||
| const READDIR_MAX_ENTRIES = 1000; | ||
| const READDIR_MAX_DEPTH = 8; | ||
|
|
||
| // Directories that are typically too large or too auto-generated to be | ||
| // useful for @-completion. Skipping them keeps the walk snappy on | ||
| // real-world repos that don't have fd or git. | ||
| const SKIP_DIRS = new Set([ | ||
| '.git', | ||
| 'node_modules', | ||
| 'dist', | ||
| 'build', | ||
| '.next', | ||
| '.turbo', | ||
| '.parcel-cache', | ||
| '.cache', | ||
| '__pycache__', | ||
| '.venv', | ||
| 'target', | ||
| '.idea', | ||
| '.vscode', | ||
| ]); | ||
|
|
||
| /** Structurally compatible with `GitSnapshot` so existing rankers accept it. */ | ||
| interface ReadDirSnapshot { | ||
| readonly files: readonly string[]; | ||
| readonly mtimeByPath: ReadonlyMap<string, number>; | ||
| readonly recencyOrder: ReadonlyMap<string, number>; | ||
| } | ||
|
|
||
| // Mirrors pi-tui's PATH_DELIMITERS. Keeping a local copy so @-detection | ||
| // stays aligned even if pi-tui extends its set. | ||
| const PATH_DELIMITERS = new Set([' ', '\t', '"', "'", '=']); | ||
|
|
||
| export class FileMentionProvider implements AutocompleteProvider { | ||
| private readonly inner: CombinedAutocompleteProvider; | ||
| private readonly readDirWalker: ReadDirWalker; | ||
|
|
||
| constructor( | ||
| slashCommands: SlashCommand[], | ||
|
|
@@ -61,6 +94,7 @@ export class FileMentionProvider implements AutocompleteProvider { | |
| private readonly gitCache: GitLsFilesCache, | ||
| ) { | ||
| this.inner = new CombinedAutocompleteProvider(slashCommands, workDir, fdPath); | ||
| this.readDirWalker = new ReadDirWalker(workDir); | ||
| } | ||
|
|
||
| async getSuggestions( | ||
|
|
@@ -85,8 +119,30 @@ export class FileMentionProvider implements AutocompleteProvider { | |
| return this.inner.getSuggestions(lines, cursorLine, cursorCol, options); | ||
| } | ||
|
|
||
| if (!this.gitCache.isGitRepo()) { | ||
| // Not in a git repo (stable for the cache's lifetime — `isGitRepo` | ||
| // is captured at TUI startup by `git rev-parse --show-toplevel`). | ||
| // Transient `git ls-files` failures inside a real repo leave | ||
| // `getSnapshot()` returning null but `isGitRepo()` still true, in | ||
| // which case we deliberately do NOT fall back to raw readdir | ||
| // (that would bypass `.gitignore`). Inner's getFuzzyFileSuggestions | ||
| // is a dead end without `fd`, so we own the candidate source here. | ||
| // See issue #266. | ||
| const readdirResult = this.buildFromReadDir(atPrefix); | ||
| if (readdirResult !== null) { | ||
| return { items: readdirResult, prefix: atPrefix }; | ||
| } | ||
| return this.inner.getSuggestions(lines, cursorLine, cursorCol, options); | ||
| } | ||
| const snapshot = this.gitCache.getSnapshot(); | ||
| if (snapshot === null || snapshot.files.length === 0) { | ||
| if (snapshot === null) { | ||
| // Inside a git repo but the snapshot fetch failed transiently | ||
| // (e.g. `git ls-files` returned non-zero, lock contention, or | ||
| // the index mtime lookup raced). Don't consult raw readdir — | ||
| // it would bypass `.gitignore` and could surface ignored files. | ||
| // Fall through to the inner provider, which can still resolve | ||
| // `/path` or quoted-path completions; on failure it returns | ||
| // null and the editor dismisses the menu. | ||
| return this.inner.getSuggestions(lines, cursorLine, cursorCol, options); | ||
| } | ||
|
|
||
|
|
@@ -102,14 +158,40 @@ export class FileMentionProvider implements AutocompleteProvider { | |
| : rankForQuery(candidates, query, snapshot); | ||
|
|
||
| if (items.length === 0) { | ||
| // Git cache had nothing useful — fall through to readdir (user | ||
| // may be typing a path that exists but isn't tracked, e.g. a | ||
| // freshly created file not yet in the 2s cache). | ||
| // Git ls-files had no match for this query. Inside a git repo we | ||
| // do NOT consult readdir — a recursive readdir would bypass | ||
| // `git ls-files --exclude-standard` and could surface | ||
| // .gitignored paths. Fall through to the inner provider, which | ||
| // can still resolve `/path` or quoted-path completions. | ||
| return this.inner.getSuggestions(lines, cursorLine, cursorCol, options); | ||
| } | ||
| return { items, prefix: atPrefix }; | ||
| } | ||
|
|
||
| private buildFromReadDir(atPrefix: string): AutocompleteItem[] | null { | ||
| const snapshot = this.readDirWalker.getSnapshot(); | ||
| if (snapshot === null || snapshot.files.length === 0) { | ||
| return null; | ||
| } | ||
| const query = atPrefix.slice(1); | ||
| const includeDotDirs = query.startsWith('.'); | ||
| const candidates = includeDotDirs | ||
| ? snapshot.files | ||
| : snapshot.files.filter((p) => !containsDotSegment(p)); | ||
| if (candidates.length === 0) { | ||
| return null; | ||
| } | ||
| const ranked = | ||
| query.length === 0 | ||
| ? rankForEmptyQuery(candidates, snapshot) | ||
| : rankForQuery(candidates, query, snapshot); | ||
| // An empty ranking means the walker saw files but none matched the | ||
| // query. Returning `null` (rather than `{ items: [] }`) lets the | ||
| // caller dismiss the autocomplete menu instead of presenting an | ||
| // empty state. | ||
| return ranked.length === 0 ? null : ranked; | ||
| } | ||
|
|
||
| applyCompletion( | ||
| lines: string[], | ||
| cursorLine: number, | ||
|
|
@@ -150,6 +232,145 @@ function containsDotSegment(path: string): boolean { | |
| return false; | ||
| } | ||
|
|
||
| /** | ||
| * Recursive readdir of the work dir, used as the @-completion source | ||
| * when `fd` is missing and we're not in a git repository. Caches the | ||
| * result for `READDIR_TTL_MS` to keep keystroke latency low. Skips | ||
| * well-known build/dependency directories so a `node_modules`-laden | ||
| * repo still walks in under ~50ms. | ||
| * | ||
| * The walker collects dot entries too (so callers can opt in via | ||
| * `@.env` / `@.github/`); the actual dot-filtering is the caller's | ||
| * responsibility, mirroring the git-backed path. | ||
| */ | ||
| class ReadDirWalker { | ||
| private snapshot: ReadDirSnapshot | null = null; | ||
| private fetchedAt = 0; | ||
| // Cap on the number of directories visited during a single | ||
| // walk. Without this, a directory-heavy tree (e.g. 10 000 empty | ||
| // subdirectories) would synchronously recurse into every one | ||
| // even though the file cap is never hit. Reset at the start of | ||
| // each walk so the snapshot TTL does not leak the counter | ||
| // across refreshes. | ||
| private dirsVisited = 0; | ||
|
|
||
| constructor(private readonly workDir: string) {} | ||
|
|
||
| getSnapshot(): ReadDirSnapshot | null { | ||
| if (!existsSync(this.workDir)) return null; | ||
| const now = Date.now(); | ||
| if (this.snapshot !== null && now - this.fetchedAt < READDIR_TTL_MS) { | ||
| return this.snapshot; | ||
| } | ||
| const next = this.walk(); | ||
| if (next === null) return null; | ||
| this.snapshot = next; | ||
| this.fetchedAt = now; | ||
| return next; | ||
| } | ||
|
|
||
| private walk(): ReadDirSnapshot | null { | ||
| this.dirsVisited = 0; | ||
| const files: string[] = []; | ||
| const mtimeByPath = new Map<string, number>(); | ||
| try { | ||
| this.walkDir(this.workDir, '', 0, files, mtimeByPath); | ||
| } catch { | ||
| return null; | ||
| } | ||
| files.sort(); | ||
| const capped = files.length > READDIR_MAX_ENTRIES ? files.slice(0, READDIR_MAX_ENTRIES) : files; | ||
| return { files: capped, mtimeByPath, recencyOrder: new Map() }; | ||
| } | ||
|
|
||
| private walkDir( | ||
| absDir: string, | ||
| relDir: string, | ||
| depth: number, | ||
| files: string[], | ||
| mtimeByPath: Map<string, number>, | ||
| ): void { | ||
| if (depth > READDIR_MAX_DEPTH) return; | ||
| if (files.length >= READDIR_MAX_ENTRIES) return; | ||
| if (this.dirsVisited >= READDIR_MAX_ENTRIES) return; | ||
| this.dirsVisited++; | ||
| let entries: import('node:fs').Dirent[]; | ||
| try { | ||
| entries = readdirSync(absDir, { withFileTypes: true }); | ||
| } catch { | ||
| return; | ||
| } | ||
| // Four-phase traversal in priority order so a single large | ||
| // directory of any kind (visible or hidden) cannot fill | ||
| // READDIR_MAX_ENTRIES and starve entries of other types: | ||
| // 1. Visible files at this level — captured first. | ||
| // 2. Recurse into visible subdirectories. | ||
| // 3. Hidden files at this level — captured after visible | ||
| // dirs so an explicit `@.env` query isn't starved by | ||
| // a sibling large visible dir. | ||
| // 4. Recurse into hidden subdirectories. | ||
| // Hidden paths are still collected, so the opt-in `@.env` / | ||
| // `@.github/` queries still work. | ||
| const collectFile = (entry: import('node:fs').Dirent): void => { | ||
| if (entry.name === '.' || entry.name === '..') return; | ||
| if (!entry.isFile() && !entry.isSymbolicLink()) return; | ||
| const absPath = join(absDir, entry.name); | ||
| const relPath = relDir === '' ? entry.name : `${relDir}/${entry.name}`; | ||
| try { | ||
| // statSync follows symlinks; if the target is a file we | ||
| // record it, otherwise we skip. | ||
| const stat = statSync(absPath); | ||
| if (!stat.isFile()) return; | ||
| files.push(relPath); | ||
| mtimeByPath.set(relPath, stat.mtimeMs); | ||
| } catch { | ||
| // File/link disappeared or unresolvable — skip it. | ||
| } | ||
| }; | ||
|
|
||
| const recurseDir = (entry: import('node:fs').Dirent): void => { | ||
| if (entry.name === '.' || entry.name === '..') return; | ||
| if (!entry.isDirectory()) return; | ||
| if (SKIP_DIRS.has(entry.name)) return; | ||
| const absChild = join(absDir, entry.name); | ||
| const relChild = relDir === '' ? entry.name : `${relDir}/${entry.name}`; | ||
| this.walkDir(absChild, relChild, depth + 1, files, mtimeByPath); | ||
| }; | ||
|
|
||
| // Phase 1: visible files at this level | ||
| for (const entry of entries) { | ||
| if (files.length >= READDIR_MAX_ENTRIES) break; | ||
| if (this.dirsVisited >= READDIR_MAX_ENTRIES) break; | ||
| if (entry.name.startsWith('.')) continue; | ||
| collectFile(entry); | ||
|
Comment on lines
+341
to
+345
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In a non-git workdir without Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| // Phase 2: recurse into visible subdirectories | ||
| for (const entry of entries) { | ||
| if (files.length >= READDIR_MAX_ENTRIES) break; | ||
| if (this.dirsVisited >= READDIR_MAX_ENTRIES) break; | ||
| if (entry.name.startsWith('.')) continue; | ||
| recurseDir(entry); | ||
| } | ||
|
|
||
| // Phase 3: hidden files at this level | ||
| for (const entry of entries) { | ||
| if (files.length >= READDIR_MAX_ENTRIES) break; | ||
| if (this.dirsVisited >= READDIR_MAX_ENTRIES) break; | ||
| if (!entry.name.startsWith('.')) continue; | ||
| collectFile(entry); | ||
| } | ||
|
|
||
| // Phase 4: recurse into hidden subdirectories | ||
| for (const entry of entries) { | ||
| if (files.length >= READDIR_MAX_ENTRIES) break; | ||
| if (this.dirsVisited >= READDIR_MAX_ENTRIES) break; | ||
| if (!entry.name.startsWith('.')) continue; | ||
| recurseDir(entry); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Empty-query ranking: stratified by signal strength. | ||
| * | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In a non-git workdir without
fd, the new fallback still synchronously recurses andstatSyncs every eligible file before this slice is applied. For large directories, typing@can block the TUI for the full traversal even thoughREADDIR_MAX_ENTRIESsuggests the work is capped; the walker should short-circuit once the cap is reached instead of only truncating the completed list.Useful? React with 👍 / 👎.