diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 475fcc988b4..e59f9126141 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -440,8 +440,12 @@ export function registerFilesystemHandlers(store: Store): void { ): Promise => { if (args.connectionId) { const provider = getSshFilesystemProvider(args.connectionId) + // Why: when the SSH connection is not yet established (cold start) or + // temporarily disconnected, return [] so quick-open shows "No matching + // files" instead of an error banner. The file list will repopulate when + // the user re-opens quick-open after the connection is restored. if (!provider) { - throw new Error(`No filesystem provider for connection "${args.connectionId}"`) + return [] } return provider.listFiles(args.rootPath) } diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index a96fb70f3d7..134fc4a3e3c 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -99,11 +99,14 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store gitWorktrees = [createFolderWorktree(repo)] } else if (repo.connectionId) { const provider = getSshGitProvider(repo.connectionId) - // Why: when SSH is disconnected the provider is null. Throwing here - // makes the renderer's fetchWorktrees catch block preserve its cached - // worktree list instead of replacing it with an empty array. + // Why: when SSH is disconnected the provider is null. Return [] so the + // renderer's fetchWorktrees guard (`worktrees.length === 0 && current.length > 0`) + // preserves its cached worktree list. This avoids a console error on every + // fetchAllWorktrees cycle while the connection is being (re-)established — + // worktrees will be properly populated when the SSH `connected` event fires + // and triggers a re-fetch. if (!provider) { - throw new Error(`SSH connection "${repo.connectionId}" is not active`) + return [] } gitWorktrees = await provider.listWorktrees(repo.path) } else { diff --git a/src/relay/fs-handler-readdir-fallback.ts b/src/relay/fs-handler-readdir-fallback.ts new file mode 100644 index 00000000000..a0422b7dbf5 --- /dev/null +++ b/src/relay/fs-handler-readdir-fallback.ts @@ -0,0 +1,82 @@ +/** + * Plain readdir-based file listing fallback. + * + * Why: when neither ripgrep nor git is available (e.g. a non-git folder on a + * remote machine without rg), we still need to list files for quick-open. + * This walks the directory tree using Node's fs.readdir, respecting the same + * blocklist and timeout constraints as the git-based fallback. + */ +import { readdir } from 'fs/promises' +import { join, relative } from 'path' + +const MAX_FILES = 10_000 +const TIMEOUT_MS = 10_000 + +const HIDDEN_DIR_BLOCKLIST = new Set([ + '.git', + '.next', + '.nuxt', + '.cache', + '.stably', + '.vscode', + '.idea', + '.yarn', + '.pnpm-store', + '.terraform', + '.docker', + '.husky' +]) + +function shouldDescend(name: string): boolean { + if (name === 'node_modules' || HIDDEN_DIR_BLOCKLIST.has(name)) { + return false + } + // Skip hidden directories (dotfiles other than those in the blocklist + // are already covered, but be explicit for any remaining) + if (name.startsWith('.') && HIDDEN_DIR_BLOCKLIST.has(name)) { + return false + } + return true +} + +/** + * Recursively list files under `rootPath` using fs.readdir. + * Returns relative POSIX paths, capped at MAX_FILES with a timeout. + */ +export async function listFilesWithReaddir(rootPath: string): Promise { + const files: string[] = [] + const deadline = Date.now() + TIMEOUT_MS + + async function walk(dir: string): Promise { + if (files.length >= MAX_FILES || Date.now() > deadline) { + return + } + + let entries + try { + entries = await readdir(dir, { withFileTypes: true }) + } catch { + // Permission denied, symlink loop, etc. — skip silently. + return + } + + for (const entry of entries) { + if (files.length >= MAX_FILES || Date.now() > deadline) { + return + } + + const name = entry.name + if (entry.isDirectory()) { + if (shouldDescend(name)) { + await walk(join(dir, name)) + } + } else if (entry.isFile()) { + const relPath = relative(rootPath, join(dir, name)) + files.push(relPath) + } + } + } + + await walk(rootPath) + return files +} diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index fd65c5e0c3c..b85e4f93592 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -24,6 +24,7 @@ import { checkRgAvailable } from './fs-handler-utils' import { listFilesWithGit, searchWithGitGrep } from './fs-handler-git-fallback' +import { listFilesWithReaddir } from './fs-handler-readdir-fallback' type WatchState = { rootPath: string @@ -228,10 +229,18 @@ export class FsHandler { const rootPath = expandTilde(params.rootPath as string) await this.context.validatePathResolved(rootPath) const rgAvailable = await checkRgAvailable() - if (!rgAvailable) { - return listFilesWithGit(rootPath) + if (rgAvailable) { + return listFilesWithRg(rootPath) } - return listFilesWithRg(rootPath) + // Why: git ls-files only works inside git repos. For non-git directories + // (e.g. a plain folder added over SSH), the command exits with an error + // and returns nothing. Fall back to a recursive readdir walk so quick-open + // still populates for non-git folders without ripgrep. + const gitFiles = await listFilesWithGit(rootPath) + if (gitFiles.length > 0) { + return gitFiles + } + return listFilesWithReaddir(rootPath) } private async watch(params: Record) {