fix(quick-open): guide rg install after fallback budget errors (#9627)

* 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 <help@stably.ai>

---------

Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
jaeyoung 2026-07-23 16:27:06 +09:00 committed by GitHub
parent 569e9d88f8
commit e8d5d50c35
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 291 additions and 99 deletions

View File

@ -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'
)
})
})

View File

@ -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<string>()

View File

@ -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<string> {
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<string> {
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<string> {
return buildSharedInstallRgMessage(cause, 'remote')
}

View File

@ -130,6 +130,7 @@ export default function QuickOpen(): React.JSX.Element | null {
return guidance ? (
<QuickOpenInstallRgGuidance
reason={guidance.reason}
location={guidance.location}
command={guidance.command}
guidance={guidance.guidance}
/>

View File

@ -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(
<QuickOpenInstallRgGuidance
reason="File listing timed out"
location="local"
command="brew install ripgrep"
guidance={null}
/>
)
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(
<QuickOpenInstallRgGuidance
reason="File listing exceeded 10000 files"
location="remote"
command="sudo apt install ripgrep"
guidance={null}
/>
)
expect(screen.getByText(/on the remote to enable fast/i)).toBeTruthy()
})
})

View File

@ -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()
})
})

View File

@ -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({
<code className="rounded bg-muted px-1 py-0.5 font-mono text-foreground">
{translate('auto.components.QuickOpen.5d80dc39bb', 'ripgrep')}
</code>{' '}
{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:'
)}
</p>
{command ? (
<div className="flex items-center gap-2 rounded border border-border bg-muted/50 px-3 py-2 font-mono text-xs text-foreground">

View File

@ -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"

View File

@ -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"

View File

@ -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": "コピー"

View File

@ -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": "복사"

View File

@ -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": "复制"

View File

@ -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<string> {
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<string> {
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}`
)
}