fix(runtime): resolve folder workspace file selectors

Folder-workspace selectors (`folder:<id>`) threw `selector_not_found`
in the file explorer because every file op routed through
`resolveWorktreeSelector` / `resolveRuntimeGitTarget`, which only match
real git worktrees. Folder workspaces (project-group/monorepo sessions)
have no backing worktree record, so opening any file or directory failed
with "Unable to load file — selector_not_found".

Add `resolveRuntimeFileTarget`, which first resolves folder workspaces
via the existing `resolveFolderWorkspaceLaunchScope` path (the same
special-casing the terminal launch path already uses) and otherwise
falls back to the worktree-selector path. The file/search/markdown
commands now route through it, so folder-workspace sessions list and
preview files for both local and SSH-backed workspaces.

Git diff/status for folder workspaces still routes through
`resolveRuntimeGitTarget` and is intentionally out of scope: a folder
workspace spans N child repos, so there is no single git target.

Supersedes #6363.

Fixes #6357

Co-authored-by: bennewell35 <newelljben@gmail.com>
This commit is contained in:
Neil
2026-06-27 23:15:24 -07:00
co-authored by bennewell35
parent b45cb545c5
commit a2b8b9329b
5 changed files with 152 additions and 31 deletions
@@ -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)
+17 -9
View File
@@ -126,20 +126,28 @@ function createRuntimeFileCommands(options?: {
path?: string
openFile?: ReturnType<typeof vi.fn>
openDiff?: ReturnType<typeof vi.fn>
resolveRuntimeFileTarget?: ReturnType<typeof vi.fn>
resolveRuntimeGitTarget?: ReturnType<typeof vi.fn>
}) {
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)
+22 -21
View File
@@ -143,11 +143,16 @@ export async function awaitRuntimeFileWatcherUnsubscribes(): Promise<void> {
}
export type ResolvedRuntimeFileWorktree = Worktree & { git: GitWorktreeInfo }
export type ResolvedRuntimeFileTarget = {
worktree: ResolvedRuntimeFileWorktree
connectionId?: string
}
export type RuntimeFileCommandHost = {
getRuntimeId(): string
requireStore(): Store
resolveWorktreeSelector(selector: string): Promise<ResolvedRuntimeFileWorktree>
resolveRuntimeFileTarget(selector: string): Promise<ResolvedRuntimeFileTarget>
resolveRuntimeGitTarget(
selector: string
): Promise<{ worktree: ResolvedRuntimeFileWorktree; connectionId?: string }>
@@ -173,9 +178,8 @@ export class RuntimeFileCommands {
async listMobileFiles(worktreeSelector: string): Promise<RuntimeFileListResult> {
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<RuntimeFileOpenResult> {
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<RuntimeFileOpenResult> {
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<RuntimeFileReadResult> {
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<RuntimeTerminalPathResolution> {
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<SearchOptions, 'rootPath'>
): Promise<SearchResult> {
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<string[]> {
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<MarkdownDocument[]> {
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
}
}
+70 -1
View File
@@ -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 = {
+36
View File
@@ -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<TerminalWorkspaceLaunchScope> {