mirror of
https://github.com/stablyai/orca.git
synced 2026-10-26 08:02:44 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user