refactor(runtime): compose typed file search and listing ownership (#27396)

Compose typed file search and listing with narrow workspace/store dependencies and preserve the existing runtime command facade. Move the mobile inventory cache to its owner, remove four inheritance classes and six unchecked-file entries, and delete unused process bookkeeping while retaining producer-owned cancellation.

Preserve file access, provider capability fallbacks, host isolation, remote negotiation, budgets and cache lifetime. Independent audits verify moved method bodies, parameters, defaults and literals. Local tests and matched native Windows controls, typechecks, quality checks and CI qualification are recorded in the PR.
This commit is contained in:
Neil
2026-10-11 00:12:36 -07:00
committed by GitHub
parent ff5c284cc1
commit 6a77d05ee0
17 changed files with 535 additions and 427 deletions
@@ -400,6 +400,7 @@ it('keeps every host when the modified SSH production module is an actual consum
{ modifiedFiles: changed }
)
expect(scope.shouldRun).toBe(true)
expect(scope).toMatchObject({ graphResolved: true, matchedFiles: [registry, ssh] })
expect(nodeServerQualification(changed, scope)).toEqual({
qualification: true,
runners: NODE_SERVER_RUNNERS
-6
View File
@@ -150,19 +150,13 @@ src/main/runtime/runtime-browser-commands-browser-tab-set-profile.ts
src/main/runtime/runtime-browser-commands-list-logical-browser-tabs.ts
src/main/runtime/runtime-browser-commands-state.ts
src/main/runtime/runtime-file-command-host.ts
src/main/runtime/runtime-file-commands-active-runtime-text-searches.ts
src/main/runtime/runtime-file-commands-assert-remote-terminal-file-grant-path-still-canonical.ts
src/main/runtime/runtime-file-commands-constructor.ts
src/main/runtime/runtime-file-commands-create-file-explorer-dir-no-clobber.ts
src/main/runtime/runtime-file-commands-mobile-file-list-limit.ts
src/main/runtime/runtime-file-commands-read-file-explorer-preview.ts
src/main/runtime/runtime-file-commands-read-mobile-file.ts
src/main/runtime/runtime-file-commands-resolve-allowed-terminal-artifact-path.ts
src/main/runtime/runtime-file-commands-resolve-terminal-path.ts
src/main/runtime/runtime-file-commands-revoke-terminal-file-grants-for-client.ts
src/main/runtime/runtime-file-commands-search-local-runtime-files.ts
src/main/runtime/runtime-file-commands-search-remote-quick-open-file-paths.ts
src/main/runtime/runtime-file-commands-search-runtime-files.ts
src/main/runtime/runtime-file-commands-terminal-artifact-access.ts
src/main/runtime/runtime-file-commands-terminal-file-paths.ts
src/main/runtime/runtime-file-commands-write-file-explorer-file.ts
@@ -0,0 +1,20 @@
import { expect, it, vi } from 'vitest'
import { listFilesystemMarkdownDocuments } from './filesystem-markdown-listing'
it('uses a native markdown capability without requiring unrelated provider methods', async () => {
const documents = [
{ filePath: '/repo/docs/a.md', relativePath: 'docs/a.md', basename: 'a.md', name: 'a' }
]
const listMarkdownDocuments = vi.fn(async () => documents)
await expect(listFilesystemMarkdownDocuments({ listMarkdownDocuments }, '/repo')).resolves.toBe(
documents
)
expect(listMarkdownDocuments).toHaveBeenCalledWith('/repo')
})
it('retains the path-list fallback for providers without native markdown listing', async () => {
const listFiles = vi.fn(async () => ['b.mdx', 'ignored.ts', '../escape.md', 'a.md'])
const documents = await listFilesystemMarkdownDocuments({ listFiles }, '/repo')
expect(documents.map((document) => document.relativePath)).toEqual(['a.md', 'b.mdx'])
expect(listFiles).toHaveBeenCalledWith('/repo')
})
@@ -2,11 +2,21 @@ import type { IFilesystemProvider } from './types'
import { markdownDocumentsFromRelativePaths } from '../../shared/markdown-document-paths'
import { FileInventoryBudget } from '../../shared/file-inventory-budget'
type MarkdownListingProvider =
| Required<Pick<IFilesystemProvider, 'listMarkdownDocuments'>>
| Pick<IFilesystemProvider, 'listMarkdownDocuments' | 'listFiles'>
function hasMarkdownListing(
provider: MarkdownListingProvider
): provider is Required<Pick<IFilesystemProvider, 'listMarkdownDocuments'>> {
return Boolean(provider.listMarkdownDocuments)
}
export async function listFilesystemMarkdownDocuments(
provider: IFilesystemProvider,
provider: MarkdownListingProvider,
rootPath: string
) {
if (provider.listMarkdownDocuments) {
if (hasMarkdownListing(provider)) {
return provider.listMarkdownDocuments(rootPath)
}
const paths = await provider.listFiles(rootPath)
+18 -18
View File
@@ -28,24 +28,24 @@ export type LocalFilesystemProviderOptions = {
* Contract methods with a runtime-command local twin. Reads, listing and watching keep
* their existing local call sites: their local limits and result shapes differ from the contract.
*/
export type LocalFilesystemProvider = Pick<
IFilesystemProvider,
| 'readDir'
| 'readDocPreviewFile'
| 'writeFile'
| 'writeFileBase64'
| 'writeFileBase64Chunk'
| 'pathsExist'
| 'stat'
| 'deletePath'
| 'createFile'
| 'createDir'
| 'createDirNoClobber'
| 'renameNoClobber'
| 'copy'
| 'search'
| 'listMarkdownDocuments'
>
export type LocalFilesystemProvider = Required<Pick<IFilesystemProvider, 'listMarkdownDocuments'>> &
Pick<
IFilesystemProvider,
| 'readDir'
| 'readDocPreviewFile'
| 'writeFile'
| 'writeFileBase64'
| 'writeFileBase64Chunk'
| 'pathsExist'
| 'stat'
| 'deletePath'
| 'createFile'
| 'createDir'
| 'createDirNoClobber'
| 'renameNoClobber'
| 'copy'
| 'search'
>
/** This machine's file operations behind the store's path authorization; build one per call. */
export function createLocalFilesystemProvider(
+2 -2
View File
@@ -1,6 +1,6 @@
import { RuntimeFileCommandsWithSearchRemoteQuickOpenFilePaths } from './runtime-file-commands-search-remote-quick-open-file-paths'
import { RuntimeFileCommandsWithCreateFileExplorerDirNoClobber } from './runtime-file-commands-create-file-explorer-dir-no-clobber'
export class RuntimeFileCommands extends RuntimeFileCommandsWithSearchRemoteQuickOpenFilePaths {}
export class RuntimeFileCommands extends RuntimeFileCommandsWithCreateFileExplorerDirNoClobber {}
export { RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES } from './runtime-file-commands-mobile-file-list-limit'
export { WINDOWS_RUNTIME_FILE_WATCH_CLOSE_DEADLINE_MS } from './runtime-file-commands-mobile-file-list-limit'
export { awaitRuntimeFileWatcherUnsubscribes } from './runtime-file-watcher-leases'
@@ -0,0 +1,205 @@
import { RuntimeFileSearch } from './runtime-file-search'
import { listFilesystemMarkdownDocuments } from '../providers/filesystem-markdown-listing'
import type { MarkdownDocument } from '../../shared/filesystem-entry-types'
import {
validatePathExistenceBatch,
type PathExistenceResult
} from '../../shared/path-existence-batch'
import { readRuntimeFilePathExistence } from './runtime-file-path-existence'
import {
isMobileBinaryPath,
isMobileMarkdownPath,
isSafeMobileRelativePath,
type RuntimeFileCommandHost
} from './runtime-file-command-host'
import type { RuntimeFileOpenResult } from '../../shared/runtime-types'
import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation'
import {
isMobilePreviewableImagePath,
type TerminalFileGrant
} from './runtime-file-commands-mobile-file-list-limit'
import { joinWorktreeRelativePath, normalizeRuntimeRelativePath } from './runtime-relative-paths'
import { isENOENT } from '../ipc/filesystem-path-containment'
import {
requireRuntimeFileProvider,
runtimeFileRouteForTarget,
type RuntimeFileExplorerPath
} from './runtime-file-command-target'
import { parseExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
import type { FileStat } from '../providers/types'
export class RuntimeFileCommandAccess {
protected readonly terminalFileGrants = new Map<string, TerminalFileGrant>()
private readonly search: RuntimeFileSearch
constructor(protected readonly host: RuntimeFileCommandHost) {
this.search = new RuntimeFileSearch(host)
}
listMobileFiles(...args: Parameters<RuntimeFileSearch['listMobileFiles']>) {
return this.search.listMobileFiles(...args)
}
searchMobileFilePaths(...args: Parameters<RuntimeFileSearch['searchMobileFilePaths']>) {
return this.search.searchMobileFilePaths(...args)
}
searchQuickOpenFilePaths(...args: Parameters<RuntimeFileSearch['searchQuickOpenFilePaths']>) {
return this.search.searchQuickOpenFilePaths(...args)
}
searchRuntimeFiles(...args: Parameters<RuntimeFileSearch['searchRuntimeFiles']>) {
return this.search.searchRuntimeFiles(...args)
}
listRuntimeFiles(...args: Parameters<RuntimeFileSearch['listRuntimeFiles']>) {
return this.search.listRuntimeFiles(...args)
}
async openMobileFile(
worktreeSelector: string,
relativePath: string,
navigation?: RuntimeNavigationTarget
): Promise<RuntimeFileOpenResult> {
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
const { worktree } = target
if (!isSafeMobileRelativePath(relativePath)) {
throw new Error('invalid_relative_path')
}
const kind = isMobilePreviewableImagePath(relativePath)
? 'image'
: isMobileBinaryPath(relativePath)
? 'binary'
: isMobileMarkdownPath(relativePath)
? 'markdown'
: 'text'
// Why: `kind` only describes the file; the desktop editor opens binaries (e.g. PDFs) like the File Explorer.
const filePath = joinWorktreeRelativePath(worktree.path, relativePath)
// Why: CLI/agents treat opened:true as success; stat first so missing paths and directories fail the RPC instead of opening a ghost tab.
await this.assertOpenTargetIsFile(filePath, target)
this.host.openFile(
worktree.id,
filePath,
relativePath,
runtimeOwnerForTarget(target),
navigation
)
return { worktree: worktree.id, relativePath, kind, opened: true }
}
protected async assertOpenTargetIsFile(
filePath: string,
target: { executionHostId: ExecutionHostId }
): Promise<void> {
const route = runtimeFileRouteForTarget(target)
let stats: FileStat
try {
stats = await requireRuntimeFileProvider(target, this.host, route).stat(filePath)
} catch (error) {
if (
isENOENT(error) ||
(route.kind === 'ssh' && RuntimeFileCommandAccess.isRemoteNotFoundErrorMessage(error))
) {
throw new Error(`ENOENT: no such file or directory, open '${filePath}'`)
}
throw error
}
if (stats.type === 'directory') {
throw new Error(`EISDIR: illegal operation on a directory, open '${filePath}'`)
}
}
async openMobileDiff(
worktreeSelector: string,
relativePath: string,
staged: boolean,
navigation?: RuntimeNavigationTarget
): Promise<RuntimeFileOpenResult> {
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
const { worktree } = target
if (!isSafeMobileRelativePath(relativePath)) {
throw new Error('invalid_relative_path')
}
const kind = isMobileBinaryPath(relativePath)
? 'binary'
: isMobileMarkdownPath(relativePath)
? 'markdown'
: 'text'
const filePath = joinWorktreeRelativePath(worktree.path, relativePath)
this.host.openDiff(
worktree.id,
filePath,
relativePath,
staged,
runtimeOwnerForTarget(target),
navigation
)
return { worktree: worktree.id, relativePath, kind, opened: true }
}
protected async resolveFileExplorerPath(
worktreeSelector: string,
relativePath: string
): Promise<RuntimeFileExplorerPath> {
const [target] = await this.resolveFileExplorerPaths(worktreeSelector, [relativePath])
return target
}
protected async resolveFileExplorerPaths(
worktreeSelector: string,
relativePaths: readonly string[]
): Promise<RuntimeFileExplorerPath[]> {
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
return relativePaths.map((relativePath) => ({
worktree: target.worktree,
path: joinWorktreeRelativePath(
target.worktree.path,
normalizeRuntimeRelativePath(relativePath, target.worktree.path)
),
executionHostId: target.executionHostId
}))
}
async listRuntimeMarkdownDocuments(worktreeSelector: string): Promise<MarkdownDocument[]> {
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
return listFilesystemMarkdownDocuments(
requireRuntimeFileProvider(target, this.host),
target.worktree.path
)
}
async pathsExistRuntimeFiles(
worktreeSelector: string,
relativePaths: string[]
): Promise<PathExistenceResult[]> {
validatePathExistenceBatch(relativePaths)
const targets = await this.resolveFileExplorerPaths(worktreeSelector, relativePaths)
return readRuntimeFilePathExistence(targets, this.host)
}
async statRuntimeFile(
worktreeSelector: string,
relativePath: string
): Promise<{ size: number; isDirectory: boolean; mtime: number; ctime?: number }> {
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
const fileStat = await requireRuntimeFileProvider(target, this.host).stat(target.path)
return {
size: fileStat.size,
isDirectory: fileStat.type === 'directory',
mtime: fileStat.mtime,
...(fileStat.ctimeMs === undefined ? {} : { ctime: fileStat.ctimeMs })
}
}
// The mux drops ErrnoException.code, so match not-found by message shape (vs transport/permission/provider errors).
protected static isRemoteNotFoundErrorMessage(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error)
return /\bENOENT\b|no such file|not found|does not exist/i.test(message)
}
}
// Why: name the resolved owner; undefined would let the renderer fall back to the focused server.
function runtimeOwnerForTarget(target: { executionHostId: ExecutionHostId }): string | null {
const host = parseExecutionHostId(target.executionHostId)
return host?.kind === 'runtime' ? host.environmentId : null
}
@@ -77,7 +77,7 @@ export function requireRuntimeFileProvider(
target: { executionHostId: ExecutionHostId },
host: RuntimeLocalFileHost,
route: RuntimeFileRoute = runtimeFileRouteForTarget(target)
): LocalFilesystemProvider {
): LocalFilesystemProvider | IFilesystemProvider {
if (route.kind === 'ssh') {
return requireSshRuntimeFileProvider(route)
}
@@ -1,25 +0,0 @@
// @ts-nocheck -- mechanically split class members.
import type { ChildProcessHandle } from '@orca/process-host/process-spec'
import type { TerminalFileGrant } from './runtime-file-commands-mobile-file-list-limit'
import {
MOBILE_FILE_PATH_SEARCH_CACHE_ENTRIES,
MOBILE_FILE_PATH_SEARCH_CACHE_TTL_MS
} from './runtime-file-commands-mobile-file-list-limit'
import { RuntimeMobileFilePathSearchCache } from './runtime-mobile-file-path-search'
export class RuntimeFileCommandsWithActiveRuntimeTextSearches {
protected activeRuntimeTextSearches = new Map<string, ChildProcessHandle>()
protected terminalFileGrants = new Map<string, TerminalFileGrant>()
protected mobileFilePathSearchCache = new RuntimeMobileFilePathSearchCache(
MOBILE_FILE_PATH_SEARCH_CACHE_ENTRIES,
MOBILE_FILE_PATH_SEARCH_CACHE_TTL_MS
)
// The mux drops ErrnoException.code, so match not-found by message shape (vs transport/permission/provider errors).
protected static isRemoteNotFoundErrorMessage(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error)
return /\bENOENT\b|no such file|not found|does not exist/i.test(message)
}
}
@@ -1,5 +1,6 @@
// @ts-nocheck -- mechanically split class members.
import { RuntimeFileCommandsWithConstructor } from './runtime-file-commands-constructor'
import type { IFilesystemProvider } from '../providers/types'
import { MOBILE_FILE_READ_MAX_BYTES } from './runtime-file-commands-mobile-file-list-limit'
import { RuntimeFileCommandAccess } from './runtime-file-command-access'
import type { RuntimeFileReadResult } from '../../shared/runtime-types'
import { isMobileBinaryPath, isSafeMobileRelativePath } from './runtime-file-command-host'
import { joinWorktreeRelativePath } from './runtime-relative-paths'
@@ -10,7 +11,7 @@ import {
runtimeFileRouteForTarget
} from './runtime-file-command-target'
export class RuntimeFileCommandsWithReadMobileFile extends RuntimeFileCommandsWithConstructor {
export class RuntimeFileCommandsWithReadMobileFile extends RuntimeFileCommandAccess {
async readMobileFile(
worktreeSelector: string,
relativePath: string
@@ -42,4 +43,20 @@ export class RuntimeFileCommandsWithReadMobileFile extends RuntimeFileCommandsWi
byteLength: truncated.byteLength
}
}
protected async readRemoteMobileFile(
filePath: string,
provider: IFilesystemProvider
): Promise<string> {
const fileStat = await provider.stat(filePath)
// Why: no ranged reads over SSH here, so reject oversized previews instead of streaming a whole file just to trim it.
if (fileStat.size > MOBILE_FILE_READ_MAX_BYTES) {
throw new Error('file_too_large')
}
const result = await provider.readFile(filePath)
if (result.isBinary) {
throw new Error('binary_file')
}
return result.content
}
}
@@ -1,6 +1,6 @@
// @ts-nocheck -- mechanically split class members.
import { RuntimeFileCommandsWithReadMobileFile } from './runtime-file-commands-read-mobile-file'
import { RuntimeFileCommandsWithActiveRuntimeTextSearches as RuntimeFileCommands } from './runtime-file-commands-active-runtime-text-searches'
import { RuntimeFileCommandAccess as RuntimeFileCommands } from './runtime-file-command-access'
import type {
RuntimeNativeChatFileContext,
RuntimeTerminalPathResolution
@@ -1,43 +0,0 @@
// @ts-nocheck -- mechanically split class members.
import { RuntimeFileCommandsWithSearchRuntimeFiles } from './runtime-file-commands-search-runtime-files'
import type { RuntimeFileExplorerPath } from './runtime-file-command-target'
import type { IFilesystemProvider } from '../providers/types'
import { joinWorktreeRelativePath, normalizeRuntimeRelativePath } from './runtime-relative-paths'
export class RuntimeFileCommandsWithSearchLocalRuntimeFiles extends RuntimeFileCommandsWithSearchRuntimeFiles {
protected async resolveFileExplorerPath(
worktreeSelector: string,
relativePath: string
): Promise<RuntimeFileExplorerPath> {
const [target] = await this.resolveFileExplorerPaths(worktreeSelector, [relativePath])
return target
}
protected async resolveFileExplorerPaths(
worktreeSelector: string,
relativePaths: readonly string[]
): Promise<RuntimeFileExplorerPath[]> {
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
return relativePaths.map((relativePath) => ({
worktree: target.worktree,
path: joinWorktreeRelativePath(
target.worktree.path,
normalizeRuntimeRelativePath(relativePath, target.worktree.path)
),
executionHostId: target.executionHostId
}))
}
// `null` provider is the caller's "this host is unreachable" answer, not "list it here".
protected async listRemoteMobileFiles(
rootPath: string,
provider: IFilesystemProvider | null,
maxResults?: number,
signal?: AbortSignal
): Promise<string[]> {
if (!provider) {
return []
}
return provider.listFiles(rootPath, { maxResults, signal })
}
}
@@ -1,79 +0,0 @@
// @ts-nocheck -- mechanically split class members.
import { RuntimeFileCommandsWithSearchLocalRuntimeFiles } from './runtime-file-commands-search-local-runtime-files'
import { resolveSshQuickOpenDiscoveryOptions } from '../providers/ssh-quick-open-discovery-options'
import type { IFilesystemProvider } from '../providers/types'
import {
MOBILE_FILE_READ_MAX_BYTES,
QUICK_OPEN_LEGACY_REMOTE_RESULT_LIMIT
} from './runtime-file-commands-mobile-file-list-limit'
import { QuickOpenPathRanker } from '../../shared/quick-open-path-search'
export class RuntimeFileCommandsWithSearchRemoteQuickOpenFilePaths extends RuntimeFileCommandsWithSearchLocalRuntimeFiles {
protected async searchRemoteQuickOpenFilePaths(
rootPath: string,
// `null` is "remote and currently unreachable": quick open reports no matches rather than
// failing the keystroke, but it never falls back to searching this machine.
provider: IFilesystemProvider | null,
query: string,
limit: number,
excludePaths?: string[],
signal?: AbortSignal,
options: {
includeIgnored?: boolean
followSymlinks?: boolean
allowLegacyIncludeIgnored?: boolean
} = {}
): Promise<{ paths: string[]; totalCount: number; truncated: boolean }> {
if (!provider) {
return { paths: [], totalCount: 0, truncated: false }
}
const discovery = await resolveSshQuickOpenDiscoveryOptions(provider, options, signal)
if (!(await provider.supportsQuickOpenSearch?.({ signal, minimumVersion: 1 }))) {
// Old relays ignore searchQuery. Keep the compatibility request below the
// 4 MiB frame ceiling even when legacy paths are near the 64 KiB path cap.
const legacyFiles = await provider.listFiles(rootPath, {
excludePaths,
maxResults: QUICK_OPEN_LEGACY_REMOTE_RESULT_LIMIT,
signal
})
const ranker = new QuickOpenPathRanker(query, limit)
for (const file of legacyFiles) {
ranker.consider(file)
}
const result = ranker.result()
return {
...result,
truncated:
legacyFiles.length >= QUICK_OPEN_LEGACY_REMOTE_RESULT_LIMIT || result.totalCount > limit
}
}
const files = await provider.listFiles(rootPath, {
excludePaths,
maxResults: limit + 1,
...discovery,
searchQuery: query,
signal
})
return {
paths: files.slice(0, limit),
totalCount: files.length,
truncated: files.length > limit
}
}
protected async readRemoteMobileFile(
filePath: string,
provider: IFilesystemProvider
): Promise<string> {
const fileStat = await provider.stat(filePath)
// Why: no ranged reads over SSH here, so reject oversized previews instead of streaming a whole file just to trim it.
if (fileStat.size > MOBILE_FILE_READ_MAX_BYTES) {
throw new Error('file_too_large')
}
const result = await provider.readFile(filePath)
if (result.isBinary) {
throw new Error('binary_file')
}
return result.content
}
}
@@ -1,141 +0,0 @@
// @ts-nocheck -- mechanically split class members.
import { RuntimeFileCommandsWithCreateFileExplorerDirNoClobber } from './runtime-file-commands-create-file-explorer-dir-no-clobber'
import { listFilesystemMarkdownDocuments } from '../providers/filesystem-markdown-listing'
import type { SearchOptions, SearchResult } from '../../shared/code-search-types'
import {
requireRuntimeFileProvider,
runtimeFileRouteForTarget
} from './runtime-file-command-target'
import { randomUUID } from 'node:crypto'
import { QUICK_OPEN_LISTING_MAX_RESULTS } from '../../shared/quick-open-listing-limits'
import { limitQuickOpenFilesBySerializedBytes } from '../../shared/quick-open-transport-budget'
import { listQuickOpenFiles } from '../ipc/filesystem-list-files'
import type { MarkdownDocument } from '../../shared/filesystem-entry-types'
import {
validatePathExistenceBatch,
type PathExistenceResult
} from '../../shared/path-existence-batch'
import { readRuntimeFilePathExistence } from './runtime-file-path-existence'
import { throwIfSignalAborted, waitForPromiseWithSignal } from '../../shared/abort-signal-reason'
export class RuntimeFileCommandsWithSearchRuntimeFiles extends RuntimeFileCommandsWithCreateFileExplorerDirNoClobber {
async searchRuntimeFiles(
worktreeSelector: string,
options: Omit<SearchOptions, 'rootPath'>,
requestOptions: { signal?: AbortSignal } = {}
): Promise<SearchResult> {
throwIfSignalAborted(requestOptions.signal)
const target = await waitForPromiseWithSignal(
this.host.resolveRuntimeFileTarget(worktreeSelector),
requestOptions.signal
)
throwIfSignalAborted(requestOptions.signal)
const provider = requireRuntimeFileProvider(target, {
requireStore: () => this.host.requireStore(),
// Why: local ripgrep children stay registered with this command set for cancellation.
onTextSearchSpawn: (child) => {
const searchKey = randomUUID()
this.activeRuntimeTextSearches.set(searchKey, child)
return () => {
if (this.activeRuntimeTextSearches.get(searchKey) === child) {
this.activeRuntimeTextSearches.delete(searchKey)
}
}
}
})
return provider.search({ ...options, rootPath: target.worktree.path }, requestOptions)
}
async listRuntimeFiles(
worktreeSelector: string,
options: {
candidatePaths?: string[]
includeIgnored?: boolean
followSymlinks?: boolean
excludePaths?: string[]
maxContentBytes?: number
maxResults?: number
signal?: AbortSignal
} = {}
): Promise<string[]> {
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
const route = runtimeFileRouteForTarget(target)
if (route.kind === 'ssh') {
// Why: quick-open listings degrade to empty for an unreachable host rather than throwing.
const provider = route.provider
if (!provider) {
return []
}
const maxResults =
options.maxResults ??
(options.maxContentBytes === undefined ? undefined : QUICK_OPEN_LISTING_MAX_RESULTS)
if (
(options.candidatePaths !== undefined ||
options.includeIgnored === false ||
options.followSymlinks) &&
!(await provider.supportsQuickOpenSearch?.({
signal: options.signal,
minimumVersion: options.candidatePaths !== undefined ? 3 : 2
}))
) {
throw new Error(
options.candidatePaths !== undefined
? 'Update the remote host to validate Quick Open recent files.'
: 'Update the remote host to use Quick Open listing options.'
)
}
const files = await provider.listFiles(target.worktree.path, {
excludePaths: options.excludePaths,
...(options.candidatePaths === undefined ? {} : { candidatePaths: options.candidatePaths }),
includeIgnored: options.includeIgnored,
followSymlinks: options.followSymlinks,
maxResults,
signal: options.signal
})
return options.maxContentBytes === undefined
? files
: limitQuickOpenFilesBySerializedBytes(files, options.maxContentBytes)
}
return listQuickOpenFiles(
target.worktree.path,
this.host.requireStore(),
options.excludePaths,
options.signal,
options.maxResults,
options.maxContentBytes,
undefined,
options
)
}
async listRuntimeMarkdownDocuments(worktreeSelector: string): Promise<MarkdownDocument[]> {
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
return listFilesystemMarkdownDocuments(
requireRuntimeFileProvider(target, this.host),
target.worktree.path
)
}
async pathsExistRuntimeFiles(
worktreeSelector: string,
relativePaths: string[]
): Promise<PathExistenceResult[]> {
validatePathExistenceBatch(relativePaths)
const targets = await this.resolveFileExplorerPaths(worktreeSelector, relativePaths)
return readRuntimeFilePathExistence(targets, this.host)
}
async statRuntimeFile(
worktreeSelector: string,
relativePath: string
): Promise<{ size: number; isDirectory: boolean; mtime: number; ctime?: number }> {
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
const fileStat = await requireRuntimeFileProvider(target, this.host).stat(target.path)
return {
size: fileStat.size,
isDirectory: fileStat.type === 'directory',
mtime: fileStat.mtime,
...(fileStat.ctimeMs === undefined ? {} : { ctime: fileStat.ctimeMs })
}
}
}
@@ -0,0 +1,78 @@
import { describe, expect, it, vi } from 'vitest'
import { ExecutionHostNotDispatchableError } from '../providers/execution-host-provider-dispatch'
import { SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE } from '../providers/ssh-filesystem-dispatch'
import { RuntimeFileSearch, type RuntimeFileSearchHost } from './runtime-file-search'
function createSearch(executionHostId: 'local' | 'ssh:unavailable' | 'runtime:other' = 'local') {
const requireStore = vi.fn(() => {
throw new Error('Must not access local storage')
})
const resolveRuntimeFileTarget = vi.fn(async () => ({
worktree: { id: 'folder-1', path: '/same-root' },
executionHostId
}))
const search = new RuntimeFileSearch({ requireStore, resolveRuntimeFileTarget })
return { search, requireStore, resolveRuntimeFileTarget }
}
describe('RuntimeFileSearch', () => {
it('rejects an already-aborted request before resolving its workspace', async () => {
const { search, requireStore, resolveRuntimeFileTarget } = createSearch()
const controller = new AbortController()
controller.abort()
await expect(
search.searchRuntimeFiles('folder-1', { query: 'needle' }, { signal: controller.signal })
).rejects.toMatchObject({ name: 'AbortError' })
expect(resolveRuntimeFileTarget).not.toHaveBeenCalled()
expect(requireStore).not.toHaveBeenCalled()
})
it('settles an abort while workspace resolution remains pending', async () => {
const { requireStore } = createSearch()
const resolveRuntimeFileTarget = vi.fn(
() =>
new Promise<Awaited<ReturnType<RuntimeFileSearchHost['resolveRuntimeFileTarget']>>>(
() => {}
)
)
const search = new RuntimeFileSearch({ requireStore, resolveRuntimeFileTarget })
const controller = new AbortController()
const pending = search.searchRuntimeFiles(
'folder-1',
{ query: 'needle' },
{ signal: controller.signal }
)
controller.abort()
await expect(pending).rejects.toMatchObject({ name: 'AbortError' })
expect(resolveRuntimeFileTarget).toHaveBeenCalledOnce()
expect(requireStore).not.toHaveBeenCalled()
})
it('never searches this machine for a workspace owned by another runtime', async () => {
const { search, requireStore } = createSearch('runtime:other')
await expect(search.searchRuntimeFiles('folder-1', { query: 'needle' })).rejects.toBeInstanceOf(
ExecutionHostNotDispatchableError
)
await expect(search.listRuntimeFiles('folder-1')).rejects.toBeInstanceOf(
ExecutionHostNotDispatchableError
)
expect(requireStore).not.toHaveBeenCalled()
})
it('preserves distinct search and listing results for unavailable SSH hosts', async () => {
const { search, requireStore } = createSearch('ssh:unavailable')
await expect(search.searchRuntimeFiles('folder-1', { query: 'needle' })).rejects.toThrow(
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE
)
await expect(search.listRuntimeFiles('folder-1')).resolves.toEqual([])
await expect(search.searchQuickOpenFilePaths('folder-1', 'needle', 10)).resolves.toEqual({
worktree: 'folder-1',
rootPath: '/same-root',
files: [],
totalCount: 0,
truncated: false,
quickOpenSearchVersion: 0
})
expect(requireStore).not.toHaveBeenCalled()
})
})
@@ -1,42 +1,52 @@
// @ts-nocheck -- mechanically split class members.
import {
QUICK_OPEN_SEARCH_VERSION,
isQuickOpenQueryTooLarge
} from '../../shared/quick-open-path-search'
import {
RuntimeFileCommandsWithActiveRuntimeTextSearches,
RuntimeFileCommandsWithActiveRuntimeTextSearches as RuntimeFileCommands
} from './runtime-file-commands-active-runtime-text-searches'
import type { RuntimeFileCommandHost } from './runtime-file-command-host'
import {
isMobileBinaryPath,
isMobileMarkdownPath,
isSafeMobileRelativePath
} from './runtime-file-command-host'
import { basenameFromRelativePath } from './runtime-file-paths'
import type { RuntimeFileListResult, RuntimeFileOpenResult } from '../../shared/runtime-types'
import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation'
import type { RuntimeFileListResult } from '../../shared/runtime-types'
import type { SearchOptions, SearchResult } from '../../shared/code-search-types'
import { throwIfSignalAborted, waitForPromiseWithSignal } from '../../shared/abort-signal-reason'
import { QUICK_OPEN_LISTING_MAX_RESULTS } from '../../shared/quick-open-listing-limits'
import { limitQuickOpenFilesBySerializedBytes } from '../../shared/quick-open-transport-budget'
import { listQuickOpenFiles } from '../ipc/filesystem-list-files'
import { searchQuickOpenFilePaths as searchHostQuickOpenFilePaths } from '../ipc/filesystem-search-file-paths'
import type { IFilesystemProvider } from '../providers/types'
import { isMobileBinaryPath, isSafeMobileRelativePath } from './runtime-file-command-host'
import {
requireRuntimeFileProvider,
runtimeFileRouteForTarget,
type RuntimeLocalFileHost,
type ResolvedRuntimeFileTarget
} from './runtime-file-command-target'
import {
MOBILE_FILE_LIST_LIMIT,
MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT,
isMobilePreviewableImagePath
MOBILE_FILE_PATH_SEARCH_CACHE_ENTRIES,
MOBILE_FILE_PATH_SEARCH_CACHE_TTL_MS
} from './runtime-file-commands-mobile-file-list-limit'
import { rankRuntimeMobileFilePaths } from './runtime-mobile-file-path-search'
import { searchQuickOpenFilePaths as searchHostQuickOpenFilePaths } from '../ipc/filesystem-search-file-paths'
import { joinWorktreeRelativePath } from './runtime-relative-paths'
import { isENOENT } from '../ipc/filesystem-path-containment'
import { basenameFromRelativePath } from './runtime-file-paths'
import {
requireRuntimeFileProvider,
runtimeFileRouteForTarget
} from './runtime-file-command-target'
import { parseExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
import type { FileStat } from '../providers/types'
RuntimeMobileFilePathSearchCache,
rankRuntimeMobileFilePaths
} from './runtime-mobile-file-path-search'
import { searchRemoteQuickOpenFilePaths } from './runtime-remote-quick-open-search'
export class RuntimeFileCommandsWithConstructor extends RuntimeFileCommandsWithActiveRuntimeTextSearches {
constructor(private readonly host: RuntimeFileCommandHost) {
super()
}
type RuntimeFileSearchTarget = {
worktree: Pick<ResolvedRuntimeFileTarget['worktree'], 'id' | 'path'>
executionHostId: ResolvedRuntimeFileTarget['executionHostId']
}
export type RuntimeFileSearchHost = {
requireStore: RuntimeLocalFileHost['requireStore']
resolveRuntimeFileTarget(selector: string): Promise<RuntimeFileSearchTarget>
}
export class RuntimeFileSearch {
private readonly mobileFilePathSearchCache = new RuntimeMobileFilePathSearchCache(
MOBILE_FILE_PATH_SEARCH_CACHE_ENTRIES,
MOBILE_FILE_PATH_SEARCH_CACHE_TTL_MS
)
constructor(private readonly host: RuntimeFileSearchHost) {}
async listMobileFiles(
worktreeSelector: string,
@@ -159,7 +169,7 @@ export class RuntimeFileCommandsWithConstructor extends RuntimeFileCommandsWithA
!query.trim() || isQuickOpenQueryTooLarge(query)
? { paths: [], totalCount: 0, truncated: false }
: route.kind === 'ssh'
? await this.searchRemoteQuickOpenFilePaths(
? await searchRemoteQuickOpenFilePaths(
worktree.path,
route.provider,
query,
@@ -189,90 +199,95 @@ export class RuntimeFileCommandsWithConstructor extends RuntimeFileCommandsWithA
}
}
async openMobileFile(
async searchRuntimeFiles(
worktreeSelector: string,
relativePath: string,
navigation?: RuntimeNavigationTarget
): Promise<RuntimeFileOpenResult> {
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
const { worktree } = target
if (!isSafeMobileRelativePath(relativePath)) {
throw new Error('invalid_relative_path')
}
const kind = isMobilePreviewableImagePath(relativePath)
? 'image'
: isMobileBinaryPath(relativePath)
? 'binary'
: isMobileMarkdownPath(relativePath)
? 'markdown'
: 'text'
// Why: `kind` only describes the file; the desktop editor opens binaries (e.g. PDFs) like the File Explorer.
const filePath = joinWorktreeRelativePath(worktree.path, relativePath)
// Why: CLI/agents treat opened:true as success; stat first so missing paths and directories fail the RPC instead of opening a ghost tab.
await this.assertOpenTargetIsFile(filePath, target)
this.host.openFile(
worktree.id,
filePath,
relativePath,
runtimeOwnerForTarget(target),
navigation
options: Omit<SearchOptions, 'rootPath'>,
requestOptions: { signal?: AbortSignal } = {}
): Promise<SearchResult> {
throwIfSignalAborted(requestOptions.signal)
const target = await waitForPromiseWithSignal(
this.host.resolveRuntimeFileTarget(worktreeSelector),
requestOptions.signal
)
return { worktree: worktree.id, relativePath, kind, opened: true }
throwIfSignalAborted(requestOptions.signal)
const provider = requireRuntimeFileProvider(target, {
requireStore: () => this.host.requireStore()
})
return provider.search({ ...options, rootPath: target.worktree.path }, requestOptions)
}
protected async assertOpenTargetIsFile(
filePath: string,
target: { executionHostId: ExecutionHostId }
): Promise<void> {
async listRuntimeFiles(
worktreeSelector: string,
options: {
candidatePaths?: string[]
includeIgnored?: boolean
followSymlinks?: boolean
excludePaths?: string[]
maxContentBytes?: number
maxResults?: number
signal?: AbortSignal
} = {}
): Promise<string[]> {
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
const route = runtimeFileRouteForTarget(target)
let stats: FileStat
try {
stats = await requireRuntimeFileProvider(target, this.host, route).stat(filePath)
} catch (error) {
if (
isENOENT(error) ||
(route.kind === 'ssh' && RuntimeFileCommands.isRemoteNotFoundErrorMessage(error))
) {
throw new Error(`ENOENT: no such file or directory, open '${filePath}'`)
if (route.kind === 'ssh') {
// Why: quick-open listings degrade to empty for an unreachable host rather than throwing.
const provider = route.provider
if (!provider) {
return []
}
throw error
const maxResults =
options.maxResults ??
(options.maxContentBytes === undefined ? undefined : QUICK_OPEN_LISTING_MAX_RESULTS)
if (
(options.candidatePaths !== undefined ||
options.includeIgnored === false ||
options.followSymlinks) &&
!(await provider.supportsQuickOpenSearch?.({
signal: options.signal,
minimumVersion: options.candidatePaths !== undefined ? 3 : 2
}))
) {
throw new Error(
options.candidatePaths !== undefined
? 'Update the remote host to validate Quick Open recent files.'
: 'Update the remote host to use Quick Open listing options.'
)
}
const files = await provider.listFiles(target.worktree.path, {
excludePaths: options.excludePaths,
...(options.candidatePaths === undefined ? {} : { candidatePaths: options.candidatePaths }),
includeIgnored: options.includeIgnored,
followSymlinks: options.followSymlinks,
maxResults,
signal: options.signal
})
return options.maxContentBytes === undefined
? files
: limitQuickOpenFilesBySerializedBytes(files, options.maxContentBytes)
}
if (stats.type === 'directory') {
throw new Error(`EISDIR: illegal operation on a directory, open '${filePath}'`)
}
}
async openMobileDiff(
worktreeSelector: string,
relativePath: string,
staged: boolean,
navigation?: RuntimeNavigationTarget
): Promise<RuntimeFileOpenResult> {
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
const { worktree } = target
if (!isSafeMobileRelativePath(relativePath)) {
throw new Error('invalid_relative_path')
}
const kind = isMobileBinaryPath(relativePath)
? 'binary'
: isMobileMarkdownPath(relativePath)
? 'markdown'
: 'text'
const filePath = joinWorktreeRelativePath(worktree.path, relativePath)
this.host.openDiff(
worktree.id,
filePath,
relativePath,
staged,
runtimeOwnerForTarget(target),
navigation
return listQuickOpenFiles(
target.worktree.path,
this.host.requireStore(),
options.excludePaths,
options.signal,
options.maxResults,
options.maxContentBytes,
undefined,
options
)
return { worktree: worktree.id, relativePath, kind, opened: true }
}
// `null` provider is the caller's "this host is unreachable" answer, not "list it here".
private async listRemoteMobileFiles(
rootPath: string,
provider: IFilesystemProvider | null,
maxResults?: number,
signal?: AbortSignal
): Promise<string[]> {
if (!provider) {
return []
}
return provider.listFiles(rootPath, { maxResults, signal })
}
}
// Why: name the resolved owner; undefined would let the renderer fall back to the focused server.
function runtimeOwnerForTarget(target: { executionHostId: ExecutionHostId }): string | null {
const host = parseExecutionHostId(target.executionHostId)
return host?.kind === 'runtime' ? host.environmentId : null
}
@@ -0,0 +1,56 @@
import { resolveSshQuickOpenDiscoveryOptions } from '../providers/ssh-quick-open-discovery-options'
import type { IFilesystemProvider } from '../providers/types'
import { QUICK_OPEN_LEGACY_REMOTE_RESULT_LIMIT } from './runtime-file-commands-mobile-file-list-limit'
import { QuickOpenPathRanker } from '../../shared/quick-open-path-search'
export async function searchRemoteQuickOpenFilePaths(
rootPath: string,
// `null` is "remote and currently unreachable": quick open reports no matches rather than
// failing the keystroke, but it never falls back to searching this machine.
provider: IFilesystemProvider | null,
query: string,
limit: number,
excludePaths?: string[],
signal?: AbortSignal,
options: {
includeIgnored?: boolean
followSymlinks?: boolean
allowLegacyIncludeIgnored?: boolean
} = {}
): Promise<{ paths: string[]; totalCount: number; truncated: boolean }> {
if (!provider) {
return { paths: [], totalCount: 0, truncated: false }
}
const discovery = await resolveSshQuickOpenDiscoveryOptions(provider, options, signal)
if (!(await provider.supportsQuickOpenSearch?.({ signal, minimumVersion: 1 }))) {
// Old relays ignore searchQuery. Keep the compatibility request below the
// 4 MiB frame ceiling even when legacy paths are near the 64 KiB path cap.
const legacyFiles = await provider.listFiles(rootPath, {
excludePaths,
maxResults: QUICK_OPEN_LEGACY_REMOTE_RESULT_LIMIT,
signal
})
const ranker = new QuickOpenPathRanker(query, limit)
for (const file of legacyFiles) {
ranker.consider(file)
}
const result = ranker.result()
return {
...result,
truncated:
legacyFiles.length >= QUICK_OPEN_LEGACY_REMOTE_RESULT_LIMIT || result.totalCount > limit
}
}
const files = await provider.listFiles(rootPath, {
excludePaths,
maxResults: limit + 1,
...discovery,
searchQuery: query,
signal
})
return {
paths: files.slice(0, limit),
totalCount: files.length,
truncated: files.length > limit
}
}