From e8d5d50c3596c5ffc6c690d9f7c1097fedac335d Mon Sep 17 00:00:00 2001 From: jaeyoung Date: Thu, 23 Jul 2026 16:27:06 +0900 Subject: [PATCH] fix(quick-open): guide rg install after fallback budget errors (#9627) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(quick-open): guide rg install after fallback budget errors * fix(quick-open): show local host wording for local install guidance The install-rg guidance component hardcoded 'on the remote', so the new local fallback path told local users to install ripgrep 'on the remote' — wrong for the exact case #9627 targets. Parse the location out of the message and render the matching wording; add the local locale string and a render test that guards against the 'on the remote' regression. Also harden the reason capture against a stray ')' in the error text. Co-authored-by: Orca --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: Orca --- .../filesystem-list-files-install-rg.test.ts | 71 +++++++++++++++ src/main/ipc/filesystem-list-files.ts | 23 +++-- src/relay/fs-handler-install-rg.ts | 88 ++----------------- src/renderer/src/components/QuickOpen.tsx | 1 + ...k-open-install-rg-guidance.render.test.tsx | 33 +++++++ .../quick-open-install-rg-guidance.test.ts | 47 ++++++++++ .../quick-open-install-rg-guidance.tsx | 26 ++++-- src/renderer/src/i18n/locales/en.json | 3 +- src/renderer/src/i18n/locales/es.json | 3 +- src/renderer/src/i18n/locales/ja.json | 3 +- src/renderer/src/i18n/locales/ko.json | 3 +- src/renderer/src/i18n/locales/zh.json | 3 +- src/shared/quick-open-install-rg.ts | 86 ++++++++++++++++++ 13 files changed, 291 insertions(+), 99 deletions(-) create mode 100644 src/main/ipc/filesystem-list-files-install-rg.test.ts create mode 100644 src/renderer/src/components/quick-open-install-rg-guidance.render.test.tsx create mode 100644 src/renderer/src/components/quick-open-install-rg-guidance.test.ts create mode 100644 src/shared/quick-open-install-rg.ts 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}` + ) +}