mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 16:03:26 +00:00
* feat(native-chat): add native chat view across mobile
* fix(native-chat): address review findings and CodeRabbit threads
Correctness:
- Restore an independent initial readSession seed and surface initial-drain
errors as snapshot frames so the chat view can never strand on 'loading'
- Pair mobile tool results to calls by ordinal FIFO (parallel calls no longer
misgraft results); clear a pending ask only when its own call resolves
- Show a new streaming reply immediately (same-turn suppression, not length)
- Delegate mobile noise filtering to the shared harness-injected classifier
- Admit soft-leaving mobile clients in beginMobileInputFloor (parity with
mobileTookFloor) so grace-window writes aren't dropped
- Self-heal a stale 'working' status once this turn's reply lands
- Catch RPC rejections in mobile file-open helpers; guard sanitizeToolInput
key collisions; settle web/runtime transports on unrecognized first frames
and forward snapshot errors
Perf:
- Throttle the mobile streaming bubble (50ms) so per-part status frames stop
re-parsing the whole accumulated markdown
- Short-circuit markdown path detection on dot-less or oversized runs
(quadratic backtracking guard)
UX/minor:
- Wire hold-mode dictation through the native chat composer
- Allow scoped-package (@) paths in file-path detection
- Move caret after mid-text autocomplete insertion; index-prefixed ask option
keys; single scroll-to-end effect; bounded wait + toast when image attach
races a resubscribe; count-based pending reconciliation; cache-hit search
cancels stale debounce; chat-tab toggle wins over in-flight preference load
- Share shouldStepNativeChatAskAnswer between desktop and mobile; import
block guards/source priority from shared instead of local copies
- Defensive non-positive transcript limits; test strengthening (TTL expiry,
post-unsubscribe stale frame, lease readiness, filtered console.error)
* refactor(native-chat): share desktop/mobile chat logic in src/shared
Extract the parity-mirrored native-chat modules into shared implementations
both surfaces re-export: ask parsing (registry, parseAskFromStatus,
extractPendingAsk, formatAskAnswer), answer stepping offsets/scheduler, diff
detection/parsing, harness-noise filtering, tool fold/pair/split, and tool
summaries. Removes the hand-synced copies and their stale Metro comments.
Divergence reconciliations take the safer side of each: diffs truncate at
120 lines/32KB everywhere (desktop previously unbounded), tool-run summaries
cap at 3 parts with bounded-depth previews, nameless tool calls are skipped,
and basenames split on both separators.
Also: settle and kill every sibling quick-open pass when one reaches
maxResults (main rg/git and relay git; relay rg already did) so a capped
search cannot leave a scan walking a huge tree; fold window-bounding into
the shared merger's applyAppend; localize the web 'Pair a host' snapshot
error.
* fix(native-chat): address CodeRabbit follow-ups on shared modules
- Attachment lease gate re-checks connection/target/tab after the bounded
wait, so a tab/host switch or disconnect mid-wait can't send into a stale
terminal; a moved-away target drops silently like the pre-wait guard and
only an unrecovered lease surfaces the toast. Adds hook tests.
- extractPendingAsk parses transcript tool-calls through the same
registered-parser + canonical-shape fallback as live status, so a custom
question tool that rendered live survives reconnect/replay.
- Direct unit tests for the shared ask parser (FIFO ordering, fallback,
malformed payloads) and tool-summary bounded preview (depth/collection
caps, circular refs, basename/command branches).
* fix(native-chat): treat initialLimit 0 as a valid empty window
Both engine guards used truthiness, so an explicit zero limit skipped the
bounded tail reader and fell back to an unbounded incremental read. Latent
only (every caller clamps positive), hardened for consistency with the
tail reader's non-positive-limit handling.
* fix(mobile): native-chat composer lock UX + send-failure feedback
- Distinguish input-lock reasons: transport 'disconnected' shows Reconnecting…
instead of mislabeling a reconnect as locked-by-another-client
- Guard the composer lock behind a 600ms hold so connState blips / lease
hand-offs don't flicker the placeholder; unlock stays instant
- Surface a rejected send inline above the composer (a bottom toast hides
behind the keyboard); auto-dismisses after 4s
- waiting-session hint invites the first message instead of implying the
agent is still starting
* test(mobile): sync answer-send pacing test to the 500ms advance buffer
Missed in merge 8fe3c391c, which carried main's NATIVE_CHAT_ADVANCE_BUFFER_MS
300->500 (#8568) into the shared stepping module that mobile derives from.
* fix(mobile): restore terminal stream after chat cold start
* fix(native-chat): harden retries, optimistic sends, and file scans
* fix(mobile): deliver AskUserQuestion answers by option number (STA-1860)
Port #8840's fix to the mobile native chat: the Ask card now tracks
per-question option INDICES (+ free text) and the answer-send hook drives
Claude's arrow-navigate selector with buildAskAnswerKeys keystroke groups —
option numbers, next-tab arrows, Enter — paced one selector step apart, instead
of pasting label text that the selector ignores (which silently committed the
default option). Non-Claude agents keep the pasted-label path via the
selection-based formatAskAnswer.
Backcompat: keystrokes are built client-side and written through the EXISTING
terminal.send passthrough with enter:false — the same contract the permission
card already uses — so an older desktop runtime (SSH/relay included) replays
them verbatim; no RPC/contract change in either update order. Free text is
newline-sanitized because terminal.send has no paste framing.
Drops the now-unused formatCompleteAskAnswer from the shared module.
* fix native chat send and runtime races
* fix mobile native chat formatting
* fix(native-chat): mobile empty state matches desktop copy
Mobile showed a single generic line ('Send a message to get started') where
desktop shows a titled two-line empty state naming the agent ('Start a chat with
Claude' + 'Ask Claude to inspect code, explain output, or make a change.'). Align
them from one source of truth so they can't drift again:
- Extract the agent-type label map + formatAgentTypeLabel to
src/shared/agent-type-label.ts (desktop re-exports; mobile imports).
- Add src/shared/native-chat-empty-state.ts with the canonical English copy;
desktop uses it as its i18n fallbacks (localization unchanged — en/es/ja/ko/zh
keys still win), mobile substitutes the agent label and renders it directly
(mobile ships English only).
- Mobile: render title + subtitle for waiting-session AND ready-but-empty (both
are 'start a chat'), error copy for errors; keep the loading spinner.
Live-verified on the iOS sim against a pn-dev of this branch. typecheck node/web
+ mobile tsc clean; 30 mobile + 428 desktop/shared native-chat tests green.
* style: oxfmt the empty-state parity test (line wrap)
---------
Co-authored-by: Brennan Benson <brennanbenson@Brennans-MacBook-Pro.local>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
446 lines
17 KiB
TypeScript
446 lines
17 KiB
TypeScript
/* eslint-disable max-lines -- Why: relay filesystem request handling shares
|
|
path expansion, file IO, search, streaming reads, and Space scans. */
|
|
import { readdir, writeFile, stat, lstat, mkdir, rename, cp, rm, realpath } from 'node:fs/promises'
|
|
import { execFile } from 'node:child_process'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import type { RelayDispatcher, RequestContext } from './dispatcher'
|
|
import type { RelayContext } from './context'
|
|
// Why: RelayContext is accepted in the constructor for protocol back-compat
|
|
// (see docs/relay-fs-allowlist-removal.md), but no longer consulted on FS ops.
|
|
import { expandTilde } from './context'
|
|
import {
|
|
DEFAULT_MAX_RESULTS,
|
|
searchWithRg,
|
|
listFilesWithRg,
|
|
checkRgAvailable
|
|
} from './fs-handler-utils'
|
|
import { listFilesWithGit, searchWithGitGrep } from './fs-handler-git-fallback'
|
|
import { listFilesWithReaddir } from './fs-handler-readdir-fallback'
|
|
import { ListFilesScanCoordinator } from './fs-list-files-scan-coordinator'
|
|
import {
|
|
isFileListingCancellation,
|
|
throwIfFileListingCancelled
|
|
} from '../shared/file-listing-cancellation'
|
|
import { isQuickOpenReaddirBudgetError } from '../shared/quick-open-readdir-walk'
|
|
import { buildExcludePathPrefixes } from '../shared/quick-open-filter'
|
|
import { buildInstallRgMessage } from './fs-handler-install-rg'
|
|
import { readRelayFileContent, readRelayFileStreamMetadata } from './fs-handler-file-read'
|
|
import {
|
|
readVerifiedTerminalArtifact,
|
|
writeVerifiedTerminalArtifact
|
|
} from './fs-handler-terminal-artifact'
|
|
import { RelayStreamRegistry } from './fs-stream-registry'
|
|
import { scanWorkspaceSpaceDirectory } from './workspace-space-scan'
|
|
import { buildRelayCommandEnv } from './relay-command-env'
|
|
import { assertNoClobberRenameDestinationAvailable } from '../shared/filesystem-rename-collision'
|
|
import { RelayFilesystemWatchRegistry } from './relay-filesystem-watch-registry'
|
|
import type { RelayWatcherProcessPool } from './relay-watcher-process-pool'
|
|
|
|
async function isDirectoryEntry(
|
|
dirPath: string,
|
|
entry: { name: string; isDirectory(): boolean; isSymbolicLink(): boolean }
|
|
): Promise<boolean> {
|
|
if (entry.isDirectory()) {
|
|
return true
|
|
}
|
|
if (!entry.isSymbolicLink()) {
|
|
return false
|
|
}
|
|
try {
|
|
// Why: the file explorer needs target type for symlinked directories so a
|
|
// workspace link to an external folder expands instead of opening as a file.
|
|
return (await stat(join(dirPath, entry.name))).isDirectory()
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
function fileStatFromLstat(stats: Awaited<ReturnType<typeof lstat>>) {
|
|
let type: 'file' | 'directory' | 'symlink' = 'file'
|
|
if (stats.isDirectory()) {
|
|
type = 'directory'
|
|
} else if (stats.isSymbolicLink()) {
|
|
type = 'symlink'
|
|
}
|
|
return {
|
|
size: stats.size,
|
|
type,
|
|
mtime: stats.mtimeMs,
|
|
mtimeMs: stats.mtimeMs,
|
|
dev: stats.dev,
|
|
ino: stats.ino,
|
|
nlink: stats.nlink
|
|
}
|
|
}
|
|
|
|
export class FsHandler {
|
|
private dispatcher: RelayDispatcher
|
|
private watchRegistry: RelayFilesystemWatchRegistry
|
|
private streamRegistry = new RelayStreamRegistry()
|
|
private listFilesScans = new ListFilesScanCoordinator()
|
|
|
|
constructor(
|
|
dispatcher: RelayDispatcher,
|
|
_context: RelayContext,
|
|
watcherPool?: RelayWatcherProcessPool
|
|
) {
|
|
this.dispatcher = dispatcher
|
|
this.watchRegistry = new RelayFilesystemWatchRegistry(dispatcher, watcherPool)
|
|
this.registerHandlers()
|
|
this.dispatcher.onClientDetached?.(() => {
|
|
// Why: a detached client's fs.streamAck frames will never arrive; wake
|
|
// any pump parked on the ack window so it re-checks staleness and exits
|
|
// instead of stranding its open file handle.
|
|
this.streamRegistry.wakeAllAckWaiters()
|
|
})
|
|
}
|
|
|
|
getWatchRegistry(): RelayFilesystemWatchRegistry {
|
|
return this.watchRegistry
|
|
}
|
|
|
|
private registerHandlers(): void {
|
|
this.dispatcher.onRequest('fs.readDir', (p) => this.readDir(p))
|
|
this.dispatcher.onRequest('fs.readFile', (p) => this.readFile(p))
|
|
this.dispatcher.onRequest('fs.readFileStream', (p, c) => this.readFileStream(p, c))
|
|
this.dispatcher.onRequest('fs.readTerminalArtifact', (p) => this.readTerminalArtifact(p))
|
|
this.dispatcher.onRequest('fs.tempDir', () => this.tempDir())
|
|
this.dispatcher.onRequest('fs.writeFile', (p) => this.writeFile(p))
|
|
this.dispatcher.onRequest('fs.writeTerminalArtifact', (p) => this.writeTerminalArtifact(p))
|
|
this.dispatcher.onRequest('fs.stat', (p) => this.stat(p))
|
|
this.dispatcher.onRequest('fs.lstat', (p) => this.lstat(p))
|
|
this.dispatcher.onRequest('fs.deletePath', (p) => this.deletePath(p))
|
|
this.dispatcher.onRequest('fs.createFile', (p) => this.createFile(p))
|
|
this.dispatcher.onRequest('fs.createDir', (p) => this.createDir(p))
|
|
this.dispatcher.onRequest('fs.createDirNoClobber', (p) => this.createDirNoClobber(p))
|
|
this.dispatcher.onRequest('fs.rename', (p) => this.rename(p))
|
|
this.dispatcher.onRequest('fs.renameNoClobber', (p) => this.renameNoClobber(p))
|
|
this.dispatcher.onRequest('fs.copy', (p) => this.copy(p))
|
|
this.dispatcher.onRequest('fs.realpath', (p) => this.realpath(p))
|
|
this.dispatcher.onRequest('fs.search', (p) => this.search(p))
|
|
this.dispatcher.onRequest('fs.listFiles', (p, c) => this.listFiles(p, c))
|
|
this.dispatcher.onRequest('fs.workspaceSpaceScan', (p, c) => this.workspaceSpaceScan(p, c))
|
|
this.dispatcher.onRequest('fs.watch', (p, context) =>
|
|
this.watchRegistry.watch(
|
|
expandTilde(p.rootPath as string),
|
|
context,
|
|
typeof p.watchId === 'number' && Number.isSafeInteger(p.watchId) ? p.watchId : undefined
|
|
)
|
|
)
|
|
this.dispatcher.onRequest('fs.unwatchAndWait', (p, context) =>
|
|
this.watchRegistry.unwatchAndWait(expandTilde(p.rootPath as string), context)
|
|
)
|
|
this.dispatcher.onNotification('fs.unwatch', (p, context) =>
|
|
this.watchRegistry.unwatch(expandTilde(p.rootPath as string), context)
|
|
)
|
|
this.dispatcher.onNotification('fs.cancelStream', (p) => this.cancelStream(p))
|
|
this.dispatcher.onNotification('fs.streamAck', (p) => this.streamAck(p))
|
|
}
|
|
|
|
private async readDir(params: Record<string, unknown>) {
|
|
const dirPath = expandTilde(params.dirPath as string)
|
|
const entries = await readdir(dirPath, { withFileTypes: true })
|
|
const mapped = await Promise.all(
|
|
entries.map(async (entry) => ({
|
|
name: entry.name,
|
|
isDirectory: await isDirectoryEntry(dirPath, entry),
|
|
isSymlink: entry.isSymbolicLink()
|
|
}))
|
|
)
|
|
return mapped.sort((a, b) => {
|
|
if (a.isDirectory !== b.isDirectory) {
|
|
return a.isDirectory ? -1 : 1
|
|
}
|
|
return a.name.localeCompare(b.name)
|
|
})
|
|
}
|
|
|
|
private async readFile(params: Record<string, unknown>) {
|
|
const filePath = expandTilde(params.filePath as string)
|
|
return readRelayFileContent(filePath)
|
|
}
|
|
|
|
private async readTerminalArtifact(params: Record<string, unknown>) {
|
|
return readVerifiedTerminalArtifact({
|
|
...params,
|
|
filePath: expandTilde(params.filePath as string)
|
|
})
|
|
}
|
|
|
|
private async readFileStream(params: Record<string, unknown>, context?: RequestContext) {
|
|
const filePath = expandTilde(params.filePath as string)
|
|
const ctx = context ?? { clientId: 0, isStale: () => false }
|
|
return readRelayFileStreamMetadata(filePath, this.dispatcher, this.streamRegistry, ctx, {
|
|
// Why: only target the requesting client when the dispatcher actually
|
|
// routed this request (context present) — direct-call tests and legacy
|
|
// paths keep broadcast semantics.
|
|
...(context ? { clientId: context.clientId } : {}),
|
|
paceWithAcks: params.flowControl === 'ack'
|
|
})
|
|
}
|
|
|
|
private async tempDir(): Promise<string> {
|
|
return tmpdir()
|
|
}
|
|
|
|
private cancelStream(params: Record<string, unknown>): void {
|
|
const streamId = params.streamId as number | undefined
|
|
if (typeof streamId === 'number') {
|
|
this.streamRegistry.abort(streamId)
|
|
}
|
|
}
|
|
|
|
private streamAck(params: Record<string, unknown>): void {
|
|
const streamId = params.streamId as number | undefined
|
|
const seq = params.seq as number | undefined
|
|
if (typeof streamId === 'number' && typeof seq === 'number') {
|
|
this.streamRegistry.recordAck(streamId, seq)
|
|
}
|
|
}
|
|
|
|
private async writeFile(params: Record<string, unknown>) {
|
|
const filePath = expandTilde(params.filePath as string)
|
|
const content = params.content as string
|
|
try {
|
|
const fileStats = await lstat(filePath)
|
|
if (fileStats.isDirectory()) {
|
|
throw new Error('Cannot write to a directory')
|
|
}
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
throw error
|
|
}
|
|
}
|
|
await writeFile(filePath, content, 'utf-8')
|
|
}
|
|
|
|
private async writeTerminalArtifact(params: Record<string, unknown>) {
|
|
return writeVerifiedTerminalArtifact({
|
|
...params,
|
|
filePath: expandTilde(params.filePath as string)
|
|
})
|
|
}
|
|
|
|
private async stat(params: Record<string, unknown>) {
|
|
const filePath = expandTilde(params.filePath as string)
|
|
const stats = await lstat(filePath)
|
|
if (stats.isSymbolicLink()) {
|
|
try {
|
|
// Why: callers use stat to decide whether to read a path or enumerate
|
|
// it; symlink-to-directory must behave like its target for that choice.
|
|
const targetStats = await stat(filePath)
|
|
return {
|
|
size: targetStats.size,
|
|
type: targetStats.isDirectory() ? 'directory' : 'file',
|
|
mtime: targetStats.mtimeMs,
|
|
mtimeMs: targetStats.mtimeMs,
|
|
dev: targetStats.dev,
|
|
ino: targetStats.ino,
|
|
nlink: targetStats.nlink
|
|
}
|
|
} catch {
|
|
return { size: stats.size, type: 'symlink', mtime: stats.mtimeMs }
|
|
}
|
|
}
|
|
return fileStatFromLstat(stats)
|
|
}
|
|
|
|
private async lstat(params: Record<string, unknown>) {
|
|
const filePath = expandTilde(params.filePath as string)
|
|
return fileStatFromLstat(await lstat(filePath))
|
|
}
|
|
|
|
private async deletePath(params: Record<string, unknown>) {
|
|
const targetPath = expandTilde(params.targetPath as string)
|
|
const recursive = params.recursive as boolean | undefined
|
|
const stats = await stat(targetPath)
|
|
if (stats.isDirectory() && !recursive) {
|
|
throw new Error('Cannot delete directory without recursive flag')
|
|
}
|
|
const remove = () => rm(targetPath, { recursive: !!recursive, force: true })
|
|
if (stats.isDirectory()) {
|
|
// Why: forced orphan cleanup bypasses git.removeWorktree but must hold
|
|
// the same relay-wide watcher fence through recursive deletion.
|
|
await this.watchRegistry.runWithRemovalFence(targetPath, remove)
|
|
return
|
|
}
|
|
await remove()
|
|
}
|
|
|
|
private async createFile(params: Record<string, unknown>) {
|
|
const filePath = expandTilde(params.filePath as string)
|
|
const { dirname } = await import('node:path')
|
|
await mkdir(dirname(filePath), { recursive: true })
|
|
await writeFile(filePath, '', { encoding: 'utf-8', flag: 'wx' })
|
|
}
|
|
|
|
private async createDir(params: Record<string, unknown>) {
|
|
const dirPath = expandTilde(params.dirPath as string)
|
|
await mkdir(dirPath, { recursive: true })
|
|
}
|
|
|
|
private async createDirNoClobber(params: Record<string, unknown>) {
|
|
const dirPath = expandTilde(params.dirPath as string)
|
|
await mkdir(dirPath, { recursive: false })
|
|
}
|
|
|
|
private async rename(params: Record<string, unknown>) {
|
|
const oldPath = expandTilde(params.oldPath as string)
|
|
const newPath = expandTilde(params.newPath as string)
|
|
await rename(oldPath, newPath)
|
|
}
|
|
|
|
private async renameNoClobber(params: Record<string, unknown>) {
|
|
const oldPath = expandTilde(params.oldPath as string)
|
|
const newPath = expandTilde(params.newPath as string)
|
|
// Why: user-facing file renames must not inherit fs.rename's overwrite
|
|
// behavior; keep the guard inside the relay so SSH checks the remote FS.
|
|
await assertNoClobberRenameDestinationAvailable(oldPath, newPath)
|
|
await rename(oldPath, newPath)
|
|
}
|
|
|
|
private async copy(params: Record<string, unknown>) {
|
|
const source = expandTilde(params.source as string)
|
|
const destination = expandTilde(params.destination as string)
|
|
try {
|
|
await cp(source, destination, { recursive: true, force: false, errorOnExist: true })
|
|
} catch (error) {
|
|
const code = error instanceof Error ? (error as NodeJS.ErrnoException).code : undefined
|
|
if (code === 'EEXIST' || code === 'ERR_FS_CP_EEXIST') {
|
|
throw new Error('EEXIST: destination already exists')
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
private async realpath(params: Record<string, unknown>) {
|
|
const filePath = expandTilde(params.filePath as string)
|
|
return await realpath(filePath)
|
|
}
|
|
|
|
private async search(params: Record<string, unknown>) {
|
|
const query = params.query as string
|
|
const rootPath = expandTilde(params.rootPath as string)
|
|
const caseSensitive = params.caseSensitive as boolean | undefined
|
|
const wholeWord = params.wholeWord as boolean | undefined
|
|
const useRegex = params.useRegex as boolean | undefined
|
|
const includePattern = params.includePattern as string | undefined
|
|
const excludePattern = params.excludePattern as string | undefined
|
|
const maxResults = Math.min(
|
|
(params.maxResults as number) || DEFAULT_MAX_RESULTS,
|
|
DEFAULT_MAX_RESULTS
|
|
)
|
|
|
|
const rgAvailable = await checkRgAvailable()
|
|
if (!rgAvailable) {
|
|
return searchWithGitGrep(rootPath, query, {
|
|
caseSensitive,
|
|
wholeWord,
|
|
useRegex,
|
|
includePattern,
|
|
excludePattern,
|
|
maxResults
|
|
})
|
|
}
|
|
|
|
return searchWithRg(rootPath, query, {
|
|
caseSensitive,
|
|
wholeWord,
|
|
useRegex,
|
|
includePattern,
|
|
excludePattern,
|
|
maxResults
|
|
})
|
|
}
|
|
|
|
private listFiles(params: Record<string, unknown>, context?: RequestContext): Promise<string[]> {
|
|
const rootPath = expandTilde(params.rootPath as string)
|
|
const maxResults =
|
|
typeof params.maxResults === 'number' &&
|
|
Number.isInteger(params.maxResults) &&
|
|
params.maxResults > 0
|
|
? Math.min(params.maxResults, 20_001)
|
|
: undefined
|
|
// Why: the main-to-relay RPC adds excludePaths so nested linked worktrees
|
|
// don't get double-scanned. The shared helper validates the shape and
|
|
// normalizes into root-relative prefixes; malformed input yields [] so
|
|
// the request still succeeds (older apps omit the field entirely).
|
|
const excludePathPrefixes = buildExcludePathPrefixes(rootPath, params.excludePaths)
|
|
// Why #7721: full-tree scans are the relay's most expensive request; the
|
|
// coordinator caps them at one per client, coalescing duplicates and
|
|
// aborting a stale scan when the workspace changes or the host cancels.
|
|
return this.listFilesScans.run({
|
|
clientId: context?.clientId ?? 0,
|
|
key: JSON.stringify([rootPath, excludePathPrefixes, maxResults]),
|
|
signal: context?.signal,
|
|
start: (signal) => this.runListFilesScan(rootPath, excludePathPrefixes, signal, maxResults)
|
|
})
|
|
}
|
|
|
|
private async runListFilesScan(
|
|
rootPath: string,
|
|
excludePathPrefixes: string[],
|
|
signal: AbortSignal,
|
|
maxResults?: number
|
|
): Promise<string[]> {
|
|
const rgAvailable = await checkRgAvailable()
|
|
throwIfFileListingCancelled(signal)
|
|
if (rgAvailable) {
|
|
return listFilesWithRg(rootPath, excludePathPrefixes, { signal, maxResults })
|
|
}
|
|
// Why: git ls-files only works inside git repos. Use rev-parse to detect
|
|
// git ancestry — unlike checking for a local .git entry, this works from
|
|
// subdirectories of a checkout (e.g. /repo/packages/app added as a folder).
|
|
// Without this, a git subdirectory would fall through to readdir and
|
|
// surface .gitignore'd build artifacts.
|
|
const isGitRepo = await new Promise<boolean>((resolve) => {
|
|
execFile(
|
|
'git',
|
|
['rev-parse', '--is-inside-work-tree'],
|
|
{ cwd: rootPath, env: buildRelayCommandEnv() },
|
|
(err) => resolve(!err)
|
|
)
|
|
})
|
|
if (isGitRepo) {
|
|
// Why: a git monorepo parent fills nested-repo subtrees via the readdir
|
|
// walk, which can exhaust the same cap/deadline. Translate only those
|
|
// budget errors into install-rg guidance; genuine git failures keep
|
|
// their own messages.
|
|
try {
|
|
return await listFilesWithGit(rootPath, excludePathPrefixes, { signal, maxResults })
|
|
} catch (err) {
|
|
if (isQuickOpenReaddirBudgetError(err)) {
|
|
throw new Error(await buildInstallRgMessage(err))
|
|
}
|
|
throw err
|
|
}
|
|
}
|
|
// Why: the readdir walker rejects on cap/deadline instead of returning a
|
|
// partial list (design doc: silent truncation is worse than an explicit
|
|
// error). On a home-root without rg that's almost always an install-rg
|
|
// problem, so translate the opaque cap error into actionable guidance
|
|
// the user can act on directly from the error toast.
|
|
try {
|
|
return await listFilesWithReaddir(rootPath, excludePathPrefixes, { signal, maxResults })
|
|
} catch (err) {
|
|
// Why: a cancelled scan is not an rg-availability problem; wrapping it
|
|
// in install-rg guidance would surface bogus advice on the client.
|
|
if (isFileListingCancellation(err)) {
|
|
throw err
|
|
}
|
|
throw new Error(await buildInstallRgMessage(err))
|
|
}
|
|
}
|
|
|
|
private async workspaceSpaceScan(params: Record<string, unknown>, context: RequestContext) {
|
|
const rootPath = expandTilde(params.rootPath as string)
|
|
return scanWorkspaceSpaceDirectory(rootPath, context)
|
|
}
|
|
|
|
dispose(): void {
|
|
this.watchRegistry.dispose()
|
|
void this.streamRegistry.disposeAll()
|
|
}
|
|
}
|