diff --git a/config/scripts/node-server-change-scope.test.mjs b/config/scripts/node-server-change-scope.test.mjs index 36e7031dc80..ba9ea2c6ecc 100644 --- a/config/scripts/node-server-change-scope.test.mjs +++ b/config/scripts/node-server-change-scope.test.mjs @@ -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 diff --git a/config/ts-nocheck-baseline.txt b/config/ts-nocheck-baseline.txt index 787e9ffe89a..454f522be4a 100644 --- a/config/ts-nocheck-baseline.txt +++ b/config/ts-nocheck-baseline.txt @@ -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 diff --git a/src/main/providers/filesystem-markdown-listing.test.ts b/src/main/providers/filesystem-markdown-listing.test.ts new file mode 100644 index 00000000000..e010ab9cc95 --- /dev/null +++ b/src/main/providers/filesystem-markdown-listing.test.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') +}) diff --git a/src/main/providers/filesystem-markdown-listing.ts b/src/main/providers/filesystem-markdown-listing.ts index e2a45e2c82d..106a8813ead 100644 --- a/src/main/providers/filesystem-markdown-listing.ts +++ b/src/main/providers/filesystem-markdown-listing.ts @@ -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 + +function hasMarkdownListing( + provider: MarkdownListingProvider +): provider is Required> { + 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) diff --git a/src/main/providers/local-filesystem-provider.ts b/src/main/providers/local-filesystem-provider.ts index 18f0e15f02e..0e4532fa97d 100644 --- a/src/main/providers/local-filesystem-provider.ts +++ b/src/main/providers/local-filesystem-provider.ts @@ -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, + | '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( diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index 75a59f606c5..6d030df33b0 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -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' diff --git a/src/main/runtime/runtime-file-command-access.ts b/src/main/runtime/runtime-file-command-access.ts new file mode 100644 index 00000000000..4bd27d12a89 --- /dev/null +++ b/src/main/runtime/runtime-file-command-access.ts @@ -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() + private readonly search: RuntimeFileSearch + + constructor(protected readonly host: RuntimeFileCommandHost) { + this.search = new RuntimeFileSearch(host) + } + + listMobileFiles(...args: Parameters) { + return this.search.listMobileFiles(...args) + } + + searchMobileFilePaths(...args: Parameters) { + return this.search.searchMobileFilePaths(...args) + } + + searchQuickOpenFilePaths(...args: Parameters) { + return this.search.searchQuickOpenFilePaths(...args) + } + + searchRuntimeFiles(...args: Parameters) { + return this.search.searchRuntimeFiles(...args) + } + + listRuntimeFiles(...args: Parameters) { + return this.search.listRuntimeFiles(...args) + } + + async openMobileFile( + worktreeSelector: string, + relativePath: string, + navigation?: RuntimeNavigationTarget + ): Promise { + 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 { + 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 { + 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 { + const [target] = await this.resolveFileExplorerPaths(worktreeSelector, [relativePath]) + return target + } + + protected async resolveFileExplorerPaths( + worktreeSelector: string, + relativePaths: readonly string[] + ): Promise { + 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 { + const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) + return listFilesystemMarkdownDocuments( + requireRuntimeFileProvider(target, this.host), + target.worktree.path + ) + } + + async pathsExistRuntimeFiles( + worktreeSelector: string, + relativePaths: string[] + ): Promise { + 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 +} diff --git a/src/main/runtime/runtime-file-command-target.ts b/src/main/runtime/runtime-file-command-target.ts index 5d5dbb6f667..12d43a23f58 100644 --- a/src/main/runtime/runtime-file-command-target.ts +++ b/src/main/runtime/runtime-file-command-target.ts @@ -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) } diff --git a/src/main/runtime/runtime-file-commands-active-runtime-text-searches.ts b/src/main/runtime/runtime-file-commands-active-runtime-text-searches.ts deleted file mode 100644 index 99312a0b059..00000000000 --- a/src/main/runtime/runtime-file-commands-active-runtime-text-searches.ts +++ /dev/null @@ -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() - - protected terminalFileGrants = new Map() - - 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) - } -} diff --git a/src/main/runtime/runtime-file-commands-read-mobile-file.ts b/src/main/runtime/runtime-file-commands-read-mobile-file.ts index a417d534778..8dbfb2008df 100644 --- a/src/main/runtime/runtime-file-commands-read-mobile-file.ts +++ b/src/main/runtime/runtime-file-commands-read-mobile-file.ts @@ -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 { + 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 + } } diff --git a/src/main/runtime/runtime-file-commands-resolve-terminal-path.ts b/src/main/runtime/runtime-file-commands-resolve-terminal-path.ts index 8b6ca0c5c51..ac4aba2e3c8 100644 --- a/src/main/runtime/runtime-file-commands-resolve-terminal-path.ts +++ b/src/main/runtime/runtime-file-commands-resolve-terminal-path.ts @@ -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 diff --git a/src/main/runtime/runtime-file-commands-search-local-runtime-files.ts b/src/main/runtime/runtime-file-commands-search-local-runtime-files.ts deleted file mode 100644 index 1b3080b6a1c..00000000000 --- a/src/main/runtime/runtime-file-commands-search-local-runtime-files.ts +++ /dev/null @@ -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 { - const [target] = await this.resolveFileExplorerPaths(worktreeSelector, [relativePath]) - return target - } - - protected async resolveFileExplorerPaths( - worktreeSelector: string, - relativePaths: readonly string[] - ): Promise { - 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 { - if (!provider) { - return [] - } - return provider.listFiles(rootPath, { maxResults, signal }) - } -} diff --git a/src/main/runtime/runtime-file-commands-search-remote-quick-open-file-paths.ts b/src/main/runtime/runtime-file-commands-search-remote-quick-open-file-paths.ts deleted file mode 100644 index 195c054c384..00000000000 --- a/src/main/runtime/runtime-file-commands-search-remote-quick-open-file-paths.ts +++ /dev/null @@ -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 { - 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 - } -} diff --git a/src/main/runtime/runtime-file-commands-search-runtime-files.ts b/src/main/runtime/runtime-file-commands-search-runtime-files.ts deleted file mode 100644 index e5595e18115..00000000000 --- a/src/main/runtime/runtime-file-commands-search-runtime-files.ts +++ /dev/null @@ -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, - requestOptions: { signal?: AbortSignal } = {} - ): Promise { - 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 { - 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 { - const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) - return listFilesystemMarkdownDocuments( - requireRuntimeFileProvider(target, this.host), - target.worktree.path - ) - } - - async pathsExistRuntimeFiles( - worktreeSelector: string, - relativePaths: string[] - ): Promise { - 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 }) - } - } -} diff --git a/src/main/runtime/runtime-file-search.test.ts b/src/main/runtime/runtime-file-search.test.ts new file mode 100644 index 00000000000..ef3b8e522ae --- /dev/null +++ b/src/main/runtime/runtime-file-search.test.ts @@ -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>>( + () => {} + ) + ) + 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() + }) +}) diff --git a/src/main/runtime/runtime-file-commands-constructor.ts b/src/main/runtime/runtime-file-search.ts similarity index 57% rename from src/main/runtime/runtime-file-commands-constructor.ts rename to src/main/runtime/runtime-file-search.ts index 7f3603a88b2..15dd288b1fa 100644 --- a/src/main/runtime/runtime-file-commands-constructor.ts +++ b/src/main/runtime/runtime-file-search.ts @@ -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 + executionHostId: ResolvedRuntimeFileTarget['executionHostId'] +} + +export type RuntimeFileSearchHost = { + requireStore: RuntimeLocalFileHost['requireStore'] + resolveRuntimeFileTarget(selector: string): Promise +} + +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 { - 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, + requestOptions: { signal?: AbortSignal } = {} + ): Promise { + 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 { + async listRuntimeFiles( + worktreeSelector: string, + options: { + candidatePaths?: string[] + includeIgnored?: boolean + followSymlinks?: boolean + excludePaths?: string[] + maxContentBytes?: number + maxResults?: number + signal?: AbortSignal + } = {} + ): Promise { + 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 { - 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 { + 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 -} diff --git a/src/main/runtime/runtime-remote-quick-open-search.ts b/src/main/runtime/runtime-remote-quick-open-search.ts new file mode 100644 index 00000000000..58c121110ff --- /dev/null +++ b/src/main/runtime/runtime-remote-quick-open-search.ts @@ -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 + } +}