diff --git a/src/main/runtime/orca-runtime-files-watch.test.ts b/src/main/runtime/orca-runtime-files-watch.test.ts index 80fb652227d..7611178f6ca 100644 --- a/src/main/runtime/orca-runtime-files-watch.test.ts +++ b/src/main/runtime/orca-runtime-files-watch.test.ts @@ -52,6 +52,13 @@ function createRuntimeFileCommands(rootPath: string) { repoId: 'repo-1', path: rootPath })), + resolveRuntimeFileTarget: vi.fn(async () => ({ + worktree: { + id: 'wt-1', + repoId: 'repo-1', + path: rootPath + } + })), resolveRuntimeGitTarget: vi.fn(), openFile: vi.fn() } as never) diff --git a/src/main/runtime/orca-runtime-files.test.ts b/src/main/runtime/orca-runtime-files.test.ts index c794fbf3998..dbc4a94dce0 100644 --- a/src/main/runtime/orca-runtime-files.test.ts +++ b/src/main/runtime/orca-runtime-files.test.ts @@ -126,20 +126,28 @@ function createRuntimeFileCommands(options?: { path?: string openFile?: ReturnType openDiff?: ReturnType + resolveRuntimeFileTarget?: ReturnType resolveRuntimeGitTarget?: ReturnType }) { const store = { getRepo: vi.fn((_repoId?: string) => undefined as { connectionId?: string } | undefined) } const path = options?.path ?? '/repo' + const worktree = { + id: 'wt-1', + repoId: 'repo-1', + path + } const commands = new RuntimeFileCommands({ getRuntimeId: () => 'runtime-1', requireStore: () => store, - resolveWorktreeSelector: vi.fn(async () => ({ - id: 'wt-1', - repoId: 'repo-1', - path - })), + resolveWorktreeSelector: vi.fn(async () => worktree), + resolveRuntimeFileTarget: + options?.resolveRuntimeFileTarget ?? + vi.fn(async () => ({ + worktree, + connectionId: store.getRepo(worktree.repoId)?.connectionId + })), resolveRuntimeGitTarget: options?.resolveRuntimeGitTarget ?? vi.fn(), openFile: options?.openFile ?? vi.fn(), ...(options?.openDiff ? { openDiff: options.openDiff } : {}) @@ -427,7 +435,7 @@ describe('RuntimeFileCommands', () => { }) it('settles and detaches runtime rg searches when timeout kill is ignored', async () => { - const resolveRuntimeGitTarget = vi.fn(async () => ({ + const resolveRuntimeFileTarget = vi.fn(async () => ({ worktree: { id: 'wt-1', repoId: 'repo-1', @@ -435,7 +443,7 @@ describe('RuntimeFileCommands', () => { }, connectionId: null })) - const { commands } = createRuntimeFileCommands({ resolveRuntimeGitTarget }) + const { commands } = createRuntimeFileCommands({ resolveRuntimeFileTarget }) const child = createRuntimeSearchChild() resolveAuthorizedPathMock.mockResolvedValue('/repo') checkRgAvailableMock.mockResolvedValue(true) @@ -460,7 +468,7 @@ describe('RuntimeFileCommands', () => { }) it('routes runtime rg searches through the registered WSL project runtime', async () => { - const resolveRuntimeGitTarget = vi.fn(async () => ({ + const resolveRuntimeFileTarget = vi.fn(async () => ({ worktree: { id: 'wt-1', repoId: 'repo-1', @@ -468,7 +476,7 @@ describe('RuntimeFileCommands', () => { }, connectionId: null })) - const { commands, store } = createRuntimeFileCommands({ resolveRuntimeGitTarget }) + const { commands, store } = createRuntimeFileCommands({ resolveRuntimeFileTarget }) const child = createRuntimeSearchChild() resolveAuthorizedPathMock.mockResolvedValue('C:\\repo') checkRgAvailableMock.mockResolvedValue(true) diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index 5b4c6047c2d..07135039e63 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -143,11 +143,16 @@ export async function awaitRuntimeFileWatcherUnsubscribes(): Promise { } export type ResolvedRuntimeFileWorktree = Worktree & { git: GitWorktreeInfo } +export type ResolvedRuntimeFileTarget = { + worktree: ResolvedRuntimeFileWorktree + connectionId?: string +} export type RuntimeFileCommandHost = { getRuntimeId(): string requireStore(): Store resolveWorktreeSelector(selector: string): Promise + resolveRuntimeFileTarget(selector: string): Promise resolveRuntimeGitTarget( selector: string ): Promise<{ worktree: ResolvedRuntimeFileWorktree; connectionId?: string }> @@ -173,9 +178,8 @@ export class RuntimeFileCommands { async listMobileFiles(worktreeSelector: string): Promise { const store = this.host.requireStore() - const worktree = await this.host.resolveWorktreeSelector(worktreeSelector) - const repo = store.getRepo(worktree.repoId) - const connectionId = repo?.connectionId ?? undefined + const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) + const { worktree, connectionId } = target const files = connectionId ? await this.listRemoteMobileFiles(worktree.path, connectionId) : await listQuickOpenFiles(worktree.path, store) @@ -202,7 +206,7 @@ export class RuntimeFileCommands { worktreeSelector: string, relativePath: string ): Promise { - const worktree = await this.host.resolveWorktreeSelector(worktreeSelector) + const { worktree } = await this.host.resolveRuntimeFileTarget(worktreeSelector) if (!isSafeMobileRelativePath(relativePath)) { throw new Error('invalid_relative_path') } @@ -234,7 +238,7 @@ export class RuntimeFileCommands { relativePath: string, staged: boolean ): Promise { - const worktree = await this.host.resolveWorktreeSelector(worktreeSelector) + const { worktree } = await this.host.resolveRuntimeFileTarget(worktreeSelector) if (!isSafeMobileRelativePath(relativePath)) { throw new Error('invalid_relative_path') } @@ -254,7 +258,8 @@ export class RuntimeFileCommands { relativePath: string ): Promise { const store = this.host.requireStore() - const worktree = await this.host.resolveWorktreeSelector(worktreeSelector) + const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) + const { worktree, connectionId } = target if (!isSafeMobileRelativePath(relativePath)) { throw new Error('invalid_relative_path') } @@ -262,10 +267,9 @@ export class RuntimeFileCommands { throw new Error('binary_file') } - const repo = store.getRepo(worktree.repoId) const filePath = joinWorktreeRelativePath(worktree.path, relativePath) - const content = repo?.connectionId - ? await this.readRemoteMobileFile(filePath, repo.connectionId) + const content = connectionId + ? await this.readRemoteMobileFile(filePath, connectionId) : await readLocalMobileFile(filePath, store) const truncated = truncateMobileFilePreview(content) @@ -291,9 +295,8 @@ export class RuntimeFileCommands { cwd?: string | null ): Promise { const store = this.host.requireStore() - const worktree = await this.host.resolveWorktreeSelector(worktreeSelector) - const repo = store.getRepo(worktree.repoId) - const connectionId = repo?.connectionId ?? undefined + const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) + const { worktree, connectionId } = target const base = cwd && cwd.trim().length > 0 ? cwd : worktree.path const empty: RuntimeTerminalPathResolution = { @@ -764,7 +767,7 @@ export class RuntimeFileCommands { worktreeSelector: string, options: Omit ): Promise { - const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null const rootPath = target.worktree.path const searchOptions = { ...options, rootPath } @@ -781,7 +784,7 @@ export class RuntimeFileCommands { worktreeSelector: string, options: { excludePaths?: string[] } = {} ): Promise { - const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null if (target.connectionId) { if (!provider) { @@ -793,7 +796,7 @@ export class RuntimeFileCommands { } async listRuntimeMarkdownDocuments(worktreeSelector: string): Promise { - const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null if (target.connectionId) { if (!provider) { @@ -943,14 +946,12 @@ export class RuntimeFileCommands { worktreeSelector: string, relativePath: string ): Promise<{ worktree: ResolvedRuntimeFileWorktree; path: string; connectionId?: string }> { - const store = this.host.requireStore() - const worktree = await this.host.resolveWorktreeSelector(worktreeSelector) + const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) const normalizedRelativePath = normalizeRuntimeRelativePath(relativePath) - const repo = store.getRepo(worktree.repoId) return { - worktree, - path: joinWorktreeRelativePath(worktree.path, normalizedRelativePath), - connectionId: repo?.connectionId ?? undefined + worktree: target.worktree, + path: joinWorktreeRelativePath(target.worktree.path, normalizedRelativePath), + connectionId: target.connectionId } } diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 3aa0b1f5688..c1e9fdecb5e 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -360,7 +360,8 @@ vi.mock('../ipc/worktree-logic', async (importOriginal) => { vi.mock('../ipc/filesystem-auth', () => ({ invalidateAuthorizedRootsCache: invalidateAuthorizedRootsCacheMock, isENOENT: (error: unknown) => - Boolean(error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') + Boolean(error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT'), + resolveAuthorizedPath: vi.fn(async (pathValue: string) => pathValue) })) vi.mock('../worktree-root-preparation', () => ({ @@ -1867,6 +1868,74 @@ describe('OrcaRuntimeService', () => { expect(gitProvider.getStatus).toHaveBeenCalledWith('//Server/Share/Repo') }) + it.each([ + { label: 'canonical folder workspace selector', selector: TEST_FOLDER_WORKSPACE_KEY }, + { label: 'id-prefixed folder workspace selector', selector: `id:${TEST_FOLDER_WORKSPACE_KEY}` } + ])('reads file explorer paths for a $label', async ({ selector }) => { + const folderPath = await mkdtemp(join(tmpdir(), 'orca-runtime-folder-files-')) + await mkdir(join(folderPath, 'src')) + await writeFile(join(folderPath, 'src', 'app.ts'), 'export {}\n') + const folderWorkspace = makeFolderWorkspace({ folderPath }) + const projectGroup = makeFolderProjectGroup({ parentPath: folderPath }) + const runtime = new OrcaRuntimeService( + createFolderWorkspaceRuntimeStore(folderWorkspace, projectGroup) as never + ) + + await expect(runtime.readFileExplorerDir(selector, 'src')).resolves.toContainEqual({ + name: 'app.ts', + isDirectory: false, + isSymlink: false + }) + await expect(runtime.readFileExplorerPreview(selector, 'src/app.ts')).resolves.toMatchObject({ + content: 'export {}\n', + isBinary: false + }) + }) + + it('routes SSH folder workspace file explorer paths through the filesystem provider', async () => { + const folderPath = '/srv/platform' + const fsProvider = { + stat: vi.fn(async (pathValue: string) => ({ + size: pathValue.endsWith('/app.ts') ? 8 : 0, + type: pathValue.endsWith('/app.ts') ? 'file' : 'directory', + mtime: 1 + })), + readDir: vi.fn().mockResolvedValue([ + { + name: 'app.ts', + isDirectory: false, + isSymlink: false + } + ]), + readFile: vi.fn().mockResolvedValue({ content: 'remote\n', isBinary: false }) + } + const folderWorkspace = makeFolderWorkspace({ folderPath, connectionId: 'ssh-folder' }) + const projectGroup = makeFolderProjectGroup({ parentPath: folderPath }) + const runtime = new OrcaRuntimeService( + createFolderWorkspaceRuntimeStore(folderWorkspace, projectGroup) as never + ) + registerSshFilesystemProvider('ssh-folder', fsProvider as never) + + try { + await expect( + runtime.readFileExplorerDir(`id:${TEST_FOLDER_WORKSPACE_KEY}`, 'src') + ).resolves.toHaveLength(1) + await expect( + runtime.readFileExplorerPreview(`id:${TEST_FOLDER_WORKSPACE_KEY}`, 'src/app.ts') + ).resolves.toMatchObject({ + content: 'remote\n', + isBinary: false + }) + } finally { + unregisterSshFilesystemProvider('ssh-folder') + } + + expect(fsProvider.stat).toHaveBeenCalledWith(folderPath) + expect(fsProvider.readDir).toHaveBeenCalledWith('/srv/platform/src') + expect(fsProvider.stat).toHaveBeenCalledWith('/srv/platform/src/app.ts') + expect(fsProvider.readFile).toHaveBeenCalledWith('/srv/platform/src/app.ts') + }) + it('lists persisted SSH worktrees while the git provider is unavailable', async () => { vi.mocked(listWorktrees).mockClear() const remoteRepo = { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 4c421345834..f724e6ee88b 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -4675,6 +4675,7 @@ export class OrcaRuntimeService { getRuntimeId: () => this.runtimeId, requireStore: () => this.requireStore(), resolveWorktreeSelector: (selector) => this.resolveWorktreeSelector(selector), + resolveRuntimeFileTarget: (selector) => this.resolveRuntimeFileTarget(selector), resolveRuntimeGitTarget: (selector) => this.resolveRuntimeGitTarget(selector), openFile: (worktreeId, filePath, relativePath, runtimeEnvironmentId) => { if (!this.notifier?.openFile) { @@ -4836,6 +4837,24 @@ export class OrcaRuntimeService { return { worktree, repo, connectionId, localGitOptions } } + private async resolveRuntimeFileTarget(worktreeSelector: string): Promise<{ + worktree: ResolvedWorktree + connectionId?: string + }> { + const folderScope = await this.resolveFolderWorkspaceLaunchScope(worktreeSelector) + if (folderScope?.folderWorkspace) { + return { + worktree: this.folderWorkspaceToResolvedWorktree(folderScope.folderWorkspace), + connectionId: folderScope.connectionId ?? undefined + } + } + + const store = this.requireStore() + const worktree = await this.resolveWorktreeSelector(worktreeSelector) + const repo = store.getRepo(worktree.repoId) + return { worktree, connectionId: repo?.connectionId ?? undefined } + } + onMobileSessionTabsChanged( listener: (snapshot: RuntimeMobileSessionTabsResult) => void ): () => void { @@ -16024,6 +16043,23 @@ export class OrcaRuntimeService { } } + private folderWorkspaceToResolvedWorktree(folderWorkspace: FolderWorkspace): ResolvedWorktree { + const worktree = folderWorkspaceToWorktree(folderWorkspace) + return { + ...worktree, + parentWorktreeId: null, + childWorktreeIds: [], + lineage: null, + git: { + path: worktree.path, + head: worktree.head, + branch: worktree.branch, + isBare: worktree.isBare, + isMainWorktree: worktree.isMainWorktree + } + } + } + private async resolveTerminalWorkspaceLaunchScope( selector: string ): Promise {