fix(ssh): support Cmd+P file search for non-git folders without ripgrep

When SSHed into a remote machine that lacks ripgrep, quick-open for
non-git directories returned zero results because the only fallback
(git ls-files) silently fails outside a git repo.

- Add readdir-based file listing fallback in the relay for when neither
  rg nor git is applicable
- Return [] instead of throwing in worktrees:list when SSH provider is
  unavailable (matches worktrees:listAll behavior, preserves cached data
  via the renderer's empty-array guard)
- Return [] from fs:listFiles when SSH filesystem provider is missing so
  quick-open shows "No matching files" instead of an error banner
This commit is contained in:
Jinwoo-H
2026-04-24 18:45:11 -04:00
parent e6abe45bc7
commit 8a087b647b
4 changed files with 106 additions and 8 deletions
+5 -1
View File
@@ -440,8 +440,12 @@ export function registerFilesystemHandlers(store: Store): void {
): Promise<string[]> => {
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)
}
+7 -4
View File
@@ -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 {
+82
View File
@@ -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<string[]> {
const files: string[] = []
const deadline = Date.now() + TIMEOUT_MS
async function walk(dir: string): Promise<void> {
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
}
+12 -3
View File
@@ -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<string, unknown>) {