mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 02:57:36 +00:00
refactor(relay): delete the SSH relay server, its build assets and tests (#27215)
* wip: delete the SSH relay server and its build, assets and tests * wip: drop main/relay plugin conformance; WSL reader lookup test * fix: drop the emptied relay bench; prune baselines * test: keep the native halves of tests that also covered the relay * chore(ci): drop shard timings for the deleted relay server tests * fix: keep the directory listing budget that darwin terminal names now import --------- Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
@@ -1,10 +0,0 @@
|
||||
export const GIT_GREP_MAX_RECORD_BYTES = 8 * 1024 * 1024
|
||||
|
||||
export class GitGrepRecordCapacityError extends Error {
|
||||
readonly code = 'git_grep_record_capacity'
|
||||
|
||||
constructor() {
|
||||
super('Git search record exceeds the 8 MiB limit.')
|
||||
this.name = 'GitGrepRecordCapacityError'
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export const PTY_RETAINED_RECORD_BYTES = 128
|
||||
|
||||
export function chargedPtyRetainedStringBytes(value: string): number {
|
||||
return Math.max(Buffer.byteLength(value, 'utf8'), 2 * value.length) + PTY_RETAINED_RECORD_BYTES
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { lstatMock, opendirMock } = vi.hoisted(() => ({
|
||||
lstatMock: vi.fn(),
|
||||
opendirMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
lstat: lstatMock,
|
||||
opendir: opendirMock
|
||||
}))
|
||||
|
||||
import { readQuickOpenDirectoryEntries } from './quick-open-directory-reader'
|
||||
import { createQuickOpenReaddirBudget } from './quick-open-readdir-budget'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
lstatMock.mockResolvedValue({
|
||||
isDirectory: () => true,
|
||||
isSymbolicLink: () => false
|
||||
})
|
||||
})
|
||||
|
||||
describe('quick-open streaming directory reader', () => {
|
||||
it('orders numbered entries naturally before the recursive walk', async () => {
|
||||
opendirMock.mockResolvedValue({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
for (const name of ['100 - notes', '9 - notes', '99 - notes']) {
|
||||
yield {
|
||||
name,
|
||||
isDirectory: () => true,
|
||||
isFile: () => false,
|
||||
isSymbolicLink: () => false
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const entries = await readQuickOpenDirectoryEntries({
|
||||
absPath: '/numbered',
|
||||
allowSymlinkedRoot: false,
|
||||
budget: createQuickOpenReaddirBudget()
|
||||
})
|
||||
|
||||
expect(entries.map((entry) => entry.name)).toEqual(['9 - notes', '99 - notes', '100 - notes'])
|
||||
})
|
||||
|
||||
it('stops a huge directory one entry beyond the exact cap and closes its iterator', async () => {
|
||||
let produced = 0
|
||||
let closed = false
|
||||
opendirMock.mockResolvedValue({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
try {
|
||||
while (produced < 1_000_000) {
|
||||
produced += 1
|
||||
yield {
|
||||
name: `directory-${produced}`,
|
||||
isDirectory: () => true,
|
||||
isFile: () => false,
|
||||
isSymbolicLink: () => false
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
closed = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await expect(
|
||||
readQuickOpenDirectoryEntries({
|
||||
absPath: '/streamed',
|
||||
allowSymlinkedRoot: false,
|
||||
budget: createQuickOpenReaddirBudget({ maxEntries: 3 })
|
||||
})
|
||||
).rejects.toThrow('File listing exceeded 3 entries')
|
||||
expect(produced).toBe(4)
|
||||
expect(closed).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,69 +0,0 @@
|
||||
import { lstat, opendir } from 'node:fs/promises'
|
||||
import { compareFileNames } from './file-name-sort'
|
||||
import { isFileListingCancellation, throwIfFileListingCancelled } from './file-listing-cancellation'
|
||||
import { isQuickOpenReadableDirectory } from './quick-open-directory-validation'
|
||||
import {
|
||||
assertQuickOpenReaddirDeadline,
|
||||
consumeQuickOpenReaddirEntryBudget,
|
||||
consumeQuickOpenReaddirPathBudget,
|
||||
isQuickOpenReaddirBudgetError,
|
||||
type QuickOpenReaddirBudget
|
||||
} from './quick-open-readdir-budget'
|
||||
|
||||
export type QuickOpenDirectoryEntry = {
|
||||
name: string
|
||||
kind: 'directory' | 'file' | 'symlink' | 'other'
|
||||
}
|
||||
|
||||
export async function readQuickOpenDirectoryEntries(opts: {
|
||||
absPath: string
|
||||
allowSymlinkedRoot: boolean
|
||||
budget: QuickOpenReaddirBudget
|
||||
signal?: AbortSignal
|
||||
}): Promise<QuickOpenDirectoryEntry[]> {
|
||||
try {
|
||||
const stat = await lstat(opts.absPath)
|
||||
if (!isQuickOpenReadableDirectory(stat, opts.allowSymlinkedRoot)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const entries: QuickOpenDirectoryEntry[] = []
|
||||
const directory = await opendir(opts.absPath)
|
||||
try {
|
||||
throwIfFileListingCancelled(opts.signal)
|
||||
assertQuickOpenReaddirDeadline(opts.budget)
|
||||
for await (const entry of directory) {
|
||||
throwIfFileListingCancelled(opts.signal)
|
||||
assertQuickOpenReaddirDeadline(opts.budget)
|
||||
consumeQuickOpenReaddirEntryBudget(opts.budget)
|
||||
consumeQuickOpenReaddirPathBudget(opts.budget, entry.name)
|
||||
entries.push({
|
||||
name: entry.name,
|
||||
kind: entry.isDirectory()
|
||||
? 'directory'
|
||||
: entry.isFile()
|
||||
? 'file'
|
||||
: entry.isSymbolicLink()
|
||||
? 'symlink'
|
||||
: 'other'
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await directory.close()
|
||||
} catch {}
|
||||
}
|
||||
entries.sort((left, right) => compareFileNames(left.name, right.name))
|
||||
|
||||
// Why: discard buffered names if the path became a symlink while its
|
||||
// directory handle was open; descendants must never escape the root.
|
||||
const statAfterRead = await lstat(opts.absPath)
|
||||
return isQuickOpenReadableDirectory(statAfterRead, opts.allowSymlinkedRoot) ? entries : []
|
||||
} catch (error) {
|
||||
if (isQuickOpenReaddirBudgetError(error) || isFileListingCancellation(error)) {
|
||||
throw error
|
||||
}
|
||||
// Permission denied or a vanished subtree must not hide readable siblings.
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { Stats } from 'node:fs'
|
||||
|
||||
export function isQuickOpenReadableDirectory(stat: Stats, allowSymlinkedRoot = false): boolean {
|
||||
// Why: an explicitly selected workspace root may be a symlink, while nested
|
||||
// traversal must never follow a symlink outside that authorized root.
|
||||
return stat.isDirectory() || Boolean(allowSymlinkedRoot && stat.isSymbolicLink())
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { collapseQuickOpenExpansionPaths } from './quick-open-expansion-paths'
|
||||
|
||||
describe('collapseQuickOpenExpansionPaths', () => {
|
||||
it('collapses descendants without confusing shared-prefix siblings', () => {
|
||||
const paths = new Map<string, boolean>([
|
||||
['foo/bar/deep', false],
|
||||
['foo-sibling', false],
|
||||
['foo', false],
|
||||
['foo/bar', true]
|
||||
])
|
||||
|
||||
expect(collapseQuickOpenExpansionPaths(paths)).toEqual([
|
||||
['foo', true],
|
||||
['foo-sibling', false]
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps many sibling placeholders distinct', () => {
|
||||
const paths = new Map(
|
||||
Array.from({ length: 10_001 }, (_, index) => [`generated-${index}`, true] as const)
|
||||
)
|
||||
|
||||
expect(collapseQuickOpenExpansionPaths(paths)).toHaveLength(paths.size)
|
||||
})
|
||||
})
|
||||
@@ -1,37 +0,0 @@
|
||||
/**
|
||||
* Remove descendant placeholders already covered by an ancestor. Sorting puts
|
||||
* ancestors first; prefix lookups avoid quadratic scans across sibling paths.
|
||||
*/
|
||||
export function collapseQuickOpenExpansionPaths(
|
||||
expansionPaths: ReadonlyMap<string, boolean>
|
||||
): [string, boolean][] {
|
||||
const sortedPaths = Array.from(expansionPaths).sort(([left], [right]) =>
|
||||
left < right ? -1 : left > right ? 1 : 0
|
||||
)
|
||||
const collapsedPaths = new Map<string, boolean>()
|
||||
|
||||
for (const [relPath, includeSymlinks] of sortedPaths) {
|
||||
let ancestorPath: string | undefined
|
||||
let slashIndex = relPath.indexOf('/')
|
||||
while (slashIndex !== -1) {
|
||||
const candidate = relPath.substring(0, slashIndex)
|
||||
if (collapsedPaths.has(candidate)) {
|
||||
ancestorPath = candidate
|
||||
break
|
||||
}
|
||||
slashIndex = relPath.indexOf('/', slashIndex + 1)
|
||||
}
|
||||
|
||||
if (ancestorPath) {
|
||||
// Why: primary and ignored passes can overlap; the ancestor walk covers
|
||||
// the descendant, but must preserve either pass's symlink-leaf contract.
|
||||
if (includeSymlinks) {
|
||||
collapsedPaths.set(ancestorPath, true)
|
||||
}
|
||||
continue
|
||||
}
|
||||
collapsedPaths.set(relPath, includeSymlinks)
|
||||
}
|
||||
|
||||
return Array.from(collapsedPaths)
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { lstat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
|
||||
export type QuickOpenGitEntryKind = 'keep' | 'fill-nested-repo' | 'drop-placeholder'
|
||||
|
||||
export type QuickOpenGitLsFilesEntry = {
|
||||
path: string
|
||||
isGitlink: boolean
|
||||
isUntrackedDir: boolean
|
||||
}
|
||||
|
||||
const GIT_LS_FILES_STAGE_ENTRY = /^([0-7]{6}) [0-9a-f]{40,64} [0-3]\t/
|
||||
|
||||
export function parseQuickOpenGitLsFilesEntry(entry: string): QuickOpenGitLsFilesEntry {
|
||||
const match = GIT_LS_FILES_STAGE_ENTRY.exec(entry)
|
||||
if (match) {
|
||||
return {
|
||||
path: entry.slice(match[0].length),
|
||||
isGitlink: match[1] === '160000',
|
||||
isUntrackedDir: false
|
||||
}
|
||||
}
|
||||
return {
|
||||
path: entry,
|
||||
isGitlink: false,
|
||||
isUntrackedDir: entry.endsWith('/')
|
||||
}
|
||||
}
|
||||
|
||||
function joinQuickOpenRootPath(rootPath: string, relPath: string): string {
|
||||
return join(rootPath, ...relPath.split('/').filter(Boolean))
|
||||
}
|
||||
|
||||
async function hasGitEntry(absPath: string): Promise<boolean> {
|
||||
try {
|
||||
const stat = await lstat(join(absPath, '.git'))
|
||||
return stat.isDirectory() || stat.isFile()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function classifyQuickOpenGitEntry(
|
||||
rootPath: string,
|
||||
entry: string
|
||||
): Promise<{ kind: QuickOpenGitEntryKind; relPath: string }> {
|
||||
const parsed = parseQuickOpenGitLsFilesEntry(entry)
|
||||
const relPath = parsed.path.replace(/\/+$/, '')
|
||||
if (!relPath) {
|
||||
return { kind: 'drop-placeholder', relPath }
|
||||
}
|
||||
if (!parsed.isGitlink && !parsed.isUntrackedDir) {
|
||||
return { kind: 'keep', relPath }
|
||||
}
|
||||
|
||||
let stat
|
||||
try {
|
||||
stat = await lstat(joinQuickOpenRootPath(rootPath, relPath))
|
||||
} catch {
|
||||
return { kind: 'drop-placeholder', relPath }
|
||||
}
|
||||
if (!stat.isDirectory()) {
|
||||
return { kind: 'drop-placeholder', relPath }
|
||||
}
|
||||
return (await hasGitEntry(joinQuickOpenRootPath(rootPath, relPath)))
|
||||
? { kind: 'fill-nested-repo', relPath }
|
||||
: { kind: 'drop-placeholder', relPath }
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { detectLinuxInstallCommandFromOsRelease } from './quick-open-install-rg'
|
||||
|
||||
describe('detectLinuxInstallCommandFromOsRelease', () => {
|
||||
it('uses apt for Debian and Ubuntu families', () => {
|
||||
expect(detectLinuxInstallCommandFromOsRelease('ID=ubuntu')).toBe('sudo apt install ripgrep')
|
||||
expect(detectLinuxInstallCommandFromOsRelease('ID=pop\nID_LIKE="ubuntu debian"')).toBe(
|
||||
'sudo apt install ripgrep'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses distro-specific commands for common non-Debian families', () => {
|
||||
expect(detectLinuxInstallCommandFromOsRelease('ID="fedora"')).toBe('sudo dnf install ripgrep')
|
||||
expect(detectLinuxInstallCommandFromOsRelease('ID=manjaro\r\nID_LIKE="arch"')).toBe(
|
||||
'sudo pacman -S ripgrep'
|
||||
)
|
||||
expect(detectLinuxInstallCommandFromOsRelease("ID=postmarketos\nID_LIKE='alpine'")).toBe(
|
||||
'sudo apk add ripgrep'
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to generic Linux package-manager guidance', () => {
|
||||
expect(detectLinuxInstallCommandFromOsRelease('ID=unknown')).toBe(
|
||||
'install ripgrep via your package manager (e.g. apt/dnf/pacman)'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,90 +0,0 @@
|
||||
import { readNodeFileWithinLimit } from './node-bounded-file-reader'
|
||||
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
|
||||
const MAX_OS_RELEASE_BYTES = 64 * 1024
|
||||
|
||||
export async function detectInstallCommand(): Promise<string> {
|
||||
if (process.platform === 'darwin') {
|
||||
return 'brew install ripgrep'
|
||||
}
|
||||
if (process.platform === 'linux') {
|
||||
try {
|
||||
const osRelease = (
|
||||
await readNodeFileWithinLimit('/etc/os-release', MAX_OS_RELEASE_BYTES)
|
||||
).buffer.toString('utf8')
|
||||
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
|
||||
}
|
||||
|
||||
// Why remote-only: the local side always has Orca's bundled rg, so only a remote host an upload
|
||||
// never reached can still fall back to the capped git/readdir listing these messages explain.
|
||||
export async function buildInstallRgMessage(cause: unknown): Promise<string> {
|
||||
const reason = cause instanceof Error ? cause.message : String(cause)
|
||||
return (
|
||||
`Quick Open scan too large (${reason}). ` +
|
||||
`Install ripgrep on the remote to enable fast, gitignore-aware listing: ${await detectInstallCommand()}`
|
||||
)
|
||||
}
|
||||
|
||||
export async function buildRipgrepRequiredMessage(): Promise<string> {
|
||||
return `Quick Open search requires ripgrep on the remote to stay resource-bounded. Install it with: ${await detectInstallCommand()}`
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
import { QUICK_OPEN_LISTING_MAX_RESULTS } from './quick-open-listing-limits'
|
||||
|
||||
export const QUICK_OPEN_READDIR_MAX_FILES = QUICK_OPEN_LISTING_MAX_RESULTS
|
||||
export const QUICK_OPEN_READDIR_MAX_ENTRIES = 50_000
|
||||
export const QUICK_OPEN_READDIR_MAX_DIRECTORIES = 25_000
|
||||
export const QUICK_OPEN_READDIR_MAX_DEPTH = 256
|
||||
export const QUICK_OPEN_READDIR_MAX_PATH_CODE_UNITS = 16 * 1024 * 1024
|
||||
export const QUICK_OPEN_READDIR_TIMEOUT_MS = 10_000
|
||||
|
||||
export type QuickOpenReaddirBudget = {
|
||||
remainingFiles: number
|
||||
remainingEntries: number
|
||||
remainingDirectories: number
|
||||
remainingPathCodeUnits: number
|
||||
maxFiles: number
|
||||
maxEntries: number
|
||||
maxDirectories: number
|
||||
maxDepth: number
|
||||
maxPathCodeUnits: number
|
||||
deadlineMs: number
|
||||
}
|
||||
|
||||
export function createQuickOpenReaddirBudget(
|
||||
opts: {
|
||||
maxFiles?: number
|
||||
maxEntries?: number
|
||||
maxDirectories?: number
|
||||
maxDepth?: number
|
||||
maxPathCodeUnits?: number
|
||||
timeoutMs?: number
|
||||
nowMs?: number
|
||||
} = {}
|
||||
): QuickOpenReaddirBudget {
|
||||
const maxFiles = opts.maxFiles ?? QUICK_OPEN_READDIR_MAX_FILES
|
||||
const maxEntries = opts.maxEntries ?? QUICK_OPEN_READDIR_MAX_ENTRIES
|
||||
const maxDirectories = opts.maxDirectories ?? QUICK_OPEN_READDIR_MAX_DIRECTORIES
|
||||
const maxDepth = opts.maxDepth ?? QUICK_OPEN_READDIR_MAX_DEPTH
|
||||
const maxPathCodeUnits = opts.maxPathCodeUnits ?? QUICK_OPEN_READDIR_MAX_PATH_CODE_UNITS
|
||||
for (const [name, value] of Object.entries({
|
||||
maxFiles,
|
||||
maxEntries,
|
||||
maxDirectories,
|
||||
maxDepth,
|
||||
maxPathCodeUnits
|
||||
})) {
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new RangeError(`${name} must be a non-negative safe integer`)
|
||||
}
|
||||
}
|
||||
return {
|
||||
remainingFiles: maxFiles,
|
||||
remainingEntries: maxEntries,
|
||||
remainingDirectories: maxDirectories,
|
||||
remainingPathCodeUnits: maxPathCodeUnits,
|
||||
maxFiles,
|
||||
maxEntries,
|
||||
maxDirectories,
|
||||
maxDepth,
|
||||
maxPathCodeUnits,
|
||||
deadlineMs: (opts.nowMs ?? Date.now()) + (opts.timeoutMs ?? QUICK_OPEN_READDIR_TIMEOUT_MS)
|
||||
}
|
||||
}
|
||||
|
||||
const FILE_LISTING_TIMED_OUT = 'File listing timed out'
|
||||
const FILE_LISTING_EXCEEDED_PREFIX = 'File listing exceeded'
|
||||
|
||||
/** Budget errors are the only fallback failures translated into install-rg guidance. */
|
||||
export function isQuickOpenReaddirBudgetError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : ''
|
||||
return message === FILE_LISTING_TIMED_OUT || message.startsWith(FILE_LISTING_EXCEEDED_PREFIX)
|
||||
}
|
||||
|
||||
export function assertQuickOpenReaddirDeadline(budget: QuickOpenReaddirBudget): void {
|
||||
if (Date.now() > budget.deadlineMs) {
|
||||
throw new Error(FILE_LISTING_TIMED_OUT)
|
||||
}
|
||||
}
|
||||
|
||||
export function consumeQuickOpenReaddirFileBudget(budget: QuickOpenReaddirBudget): void {
|
||||
if (budget.remainingFiles <= 0) {
|
||||
throw new Error(`${FILE_LISTING_EXCEEDED_PREFIX} ${budget.maxFiles} files`)
|
||||
}
|
||||
budget.remainingFiles--
|
||||
}
|
||||
|
||||
export function consumeQuickOpenReaddirEntryBudget(budget: QuickOpenReaddirBudget): void {
|
||||
if (budget.remainingEntries <= 0) {
|
||||
throw new Error(`${FILE_LISTING_EXCEEDED_PREFIX} ${budget.maxEntries} entries`)
|
||||
}
|
||||
budget.remainingEntries--
|
||||
}
|
||||
|
||||
export function consumeQuickOpenReaddirDirectoryBudget(budget: QuickOpenReaddirBudget): void {
|
||||
if (budget.remainingDirectories <= 0) {
|
||||
throw new Error(`${FILE_LISTING_EXCEEDED_PREFIX} ${budget.maxDirectories} directories`)
|
||||
}
|
||||
budget.remainingDirectories--
|
||||
}
|
||||
|
||||
export function assertQuickOpenReaddirDepth(budget: QuickOpenReaddirBudget, depth: number): void {
|
||||
if (depth > budget.maxDepth) {
|
||||
throw new Error(`${FILE_LISTING_EXCEEDED_PREFIX} depth ${budget.maxDepth}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function consumeQuickOpenReaddirPathBudget(
|
||||
budget: QuickOpenReaddirBudget,
|
||||
path: string
|
||||
): void {
|
||||
if (path.length > budget.remainingPathCodeUnits) {
|
||||
throw new Error(`${FILE_LISTING_EXCEEDED_PREFIX} ${budget.maxPathCodeUnits} path code units`)
|
||||
}
|
||||
budget.remainingPathCodeUnits -= path.length
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createQuickOpenReaddirBudget,
|
||||
listQuickOpenFilesWithReaddir
|
||||
} from './quick-open-readdir-walk'
|
||||
|
||||
const tempRoots: string[] = []
|
||||
|
||||
async function makeRoot(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-quick-open-budget-'))
|
||||
tempRoots.push(root)
|
||||
return root
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('quick-open readdir memory limits', () => {
|
||||
it('accepts the exact entry limit and rejects the next zero-file entry', async () => {
|
||||
const root = await makeRoot()
|
||||
await mkdir(join(root, 'a'))
|
||||
await mkdir(join(root, 'b'))
|
||||
|
||||
await expect(
|
||||
listQuickOpenFilesWithReaddir(root, {
|
||||
budget: createQuickOpenReaddirBudget({ maxEntries: 2 })
|
||||
})
|
||||
).resolves.toEqual([])
|
||||
|
||||
await mkdir(join(root, 'c'))
|
||||
await expect(
|
||||
listQuickOpenFilesWithReaddir(root, {
|
||||
budget: createQuickOpenReaddirBudget({ maxEntries: 2 })
|
||||
})
|
||||
).rejects.toThrow('File listing exceeded 2 entries')
|
||||
})
|
||||
|
||||
it('caps retained directory paths even when the tree contains no files', async () => {
|
||||
const root = await makeRoot()
|
||||
await mkdir(join(root, 'a'))
|
||||
await mkdir(join(root, 'b'))
|
||||
|
||||
await expect(
|
||||
listQuickOpenFilesWithReaddir(root, {
|
||||
budget: createQuickOpenReaddirBudget({ maxDirectories: 3 })
|
||||
})
|
||||
).resolves.toEqual([])
|
||||
await expect(
|
||||
listQuickOpenFilesWithReaddir(root, {
|
||||
budget: createQuickOpenReaddirBudget({ maxDirectories: 2 })
|
||||
})
|
||||
).rejects.toThrow('File listing exceeded 2 directories')
|
||||
})
|
||||
|
||||
it('accepts the exact depth limit and rejects a deeper directory', async () => {
|
||||
const root = await makeRoot()
|
||||
await mkdir(join(root, 'a', 'b'), { recursive: true })
|
||||
|
||||
await expect(
|
||||
listQuickOpenFilesWithReaddir(root, {
|
||||
budget: createQuickOpenReaddirBudget({ maxDepth: 2 })
|
||||
})
|
||||
).resolves.toEqual([])
|
||||
await expect(
|
||||
listQuickOpenFilesWithReaddir(root, {
|
||||
budget: createQuickOpenReaddirBudget({ maxDepth: 1 })
|
||||
})
|
||||
).rejects.toThrow('File listing exceeded depth 1')
|
||||
})
|
||||
|
||||
it('bounds aggregate path storage without changing exact-boundary output', async () => {
|
||||
const root = await makeRoot()
|
||||
const fileName = 'a.ts'
|
||||
await writeFile(join(root, fileName), 'x')
|
||||
const exactPathCodeUnits = root.length + fileName.length * 2
|
||||
|
||||
await expect(
|
||||
listQuickOpenFilesWithReaddir(root, {
|
||||
budget: createQuickOpenReaddirBudget({ maxPathCodeUnits: exactPathCodeUnits })
|
||||
})
|
||||
).resolves.toEqual([fileName])
|
||||
await expect(
|
||||
listQuickOpenFilesWithReaddir(root, {
|
||||
budget: createQuickOpenReaddirBudget({ maxPathCodeUnits: exactPathCodeUnits - 1 })
|
||||
})
|
||||
).rejects.toThrow(`File listing exceeded ${exactPathCodeUnits - 1} path code units`)
|
||||
})
|
||||
})
|
||||
@@ -1,559 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as NodeFsPromises from 'node:fs/promises'
|
||||
|
||||
const { lstatMock, opendirMock } = vi.hoisted(() => ({
|
||||
lstatMock: vi.fn(),
|
||||
opendirMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('fs/promises', async () => {
|
||||
const actual = await vi.importActual<typeof NodeFsPromises>('fs/promises')
|
||||
lstatMock.mockImplementation(actual.lstat)
|
||||
opendirMock.mockImplementation(actual.opendir)
|
||||
return {
|
||||
...actual,
|
||||
lstat: lstatMock,
|
||||
opendir: opendirMock
|
||||
}
|
||||
})
|
||||
|
||||
import { mkdtemp, mkdir, rename, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import {
|
||||
classifyQuickOpenGitEntry,
|
||||
createQuickOpenReaddirBudget,
|
||||
expandQuickOpenGitFileListing,
|
||||
isQuickOpenReaddirBudgetError,
|
||||
listQuickOpenFilesWithReaddir,
|
||||
parseQuickOpenGitLsFilesEntry,
|
||||
QUICK_OPEN_READDIR_MAX_FILES
|
||||
} from './quick-open-readdir-walk'
|
||||
import { isFileListingCancellation } from './file-listing-cancellation'
|
||||
|
||||
const tempDirs: string[] = []
|
||||
const SHA1 = '0123456789abcdef0123456789abcdef01234567'
|
||||
const SHA256 = `${SHA1}89abcdef0123456789abcdef`
|
||||
|
||||
function staged(mode: string, path: string, sha = SHA1): string {
|
||||
return `${mode} ${sha} 0\t${path}`
|
||||
}
|
||||
|
||||
async function makeTempRoot(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-quick-open-readdir-'))
|
||||
tempDirs.push(root)
|
||||
return root
|
||||
}
|
||||
|
||||
async function writeRel(root: string, relPath: string, content = 'x'): Promise<void> {
|
||||
const absPath = join(root, ...relPath.split('/'))
|
||||
await mkdir(dirname(absPath), { recursive: true })
|
||||
await writeFile(absPath, content)
|
||||
}
|
||||
|
||||
async function mkdirRel(root: string, relPath: string): Promise<void> {
|
||||
await mkdir(join(root, ...relPath.split('/')), { recursive: true })
|
||||
}
|
||||
|
||||
async function makeNestedRepo(root: string, relPath: string, gitEntry: 'dir' | 'file' = 'dir') {
|
||||
await mkdirRel(root, relPath)
|
||||
const gitPath = join(root, ...relPath.split('/'), '.git')
|
||||
await (gitEntry === 'dir'
|
||||
? mkdir(gitPath, { recursive: true })
|
||||
: writeFile(gitPath, 'gitdir: ../.git/worktrees/example'))
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('quick-open readdir walk', () => {
|
||||
it('parses git ls-files stage output and bare untracked entries', () => {
|
||||
expect(parseQuickOpenGitLsFilesEntry(staged('100644', 'src/index.ts'))).toEqual({
|
||||
path: 'src/index.ts',
|
||||
isGitlink: false,
|
||||
isUntrackedDir: false
|
||||
})
|
||||
expect(parseQuickOpenGitLsFilesEntry(staged('160000', 'packages/app'))).toEqual({
|
||||
path: 'packages/app',
|
||||
isGitlink: true,
|
||||
isUntrackedDir: false
|
||||
})
|
||||
expect(parseQuickOpenGitLsFilesEntry(staged('100755', 'bin/run', SHA256))).toEqual({
|
||||
path: 'bin/run',
|
||||
isGitlink: false,
|
||||
isUntrackedDir: false
|
||||
})
|
||||
expect(parseQuickOpenGitLsFilesEntry('scratch.txt')).toEqual({
|
||||
path: 'scratch.txt',
|
||||
isGitlink: false,
|
||||
isUntrackedDir: false
|
||||
})
|
||||
expect(parseQuickOpenGitLsFilesEntry('packages/lib/')).toEqual({
|
||||
path: 'packages/lib/',
|
||||
isGitlink: false,
|
||||
isUntrackedDir: true
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps ordinary git entries without lstat calls', async () => {
|
||||
await expect(
|
||||
expandQuickOpenGitFileListing({
|
||||
rootPath: '/unused/root',
|
||||
gitPaths: [
|
||||
staged('100644', 'README.md'),
|
||||
staged('100755', 'bin/run', SHA256),
|
||||
'scratch.txt'
|
||||
]
|
||||
})
|
||||
).resolves.toEqual(['README.md', 'bin/run', 'scratch.txt'])
|
||||
|
||||
expect(lstatMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('classifies nested repo placeholders without confusing extensionless files', async () => {
|
||||
const root = await makeTempRoot()
|
||||
await writeRel(root, 'Makefile')
|
||||
await makeNestedRepo(root, 'packages/app')
|
||||
await makeNestedRepo(root, 'packages/lib', 'file')
|
||||
await mkdirRel(root, 'packages/unchecked')
|
||||
|
||||
await expect(classifyQuickOpenGitEntry(root, staged('100644', 'Makefile'))).resolves.toEqual({
|
||||
kind: 'keep',
|
||||
relPath: 'Makefile'
|
||||
})
|
||||
await expect(
|
||||
classifyQuickOpenGitEntry(root, staged('160000', 'packages/app'))
|
||||
).resolves.toEqual({
|
||||
kind: 'fill-nested-repo',
|
||||
relPath: 'packages/app'
|
||||
})
|
||||
await expect(classifyQuickOpenGitEntry(root, 'packages/lib/')).resolves.toEqual({
|
||||
kind: 'fill-nested-repo',
|
||||
relPath: 'packages/lib'
|
||||
})
|
||||
await expect(classifyQuickOpenGitEntry(root, 'packages/unchecked')).resolves.toEqual({
|
||||
kind: 'keep',
|
||||
relPath: 'packages/unchecked'
|
||||
})
|
||||
await expect(classifyQuickOpenGitEntry(root, 'packages/unchecked/')).resolves.toEqual({
|
||||
kind: 'drop-placeholder',
|
||||
relPath: 'packages/unchecked'
|
||||
})
|
||||
})
|
||||
|
||||
it('re-prefixes nested children and filters final workspace-relative paths', async () => {
|
||||
const root = await makeTempRoot()
|
||||
await writeRel(root, 'README.md')
|
||||
await writeRel(root, 'Makefile')
|
||||
await makeNestedRepo(root, 'packages/app')
|
||||
await makeNestedRepo(root, 'packages/lib', 'file')
|
||||
await mkdirRel(root, 'packages/empty')
|
||||
await writeRel(root, 'packages/app/src/main.ts')
|
||||
await writeRel(root, 'packages/app/node_modules/pkg/index.js')
|
||||
await writeRel(root, 'packages/app/.git/config')
|
||||
await writeRel(root, 'packages/app/linked-worktree/file.ts')
|
||||
await writeRel(root, 'packages/lib/src/lib.ts')
|
||||
|
||||
await expect(
|
||||
expandQuickOpenGitFileListing({
|
||||
rootPath: root,
|
||||
gitPaths: [
|
||||
staged('100644', 'README.md'),
|
||||
staged('100644', 'Makefile'),
|
||||
staged('160000', 'packages/app'),
|
||||
'packages/lib/',
|
||||
'packages/empty/'
|
||||
],
|
||||
excludePathPrefixes: ['packages/app/linked-worktree']
|
||||
})
|
||||
).resolves.toEqual([
|
||||
'README.md',
|
||||
'Makefile',
|
||||
'packages/app/src/main.ts',
|
||||
'packages/lib/src/lib.ts'
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects on cap and shares one budget across nested subtrees', async () => {
|
||||
const root = await makeTempRoot()
|
||||
await makeNestedRepo(root, 'packages/app')
|
||||
await makeNestedRepo(root, 'packages/lib')
|
||||
await writeRel(root, 'packages/app/a.ts')
|
||||
await writeRel(root, 'packages/app/b.ts')
|
||||
await writeRel(root, 'packages/lib/c.ts')
|
||||
|
||||
await expect(
|
||||
expandQuickOpenGitFileListing({
|
||||
rootPath: root,
|
||||
gitPaths: [staged('160000', 'packages/app'), staged('160000', 'packages/lib')],
|
||||
budget: createQuickOpenReaddirBudget({ maxFiles: 2 })
|
||||
})
|
||||
).rejects.toThrow('File listing exceeded')
|
||||
})
|
||||
|
||||
it('prunes excluded nested subtrees during traversal without consuming the budget', async () => {
|
||||
const root = await makeTempRoot()
|
||||
await makeNestedRepo(root, 'packages/app')
|
||||
await writeRel(root, 'packages/app/keep.ts')
|
||||
// A large excluded subtree inside the nested repo: if it were walked before
|
||||
// being filtered, it would exhaust the tiny budget and reject.
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
await writeRel(root, `packages/app/excluded/file-${i}.ts`)
|
||||
}
|
||||
|
||||
await expect(
|
||||
expandQuickOpenGitFileListing({
|
||||
rootPath: root,
|
||||
gitPaths: [staged('160000', 'packages/app')],
|
||||
excludePathPrefixes: ['packages/app/excluded'],
|
||||
budget: createQuickOpenReaddirBudget({ maxFiles: 5 })
|
||||
})
|
||||
).resolves.toEqual(['packages/app/keep.ts'])
|
||||
})
|
||||
|
||||
it('expands allowed ignored directories without walking blocked or excluded directories', async () => {
|
||||
const root = await makeTempRoot()
|
||||
await writeRel(root, 'dist/generated.js')
|
||||
await writeRel(root, 'node_modules/pkg/index.js')
|
||||
await writeRel(root, '.cache/state.json')
|
||||
await writeRel(root, '.local/share/state.json')
|
||||
await writeRel(root, '.local/config.toml')
|
||||
await writeRel(root, 'excluded/other.js')
|
||||
|
||||
await expect(
|
||||
expandQuickOpenGitFileListing({
|
||||
rootPath: root,
|
||||
gitPaths: [],
|
||||
directoryPaths: [
|
||||
'dist/',
|
||||
'.local/',
|
||||
'node_modules/',
|
||||
'.cache/',
|
||||
'.local/share/',
|
||||
'excluded/'
|
||||
],
|
||||
excludePathPrefixes: ['excluded'],
|
||||
budget: createQuickOpenReaddirBudget({ maxFiles: 2 })
|
||||
})
|
||||
).resolves.toEqual(['.local/config.toml', 'dist/generated.js'])
|
||||
|
||||
const walkedPaths = opendirMock.mock.calls.map(([path]) => path)
|
||||
expect(walkedPaths).toContain(join(root, 'dist'))
|
||||
expect(walkedPaths).toContain(join(root, '.local'))
|
||||
expect(walkedPaths).not.toContain(join(root, '.local', 'share'))
|
||||
})
|
||||
|
||||
it('batches many allowed directory placeholders with bounded concurrency', async () => {
|
||||
const root = await makeTempRoot()
|
||||
const directoryPaths = Array.from({ length: 40 }, (_, index) => `generated-${index}/`)
|
||||
await Promise.all(
|
||||
directoryPaths.map((directoryPath, index) =>
|
||||
writeRel(root, `${directoryPath}file-${index}.ts`)
|
||||
)
|
||||
)
|
||||
|
||||
const actual = await vi.importActual<typeof import('node:fs/promises')>('node:fs/promises') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
|
||||
let activeReads = 0
|
||||
let maxActiveReads = 0
|
||||
opendirMock.mockImplementation(async (...args: Parameters<typeof actual.opendir>) => {
|
||||
activeReads++
|
||||
maxActiveReads = Math.max(maxActiveReads, activeReads)
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
try {
|
||||
return await actual.opendir(...args)
|
||||
} finally {
|
||||
activeReads--
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
const files = await expandQuickOpenGitFileListing({
|
||||
rootPath: root,
|
||||
gitPaths: [],
|
||||
directoryPaths
|
||||
})
|
||||
expect(files).toHaveLength(directoryPaths.length)
|
||||
expect(maxActiveReads).toBeGreaterThan(1)
|
||||
expect(maxActiveReads).toBeLessThanOrEqual(32)
|
||||
} finally {
|
||||
opendirMock.mockImplementation(actual.opendir)
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves symlink leaves from collapsed Git directories without following them', async () => {
|
||||
const root = await makeTempRoot()
|
||||
await mkdirRel(root, 'scratch')
|
||||
await writeRel(root, 'target/file.ts')
|
||||
|
||||
try {
|
||||
await symlink(join(root, 'target', 'file.ts'), join(root, 'scratch', 'link.ts'))
|
||||
await symlink(join(root, 'target'), join(root, 'scratch', 'linked-dir'), 'dir')
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'EPERM') {
|
||||
return
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
await expect(
|
||||
expandQuickOpenGitFileListing({
|
||||
rootPath: root,
|
||||
gitPaths: [],
|
||||
directoryPaths: ['scratch/']
|
||||
})
|
||||
).resolves.toEqual(['scratch/link.ts', 'scratch/linked-dir'])
|
||||
})
|
||||
|
||||
it('does not follow a collapsed directory replaced by a symlink', async () => {
|
||||
const root = await makeTempRoot()
|
||||
const outsideRoot = await makeTempRoot()
|
||||
await writeRel(outsideRoot, 'secret.ts')
|
||||
|
||||
try {
|
||||
await symlink(outsideRoot, join(root, 'dist'), 'dir')
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'EPERM') {
|
||||
return
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
await expect(
|
||||
expandQuickOpenGitFileListing({
|
||||
rootPath: root,
|
||||
gitPaths: [],
|
||||
directoryPaths: ['dist/']
|
||||
})
|
||||
).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('discards entries when a collapsed directory changes during opendir', async () => {
|
||||
const root = await makeTempRoot()
|
||||
const outsideRoot = await makeTempRoot()
|
||||
await mkdirRel(root, 'dist')
|
||||
await writeRel(outsideRoot, 'secret.ts')
|
||||
const actual = await vi.importActual<typeof import('node:fs/promises')>('node:fs/promises') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
|
||||
const distPath = join(root, 'dist')
|
||||
let swapped = false
|
||||
opendirMock.mockImplementation(async (...args: Parameters<typeof actual.opendir>) => {
|
||||
if (!swapped && args[0] === distPath) {
|
||||
swapped = true
|
||||
await rename(distPath, join(root, 'old-dist'))
|
||||
await symlink(outsideRoot, distPath, 'dir')
|
||||
}
|
||||
return actual.opendir(...args)
|
||||
})
|
||||
|
||||
try {
|
||||
await expect(
|
||||
expandQuickOpenGitFileListing({
|
||||
rootPath: root,
|
||||
gitPaths: [],
|
||||
directoryPaths: ['dist/']
|
||||
})
|
||||
).resolves.toEqual([])
|
||||
} finally {
|
||||
opendirMock.mockImplementation(actual.opendir)
|
||||
}
|
||||
})
|
||||
|
||||
it('walks overlapping primary and ignored placeholders only once', async () => {
|
||||
const root = await makeTempRoot()
|
||||
await writeRel(root, 'foo/a.ts')
|
||||
await writeRel(root, 'foo/bar/b.ts')
|
||||
|
||||
await expect(
|
||||
expandQuickOpenGitFileListing({
|
||||
rootPath: root,
|
||||
gitPaths: [],
|
||||
directoryPaths: ['foo/', 'foo/bar/'],
|
||||
budget: createQuickOpenReaddirBudget({ maxFiles: 2 })
|
||||
})
|
||||
).resolves.toEqual(['foo/a.ts', 'foo/bar/b.ts'])
|
||||
|
||||
expect(
|
||||
opendirMock.mock.calls.filter(([path]) => path === join(root, 'foo', 'bar'))
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('rejects instead of returning a partial ignored-directory expansion', async () => {
|
||||
const root = await makeTempRoot()
|
||||
await writeRel(root, 'dist/a.js')
|
||||
await writeRel(root, 'dist/b.js')
|
||||
|
||||
await expect(
|
||||
expandQuickOpenGitFileListing({
|
||||
rootPath: root,
|
||||
gitPaths: [],
|
||||
directoryPaths: ['dist/'],
|
||||
budget: createQuickOpenReaddirBudget({ maxFiles: 1 })
|
||||
})
|
||||
).rejects.toThrow('File listing exceeded')
|
||||
})
|
||||
|
||||
it('supports more than 10,000 files while keeping the default safety cap', async () => {
|
||||
expect(QUICK_OPEN_READDIR_MAX_FILES).toBeGreaterThan(10_000)
|
||||
const root = await makeTempRoot()
|
||||
await mkdirRel(root, 'dist')
|
||||
opendirMock.mockResolvedValueOnce({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
for (let index = 0; index <= QUICK_OPEN_READDIR_MAX_FILES; index += 1) {
|
||||
yield {
|
||||
name: `file-${index}.ts`,
|
||||
isDirectory: () => false,
|
||||
isFile: () => true,
|
||||
isSymbolicLink: () => false
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Why: directory collapse prevents generated trees from flooding the relay;
|
||||
// the Git fallback must reject rather than silently return a partial list.
|
||||
await expect(
|
||||
expandQuickOpenGitFileListing({
|
||||
rootPath: root,
|
||||
gitPaths: [],
|
||||
directoryPaths: ['dist/']
|
||||
})
|
||||
).rejects.toThrow(`File listing exceeded ${QUICK_OPEN_READDIR_MAX_FILES} files`)
|
||||
})
|
||||
|
||||
it('identifies budget errors so callers can translate only those to install-rg guidance', () => {
|
||||
expect(isQuickOpenReaddirBudgetError(new Error('File listing timed out'))).toBe(true)
|
||||
expect(isQuickOpenReaddirBudgetError(new Error('File listing exceeded 10000 files'))).toBe(true)
|
||||
// Genuine git failures must keep their own message, not the install-rg toast.
|
||||
expect(isQuickOpenReaddirBudgetError(new Error('git ls-files killed by SIGTERM'))).toBe(false)
|
||||
expect(isQuickOpenReaddirBudgetError('File listing timed out')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects on deadline instead of returning a partial list', async () => {
|
||||
const root = await makeTempRoot()
|
||||
await writeRel(root, 'src/index.ts')
|
||||
|
||||
await expect(
|
||||
listQuickOpenFilesWithReaddir(root, {
|
||||
budget: createQuickOpenReaddirBudget({ nowMs: Date.now() - 2_000, timeoutMs: 1_000 })
|
||||
})
|
||||
).rejects.toThrow('File listing timed out')
|
||||
})
|
||||
|
||||
it('returns a successful bounded prefix without consuming the safety cap', async () => {
|
||||
const root = await makeTempRoot()
|
||||
await writeRel(root, 'a.ts')
|
||||
await writeRel(root, 'b.ts')
|
||||
|
||||
const files = await listQuickOpenFilesWithReaddir(root, { maxResults: 1 })
|
||||
|
||||
expect(files).toHaveLength(1)
|
||||
expect(['a.ts', 'b.ts']).toContain(files[0])
|
||||
})
|
||||
|
||||
it('does not list symlinked files or follow symlinked directories', async () => {
|
||||
const root = await makeTempRoot()
|
||||
await writeRel(root, 'src/index.ts')
|
||||
await writeRel(root, 'target/file.ts')
|
||||
|
||||
try {
|
||||
await symlink(join(root, 'src/index.ts'), join(root, 'src/link.ts'))
|
||||
await symlink(join(root, 'target'), join(root, 'linked-dir'), 'dir')
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'EPERM') {
|
||||
return
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
const files = await listQuickOpenFilesWithReaddir(root)
|
||||
expect(files).toEqual(expect.arrayContaining(['src/index.ts', 'target/file.ts']))
|
||||
expect(files).not.toContain('src/link.ts')
|
||||
expect(files).not.toContain('linked-dir/file.ts')
|
||||
})
|
||||
|
||||
it('walks an explicitly selected symlinked workspace root', async () => {
|
||||
const targetRoot = await makeTempRoot()
|
||||
const linkContainer = await makeTempRoot()
|
||||
await writeRel(targetRoot, 'src/index.ts')
|
||||
const linkedRoot = join(linkContainer, 'linked-workspace')
|
||||
|
||||
try {
|
||||
await symlink(targetRoot, linkedRoot, 'dir')
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'EPERM') {
|
||||
return
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
await expect(listQuickOpenFilesWithReaddir(linkedRoot)).resolves.toEqual(['src/index.ts'])
|
||||
})
|
||||
|
||||
it('fills nested repo paths containing spaces and glob metacharacters', async () => {
|
||||
const root = await makeTempRoot()
|
||||
await makeNestedRepo(root, 'packages/app [one] space')
|
||||
await writeRel(root, 'packages/app [one] space/src/main.ts')
|
||||
|
||||
await expect(
|
||||
expandQuickOpenGitFileListing({
|
||||
rootPath: root,
|
||||
gitPaths: ['packages/app [one] space/']
|
||||
})
|
||||
).resolves.toEqual(['packages/app [one] space/src/main.ts'])
|
||||
})
|
||||
|
||||
it('stops the walk with a cancellation error when the signal aborts (#7721)', async () => {
|
||||
const root = await makeTempRoot()
|
||||
await writeRel(root, 'src/a.ts')
|
||||
await writeRel(root, 'src/b.ts')
|
||||
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
|
||||
const rejection = listQuickOpenFilesWithReaddir(root, { signal: controller.signal })
|
||||
await expect(rejection).rejects.toSatisfy(isFileListingCancellation)
|
||||
// Cancellation must never be mistaken for a budget error, which callers
|
||||
// translate into "install rg" guidance.
|
||||
await rejection.catch((err) => expect(isQuickOpenReaddirBudgetError(err)).toBe(false))
|
||||
})
|
||||
|
||||
it('stops ignored-directory expansion when the signal aborts (#7721)', async () => {
|
||||
const root = await makeTempRoot()
|
||||
await writeRel(root, 'src/kept.ts')
|
||||
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
|
||||
await expect(
|
||||
expandQuickOpenGitFileListing({
|
||||
rootPath: root,
|
||||
gitPaths: [],
|
||||
directoryPaths: ['src/'],
|
||||
signal: controller.signal
|
||||
})
|
||||
).rejects.toSatisfy(isFileListingCancellation)
|
||||
})
|
||||
|
||||
it('closes the directory when cancellation lands after opendir', async () => {
|
||||
const root = await makeTempRoot()
|
||||
const controller = new AbortController()
|
||||
const actual = await vi.importActual<typeof NodeFsPromises>('node:fs/promises')
|
||||
let closeCalls = 0
|
||||
opendirMock.mockImplementationOnce(async (...args: Parameters<typeof actual.opendir>) => {
|
||||
const directory = await actual.opendir(...args)
|
||||
const close = directory.close.bind(directory)
|
||||
directory.close = async () => {
|
||||
closeCalls += 1
|
||||
await close()
|
||||
}
|
||||
controller.abort()
|
||||
return directory
|
||||
})
|
||||
|
||||
await expect(
|
||||
listQuickOpenFilesWithReaddir(root, { signal: controller.signal })
|
||||
).rejects.toSatisfy(isFileListingCancellation)
|
||||
expect(closeCalls).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -1,317 +0,0 @@
|
||||
import { join, relative } from 'node:path'
|
||||
import { throwIfFileListingCancelled } from './file-listing-cancellation'
|
||||
import { readQuickOpenDirectoryEntries } from './quick-open-directory-reader'
|
||||
import { collapseQuickOpenExpansionPaths } from './quick-open-expansion-paths'
|
||||
import { classifyQuickOpenGitEntry } from './quick-open-git-entry-classification'
|
||||
import {
|
||||
HIDDEN_DIR_BLOCKLIST,
|
||||
shouldExcludeQuickOpenRelPath,
|
||||
shouldIncludeQuickOpenPath
|
||||
} from './quick-open-filter'
|
||||
import {
|
||||
assertQuickOpenReaddirDepth,
|
||||
assertQuickOpenReaddirDeadline,
|
||||
consumeQuickOpenReaddirDirectoryBudget,
|
||||
consumeQuickOpenReaddirEntryBudget,
|
||||
consumeQuickOpenReaddirFileBudget,
|
||||
consumeQuickOpenReaddirPathBudget,
|
||||
createQuickOpenReaddirBudget,
|
||||
type QuickOpenReaddirBudget
|
||||
} from './quick-open-readdir-budget'
|
||||
|
||||
export {
|
||||
classifyQuickOpenGitEntry,
|
||||
parseQuickOpenGitLsFilesEntry,
|
||||
type QuickOpenGitEntryKind,
|
||||
type QuickOpenGitLsFilesEntry
|
||||
} from './quick-open-git-entry-classification'
|
||||
|
||||
export {
|
||||
createQuickOpenReaddirBudget,
|
||||
isQuickOpenReaddirBudgetError,
|
||||
QUICK_OPEN_READDIR_MAX_DEPTH,
|
||||
QUICK_OPEN_READDIR_MAX_DIRECTORIES,
|
||||
QUICK_OPEN_READDIR_MAX_ENTRIES,
|
||||
QUICK_OPEN_READDIR_MAX_FILES,
|
||||
QUICK_OPEN_READDIR_MAX_PATH_CODE_UNITS,
|
||||
QUICK_OPEN_READDIR_TIMEOUT_MS
|
||||
} from './quick-open-readdir-budget'
|
||||
|
||||
const QUICK_OPEN_READDIR_CONCURRENCY = 32
|
||||
|
||||
function shouldDescend(name: string): boolean {
|
||||
return name !== 'node_modules' && !HIDDEN_DIR_BLOCKLIST.has(name)
|
||||
}
|
||||
|
||||
function toRelPath(rootPath: string, absPath: string): string {
|
||||
// Why: path.relative returns backslashes on Windows, while Quick Open paths
|
||||
// are always stored and matched with POSIX separators.
|
||||
return relative(rootPath, absPath).replace(/\\/g, '/')
|
||||
}
|
||||
|
||||
function joinRootRel(rootPath: string, relPath: string): string {
|
||||
return join(rootPath, ...relPath.split('/').filter(Boolean))
|
||||
}
|
||||
|
||||
function normalizeGitEntry(entry: string): string {
|
||||
return entry.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
// Translate workspace-root-relative exclude prefixes into prefixes relative to
|
||||
// one expanded subtree, so its walk prunes them during traversal. Prefixes
|
||||
// outside that subtree are dropped because they cannot match.
|
||||
function rebaseExcludePrefixesForSubtree(
|
||||
excludePathPrefixes: readonly string[],
|
||||
subtreeRelPath: string
|
||||
): string[] {
|
||||
const base = `${subtreeRelPath}/`
|
||||
const rebased: string[] = []
|
||||
for (const prefix of excludePathPrefixes) {
|
||||
if (prefix.startsWith(base)) {
|
||||
rebased.push(prefix.slice(base.length))
|
||||
}
|
||||
}
|
||||
return rebased
|
||||
}
|
||||
|
||||
export async function listQuickOpenFilesWithReaddir(
|
||||
rootPath: string,
|
||||
opts: {
|
||||
excludePathPrefixes?: readonly string[]
|
||||
workspaceRelPathPrefix?: string
|
||||
budget?: QuickOpenReaddirBudget
|
||||
maxResults?: number
|
||||
signal?: AbortSignal
|
||||
} = {}
|
||||
): Promise<string[]> {
|
||||
return listQuickOpenFilesFromRoots(
|
||||
[
|
||||
{
|
||||
rootPath,
|
||||
excludePathPrefixes: opts.excludePathPrefixes ?? [],
|
||||
workspaceRelPathPrefix: opts.workspaceRelPathPrefix,
|
||||
allowRootSymlink: true
|
||||
}
|
||||
],
|
||||
opts.budget ?? createQuickOpenReaddirBudget(),
|
||||
opts.signal,
|
||||
opts.maxResults
|
||||
)
|
||||
}
|
||||
|
||||
type QuickOpenReaddirRoot = {
|
||||
rootPath: string
|
||||
excludePathPrefixes: readonly string[]
|
||||
workspaceRelPathPrefix?: string
|
||||
outputPathPrefix?: string
|
||||
includeSymlinks?: boolean
|
||||
allowRootSymlink?: boolean
|
||||
}
|
||||
|
||||
async function listQuickOpenFilesFromRoots(
|
||||
roots: readonly QuickOpenReaddirRoot[],
|
||||
budget: QuickOpenReaddirBudget,
|
||||
signal?: AbortSignal,
|
||||
maxResults?: number,
|
||||
knownFiles?: ReadonlySet<string>
|
||||
): Promise<string[]> {
|
||||
const files: string[] = []
|
||||
if (maxResults !== undefined && maxResults <= 0) {
|
||||
return files
|
||||
}
|
||||
let pendingDirectories: {
|
||||
root: QuickOpenReaddirRoot
|
||||
absPath: string
|
||||
depth: number
|
||||
isRoot: boolean
|
||||
}[] = []
|
||||
for (const root of roots) {
|
||||
assertQuickOpenReaddirDepth(budget, 0)
|
||||
consumeQuickOpenReaddirDirectoryBudget(budget)
|
||||
consumeQuickOpenReaddirPathBudget(budget, root.rootPath)
|
||||
pendingDirectories.push({ root, absPath: root.rootPath, depth: 0, isRoot: true })
|
||||
}
|
||||
|
||||
while (pendingDirectories.length > 0) {
|
||||
const nextDirectories: typeof pendingDirectories = []
|
||||
for (
|
||||
let offset = 0;
|
||||
offset < pendingDirectories.length;
|
||||
offset += QUICK_OPEN_READDIR_CONCURRENCY
|
||||
) {
|
||||
// Why: batch only the filesystem calls. Result processing stays serial,
|
||||
// so the shared cap remains exact while shallow placeholder-heavy repos
|
||||
// do not pay one relay event-loop turn per directory.
|
||||
throwIfFileListingCancelled(signal)
|
||||
assertQuickOpenReaddirDeadline(budget)
|
||||
const batch = pendingDirectories.slice(offset, offset + QUICK_OPEN_READDIR_CONCURRENCY)
|
||||
const readResults = await Promise.allSettled(
|
||||
batch.map(async (pending) => {
|
||||
const entries = await readQuickOpenDirectoryEntries({
|
||||
absPath: pending.absPath,
|
||||
allowSymlinkedRoot: Boolean(pending.isRoot && pending.root.allowRootSymlink),
|
||||
budget,
|
||||
signal
|
||||
})
|
||||
return { pending, entries }
|
||||
})
|
||||
)
|
||||
const entryGroups: {
|
||||
pending: (typeof pendingDirectories)[number]
|
||||
entries: Awaited<ReturnType<typeof readQuickOpenDirectoryEntries>>
|
||||
}[] = []
|
||||
for (const result of readResults) {
|
||||
if (result.status === 'rejected') {
|
||||
throw result.reason
|
||||
}
|
||||
entryGroups.push(result.value)
|
||||
}
|
||||
// Why: an empty directory has no per-entry checkpoint below. Cancellation
|
||||
// or timeout that lands during opendir must still reject, never resolve [].
|
||||
throwIfFileListingCancelled(signal)
|
||||
assertQuickOpenReaddirDeadline(budget)
|
||||
|
||||
for (const { pending, entries } of entryGroups) {
|
||||
for (const entry of entries) {
|
||||
throwIfFileListingCancelled(signal)
|
||||
assertQuickOpenReaddirDeadline(budget)
|
||||
|
||||
const name = entry.name
|
||||
const absPath = join(pending.absPath, name)
|
||||
const relPath = toRelPath(pending.root.rootPath, absPath)
|
||||
const workspaceRelPath = pending.root.workspaceRelPathPrefix
|
||||
? `${pending.root.workspaceRelPathPrefix}/${relPath}`
|
||||
: relPath
|
||||
if (shouldExcludeQuickOpenRelPath(relPath, pending.root.excludePathPrefixes)) {
|
||||
continue
|
||||
}
|
||||
if (entry.kind === 'directory') {
|
||||
if (shouldDescend(name) && shouldIncludeQuickOpenPath(workspaceRelPath)) {
|
||||
const depth = pending.depth + 1
|
||||
assertQuickOpenReaddirDepth(budget, depth)
|
||||
consumeQuickOpenReaddirDirectoryBudget(budget)
|
||||
consumeQuickOpenReaddirPathBudget(budget, absPath)
|
||||
nextDirectories.push({ root: pending.root, absPath, depth, isRoot: false })
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (
|
||||
(entry.kind === 'file' || (pending.root.includeSymlinks && entry.kind === 'symlink')) &&
|
||||
shouldIncludeQuickOpenPath(workspaceRelPath)
|
||||
) {
|
||||
const outputPath = pending.root.outputPathPrefix
|
||||
? `${pending.root.outputPathPrefix}/${relPath}`
|
||||
: relPath
|
||||
if (knownFiles?.has(outputPath)) {
|
||||
continue
|
||||
}
|
||||
consumeQuickOpenReaddirFileBudget(budget)
|
||||
consumeQuickOpenReaddirPathBudget(budget, outputPath)
|
||||
files.push(outputPath)
|
||||
// Why: a caller result limit is a successful bounded prefix, while
|
||||
// the separate traversal budget still rejects incomplete scans.
|
||||
if (maxResults !== undefined && files.length >= maxResults) {
|
||||
return files
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pendingDirectories = nextDirectories
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
export async function expandQuickOpenGitFileListing(opts: {
|
||||
rootPath: string
|
||||
gitPaths: Iterable<string>
|
||||
directoryPaths?: Iterable<string>
|
||||
excludePathPrefixes?: readonly string[]
|
||||
budget?: QuickOpenReaddirBudget
|
||||
maxResults?: number
|
||||
signal?: AbortSignal
|
||||
}): Promise<string[]> {
|
||||
if (opts.maxResults !== undefined && opts.maxResults <= 0) {
|
||||
return []
|
||||
}
|
||||
const files = new Set<string>()
|
||||
const excludePathPrefixes = opts.excludePathPrefixes ?? []
|
||||
const budget = opts.budget ?? createQuickOpenReaddirBudget()
|
||||
const expansionPaths = new Map<string, boolean>()
|
||||
|
||||
const addFinalPath = (relPath: string): void => {
|
||||
if (!relPath) {
|
||||
return
|
||||
}
|
||||
if (shouldExcludeQuickOpenRelPath(relPath, excludePathPrefixes)) {
|
||||
return
|
||||
}
|
||||
if (shouldIncludeQuickOpenPath(relPath)) {
|
||||
files.add(relPath)
|
||||
}
|
||||
}
|
||||
|
||||
for (const rawPath of opts.gitPaths) {
|
||||
throwIfFileListingCancelled(opts.signal)
|
||||
assertQuickOpenReaddirDeadline(budget)
|
||||
|
||||
const { kind, relPath } = await classifyQuickOpenGitEntry(opts.rootPath, rawPath)
|
||||
if (kind === 'keep') {
|
||||
addFinalPath(relPath)
|
||||
continue
|
||||
}
|
||||
if (kind === 'drop-placeholder') {
|
||||
continue
|
||||
}
|
||||
|
||||
consumeQuickOpenReaddirEntryBudget(budget)
|
||||
consumeQuickOpenReaddirPathBudget(budget, relPath)
|
||||
expansionPaths.set(relPath, expansionPaths.get(relPath) ?? false)
|
||||
}
|
||||
|
||||
for (const rawPath of opts.directoryPaths ?? []) {
|
||||
throwIfFileListingCancelled(opts.signal)
|
||||
assertQuickOpenReaddirDeadline(budget)
|
||||
|
||||
const relPath = normalizeGitEntry(rawPath)
|
||||
// Why: Git intentionally leaves collapsed directories unexpanded; reject
|
||||
// blocked and nested-worktree placeholders before any filesystem IO.
|
||||
if (
|
||||
!relPath ||
|
||||
shouldExcludeQuickOpenRelPath(relPath, excludePathPrefixes) ||
|
||||
!shouldIncludeQuickOpenPath(relPath)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
consumeQuickOpenReaddirEntryBudget(budget)
|
||||
consumeQuickOpenReaddirPathBudget(budget, relPath)
|
||||
// Why: before directory collapse, Git returned untracked symlink entries
|
||||
// without following them. Preserve those paths when expanding placeholders.
|
||||
expansionPaths.set(relPath, true)
|
||||
}
|
||||
|
||||
const expandedFiles = await listQuickOpenFilesFromRoots(
|
||||
collapseQuickOpenExpansionPaths(expansionPaths).map(([relPath, includeSymlinks]) => ({
|
||||
rootPath: joinRootRel(opts.rootPath, relPath),
|
||||
// Why: exclude prefixes are workspace-root-relative; rebase them onto
|
||||
// each expanded subtree so blocked work is pruned before consuming cap.
|
||||
excludePathPrefixes: rebaseExcludePrefixesForSubtree(excludePathPrefixes, relPath),
|
||||
// Why: Git can collapse `.local/share/` to `.local/`; keep workspace
|
||||
// context so the walker still prunes the multi-segment blocklist.
|
||||
workspaceRelPathPrefix: relPath,
|
||||
outputPathPrefix: relPath,
|
||||
includeSymlinks
|
||||
})),
|
||||
budget,
|
||||
opts.signal,
|
||||
opts.maxResults === undefined ? undefined : Math.max(0, opts.maxResults - files.size),
|
||||
files
|
||||
)
|
||||
for (const expandedFile of expandedFiles) {
|
||||
addFinalPath(expandedFile)
|
||||
}
|
||||
|
||||
return Array.from(files).slice(0, opts.maxResults)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
export const SSH_AI_VAULT_LIST_SESSIONS_METHOD = 'aiVault.listSessions' as const
|
||||
export const SSH_AI_VAULT_RESOLVE_SESSION_TITLES_METHOD = 'aiVault.resolveSessionTitles' as const
|
||||
export const SSH_AI_VAULT_LIST_SESSIONS_TIMEOUT_MS = 130_000
|
||||
export const SSH_AI_VAULT_RESOLVE_SESSION_TITLES_TIMEOUT_MS = 15_000
|
||||
export const SSH_AI_VAULT_LIST_LIMIT_MAX = 1000
|
||||
export const SSH_AI_VAULT_SCOPE_PATH_MAX_LENGTH = 4096
|
||||
|
||||
export type SshAiVaultRelayListParams = {
|
||||
includeAntigravityIdeSessions?: boolean
|
||||
limit?: number
|
||||
unlimited?: boolean
|
||||
force?: boolean
|
||||
scopePaths?: string[]
|
||||
scopePathsTruncated?: boolean
|
||||
}
|
||||
|
||||
export type SshAiVaultRelayTitleParams = {
|
||||
requests: {
|
||||
agent: 'claude' | 'codex'
|
||||
sessionId: string
|
||||
transcriptPath?: string
|
||||
}[]
|
||||
}
|
||||
@@ -419,9 +419,7 @@ export type PortForwardEntry = {
|
||||
advertisedProtocol?: 'http' | 'https'
|
||||
}
|
||||
|
||||
/** A listening port detected on the remote host by the relay.
|
||||
* Keep in sync with src/relay/port-scan-handler.ts — DetectedPort.
|
||||
* The relay is deployed as a standalone bundle and cannot import from shared. */
|
||||
/** A listening port detected on a remote host. */
|
||||
export type DetectedPort = {
|
||||
port: number
|
||||
host: string
|
||||
|
||||
Reference in New Issue
Block a user