diff --git a/src/main/ipc/filesystem-list-files-install-rg.test.ts b/src/main/ipc/filesystem-list-files-install-rg.test.ts
new file mode 100644
index 000000000..7f4c34f5e
--- /dev/null
+++ b/src/main/ipc/filesystem-list-files-install-rg.test.ts
@@ -0,0 +1,71 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { Store } from '../persistence'
+
+const {
+ listFilesWithGitMock,
+ resolveAuthorizedPathMock,
+ checkRgAvailableMock,
+ getLocalGitOptionsForRegisteredWorktreeMock
+} = vi.hoisted(() => ({
+ listFilesWithGitMock: vi.fn(),
+ resolveAuthorizedPathMock: vi.fn(),
+ checkRgAvailableMock: vi.fn(),
+ getLocalGitOptionsForRegisteredWorktreeMock: vi.fn()
+}))
+
+vi.mock('./filesystem-list-files-git-fallback', () => ({
+ listFilesWithGit: listFilesWithGitMock
+}))
+
+vi.mock('./filesystem-auth', () => ({
+ resolveAuthorizedPath: resolveAuthorizedPathMock
+}))
+
+vi.mock('./rg-availability', () => ({
+ checkRgAvailable: checkRgAvailableMock
+}))
+
+vi.mock('./local-worktree-runtime-options', () => ({
+ getLocalGitOptionsForRegisteredWorktree: getLocalGitOptionsForRegisteredWorktreeMock
+}))
+
+import { listQuickOpenFiles } from './filesystem-list-files'
+
+describe('filesystem-list-files ripgrep guidance', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ resolveAuthorizedPathMock.mockImplementation(async (path) => path)
+ checkRgAvailableMock.mockResolvedValue(false)
+ getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({})
+ })
+
+ it('turns only a readdir budget failure into install guidance', async () => {
+ listFilesWithGitMock.mockRejectedValue(new Error('File listing exceeded 10000 files'))
+ const rejection = listQuickOpenFiles('/workspace', {} as Store)
+
+ await expect(rejection).rejects.toThrow(
+ 'Quick Open scan too large (File listing exceeded 10000 files).'
+ )
+ await rejection.catch((error: Error) =>
+ expect(error.message).toContain('Install ripgrep on the host running the Quick Open scan')
+ )
+ })
+
+ it('keeps cancellation and Git errors unchanged', async () => {
+ const cancellation = new Error('File listing cancelled')
+ listFilesWithGitMock.mockRejectedValueOnce(cancellation)
+ await expect(listQuickOpenFiles('/workspace', {} as Store)).rejects.toBe(cancellation)
+
+ const gitFailure = new Error('git ls-files exited with code 128')
+ listFilesWithGitMock.mockRejectedValueOnce(gitFailure)
+ await expect(listQuickOpenFiles('/workspace', {} as Store)).rejects.toBe(gitFailure)
+ })
+
+ it.skipIf(process.platform !== 'darwin')('shows the macOS install command', async () => {
+ listFilesWithGitMock.mockRejectedValue(new Error('File listing timed out'))
+
+ await expect(listQuickOpenFiles('/workspace', {} as Store)).rejects.toThrow(
+ 'brew install ripgrep'
+ )
+ })
+})
diff --git a/src/main/ipc/filesystem-list-files.ts b/src/main/ipc/filesystem-list-files.ts
index 58b1c0c0c..7edb688de 100644
--- a/src/main/ipc/filesystem-list-files.ts
+++ b/src/main/ipc/filesystem-list-files.ts
@@ -14,6 +14,8 @@ import {
shouldExcludeQuickOpenRelPath,
shouldIncludeQuickOpenPath
} from '../../shared/quick-open-filter'
+import { isQuickOpenReaddirBudgetError } from '../../shared/quick-open-readdir-walk'
+import { buildInstallRgMessage } from '../../shared/quick-open-install-rg'
import { listFilesWithGit } from './filesystem-list-files-git-fallback'
export async function listQuickOpenFiles(
@@ -42,13 +44,20 @@ export async function listQuickOpenFiles(
// can run.
const rgAvailable = await checkRgAvailable(authorizedRootPath, localGitOptions.wslDistro)
if (!rgAvailable) {
- return listFilesWithGit(
- authorizedRootPath,
- excludePathPrefixes,
- localGitOptions,
- signal,
- maxResults
- )
+ try {
+ return await listFilesWithGit(
+ authorizedRootPath,
+ excludePathPrefixes,
+ localGitOptions,
+ signal,
+ maxResults
+ )
+ } catch (err) {
+ if (!isQuickOpenReaddirBudgetError(err)) {
+ throw err
+ }
+ throw new Error(await buildInstallRgMessage(err))
+ }
}
const files = new Set()
diff --git a/src/relay/fs-handler-install-rg.ts b/src/relay/fs-handler-install-rg.ts
index 9b0cd21b0..4f4be970a 100644
--- a/src/relay/fs-handler-install-rg.ts
+++ b/src/relay/fs-handler-install-rg.ts
@@ -1,85 +1,11 @@
-import { readFile } from 'node:fs/promises'
import {
- getProcessOutputFields,
- iterateProcessOutputLines
-} from '../shared/process-output-field-scanner'
+ buildInstallRgMessage as buildSharedInstallRgMessage,
+ detectInstallCommand,
+ detectLinuxInstallCommandFromOsRelease
+} from '../shared/quick-open-install-rg'
-const GENERIC_LINUX_RIPGREP_INSTALL =
- 'install ripgrep via your package manager (e.g. apt/dnf/pacman)'
-const OS_RELEASE_ID_LIKE_MAX_FIELDS = 16
+export { detectInstallCommand, detectLinuxInstallCommandFromOsRelease }
-export async function detectInstallCommand(): Promise {
- if (process.platform === 'darwin') {
- return 'brew install ripgrep'
- }
- if (process.platform === 'linux') {
- try {
- const osRelease = await readFile('/etc/os-release', 'utf-8')
- return detectLinuxInstallCommandFromOsRelease(osRelease)
- } catch {
- /* fall through to generic guidance */
- }
- return GENERIC_LINUX_RIPGREP_INSTALL
- }
- return 'install ripgrep (https://github.com/BurntSushi/ripgrep#installation)'
-}
-
-export function detectLinuxInstallCommandFromOsRelease(osRelease: string): string {
- for (const id of getOsReleasePackageFamilyIds(osRelease)) {
- if (id === 'debian' || id === 'ubuntu') {
- return 'sudo apt install ripgrep'
- }
- if (id === 'fedora' || id === 'rhel' || id === 'centos') {
- return 'sudo dnf install ripgrep'
- }
- if (id === 'arch') {
- return 'sudo pacman -S ripgrep'
- }
- if (id === 'alpine') {
- return 'sudo apk add ripgrep'
- }
- }
-
- return GENERIC_LINUX_RIPGREP_INSTALL
-}
-
-function getOsReleasePackageFamilyIds(osRelease: string): string[] {
- const ids: string[] = []
-
- for (const line of iterateProcessOutputLines(osRelease)) {
- const separatorIndex = line.indexOf('=')
- if (separatorIndex <= 0) {
- continue
- }
-
- const key = line.slice(0, separatorIndex)
- const value = readOsReleaseValue(line.slice(separatorIndex + 1))
- if (key === 'ID') {
- const id = getProcessOutputFields(value, 1)[0]
- if (id) {
- ids.push(id)
- }
- } else if (key === 'ID_LIKE') {
- ids.push(...getProcessOutputFields(value, OS_RELEASE_ID_LIKE_MAX_FIELDS))
- }
- }
-
- return ids
-}
-
-function readOsReleaseValue(rawValue: string): string {
- const trimmed = rawValue.trim()
- const quote = trimmed[0]
- return (quote === '"' || quote === "'") && trimmed.at(-1) === quote
- ? trimmed.slice(1, -1)
- : trimmed
-}
-
-export async function buildInstallRgMessage(cause: unknown): Promise {
- const reason = cause instanceof Error ? cause.message : String(cause)
- const cmd = await detectInstallCommand()
- return (
- `Quick Open scan too large (${reason}). ` +
- `Install ripgrep on the remote to enable fast, gitignore-aware listing: ${cmd}`
- )
+export function buildInstallRgMessage(cause: unknown): Promise {
+ return buildSharedInstallRgMessage(cause, 'remote')
}
diff --git a/src/renderer/src/components/QuickOpen.tsx b/src/renderer/src/components/QuickOpen.tsx
index 395628c0b..a2f2d48f3 100644
--- a/src/renderer/src/components/QuickOpen.tsx
+++ b/src/renderer/src/components/QuickOpen.tsx
@@ -130,6 +130,7 @@ export default function QuickOpen(): React.JSX.Element | null {
return guidance ? (
diff --git a/src/renderer/src/components/quick-open-install-rg-guidance.render.test.tsx b/src/renderer/src/components/quick-open-install-rg-guidance.render.test.tsx
new file mode 100644
index 000000000..361f14576
--- /dev/null
+++ b/src/renderer/src/components/quick-open-install-rg-guidance.render.test.tsx
@@ -0,0 +1,33 @@
+// @vitest-environment happy-dom
+import { cleanup, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it } from 'vitest'
+import { QuickOpenInstallRgGuidance } from './quick-open-install-rg-guidance'
+
+afterEach(cleanup)
+
+describe('QuickOpenInstallRgGuidance', () => {
+ it('says the local host, not the remote, for a local scan', () => {
+ render(
+
+ )
+ expect(screen.getByText(/on the host running the Quick Open scan/i)).toBeTruthy()
+ expect(screen.queryByText(/on the remote/i)).toBeNull()
+ })
+
+ it('says the remote for a relay scan', () => {
+ render(
+
+ )
+ expect(screen.getByText(/on the remote to enable fast/i)).toBeTruthy()
+ })
+})
diff --git a/src/renderer/src/components/quick-open-install-rg-guidance.test.ts b/src/renderer/src/components/quick-open-install-rg-guidance.test.ts
new file mode 100644
index 000000000..0429563da
--- /dev/null
+++ b/src/renderer/src/components/quick-open-install-rg-guidance.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from 'vitest'
+import { parseQuickOpenInstallRgGuidance } from './quick-open-install-rg-guidance'
+
+describe('parseQuickOpenInstallRgGuidance', () => {
+ it('parses the local message and reports the local location', () => {
+ expect(
+ parseQuickOpenInstallRgGuidance(
+ 'Quick Open scan too large (File listing timed out). Install ripgrep on the host running the Quick Open scan to enable fast, gitignore-aware listing: brew install ripgrep'
+ )
+ ).toEqual({
+ reason: 'File listing timed out',
+ location: 'local',
+ command: 'brew install ripgrep',
+ guidance: null
+ })
+ })
+
+ it('keeps parsing the legacy remote message and reports the remote location', () => {
+ expect(
+ parseQuickOpenInstallRgGuidance(
+ 'Quick Open scan too large (File listing exceeded 10000 files). Install ripgrep on the remote to enable fast, gitignore-aware listing: sudo apt install ripgrep'
+ )
+ ).toEqual({
+ reason: 'File listing exceeded 10000 files',
+ location: 'remote',
+ command: 'sudo apt install ripgrep',
+ guidance: null
+ })
+ })
+
+ it('renders generic install prose through the guidance path', () => {
+ expect(
+ parseQuickOpenInstallRgGuidance(
+ 'Quick Open scan too large (File listing timed out). Install ripgrep on the host running the Quick Open scan to enable fast, gitignore-aware listing: install ripgrep via your package manager (e.g. apt/dnf/pacman)'
+ )
+ ).toEqual({
+ reason: 'File listing timed out',
+ location: 'local',
+ command: null,
+ guidance: 'install ripgrep via your package manager (e.g. apt/dnf/pacman)'
+ })
+ })
+
+ it('returns null for regular errors', () => {
+ expect(parseQuickOpenInstallRgGuidance('git ls-files exited with code 128')).toBeNull()
+ })
+})
diff --git a/src/renderer/src/components/quick-open-install-rg-guidance.tsx b/src/renderer/src/components/quick-open-install-rg-guidance.tsx
index 1a05278ae..a8f396792 100644
--- a/src/renderer/src/components/quick-open-install-rg-guidance.tsx
+++ b/src/renderer/src/components/quick-open-install-rg-guidance.tsx
@@ -5,10 +5,16 @@ import { translate } from '@/i18n/i18n'
export type QuickOpenInstallRgGuidanceParts = {
reason: string
+ // Why: the fallback runs on whichever host performs the scan. A local scan
+ // must not tell the user to install ripgrep "on the remote"; the location
+ // phrase in the message is the only signal for which wording to render.
+ location: 'local' | 'remote'
command: string | null
guidance: string | null
}
+const REMOTE_LOCATION_PHRASE = 'on the remote'
+
/**
* Parses the install-ripgrep guidance message produced by the relay's
* buildInstallRgMessage(). Returns the parts needed to render as formatted
@@ -23,19 +29,21 @@ export function parseQuickOpenInstallRgGuidance(
message: string
): QuickOpenInstallRgGuidanceParts | null {
const match = message.match(
- /^Quick Open scan too large \(([^)]+)\)\. Install ripgrep on the remote to enable fast, gitignore-aware listing: (.+)$/
+ /^Quick Open scan too large \((.+?)\)\. Install ripgrep (on the remote|on the host running the Quick Open scan) to enable fast, gitignore-aware listing: (.+)$/
)
if (!match) {
return null
}
const reason = match[1]
- const tail = match[2].trim()
+ const location = match[2] === REMOTE_LOCATION_PHRASE ? 'remote' : 'local'
+ const tail = match[3].trim()
// Why: on unknown distros the relay emits prose like "install ripgrep via
// your package manager (e.g. apt/dnf/pacman)"; there is no single command
// to copy, so surface it as plain guidance without the code block.
const looksLikeCommand = /^(sudo\s+)?(brew|apt|dnf|pacman|apk)\s/.test(tail)
return {
reason,
+ location,
command: looksLikeCommand ? tail : null,
guidance: looksLikeCommand ? null : tail
}
@@ -43,6 +51,7 @@ export function parseQuickOpenInstallRgGuidance(
export function QuickOpenInstallRgGuidance({
reason,
+ location,
command,
guidance
}: QuickOpenInstallRgGuidanceParts): React.JSX.Element {
@@ -112,10 +121,15 @@ export function QuickOpenInstallRgGuidance({
{translate('auto.components.QuickOpen.5d80dc39bb', 'ripgrep')}
{' '}
- {translate(
- 'auto.components.QuickOpen.1cf8561ab4',
- 'on the remote to enable fast, gitignore-aware listing:'
- )}
+ {location === 'remote'
+ ? translate(
+ 'auto.components.QuickOpen.1cf8561ab4',
+ 'on the remote to enable fast, gitignore-aware listing:'
+ )
+ : translate(
+ 'auto.components.QuickOpen.344f8a48dd',
+ 'on the host running the Quick Open scan to enable fast, gitignore-aware listing:'
+ )}
{command ? (
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 3d38c3bce..08df452db 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -1486,7 +1486,8 @@
"4725b0e931": "Quick Open scan too large (",
"b227d88520": "{{value0}} files found",
"995be8ea22": "Copy",
- "cf144856dc": "Copied"
+ "cf144856dc": "Copied",
+ "344f8a48dd": "on the host running the Quick Open scan to enable fast, gitignore-aware listing:"
},
"SelectedTextCopyMenu": {
"9b40d7b018": "Copy"
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index 52d0aa8a9..b5ab19f41 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -1463,7 +1463,8 @@
"4725b0e931": "El escaneo de apertura rápida es demasiado grande (",
"b227d88520": "{{value0}} archivos encontrados",
"995be8ea22": "Copiar",
- "cf144856dc": "Copiado"
+ "cf144856dc": "Copiado",
+ "344f8a48dd": "on the host running the Quick Open scan to enable fast, gitignore-aware listing:"
},
"SelectedTextCopyMenu": {
"9b40d7b018": "Copiar"
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index 3f1527350..611063cc0 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -1463,7 +1463,8 @@
"4725b0e931": "クイック オープン スキャンが大きすぎます (",
"b227d88520": "{{value0}} ファイルが見つかりました",
"995be8ea22": "コピー",
- "cf144856dc": "コピーされました"
+ "cf144856dc": "コピーされました",
+ "344f8a48dd": "on the host running the Quick Open scan to enable fast, gitignore-aware listing:"
},
"SelectedTextCopyMenu": {
"9b40d7b018": "コピー"
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index a45c819cc..e0e9d3c85 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -1463,7 +1463,8 @@
"4725b0e931": "Quick Open 스캔이 너무 큼(",
"b227d88520": "{{value0}} 파일을 찾았습니다.",
"995be8ea22": "복사",
- "cf144856dc": "복사됨"
+ "cf144856dc": "복사됨",
+ "344f8a48dd": "on the host running the Quick Open scan to enable fast, gitignore-aware listing:"
},
"SelectedTextCopyMenu": {
"9b40d7b018": "복사"
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index b4a27eb0f..f0a69c156 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -1463,7 +1463,8 @@
"4725b0e931": "快速打开扫描太大(",
"b227d88520": "找到 {{value0}} 文件",
"995be8ea22": "复制",
- "cf144856dc": "已复制"
+ "cf144856dc": "已复制",
+ "344f8a48dd": "on the host running the Quick Open scan to enable fast, gitignore-aware listing:"
},
"SelectedTextCopyMenu": {
"9b40d7b018": "复制"
diff --git a/src/shared/quick-open-install-rg.ts b/src/shared/quick-open-install-rg.ts
new file mode 100644
index 000000000..5ba70bc8f
--- /dev/null
+++ b/src/shared/quick-open-install-rg.ts
@@ -0,0 +1,86 @@
+import { readFile } from 'node:fs/promises'
+import { getProcessOutputFields, iterateProcessOutputLines } from './process-output-field-scanner'
+
+const GENERIC_LINUX_RIPGREP_INSTALL =
+ 'install ripgrep via your package manager (e.g. apt/dnf/pacman)'
+const OS_RELEASE_ID_LIKE_MAX_FIELDS = 16
+
+export async function detectInstallCommand(): Promise {
+ if (process.platform === 'darwin') {
+ return 'brew install ripgrep'
+ }
+ if (process.platform === 'linux') {
+ try {
+ const osRelease = await readFile('/etc/os-release', 'utf-8')
+ return detectLinuxInstallCommandFromOsRelease(osRelease)
+ } catch {
+ /* fall through to generic guidance */
+ }
+ return GENERIC_LINUX_RIPGREP_INSTALL
+ }
+ return 'install ripgrep (https://github.com/BurntSushi/ripgrep#installation)'
+}
+
+export function detectLinuxInstallCommandFromOsRelease(osRelease: string): string {
+ for (const id of getOsReleasePackageFamilyIds(osRelease)) {
+ if (id === 'debian' || id === 'ubuntu') {
+ return 'sudo apt install ripgrep'
+ }
+ if (id === 'fedora' || id === 'rhel' || id === 'centos') {
+ return 'sudo dnf install ripgrep'
+ }
+ if (id === 'arch') {
+ return 'sudo pacman -S ripgrep'
+ }
+ if (id === 'alpine') {
+ return 'sudo apk add ripgrep'
+ }
+ }
+
+ return GENERIC_LINUX_RIPGREP_INSTALL
+}
+
+function getOsReleasePackageFamilyIds(osRelease: string): string[] {
+ const ids: string[] = []
+
+ for (const line of iterateProcessOutputLines(osRelease)) {
+ const separatorIndex = line.indexOf('=')
+ if (separatorIndex <= 0) {
+ continue
+ }
+
+ const key = line.slice(0, separatorIndex)
+ const value = readOsReleaseValue(line.slice(separatorIndex + 1))
+ if (key === 'ID') {
+ const id = getProcessOutputFields(value, 1)[0]
+ if (id) {
+ ids.push(id)
+ }
+ } else if (key === 'ID_LIKE') {
+ ids.push(...getProcessOutputFields(value, OS_RELEASE_ID_LIKE_MAX_FIELDS))
+ }
+ }
+
+ return ids
+}
+
+function readOsReleaseValue(rawValue: string): string {
+ const trimmed = rawValue.trim()
+ const quote = trimmed[0]
+ return (quote === '"' || quote === "'") && trimmed.at(-1) === quote
+ ? trimmed.slice(1, -1)
+ : trimmed
+}
+
+export async function buildInstallRgMessage(
+ cause: unknown,
+ host: 'local' | 'remote' = 'local'
+): Promise {
+ const reason = cause instanceof Error ? cause.message : String(cause)
+ const cmd = await detectInstallCommand()
+ const location = host === 'local' ? 'on the host running the Quick Open scan' : 'on the remote'
+ return (
+ `Quick Open scan too large (${reason}). ` +
+ `Install ripgrep ${location} to enable fast, gitignore-aware listing: ${cmd}`
+ )
+}