From c93dd901a3ee3ba906e4e69f789a42b7dfe157f7 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 23 May 2026 12:33:49 -0700 Subject: [PATCH] fix: address review findings (#2695) --- src/main/daemon/wsl-session-context.ts | 6 +- src/main/ipc/worktrees.test.ts | 98 +++++++- src/main/ipc/worktrees.ts | 186 +++++++++++++- src/main/memory/collector.ts | 4 +- src/main/ports/workspace-port-ownership.ts | 10 +- src/main/runtime/orca-runtime.test.ts | 106 +++++++- src/main/runtime/orca-runtime.ts | 237 +++++++++++++++++- src/main/usage-worktree-metadata.ts | 16 +- src/renderer/src/components/Landing.tsx | 19 +- .../components/NewWorkspaceComposerCard.tsx | 15 +- .../components/NewWorkspaceComposerModal.tsx | 9 +- src/renderer/src/components/TaskPage.tsx | 16 +- .../src/components/WorktreeJumpPalette.tsx | 6 +- .../new-workspace/SmartWorkspaceNameField.tsx | 114 +++++---- .../src/components/repo/RepoCombobox.tsx | 10 +- .../settings/ManageSessionsSection.tsx | 4 +- .../components/settings/RepositoryPane.tsx | 37 +-- .../src/components/settings/Settings.tsx | 2 +- .../components/settings/SettingsSidebar.tsx | 4 +- .../sidebar/DeleteWorktreeDialog.test.tsx | 29 ++- .../sidebar/DeleteWorktreeDialog.tsx | 38 +-- .../src/components/sidebar/SidebarFilter.tsx | 12 +- .../src/components/sidebar/SidebarHeader.tsx | 17 +- .../sidebar/SidebarWorkspaceOptionsMenu.tsx | 4 +- .../sidebar/WorkspaceKanbanStatusLane.tsx | 2 +- .../WorktreeCard.quick-actions.test.tsx | 35 ++- .../src/components/sidebar/WorktreeCard.tsx | 4 +- .../sidebar/WorktreeContextMenu.test.ts | 18 ++ .../sidebar/WorktreeContextMenu.tsx | 31 ++- .../src/components/sidebar/WorktreeList.tsx | 4 +- .../sidebar/delete-worktree-dialog-copy.ts | 64 +++++ .../sidebar/delete-worktree-flow.ts | 11 +- .../sidebar/repo-header-create-state.test.ts | 12 +- .../sidebar/repo-header-create-state.ts | 10 +- .../use-workspace-kanban-create-worktree.ts | 3 +- .../status-bar/mergeSnapshotAndSessions.ts | 16 +- .../src/components/ui/repo-multi-combobox.tsx | 18 +- src/renderer/src/hooks/useComposerState.ts | 95 ++++--- src/renderer/src/hooks/useIpcEvents.ts | 7 +- src/renderer/src/store/slices/github.ts | 2 +- src/shared/worktree-id.test.ts | 28 ++- src/shared/worktree-id.ts | 21 +- 42 files changed, 1110 insertions(+), 270 deletions(-) create mode 100644 src/renderer/src/components/sidebar/delete-worktree-dialog-copy.ts diff --git a/src/main/daemon/wsl-session-context.ts b/src/main/daemon/wsl-session-context.ts index 8a3681fbf..372407f9a 100644 --- a/src/main/daemon/wsl-session-context.ts +++ b/src/main/daemon/wsl-session-context.ts @@ -1,4 +1,4 @@ -import { splitWorktreeId } from '../../shared/worktree-id' +import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id' import { parseWslPath } from '../wsl' import { parsePtySessionId } from './pty-session-id' @@ -8,7 +8,9 @@ export type WslSessionContext = { export function getWslContextFromSessionId(sessionId: string): WslSessionContext | undefined { const worktreeId = parsePtySessionId(sessionId).worktreeId - const worktreePath = worktreeId ? splitWorktreeId(worktreeId)?.worktreePath : undefined + const worktreePath = worktreeId + ? splitWorktreeIdForFilesystem(worktreeId)?.worktreePath + : undefined const wslInfo = worktreePath ? parseWslPath(worktreePath) : null return wslInfo ? { distro: wslInfo.distro } : undefined } diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index b02022700..efc6b9ae5 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -253,7 +253,8 @@ describe('registerWorktreeHandlers', () => { store.getAllWorktreeLineage, store.removeWorktreeLineage, killAllProcessesForWorktreeMock, - getLocalPtyProviderMock + getLocalPtyProviderMock, + deleteWorktreeHistoryDirMock ]) { m.mockReset() } @@ -486,6 +487,52 @@ describe('registerWorktreeHandlers', () => { }) }) + it('creates an additional workspace for folder-mode repos without git worktree add', async () => { + const repo = { + id: 'repo-folder', + path: '/workspace/folder', + displayName: 'folder', + badgeColor: '#000', + addedAt: 0, + kind: 'folder' as const + } + store.getRepo.mockReturnValue(repo) + store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => ({ + displayName: '', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + ...meta + })) + + const result = (await handlers['worktrees:create'](null, { + repoId: 'repo-folder', + name: 'folder-session', + createdWithAgent: 'codex' + })) as { worktree: { id: string } } + + expect(addWorktreeMock).not.toHaveBeenCalled() + expect(result.worktree).toEqual( + expect.objectContaining({ + id: expect.stringMatching(/^repo-folder::\/workspace\/folder::workspace:[0-9a-f-]{36}$/), + repoId: 'repo-folder', + path: '/workspace/folder', + displayName: 'folder-session', + instanceId: expect.stringMatching(/^[0-9a-f-]{36}$/), + createdWithAgent: 'codex' + }) + ) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', { + repoId: 'repo-folder' + }) + }) + it('checks out a selected existing local branch exactly', async () => { listWorktreesMock .mockResolvedValueOnce([ @@ -2425,6 +2472,55 @@ describe('registerWorktreeHandlers', () => { }) }) + it('refuses to delete the root workspace for folder-mode repos', async () => { + store.getRepo.mockReturnValue({ + id: 'repo-folder', + path: '/workspace/folder', + displayName: 'folder', + badgeColor: '#000', + addedAt: 0, + kind: 'folder' + }) + + await expect( + handlers['worktrees:remove'](null, { + worktreeId: 'repo-folder::/workspace/folder' + }) + ).rejects.toThrow('Cannot delete the project root workspace') + + expect(store.removeWorktreeMeta).not.toHaveBeenCalled() + expect(deleteWorktreeHistoryDirMock).not.toHaveBeenCalled() + }) + + it('kills PTYs before removing additional folder workspace metadata', async () => { + const ptyProvider = {} as never + const worktreeId = 'repo-folder::/workspace/folder::workspace:child-1' + store.getRepo.mockReturnValue({ + id: 'repo-folder', + path: '/workspace/folder', + displayName: 'folder', + badgeColor: '#000', + addedAt: 0, + kind: 'folder' + }) + getLocalPtyProviderMock.mockReturnValue(ptyProvider) + + await handlers['worktrees:remove'](null, { worktreeId }) + + expect(killAllProcessesForWorktreeMock).toHaveBeenCalledWith(worktreeId, { + runtime: runtimeStub, + localProvider: ptyProvider + }) + expect(killAllProcessesForWorktreeMock.mock.invocationCallOrder[0]).toBeLessThan( + store.removeWorktreeMeta.mock.invocationCallOrder[0] + ) + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId) + expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(worktreeId) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', { + repoId: 'repo-folder' + }) + }) + it('runs the archive hook on remove when skipArchive is not set', async () => { mockKnownFeatureWorktree() removeWorktreeMock.mockResolvedValue(undefined) diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index ab8eb2680..9293c346b 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -33,7 +33,7 @@ import { gitExecFileAsync } from '../git/runner' import { withWorktreeSpan } from '../observability/instrumentation' import { resolveGitHubPrStartPoint } from '../github/pr-start-point' import { getDefaultRemote } from '../git/repo' -import { listRepoWorktrees, createFolderWorktree } from '../repo-worktrees' +import { listRepoWorktrees } from '../repo-worktrees' import { getSshGitProvider, requireSshGitProvider } from '../providers/ssh-git-dispatch' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { @@ -84,6 +84,8 @@ import { isWorktreePathMissing } from '../worktree-removal-safety' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' +import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' +import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree-id' const WORKTREE_ARCHIVE_HOOK_TIMEOUT_MS = 120_000 @@ -370,6 +372,139 @@ function stampAndMergeVisibleDetectedWorktree( return mergeWorktree(repo.id, detected, meta, repo.displayName) } +function getFolderWorkspaceRootId(repo: Repo): string { + return `${repo.id}::${repo.path}` +} + +function getFolderWorkspaceInstanceId(repo: Repo, instanceId: string): string { + return `${getFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}${instanceId}` +} + +function getFolderWorkspaceInstanceIdentity(repo: Repo, worktreeId: string): string { + const prefix = `${getFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}` + return worktreeId.startsWith(prefix) ? worktreeId.slice(prefix.length) : randomUUID() +} + +function isFolderWorkspaceIdForRepo(repo: Repo, worktreeId: string): boolean { + const rootId = getFolderWorkspaceRootId(repo) + return ( + worktreeId === rootId || + worktreeId.startsWith(`${rootId}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}`) + ) +} + +function mergeFolderWorkspace(repo: Repo, worktreeId: string, meta: WorktreeMeta): Worktree { + return { + id: worktreeId, + ...(meta.instanceId !== undefined ? { instanceId: meta.instanceId } : {}), + repoId: repo.id, + path: repo.path, + head: '', + branch: '', + isBare: false, + isMainWorktree: worktreeId === getFolderWorkspaceRootId(repo), + displayName: meta.displayName || repo.displayName, + comment: meta.comment || '', + linkedIssue: meta.linkedIssue ?? null, + linkedPR: meta.linkedPR ?? null, + linkedLinearIssue: meta.linkedLinearIssue ?? null, + linkedGitLabMR: meta.linkedGitLabMR ?? null, + linkedGitLabIssue: meta.linkedGitLabIssue ?? null, + isArchived: meta.isArchived ?? false, + isUnread: meta.isUnread ?? false, + isPinned: meta.isPinned ?? false, + sortOrder: meta.sortOrder ?? 0, + ...(meta.manualOrder !== undefined ? { manualOrder: meta.manualOrder } : {}), + lastActivityAt: meta.lastActivityAt ?? 0, + ...(meta.createdAt !== undefined ? { createdAt: meta.createdAt } : {}), + ...(meta.createdWithAgent !== undefined ? { createdWithAgent: meta.createdWithAgent } : {}), + workspaceStatus: meta.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID, + diffComments: meta.diffComments + } +} + +function listFolderWorkspaces(store: Store, repo: Repo): Worktree[] { + const rootId = getFolderWorkspaceRootId(repo) + const allMeta = store.getAllWorktreeMeta() + const ids = Object.keys(allMeta).filter((worktreeId) => + isFolderWorkspaceIdForRepo(repo, worktreeId) + ) + if (!ids.includes(rootId)) { + ids.unshift(rootId) + } + + return ids + .map((worktreeId) => { + const existing = allMeta[worktreeId] + const meta = existing?.instanceId + ? existing + : store.setWorktreeMeta(worktreeId, { + instanceId: getFolderWorkspaceInstanceIdentity(repo, worktreeId), + ...(existing ? {} : { displayName: repo.displayName, lastActivityAt: Date.now() }) + }) + return mergeFolderWorkspace(repo, worktreeId, meta) + }) + .sort((a, b) => { + if (a.id === rootId) { + return -1 + } + if (b.id === rootId) { + return 1 + } + return (b.createdAt ?? b.lastActivityAt) - (a.createdAt ?? a.lastActivityAt) + }) +} + +function buildFolderDetectedWorktrees(store: Store, repo: Repo): DetectedWorktree[] { + const settings = store.getSettings() + return listFolderWorkspaces(store, repo).map((worktree) => + toDetectedWorktree({ + repo, + worktree, + meta: store.getWorktreeMeta(worktree.id), + settings, + knownOrcaLayouts: [], + isLegacyRepoForVisibility: true + }) + ) +} + +function listVisibleFolderWorkspaces(store: Store, repo: Repo): Worktree[] { + return buildFolderDetectedWorktrees(store, repo) + .filter((worktree) => worktree.visible) + .map((worktree) => { + const meta = store.getWorktreeMeta(worktree.id) + return mergeFolderWorkspace(repo, worktree.id, meta ?? store.setWorktreeMeta(worktree.id, {})) + }) +} + +function createFolderWorkspace( + args: CreateWorktreeArgs, + repo: Repo, + store: Store +): CreateWorktreeResult { + const now = Date.now() + const instanceId = randomUUID() + const worktreeId = getFolderWorkspaceInstanceId(repo, instanceId) + const meta = store.setWorktreeMeta(worktreeId, { + instanceId, + displayName: args.displayName || args.name, + lastActivityAt: now, + createdAt: now, + orcaCreatedAt: now, + orcaCreationSource: 'desktop', + ...(args.createdWithAgent ? { createdWithAgent: args.createdWithAgent } : {}), + ...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}), + ...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}), + ...(args.linkedLinearIssue !== undefined ? { linkedLinearIssue: args.linkedLinearIssue } : {}), + ...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}), + ...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}), + ...(args.linkedGitLabIssue !== undefined ? { linkedGitLabIssue: args.linkedGitLabIssue } : {}), + ...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}) + }) + return { worktree: mergeFolderWorkspace(repo, worktreeId, meta) } +} + function buildDisconnectedDetectedWorktrees( store: Store, repo: Repo, @@ -431,7 +566,7 @@ export function registerWorktreeHandlers( try { let gitWorktrees if (isFolderRepo(repo)) { - gitWorktrees = [createFolderWorktree(repo)] + return listVisibleFolderWorkspaces(store, repo) } else if (repo.connectionId) { const provider = getSshGitProvider(repo.connectionId) if (!provider) { @@ -497,7 +632,7 @@ export function registerWorktreeHandlers( try { let gitWorktrees if (isFolderRepo(repo)) { - gitWorktrees = [createFolderWorktree(repo)] + return listVisibleFolderWorkspaces(store, repo) } else if (repo.connectionId) { const provider = getSshGitProvider(repo.connectionId) if (!provider) { @@ -561,7 +696,12 @@ export function registerWorktreeHandlers( try { let gitWorktrees: GitWorktreeInfo[] if (isFolderRepo(repo)) { - gitWorktrees = [createFolderWorktree(repo)] + return { + repoId: repo.id, + authoritative: true, + source: 'git', + worktrees: buildFolderDetectedWorktrees(store, repo) + } } else if (repo.connectionId) { const provider = getSshGitProvider(repo.connectionId) if (!provider) { @@ -623,9 +763,6 @@ export function registerWorktreeHandlers( if (!repo) { throw new Error(`Repo not found: ${args.repoId}`) } - if (isFolderRepo(repo)) { - throw new Error('Folder mode does not support creating worktrees.') - } const sourceParse = workspaceSourceSchema.safeParse(args.telemetrySource) const source: WorkspaceSource = sourceParse.success ? sourceParse.data : 'unknown' @@ -637,9 +774,11 @@ export function registerWorktreeHandlers( // worktrees`) signal IPC-shape bugs, not the user-visible // git/filesystem failures the funnel cares about — bucketing them // into `unknown` would pollute the failure taxonomy. - result = repo.connectionId - ? await createRemoteWorktree(args, repo, store, mainWindow) - : await createLocalWorktree(args, repo, store, mainWindow, runtime) + result = isFolderRepo(repo) + ? createFolderWorkspace(args, repo, store) + : repo.connectionId + ? await createRemoteWorktree(args, repo, store, mainWindow) + : await createLocalWorktree(args, repo, store, mainWindow, runtime) } catch (error) { track('workspace_create_failed', { source, @@ -659,10 +798,17 @@ export function registerWorktreeHandlers( // the branch name itself. track('workspace_created', { source, - from_existing_branch: typeof args.baseBranch === 'string' && args.baseBranch.length > 0, + from_existing_branch: + !isFolderRepo(repo) && + typeof args.baseBranch === 'string' && + args.baseBranch.length > 0, ...getCohortAtEmit() }) + if (isFolderRepo(repo)) { + notifyWorktreesChanged(mainWindow, repo.id) + } + return result }) } @@ -768,7 +914,23 @@ export function registerWorktreeHandlers( throw new Error(`Repo not found: ${repoId}`) } if (isFolderRepo(repo)) { - throw new Error('Folder mode does not support deleting worktrees.') + if (args.worktreeId === getFolderWorkspaceRootId(repo)) { + throw new Error( + 'Cannot delete the project root workspace. Remove the folder project instead.' + ) + } + // Why: folder workspaces share one filesystem root, so there is no Git + // remove step to close shells; sweep PTYs before dropping metadata. + await killAllProcessesForWorktree(args.worktreeId, { + runtime, + localProvider: getLocalPtyProvider() + }).catch((err) => { + console.warn(`[worktree-teardown] failed for ${args.worktreeId}:`, err) + }) + store.removeWorktreeMeta(args.worktreeId) + deleteWorktreeHistoryDir(args.worktreeId) + notifyWorktreesChanged(mainWindow, repoId) + return } // Why: the renderer-supplied worktreeId contains a filesystem path. diff --git a/src/main/memory/collector.ts b/src/main/memory/collector.ts index bd5d93c39..045c49de2 100644 --- a/src/main/memory/collector.ts +++ b/src/main/memory/collector.ts @@ -23,7 +23,7 @@ import { basename } from 'node:path' import { exec } from 'node:child_process' import { promisify } from 'node:util' import os from 'node:os' -import { splitWorktreeId } from '../../shared/worktree-id' +import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id' import { app } from 'electron' import type { AppMemory, @@ -375,7 +375,7 @@ function resolveWorktreeNames( repoName: string } { // Orca worktree ids look like `${repoId}::${absolutePath}`. - const parsed = splitWorktreeId(worktreeId) + const parsed = splitWorktreeIdForFilesystem(worktreeId) const repoId = parsed?.repoId ?? worktreeId const worktreePath = parsed?.worktreePath ?? '' const fallbackName = worktreePath ? basename(worktreePath) : worktreeId diff --git a/src/main/ports/workspace-port-ownership.ts b/src/main/ports/workspace-port-ownership.ts index e0aae1821..ab810e8d8 100644 --- a/src/main/ports/workspace-port-ownership.ts +++ b/src/main/ports/workspace-port-ownership.ts @@ -1,6 +1,7 @@ import path from 'path' import type { Store } from '../persistence' -import { splitWorktreeId } from '../../shared/worktree-id' +import { splitWorktreeId, splitWorktreeIdForFilesystem } from '../../shared/worktree-id' +import { isFolderRepo } from '../../shared/repo-kind' import type { WorkspacePortKillRequest, WorkspacePortKillResult, @@ -27,12 +28,15 @@ export function getStoreWorkspacePortProbes( if (!repo || repo.connectionId) { return [] } + const worktreePath = isFolderRepo(repo) + ? (splitWorktreeIdForFilesystem(worktreeId)?.worktreePath ?? parsed.worktreePath) + : parsed.worktreePath return [ { id: worktreeId, repoId: parsed.repoId, - displayName: meta.displayName || path.basename(parsed.worktreePath), - path: parsed.worktreePath + displayName: meta.displayName || path.basename(worktreePath), + path: worktreePath } ] }) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 7fdeccb32..50404c8af 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -68,7 +68,8 @@ const { reopenGitLabMRMock, getGlabKnownHostsMock, getGitLabWorkItemDetailsMock, - getIssueMock + getIssueMock, + deleteWorktreeHistoryDirMock } = vi.hoisted(() => { // Why: SSH runtime tests register providers through the public dispatcher API, // so the mock needs the same registry semantics as the real module. @@ -120,7 +121,8 @@ const { reopenGitLabMRMock: vi.fn(), getGlabKnownHostsMock: vi.fn(), getGitLabWorkItemDetailsMock: vi.fn(), - getIssueMock: vi.fn() + getIssueMock: vi.fn(), + deleteWorktreeHistoryDirMock: vi.fn() } }) @@ -131,6 +133,10 @@ vi.mock('../git/worktree', () => ({ removeWorktree: removeWorktreeMock })) +vi.mock('../terminal-history', () => ({ + deleteWorktreeHistoryDir: deleteWorktreeHistoryDirMock +})) + vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProvider: getSshGitProviderMock, SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: @@ -917,6 +923,102 @@ describe('OrcaRuntimeService', () => { }) }) + it('creates additional workspace metadata for folder-mode repos through runtime create', async () => { + const folderRepo = { + id: 'folder-repo', + path: '/workspace/folder', + displayName: 'Folder', + badgeColor: 'blue', + addedAt: 1, + kind: 'folder' as const + } + const metaById: Record = {} + const runtimeStore = { + ...store, + getRepos: () => [folderRepo], + getRepo: (id: string) => (id === folderRepo.id ? folderRepo : undefined), + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } + return metaById[worktreeId] + }, + removeWorktreeMeta: (worktreeId: string) => { + delete metaById[worktreeId] + } + } + let deletedWorktreeId = '' + const localProvider = { + listProcesses: vi.fn(async () => [{ id: `${deletedWorktreeId}@@pty-1` }]), + shutdown: vi.fn(async () => undefined) + } + const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { + getLocalProvider: () => localProvider as never + }) + const notifier = { worktreesChanged: vi.fn() } + runtime.setNotifier(notifier as never) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:folder-repo', + name: 'folder-session', + createdWithAgent: 'codex' + }) + + expect(addWorktreeMock).not.toHaveBeenCalled() + expect(result.worktree).toEqual( + expect.objectContaining({ + id: expect.stringMatching(/^folder-repo::\/workspace\/folder::workspace:[0-9a-f-]{36}$/), + repoId: 'folder-repo', + path: '/workspace/folder', + displayName: 'folder-session', + isMainWorktree: false, + createdWithAgent: 'codex' + }) + ) + expect(metaById[result.worktree.id]).toMatchObject({ + instanceId: result.worktree.instanceId, + displayName: 'folder-session', + orcaCreationSource: 'runtime', + createdWithAgent: 'codex' + }) + await expect(runtime.showManagedWorktree(`id:${result.worktree.id}`)).resolves.toMatchObject({ + id: result.worktree.id, + repoId: 'folder-repo', + path: '/workspace/folder', + displayName: 'folder-session' + }) + await expect(runtime.listManagedWorktrees('id:folder-repo')).resolves.toMatchObject({ + totalCount: 2, + worktrees: [ + expect.objectContaining({ + id: 'folder-repo::/workspace/folder', + isMainWorktree: true + }), + expect.objectContaining({ + id: result.worktree.id, + isMainWorktree: false + }) + ] + }) + await expect( + runtime.updateManagedWorktreeMeta(`id:${result.worktree.id}`, { comment: 'note' }) + ).resolves.toMatchObject({ + id: result.worktree.id, + comment: 'note' + }) + await expect( + runtime.removeManagedWorktree('id:folder-repo::/workspace/folder') + ).rejects.toThrow('Cannot delete the project root workspace') + deletedWorktreeId = result.worktree.id + await expect(runtime.removeManagedWorktree(`id:${result.worktree.id}`)).resolves.toEqual({}) + expect(localProvider.shutdown).toHaveBeenCalledWith(`${result.worktree.id}@@pty-1`, { + immediate: true + }) + expect(metaById[result.worktree.id]).toBeUndefined() + expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(result.worktree.id) + expect(notifier.worktreesChanged).toHaveBeenCalledWith('folder-repo') + }) + it('refreshes runtime remote-tracking bases before creating local worktrees', async () => { const runtime = new OrcaRuntimeService(store) const refresh = deferred<{ stdout: string; stderr: string }>() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 9ef7f8bb2..eeb71ad09 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -46,8 +46,9 @@ import type { TabGroupLayoutNode, TuiAgent } from '../../shared/types' -import { splitWorktreeId } from '../../shared/worktree-id' +import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR, splitWorktreeId } from '../../shared/worktree-id' import { isFolderRepo } from '../../shared/repo-kind' +import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' import { buildSetupRunnerCommand } from '../../shared/setup-runner-command' import { FIRST_PANE_ID } from '../../shared/pane-key' import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id' @@ -317,8 +318,9 @@ import { writeIssueCommand } from '../hooks' import { DEFAULT_REPO_BADGE_COLOR, getDefaultVoiceSettings } from '../../shared/constants' -import { createFolderWorktree, listRepoWorktrees } from '../repo-worktrees' +import { listRepoWorktrees } from '../repo-worktrees' import { createWorktreeSymlinks } from '../ipc/worktree-symlinks' +import { deleteWorktreeHistoryDir } from '../terminal-history' import { cleanupUnusedWorktreePushTargetRemote, cleanupUnusedWorktreePushTargetRemoteSsh, @@ -704,6 +706,92 @@ function getRuntimeWorktreeRemovalOptionsKey(force: boolean, runHooks: boolean): return `${force ? 'force' : 'normal'}:${runHooks ? 'run-hooks' : 'skip-hooks'}` } +function getRuntimeFolderWorkspaceRootId(repo: Repo): string { + return `${repo.id}::${repo.path}` +} + +function getRuntimeFolderWorkspaceInstanceId(repo: Repo, instanceId: string): string { + return `${getRuntimeFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}${instanceId}` +} + +function getRuntimeFolderWorkspaceInstanceIdentity(repo: Repo, worktreeId: string): string { + const prefix = `${getRuntimeFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}` + return worktreeId.startsWith(prefix) ? worktreeId.slice(prefix.length) : randomUUID() +} + +function isRuntimeFolderWorkspaceIdForRepo(repo: Repo, worktreeId: string): boolean { + const rootId = getRuntimeFolderWorkspaceRootId(repo) + return ( + worktreeId === rootId || + worktreeId.startsWith(`${rootId}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}`) + ) +} + +function mergeRuntimeFolderWorkspace(repo: Repo, worktreeId: string, meta: WorktreeMeta): Worktree { + return { + id: worktreeId, + ...(meta.instanceId !== undefined ? { instanceId: meta.instanceId } : {}), + repoId: repo.id, + path: repo.path, + head: '', + branch: '', + isBare: false, + isMainWorktree: worktreeId === getRuntimeFolderWorkspaceRootId(repo), + displayName: meta.displayName || repo.displayName, + comment: meta.comment || '', + linkedIssue: meta.linkedIssue ?? null, + linkedPR: meta.linkedPR ?? null, + linkedLinearIssue: meta.linkedLinearIssue ?? null, + linkedGitLabMR: meta.linkedGitLabMR ?? null, + linkedGitLabIssue: meta.linkedGitLabIssue ?? null, + isArchived: meta.isArchived ?? false, + isUnread: meta.isUnread ?? false, + isPinned: meta.isPinned ?? false, + sortOrder: meta.sortOrder ?? 0, + ...(meta.manualOrder !== undefined ? { manualOrder: meta.manualOrder } : {}), + lastActivityAt: meta.lastActivityAt ?? 0, + ...(meta.createdAt !== undefined ? { createdAt: meta.createdAt } : {}), + ...(meta.createdWithAgent !== undefined ? { createdWithAgent: meta.createdWithAgent } : {}), + workspaceStatus: meta.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID, + diffComments: meta.diffComments + } +} + +function listRuntimeFolderWorkspaces( + store: Pick, + repo: Repo +): Worktree[] { + const rootId = getRuntimeFolderWorkspaceRootId(repo) + const allMeta = store.getAllWorktreeMeta() + const ids = Object.keys(allMeta).filter((worktreeId) => + isRuntimeFolderWorkspaceIdForRepo(repo, worktreeId) + ) + if (!ids.includes(rootId)) { + ids.unshift(rootId) + } else { + ids.sort((left, right) => { + if (left === rootId) { + return -1 + } + if (right === rootId) { + return 1 + } + return 0 + }) + } + + return ids.map((worktreeId) => { + const existing = allMeta[worktreeId] + const meta = existing?.instanceId + ? existing + : store.setWorktreeMeta(worktreeId, { + instanceId: getRuntimeFolderWorkspaceInstanceIdentity(repo, worktreeId), + ...(existing ? {} : { displayName: repo.displayName, lastActivityAt: Date.now() }) + }) + return mergeRuntimeFolderWorkspace(repo, worktreeId, meta) + }) +} + function parseExactWorktreeIdSelector(selector: string): RuntimeWorktreeRemovalTarget | null { const worktreeId = selector.startsWith('id:') ? selector.slice(3) : selector const parsed = splitWorktreeId(worktreeId) @@ -6417,11 +6505,18 @@ export class OrcaRuntimeService { async listDetectedManagedWorktrees(repoSelector: string): Promise { const repo = await this.resolveRepoSelector(repoSelector) + if (isFolderRepo(repo)) { + const worktrees = listRuntimeFolderWorkspaces(this.requireStore(), repo) + return { + repoId: repo.id, + authoritative: true, + source: 'git', + worktrees: worktrees.map((worktree) => this.toRuntimeDetectedWorktree(repo, worktree)) + } + } let scan: RuntimeWorktreeScanResult try { - scan = isFolderRepo(repo) - ? { ok: true, worktrees: [createFolderWorktree(repo)] } - : await this.listRepoWorktreesForResolution(repo) + scan = await this.listRepoWorktreesForResolution(repo) } catch { scan = { ok: false, worktrees: [] } } @@ -6902,15 +6997,102 @@ export class OrcaRuntimeService { } const repo = await this.resolveRepoSelector(args.repoSelector) - if (isFolderRepo(repo)) { - throw new Error('Folder mode does not support creating worktrees.') - } const draftStartup = args.startupDraft ? await this.buildStartupForDraft(repo, args.startupDraft, args.createdWithAgent) : null const effectiveStartup = args.startup ?? draftStartup?.startup const effectiveCreatedWithAgent = args.createdWithAgent ?? draftStartup?.agent const effectiveDraftPaste = args.startupDraftPaste ?? draftStartup?.draftPaste + if (isFolderRepo(repo)) { + const now = Date.now() + const settings = this.store.getSettings() + const instanceId = randomUUID() + const worktreeId = getRuntimeFolderWorkspaceInstanceId(repo, instanceId) + const meta = this.store.setWorktreeMeta(worktreeId, { + instanceId, + displayName: args.displayName?.trim() || args.name, + lastActivityAt: now, + createdAt: now, + orcaCreatedAt: now, + orcaCreationSource: 'runtime', + orcaCreationWorkspaceLayout: { + path: settings.workspaceDir, + nestWorkspaces: settings.nestWorkspaces + }, + ...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}), + ...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}), + ...(args.linkedLinearIssue !== undefined + ? { linkedLinearIssue: args.linkedLinearIssue } + : {}), + ...(args.linkedGitLabIssue !== undefined + ? { linkedGitLabIssue: args.linkedGitLabIssue } + : {}), + ...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}), + ...(effectiveCreatedWithAgent ? { createdWithAgent: effectiveCreatedWithAgent } : {}), + ...(args.comment !== undefined ? { comment: args.comment } : {}), + ...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}), + ...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}) + }) + const worktree = mergeRuntimeFolderWorkspace(repo, worktreeId, meta) + this.invalidateResolvedWorktreeCache() + this.notifier?.worktreesChanged(repo.id) + const shouldActivate = args.activate === true || args.runHooks === true + let warning: string | undefined + let didSpawnStartup = false + if (effectiveStartup && this.ptyController?.spawn) { + try { + const startupTrustAgent = effectiveDraftPaste?.agent ?? effectiveCreatedWithAgent + if (startupTrustAgent) { + this.markLocalWorkspaceTrustedForAgent(startupTrustAgent, worktree.path) + } + const terminal = await this.createTerminal(`id:${worktree.id}`, { + command: effectiveStartup.command, + env: effectiveStartup.env + }) + if (effectiveDraftPaste) { + this.pasteStartupDraftWhenReady(terminal.handle, effectiveDraftPaste) + } + didSpawnStartup = true + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + warning = `Failed to create the startup terminal for ${worktree.path}: ${message}` + console.warn(`[worktree-create] ${warning}`) + } + } + if (shouldActivate) { + if (effectiveStartup && !didSpawnStartup) { + this.notifier?.activateWorktree(repo.id, worktree.id, undefined, effectiveStartup) + } else { + this.notifier?.activateWorktree(repo.id, worktree.id) + } + } else if (this.ptyController?.spawn && !didSpawnStartup) { + try { + await this.createTerminal(`id:${worktree.id}`) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + warning = warning + ? `${warning} Also failed to create the initial terminal for ${worktree.path}: ${message}` + : `Failed to create the initial terminal for ${worktree.path}: ${message}` + console.warn(`[worktree-create] ${warning}`) + } + } + return { + worktree: { + ...worktree, + parentWorktreeId: null, + childWorktreeIds: [], + lineage: null, + git: { + path: worktree.path, + head: worktree.head, + branch: worktree.branch, + isBare: worktree.isBare, + isMainWorktree: worktree.isMainWorktree + } + }, + ...(warning ? { warning } : {}) + } + } if (repo.connectionId) { return await this.createManagedRemoteWorktree(repo, { ...args, @@ -8259,7 +8441,27 @@ export class OrcaRuntimeService { throw new Error('repo_not_found') } if (isFolderRepo(repo)) { - throw new Error('Folder mode does not support deleting worktrees.') + if (removalTarget.id === getRuntimeFolderWorkspaceRootId(repo)) { + throw new Error( + 'Cannot delete the project root workspace. Remove the folder project instead.' + ) + } + const localProvider = this.getLocalProvider() + if (localProvider) { + // Why: folder workspace deletion has no Git removal phase where PTYs + // would otherwise be swept; tear them down before hiding the workspace. + await killAllProcessesForWorktree(removalTarget.id, { + runtime: this, + localProvider + }).catch((err) => { + console.warn(`[worktree-teardown] failed for ${removalTarget.id}:`, err) + }) + } + store.removeWorktreeMeta(removalTarget.id) + deleteWorktreeHistoryDir(removalTarget.id) + this.invalidateResolvedWorktreeCache() + this.notifier?.worktreesChanged(repo.id) + return {} } const provider = repo.connectionId ? requireSshGitProvider(repo.connectionId) : null const registeredWorktrees = repo.connectionId @@ -9613,6 +9815,23 @@ export class OrcaRuntimeService { const now = Date.now() const perRepoWorktrees = await Promise.all( this.store.getRepos().map(async (repo) => { + if (isFolderRepo(repo)) { + return listRuntimeFolderWorkspaces(this.requireStore(), repo).map((worktree) => ({ + ...worktree, + parentWorktreeId: null, + childWorktreeIds: [], + lineage: null, + git: { + path: worktree.path, + head: worktree.head, + branch: worktree.branch, + isBare: worktree.isBare, + isMainWorktree: worktree.isMainWorktree + }, + displayName: worktree.displayName, + comment: worktree.comment + })) + } // Why: mobile startup RPCs share this path. A slow repo scan should // degrade one repo's metadata, not block all terminal/session loading. const scan = await withTimeout( diff --git a/src/main/usage-worktree-metadata.ts b/src/main/usage-worktree-metadata.ts index 442638d9f..1047f54f3 100644 --- a/src/main/usage-worktree-metadata.ts +++ b/src/main/usage-worktree-metadata.ts @@ -1,6 +1,7 @@ import { basename } from 'path' import type { Repo } from '../shared/types' -import { splitWorktreeId } from '../shared/worktree-id' +import { splitWorktreeId, splitWorktreeIdForFilesystem } from '../shared/worktree-id' +import { isFolderRepo } from '../shared/repo-kind' import type { Store } from './persistence' export type UsageWorktreeRef = { @@ -40,15 +41,20 @@ export function loadKnownUsageWorktreesByRepo( if (!parsed || !repoIds.has(parsed.repoId)) { continue } + const repo = localRepos.find((item) => item.id === parsed.repoId) + const worktreePath = + repo && isFolderRepo(repo) + ? (splitWorktreeIdForFilesystem(worktreeId)?.worktreePath ?? parsed.worktreePath) + : parsed.worktreePath const seenPaths = seenPathsByRepo.get(parsed.repoId) - if (seenPaths?.has(parsed.worktreePath)) { + if (seenPaths?.has(worktreePath)) { continue } - seenPaths?.add(parsed.worktreePath) + seenPaths?.add(worktreePath) worktreesByRepo.get(parsed.repoId)?.push({ worktreeId, - path: parsed.worktreePath, - displayName: meta.displayName || getDefaultUsageWorktreeLabel(parsed.worktreePath) + path: worktreePath, + displayName: meta.displayName || getDefaultUsageWorktreeLabel(worktreePath) }) } diff --git a/src/renderer/src/components/Landing.tsx b/src/renderer/src/components/Landing.tsx index 2a4e9f912..3d0ac6513 100644 --- a/src/renderer/src/components/Landing.tsx +++ b/src/renderer/src/components/Landing.tsx @@ -31,8 +31,7 @@ function getPreflightIssues(status: { issues.push({ id: 'git', title: 'Git is not installed', - description: - 'Git is required for Git repositories, source control, and workspace management.', + description: 'Git is required for Git projects, source control, and workspace management.', fixLabel: 'Install Git', fixUrl: 'https://git-scm.com/downloads' }) @@ -194,7 +193,9 @@ export default function Landing(): React.JSX.Element { const repos = useAppStore((s) => s.repos) const openModal = useAppStore((s) => s.openModal) - const canCreateWorktree = repos.some((repo) => isGitRepoKind(repo)) + const canCreateWorktree = repos.length > 0 + const createTargetLabel = + canCreateWorktree && repos.every((repo) => isGitRepoKind(repo)) ? 'Worktree' : 'Workspace' const [preflightIssues, setPreflightIssues] = useState([]) @@ -250,11 +251,15 @@ export default function Landing(): React.JSX.Element { const nextWorktreeKeys = useShortcutKeys('worktree.navigateDown') const shortcuts = useMemo(() => { return [ - { id: 'create', keys: createWorktreeKeys, action: 'Create workspace' }, + { + id: 'create', + keys: createWorktreeKeys, + action: `Create ${createTargetLabel.toLowerCase()}` + }, { id: 'up', keys: previousWorktreeKeys, action: 'Move up workspace' }, { id: 'down', keys: nextWorktreeKeys, action: 'Move down workspace' } ] - }, [createWorktreeKeys, nextWorktreeKeys, previousWorktreeKeys]) + }, [createTargetLabel, createWorktreeKeys, nextWorktreeKeys, previousWorktreeKeys]) return (
@@ -288,11 +293,11 @@ export default function Landing(): React.JSX.Element {
diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 1b7254156..d0403ba91 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -45,7 +45,9 @@ type NewWorkspaceComposerCardProps = { onQuickAgentChange: (agent: TuiAgent | null) => void eligibleRepos: RepoOption[] repoId: string + selectedRepoIsGit: boolean onRepoChange: (value: string) => void + primaryActionLabel: string name: string onNameValueChange: (value: string) => void onSmartGitHubItemSelect: (item: GitHubWorkItem) => void @@ -207,7 +209,9 @@ export default function NewWorkspaceComposerCard({ onQuickAgentChange, eligibleRepos, repoId, + selectedRepoIsGit, onRepoChange, + primaryActionLabel, name, onNameValueChange, onSmartGitHubItemSelect, @@ -249,7 +253,7 @@ export default function NewWorkspaceComposerCard({ const submitShortcutModifierLabel = getScreenSubmitModifierLabel() const selectedRepoName = React.useMemo(() => { const repo = eligibleRepos.find((candidate) => candidate.id === repoId) - return repo?.displayName ?? repo?.path ?? 'This repository' + return repo?.displayName ?? repo?.path ?? 'This project' }, [eligibleRepos, repoId]) const sshStatusLabel = selectedRepoSshStatus ? SSH_STATUS_LABELS[selectedRepoSshStatus] @@ -314,7 +318,7 @@ export default function NewWorkspaceComposerCard({ size="icon-xs" onClick={handleAddRepo} className="size-5 shrink-0 rounded-sm text-muted-foreground hover:text-foreground" - aria-label="Add folder or repository" + aria-label="Add project" > @@ -372,7 +376,7 @@ export default function NewWorkspaceComposerCard({
{ // Why: Enter on the workspace name advances focus to the next // field (Agent combobox) rather than submitting, letting the user @@ -611,7 +616,7 @@ export default function NewWorkspaceComposerCard({ /> {!canUseSparseCheckout ? (

- Only available for local repositories. + Only available for local Git projects.

) : null}
@@ -645,7 +650,7 @@ export default function NewWorkspaceComposerCard({ className="text-xs" > {creating ? : null} - Create Workspace + {primaryActionLabel} {submitShortcutModifierLabel} diff --git a/src/renderer/src/components/NewWorkspaceComposerModal.tsx b/src/renderer/src/components/NewWorkspaceComposerModal.tsx index 469ec8d4b..02bf3bd0b 100644 --- a/src/renderer/src/components/NewWorkspaceComposerModal.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerModal.tsx @@ -83,10 +83,6 @@ function ComposerModalBody({ trigger?.focus({ preventScroll: true }) }} > - - Create Workspace - - @@ -155,6 +151,7 @@ function QuickTabBody({ const handleCreate = useCallback(async (): Promise => { await submitQuick(quickAgent) }, [quickAgent, submitQuick]) + const primaryActionLabel = cardProps.selectedRepoIsGit ? 'Create Worktree' : 'Create Workspace' // Cmd/Ctrl+Enter submits, Esc first blurs the focused input (like the full page). useEffect(() => { @@ -206,12 +203,16 @@ function QuickTabBody({ return ( <> + + {primaryActionLabel} + setAgentSettingsOpen(true)} onCreate={() => void handleCreate()} /> diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index ddddad5ec..3408f4267 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -4305,14 +4305,14 @@ export default function TaskPage(): React.JSX.Element { onChange={(next) => { setRepoSelection(next) void updateSettings({ defaultRepoSelection: [...next] }).catch(() => { - toast.error('Failed to save repo selection.') + toast.error('Failed to save project selection.') }) }} onSelectAll={() => { const allIds = new Set(eligibleRepos.map((r) => r.id)) setRepoSelection(allIds) void updateSettings({ defaultRepoSelection: null }).catch(() => { - toast.error('Failed to save repo selection.') + toast.error('Failed to save project selection.') }) }} triggerClassName="h-8 w-auto max-w-[220px] rounded-md border border-border/50 bg-muted/50 px-2 text-xs font-medium shadow-sm transition hover:bg-muted/50 focus:ring-2 focus:ring-ring/20 focus:outline-none" @@ -4667,14 +4667,14 @@ export default function TaskPage(): React.JSX.Element { onChange={(next) => { setRepoSelection(next) void updateSettings({ defaultRepoSelection: [...next] }).catch(() => { - toast.error('Failed to save repo selection.') + toast.error('Failed to save project selection.') }) }} onSelectAll={() => { const allIds = new Set(eligibleRepos.map((r) => r.id)) setRepoSelection(allIds) void updateSettings({ defaultRepoSelection: null }).catch(() => { - toast.error('Failed to save repo selection.') + toast.error('Failed to save project selection.') }) }} triggerClassName="h-8 w-full rounded-md border border-border/50 bg-muted/50 px-2 text-xs font-medium shadow-sm transition hover:bg-muted/50 focus:ring-2 focus:ring-ring/20 focus:outline-none" @@ -4823,7 +4823,7 @@ export default function TaskPage(): React.JSX.Element { // Why: per-repo partial-failure signal — distinct from a hard // IPC reject (tasksError). The two are mutually exclusive.
- {failedCount} of {selectedRepos.length} repos failed to load + {failedCount} of {selectedRepos.length} projects failed to load
) : null} @@ -5177,7 +5177,7 @@ export default function TaskPage(): React.JSX.Element {
{primaryRepo ? 'No pending todos. You’re all caught up!' - : 'Select a repo so we can authenticate to GitLab.'} + : 'Select a project so we can authenticate to GitLab.'}
) : null}
@@ -5271,7 +5271,7 @@ export default function TaskPage(): React.JSX.Element { : gitlabView === 'mrs' ? 'No GitLab MRs match this filter.' : 'No GitLab work matches this filter.' - : 'Select a repo to see GitLab work items.'} + : 'Select a project to see GitLab work items.'}
) : null}
@@ -5990,7 +5990,7 @@ export default function TaskPage(): React.JSX.Element {
{selectedRepos.length > 1 ? (
- +