From 6a77d05ee0c01d60d511eb168cd52a376a661033 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:12:36 -0700 Subject: [PATCH] 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. --- .../scripts/node-server-change-scope.test.mjs | 1 + config/ts-nocheck-baseline.txt | 6 - .../filesystem-markdown-listing.test.ts | 20 ++ .../providers/filesystem-markdown-listing.ts | 14 +- .../providers/local-filesystem-provider.ts | 36 +-- src/main/runtime/orca-runtime-files.ts | 4 +- .../runtime/runtime-file-command-access.ts | 205 ++++++++++++++++ .../runtime/runtime-file-command-target.ts | 2 +- ...e-commands-active-runtime-text-searches.ts | 25 -- .../runtime-file-commands-read-mobile-file.ts | 23 +- ...ime-file-commands-resolve-terminal-path.ts | 2 +- ...ile-commands-search-local-runtime-files.ts | 43 ---- ...nds-search-remote-quick-open-file-paths.ts | 79 ------ ...time-file-commands-search-runtime-files.ts | 141 ----------- src/main/runtime/runtime-file-search.test.ts | 78 ++++++ ...-constructor.ts => runtime-file-search.ts} | 227 ++++++++++-------- .../runtime-remote-quick-open-search.ts | 56 +++++ 17 files changed, 535 insertions(+), 427 deletions(-) create mode 100644 src/main/providers/filesystem-markdown-listing.test.ts create mode 100644 src/main/runtime/runtime-file-command-access.ts delete mode 100644 src/main/runtime/runtime-file-commands-active-runtime-text-searches.ts delete mode 100644 src/main/runtime/runtime-file-commands-search-local-runtime-files.ts delete mode 100644 src/main/runtime/runtime-file-commands-search-remote-quick-open-file-paths.ts delete mode 100644 src/main/runtime/runtime-file-commands-search-runtime-files.ts create mode 100644 src/main/runtime/runtime-file-search.test.ts rename src/main/runtime/{runtime-file-commands-constructor.ts => runtime-file-search.ts} (57%) create mode 100644 src/main/runtime/runtime-remote-quick-open-search.ts 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 + } +}