From 91e354ccb16dbb50f05552e7bf3dbd3401ca7a98 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 11 Jun 2026 02:34:01 -0700 Subject: [PATCH] Add multi-repo folder workspaces (v1) (#5172) Co-authored-by: Orca --- .../first-work-branch-rename.test.ts | 37 ++ .../agent-hooks/first-work-branch-rename.ts | 70 +++ src/main/index.ts | 57 ++- src/main/ipc/filesystem-auth.test.ts | 154 +++++- src/main/ipc/filesystem-auth.ts | 79 ++- src/main/ipc/pty.ts | 23 + src/main/ipc/repos.ts | 173 +++++++ src/main/persistence.test.ts | 365 ++++++++++++++ src/main/persistence.ts | 429 +++++++++++++++- .../folder-workspace-path-status.test.ts | 221 ++++++++ .../folder-workspace-path-status.ts | 215 ++++++++ .../project-groups/nested-repo-import.test.ts | 147 +++++- src/main/project-groups/nested-repo-import.ts | 148 +++++- src/main/runtime/orca-runtime.test.ts | 211 ++++++++ src/main/runtime/orca-runtime.ts | 298 +++++++++-- .../runtime/rpc/methods/folder-workspace.ts | 98 ++++ src/main/runtime/rpc/methods/repo.test.ts | 69 ++- src/main/runtime/rpc/methods/repo.ts | 3 + src/preload/api-types.ts | 42 ++ src/preload/index.ts | 8 + src/renderer/src/App.tsx | 2 + .../components/NewWorkspaceComposerCard.tsx | 125 +++-- src/renderer/src/components/Terminal.tsx | 66 ++- .../src/components/mobile/MobileHero.tsx | 180 +++++-- .../new-workspace/SmartWorkspaceNameField.tsx | 20 +- .../smart-workspace-source-results.test.ts | 25 + .../smart-workspace-source-results.ts | 10 +- .../src/components/onboarding/ThemeStep.tsx | 113 ++++- .../right-sidebar/HostedReviewActions.tsx | 66 ++- .../src/components/right-sidebar/Search.tsx | 119 +---- .../right-sidebar/SearchResultsPane.tsx | 133 +++++ .../src/components/right-sidebar/index.tsx | 47 +- .../right-sidebar-measured-width.ts | 35 ++ ...ebar-titlebar-drag-regions.render.test.tsx | 28 +- .../components/settings/McpConfigSection.tsx | 57 ++- .../sidebar/AddRepoDialogStepContent.test.tsx | 20 +- .../sidebar/AddRepoDialogStepContent.tsx | 1 - .../sidebar/AddRepoNestedImportStep.test.tsx | 153 ++++-- .../sidebar/AddRepoNestedImportStep.tsx | 168 ++++--- .../sidebar/FolderWorkspaceComposerDialog.tsx | 408 +++++++++++++++ .../WorktreeCard.quick-actions.test.tsx | 23 +- .../src/components/sidebar/WorktreeCard.tsx | 56 ++- .../sidebar/WorktreeContextMenu.tsx | 41 +- .../src/components/sidebar/WorktreeList.tsx | 473 +++++++++++++++--- .../folder-workspace-composer-helpers.ts | 116 +++++ ...er-workspace-composer-path-status.test.tsx | 182 +++++++ .../folder-workspace-composer-path-status.ts | 71 +++ .../folder-workspace-composer-submit.ts | 100 ++++ .../components/sidebar/worktree-drag-units.ts | 7 +- .../worktree-list-folder-reveal.test.ts | 119 +++++ .../sidebar/worktree-list-folder-reveal.ts | 65 +++ .../sidebar/worktree-list-groups.test.ts | 204 ++++++++ .../sidebar/worktree-list-groups.ts | 60 ++- .../sidebar/worktree-list-indentation.test.ts | 12 +- .../sidebar/worktree-list-indentation.ts | 10 + .../sidebar/worktree-list-virtual-rows.ts | 4 + .../src/components/stats/ClaudeUsagePane.tsx | 184 +++++-- .../src/components/stats/CodexUsagePane.tsx | 178 +++++-- .../components/stats/OpenCodeUsagePane.tsx | 215 ++++++-- .../terminal-pane/pty-connection.ts | 26 +- src/renderer/src/hooks/useIpcEvents.ts | 2 + src/renderer/src/i18n/locales/en.json | 25 +- src/renderer/src/i18n/locales/es.json | 25 +- src/renderer/src/i18n/locales/ja.json | 25 +- src/renderer/src/i18n/locales/ko.json | 25 +- src/renderer/src/i18n/locales/zh.json | 25 +- .../src/lib/connection-context.test.ts | 259 ++++++++++ src/renderer/src/lib/connection-context.ts | 9 + .../src/lib/folder-workspace-connection.ts | 92 ++++ ...lder-workspace-path-status-cache-expiry.ts | 32 ++ .../src/lib/folder-workspace-path-status.ts | 80 +++ src/renderer/src/lib/new-workspace.ts | 14 +- .../lib/sidebar-worktree-activation.test.ts | 12 + .../src/lib/sidebar-worktree-activation.ts | 11 +- .../workspace-session-browser-history.test.ts | 1 + .../workspace-session-editor-drafts.test.ts | 1 + .../lib/workspace-session-liveness.test.ts | 1 + .../workspace-session-relevant-fields.test.ts | 1 + src/renderer/src/lib/workspace-session.ts | 3 + src/renderer/src/lib/worktree-activation.ts | 73 +++ src/renderer/src/store/slices/browser.ts | 7 + src/renderer/src/store/slices/editor.ts | 4 + .../store/slices/repos-project-groups.test.ts | 407 ++++++++++++++- src/renderer/src/store/slices/repos.ts | 366 +++++++++++++- src/renderer/src/store/slices/settings.ts | 2 + src/renderer/src/store/slices/tabs.ts | 5 + src/renderer/src/store/slices/terminals.ts | 38 +- .../src/store/slices/worktree-helpers.ts | 5 +- .../src/store/slices/worktrees.test.ts | 41 ++ src/renderer/src/store/slices/worktrees.ts | 249 ++++++++- src/shared/constants.ts | 1 + src/shared/folder-workspace-path-status.ts | 34 ++ src/shared/folder-workspace-worktree.test.ts | 129 +++++ src/shared/folder-workspace-worktree.ts | 37 ++ src/shared/folder-workspaces.ts | 144 ++++++ src/shared/project-groups.ts | 8 + src/shared/types.ts | 47 ++ src/shared/workspace-scope.ts | 31 ++ src/shared/workspace-session-schema.ts | 6 + 99 files changed, 8285 insertions(+), 761 deletions(-) create mode 100644 src/main/project-groups/folder-workspace-path-status.test.ts create mode 100644 src/main/project-groups/folder-workspace-path-status.ts create mode 100644 src/main/runtime/rpc/methods/folder-workspace.ts create mode 100644 src/renderer/src/components/right-sidebar/SearchResultsPane.tsx create mode 100644 src/renderer/src/components/right-sidebar/right-sidebar-measured-width.ts create mode 100644 src/renderer/src/components/sidebar/FolderWorkspaceComposerDialog.tsx create mode 100644 src/renderer/src/components/sidebar/folder-workspace-composer-helpers.ts create mode 100644 src/renderer/src/components/sidebar/folder-workspace-composer-path-status.test.tsx create mode 100644 src/renderer/src/components/sidebar/folder-workspace-composer-path-status.ts create mode 100644 src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list-folder-reveal.test.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list-folder-reveal.ts create mode 100644 src/renderer/src/lib/folder-workspace-connection.ts create mode 100644 src/renderer/src/lib/folder-workspace-path-status-cache-expiry.ts create mode 100644 src/renderer/src/lib/folder-workspace-path-status.ts create mode 100644 src/shared/folder-workspace-path-status.ts create mode 100644 src/shared/folder-workspace-worktree.test.ts create mode 100644 src/shared/folder-workspace-worktree.ts create mode 100644 src/shared/folder-workspaces.ts create mode 100644 src/shared/workspace-scope.ts diff --git a/src/main/agent-hooks/first-work-branch-rename.test.ts b/src/main/agent-hooks/first-work-branch-rename.test.ts index 87d1f900d..fed28e501 100644 --- a/src/main/agent-hooks/first-work-branch-rename.test.ts +++ b/src/main/agent-hooks/first-work-branch-rename.test.ts @@ -47,6 +47,8 @@ import { const REPO_ID = 'repo1' const WORKTREE_ID = `${REPO_ID}${WORKTREE_ID_SEPARATOR}/repo/wt` +const FOLDER_WORKSPACE_ID = 'folder-workspace-1' +const FOLDER_WORKTREE_ID = `folder:${FOLDER_WORKSPACE_ID}` const TAB_ID = 'tab-1' const PANE_KEY = `${TAB_ID}:leaf-1` @@ -192,6 +194,41 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { expect(gitExecFileAsyncMock).not.toHaveBeenCalled() }) + it('renames a pending folder workspace title without touching git', async () => { + const { deps, onRenamed, setDisplayName } = makeDeps({ + resolveWorktreeIdForTab: () => FOLDER_WORKTREE_ID, + getFolderWorkspacePath: () => '/workspace/platform', + isPendingFirstAgentMessageRename: () => true, + getCurrentDisplayName: () => 'Platform workspace' + }) + + await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps) + + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + expect(resolveTextGenerationParamsMock).toHaveBeenCalledWith( + expect.anything(), + 'local', + 'branchName', + null + ) + expect(setDisplayName).toHaveBeenCalledWith(FOLDER_WORKTREE_ID, 'Fix auth') + expect(onRenamed).toHaveBeenCalledWith(FOLDER_WORKTREE_ID) + }) + + it('does not rename folder workspace titles without the pending marker', async () => { + const { deps, setDisplayName } = makeDeps({ + resolveWorktreeIdForTab: () => FOLDER_WORKTREE_ID, + getFolderWorkspacePath: () => '/workspace/platform', + isPendingFirstAgentMessageRename: () => false + }) + + await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps) + + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + expect(generateBranchNameMock).not.toHaveBeenCalled() + expect(setDisplayName).not.toHaveBeenCalled() + }) + it('ignores replayed events and non-working states', async () => { const { deps } = makeDeps() await maybeAutoRenameBranchOnFirstWork(workingEvent({ isReplay: true }), deps) diff --git a/src/main/agent-hooks/first-work-branch-rename.ts b/src/main/agent-hooks/first-work-branch-rename.ts index 25c5392f0..448633892 100644 --- a/src/main/agent-hooks/first-work-branch-rename.ts +++ b/src/main/agent-hooks/first-work-branch-rename.ts @@ -5,6 +5,7 @@ // summarize the prompt via the configured agent, and rename. import type { GlobalSettings, Repo } from '../../shared/types' import { getRepoIdFromWorktreeId, splitWorktreeId } from '../../shared/worktree-id' +import { parseWorkspaceKey } from '../../shared/workspace-scope' import { parsePaneKey } from '../../shared/stable-pane-id' import { humanizeBranchSlug, @@ -50,6 +51,10 @@ export type FirstWorkBranchRenameDeps = { getAgentEnvResolvers: () => CommitMessageAgentEnvironmentResolvers | undefined /** Current sidebar display name for the worktree, if one is stored. */ getCurrentDisplayName: (worktreeId: string) => string | undefined + /** Current workspace path for non-git folder workspaces. */ + getFolderWorkspacePath?: (worktreeId: string) => string | undefined + /** True while a workspace title is waiting for the first agent message. */ + isPendingFirstAgentMessageRename?: (worktreeId: string) => boolean /** True only for Orca-created worktrees whose branch Orca is allowed to rename. */ canRenameOrcaCreatedBranch: (worktreeId: string) => boolean /** Persist a new sidebar display name for the worktree. */ @@ -164,6 +169,18 @@ async function runAutoRename( return false } + const workspaceScope = parseWorkspaceKey(worktreeId) + if (workspaceScope?.type === 'folder') { + return runFolderWorkspaceTitleAutoRename( + worktreeId, + prompt, + assistantMessage, + deps, + stop, + retry + ) + } + const repo = deps.getRepo(getRepoIdFromWorktreeId(worktreeId)) const parsed = splitWorktreeId(worktreeId) if (!repo || !parsed) { @@ -276,6 +293,59 @@ async function runAutoRename( return true } +async function runFolderWorkspaceTitleAutoRename( + worktreeId: string, + prompt: string, + assistantMessage: string | undefined, + deps: FirstWorkBranchRenameDeps, + stop: (reason: string, clearError?: boolean) => true, + retry: (reason: string) => false +): Promise { + if (deps.isPendingFirstAgentMessageRename?.(worktreeId) !== true) { + return stop('folder workspace is not pending title rename', true) + } + const folderPath = deps.getFolderWorkspacePath?.(worktreeId) + if (!folderPath) { + return stop('folder workspace path unavailable') + } + + const settings = deps.getSettings() + const resolvedParams = resolveTextGenerationParams(settings, 'local', 'branchName', null) + if (!resolvedParams.ok) { + deps.setRenameError(worktreeId, resolvedParams.error) + return stop(`no generation agent: ${resolvedParams.error}`) + } + const target = await resolveGenerationTarget( + folderPath, + resolvedParams.params.agentId, + null, + deps + ) + if (!target) { + deps.setRenameError(worktreeId, 'Could not prepare the workspace-name generation environment.') + return retry('could not prepare generation environment') + } + + const generated = await generateBranchNameFromContext( + { firstPrompt: prompt, assistantMessage }, + resolvedParams.params, + target + ) + if (!generated.success) { + if (!generated.canceled) { + deps.setRenameError(worktreeId, generated.error) + } + return retry(`generation failed: ${generated.error}`) + } + + const newDisplayName = humanizeBranchSlug(generated.slug) + deps.setDisplayName(worktreeId, newDisplayName) + deps.setRenameError(worktreeId, null) + deps.onRenamed(worktreeId) + console.info(`[auto-branch-rename] renamed folder workspace title -> "${newDisplayName}"`) + return true +} + async function resolveGenerationTarget( worktreePath: string, agentId: string, diff --git a/src/main/index.ts b/src/main/index.ts index 04ad7f472..943a60d5a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -93,6 +93,7 @@ import { StarNagService } from './star-nag/service' import { agentHookServer } from './agent-hooks/server' import { maybeAutoRenameBranchOnFirstWork } from './agent-hooks/first-work-branch-rename' import { getRepoIdFromWorktreeId } from '../shared/worktree-id' +import { parseWorkspaceKey } from '../shared/workspace-scope' import { setMigrationUnsupportedPtyListener } from './agent-hooks/migration-unsupported-pty-state' import { clearProviderPtyState, @@ -207,7 +208,29 @@ function maybeAutoRenameBranchOnFirstWorkFromHook(event: { getSettings: () => currentStore.getSettings(), getRepo: (repoId) => currentStore.getRepo(repoId), getAgentEnvResolvers: () => currentRuntime.getCommitMessageAgentEnvironmentResolvers(), - getCurrentDisplayName: (worktreeId) => currentStore.getWorktreeMeta(worktreeId)?.displayName, + getCurrentDisplayName: (worktreeId) => { + const scope = parseWorkspaceKey(worktreeId) + if (scope?.type === 'folder') { + return currentStore.getFolderWorkspace(scope.folderWorkspaceId)?.name + } + return currentStore.getWorktreeMeta(worktreeId)?.displayName + }, + getFolderWorkspacePath: (worktreeId) => { + const scope = parseWorkspaceKey(worktreeId) + return scope?.type === 'folder' + ? currentStore.getFolderWorkspace(scope.folderWorkspaceId)?.folderPath + : undefined + }, + isPendingFirstAgentMessageRename: (worktreeId) => { + const scope = parseWorkspaceKey(worktreeId) + if (scope?.type === 'folder') { + return ( + currentStore.getFolderWorkspace(scope.folderWorkspaceId) + ?.pendingFirstAgentMessageRename === true + ) + } + return currentStore.getWorktreeMeta(worktreeId)?.pendingFirstAgentMessageRename === true + }, canRenameOrcaCreatedBranch: (worktreeId) => { const meta = currentStore.getWorktreeMeta(worktreeId) // Why: a user/imported branch can coincidentally be named after a creature. @@ -215,6 +238,16 @@ function maybeAutoRenameBranchOnFirstWorkFromHook(event: { return !!meta?.orcaCreationSource && meta.preserveBranchOnDelete !== true }, setDisplayName: (worktreeId, displayName) => { + const scope = parseWorkspaceKey(worktreeId) + if (scope?.type === 'folder') { + currentStore.updateFolderWorkspace(scope.folderWorkspaceId, { + name: displayName, + pendingFirstAgentMessageRename: false, + firstAgentMessageRenameError: null + }) + currentRuntime.notifyFolderWorkspaceChanged() + return + } currentStore.setWorktreeMeta(worktreeId, { displayName, pendingFirstAgentMessageRename: false, @@ -226,6 +259,20 @@ function maybeAutoRenameBranchOnFirstWorkFromHook(event: { setRenameError: (worktreeId, error) => { // Skip the write + renderer push when nothing changes — benign skips // clear the error on every settled worktree, most of which never had one. + const scope = parseWorkspaceKey(worktreeId) + if (scope?.type === 'folder') { + const current = currentStore.getFolderWorkspace( + scope.folderWorkspaceId + )?.firstAgentMessageRenameError + if ((current ?? null) === (error ?? null)) { + return + } + currentStore.updateFolderWorkspace(scope.folderWorkspaceId, { + firstAgentMessageRenameError: error + }) + currentRuntime.notifyFolderWorkspaceChanged() + return + } const current = currentStore.getWorktreeMeta(worktreeId)?.firstAgentMessageRenameError if ((current ?? null) === (error ?? null)) { return @@ -236,7 +283,13 @@ function maybeAutoRenameBranchOnFirstWorkFromHook(event: { currentRuntime.notifyBranchRenamed(getRepoIdFromWorktreeId(worktreeId)) }, resolveWorktreeIdForTab: (tabId) => currentStore.getWorktreeIdForTab(tabId), - onRenamed: (repoId) => currentRuntime.notifyBranchRenamed(repoId) + onRenamed: (repoIdOrWorktreeId) => { + if (parseWorkspaceKey(repoIdOrWorktreeId)?.type === 'folder') { + currentRuntime.notifyFolderWorkspaceChanged() + return + } + currentRuntime.notifyBranchRenamed(repoIdOrWorktreeId) + } } ) } diff --git a/src/main/ipc/filesystem-auth.test.ts b/src/main/ipc/filesystem-auth.test.ts index 1b7f76272..7d0271249 100644 --- a/src/main/ipc/filesystem-auth.test.ts +++ b/src/main/ipc/filesystem-auth.test.ts @@ -6,7 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Store } from '../persistence' import type * as RepoWorktrees from '../repo-worktrees' import { listRepoWorktrees } from '../repo-worktrees' -import type { GitWorktreeInfo, Repo } from '../../shared/types' +import type { FolderWorkspace, GitWorktreeInfo, ProjectGroup, Repo } from '../../shared/types' import { invalidateAuthorizedRootsCache, isDescendantOrEqual, @@ -35,9 +35,52 @@ const repo: Repo = { kind: 'git' } -function makeStore(repos: Repo[] = [repo]): Store { +function makeProjectGroup(overrides: Partial = {}): ProjectGroup { + return { + id: 'group-1', + name: 'Workspace', + parentPath: '/folders/workspace', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +function makeFolderWorkspace(overrides: Partial = {}): FolderWorkspace { + return { + id: 'folder-workspace-1', + projectGroupId: 'group-1', + name: 'Feature', + folderPath: '/folders/workspace', + comment: '', + linkedTask: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 1, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +function makeStore( + repos: Repo[] = [repo], + options: { + projectGroups?: ProjectGroup[] + folderWorkspaces?: FolderWorkspace[] + } = {} +): Store { return { getRepos: () => repos, + getProjectGroups: () => options.projectGroups ?? [], + getFolderWorkspaces: () => options.folderWorkspaces ?? [], getSettings: () => ({}) } as unknown as Store } @@ -111,6 +154,113 @@ describe('filesystem-auth path containment', () => { } }) + it('authorizes local folder workspace roots outside child repo roots', async () => { + const tempRoot = await mkdtemp(join(tmpdir(), 'orca-auth-folder-workspace-')) + try { + const folderPath = join(tempRoot, 'platform') + const repoPath = join(folderPath, 'web') + await mkdir(repoPath, { recursive: true }) + const projectGroup = makeProjectGroup({ parentPath: folderPath }) + const folderWorkspace = makeFolderWorkspace({ folderPath, projectGroupId: projectGroup.id }) + const store = makeStore([{ ...repo, id: 'repo-temp', path: repoPath }], { + projectGroups: [projectGroup], + folderWorkspaces: [folderWorkspace] + }) + + await expect(resolveAuthorizedPath(folderPath, store)).resolves.toBe( + await realpath(folderPath) + ) + await expect(resolveAuthorizedPath(join(folderPath, 'notes.md'), store)).resolves.toBe( + join(await realpath(folderPath), 'notes.md') + ) + } finally { + await rm(tempRoot, { recursive: true, force: true }) + } + }) + + it('authorizes local folder-backed project group roots outside child repo roots', async () => { + const tempRoot = await mkdtemp(join(tmpdir(), 'orca-auth-project-group-')) + try { + const folderPath = join(tempRoot, 'platform') + const repoPath = join(folderPath, 'web') + await mkdir(repoPath, { recursive: true }) + const projectGroup = makeProjectGroup({ parentPath: folderPath }) + const store = makeStore([{ ...repo, id: 'repo-temp', path: repoPath }], { + projectGroups: [projectGroup] + }) + + await expect(resolveAuthorizedPath(folderPath, store)).resolves.toBe( + await realpath(folderPath) + ) + } finally { + await rm(tempRoot, { recursive: true, force: true }) + } + }) + + it('does not authorize SSH-only folder workspace roots as local paths', async () => { + const tempRoot = await mkdtemp(join(tmpdir(), 'orca-auth-remote-folder-workspace-')) + try { + const folderPath = join(tempRoot, 'remote-platform') + const repoPath = join(folderPath, 'web') + await mkdir(repoPath, { recursive: true }) + const projectGroup = makeProjectGroup({ parentPath: folderPath }) + const folderWorkspace = makeFolderWorkspace({ folderPath, projectGroupId: projectGroup.id }) + const store = makeStore( + [{ ...repo, id: 'repo-temp', path: repoPath, connectionId: 'ssh-1' }], + { + projectGroups: [projectGroup], + folderWorkspaces: [folderWorkspace] + } + ) + + await expect(resolveAuthorizedPath(folderPath, store)).rejects.toThrow('Access denied') + } finally { + await rm(tempRoot, { recursive: true, force: true }) + } + }) + + it('does not authorize repo-less SSH-provenance folder roots as local paths', async () => { + const tempRoot = await mkdtemp(join(tmpdir(), 'orca-auth-remote-folder-provenance-')) + try { + const folderPath = join(tempRoot, 'remote-platform') + await mkdir(folderPath, { recursive: true }) + const projectGroup = makeProjectGroup({ parentPath: folderPath, connectionId: 'ssh-1' }) + const folderWorkspace = makeFolderWorkspace({ + folderPath, + projectGroupId: projectGroup.id, + connectionId: 'ssh-1' + }) + const store = makeStore([], { + projectGroups: [projectGroup], + folderWorkspaces: [folderWorkspace] + }) + + await expect(resolveAuthorizedPath(folderPath, store)).rejects.toThrow('Access denied') + } finally { + await rm(tempRoot, { recursive: true, force: true }) + } + }) + + it('does not authorize SSH-only folder-backed project group roots as local paths', async () => { + const tempRoot = await mkdtemp(join(tmpdir(), 'orca-auth-remote-project-group-')) + try { + const folderPath = join(tempRoot, 'remote-platform') + const repoPath = join(folderPath, 'web') + await mkdir(repoPath, { recursive: true }) + const projectGroup = makeProjectGroup({ parentPath: folderPath }) + const store = makeStore( + [{ ...repo, id: 'repo-temp', path: repoPath, connectionId: 'ssh-1' }], + { + projectGroups: [projectGroup] + } + ) + + await expect(resolveAuthorizedPath(folderPath, store)).rejects.toThrow('Access denied') + } finally { + await rm(tempRoot, { recursive: true, force: true }) + } + }) + it.skipIf(process.platform === 'win32')( 'rejects missing descendants under a symlinked ancestor outside the repo', async () => { diff --git a/src/main/ipc/filesystem-auth.ts b/src/main/ipc/filesystem-auth.ts index e92966c18..ec009509b 100644 --- a/src/main/ipc/filesystem-auth.ts +++ b/src/main/ipc/filesystem-auth.ts @@ -7,6 +7,9 @@ import { realpath } from 'fs/promises' import type { Store } from '../persistence' import { isRepoRoot, listRepoWorktrees } from '../repo-worktrees' import { computeWorkspaceRoot, getWorktreePathSettings } from './worktree-logic' +import { isPathInsideOrEqual } from '../../shared/cross-platform-path' +import { getProjectGroupSubtreeIds } from '../../shared/project-groups' +import type { FolderWorkspace, ProjectGroup, Repo } from '../../shared/types' export const PATH_ACCESS_DENIED_MESSAGE = 'Access denied: path resolves outside allowed directories. If this blocks a legitimate workflow, please file a GitHub issue.' @@ -17,6 +20,8 @@ const registeredWorktreeRootRepoIds = new Set() let registeredWorktreeRootsDirty = true let registeredWorktreeRootsRefresh: Promise | null = null const AUTHORIZED_ROOTS_REBUILD_CONCURRENCY = 8 +type FolderScopeStore = Pick & + Partial> export function authorizeExternalPath(targetPath: string): void { const resolvedTarget = resolve(targetPath) @@ -43,6 +48,75 @@ function getLocalRepos(store: Store) { return store.getRepos().filter((repo) => !repo.connectionId) } +function getFolderScopeCandidateRepos( + folderPath: string, + projectGroupId: string, + projectGroups: readonly ProjectGroup[], + repos: readonly Repo[] +): Repo[] { + const groupIds = getProjectGroupSubtreeIds(projectGroups, projectGroupId) + return repos.filter( + (repo) => + (typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) || + isPathInsideOrEqual(folderPath, repo.path) + ) +} + +function isRemoteOnlyFolderScope( + folderPath: string, + projectGroupId: string, + connectionId: string | null | undefined, + projectGroups: readonly ProjectGroup[], + repos: readonly Repo[] +): boolean { + if (connectionId) { + return true + } + const candidates = getFolderScopeCandidateRepos(folderPath, projectGroupId, projectGroups, repos) + return candidates.length > 0 && candidates.every((repo) => Boolean(repo.connectionId)) +} + +function getFolderWorkspaceConnectionId( + workspace: FolderWorkspace, + projectGroups: readonly ProjectGroup[] +): string | null { + return ( + workspace.connectionId ?? + projectGroups.find((group) => group.id === workspace.projectGroupId)?.connectionId ?? + null + ) +} + +function getLocalFolderScopeRoots(store: Store): string[] { + const scopeStore = store as FolderScopeStore + const repos = scopeStore.getRepos() + // Why: many filesystem tests use narrow Store doubles; folder scopes are additive. + const projectGroups = scopeStore.getProjectGroups?.() ?? [] + const roots: string[] = [] + for (const group of projectGroups) { + if ( + group.parentPath && + !isRemoteOnlyFolderScope(group.parentPath, group.id, group.connectionId, projectGroups, repos) + ) { + roots.push(resolve(group.parentPath)) + } + } + for (const workspace of scopeStore.getFolderWorkspaces?.() ?? []) { + if ( + !isRemoteOnlyFolderScope( + workspace.folderPath, + workspace.projectGroupId, + getFolderWorkspaceConnectionId(workspace, projectGroups), + projectGroups, + repos + ) + ) { + roots.push(resolve(workspace.folderPath)) + } + } + return roots +} + /** * Check whether resolvedTarget is equal to or a descendant of resolvedBase. * Uses relative() so it works with both `/` (Unix) and `\` (Windows) separators. @@ -63,7 +137,10 @@ export function isDescendantOrEqual(resolvedTarget: string, resolvedBase: string export function getAllowedRoots(store: Store): string[] { const localRepos = getLocalRepos(store) const settings = store.getSettings() - const roots = localRepos.map((repo) => resolve(repo.path)) + const roots = [ + ...localRepos.map((repo) => resolve(repo.path)), + ...getLocalFolderScopeRoots(store) + ] if (settings.workspaceDir) { if (localRepos.length === 0) { roots.push(resolve(settings.workspaceDir)) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 2838df9be..9636c4621 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -64,6 +64,12 @@ import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env' import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' import { buildConfiguredProxyEnv, type NetworkProxySettings } from '../../shared/network-proxy' +import { parseWorkspaceKey } from '../../shared/workspace-scope' +import { + assertFolderWorkspacePathUsable, + getFolderWorkspacePathStatus +} from '../project-groups/folder-workspace-path-status' +import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' // ─── Provider Registry ────────────────────────────────────────────── // Routes PTY operations by connectionId. null = local provider. @@ -1569,6 +1575,21 @@ export function registerPtyHandlers( mainWindow.webContents.on('did-finish-load', didFinishLoadHandler) } + const assertFolderWorkspacePtyPathUsable = async ( + worktreeId: string | undefined + ): Promise => { + const workspaceScope = typeof worktreeId === 'string' ? parseWorkspaceKey(worktreeId) : null + if (!store || workspaceScope?.type !== 'folder') { + return + } + const status = await getFolderWorkspacePathStatus( + store, + { scope: 'folder-workspace', folderWorkspaceId: workspaceScope.folderWorkspaceId }, + { getSshFilesystemProvider } + ) + assertFolderWorkspacePathUsable(status) + } + // Why: the runtime controller must route through getProviderForPty() so that // CLI commands (terminal.send, terminal.stop) work for both local and remote PTYs. // Hardcoding localProvider.getPtyProcess() would silently fail for remote PTYs. @@ -1578,6 +1599,7 @@ export function registerPtyHandlers( if (startupPromise) { await startupPromise } + await assertFolderWorkspacePtyPathUsable(args.worktreeId) const provider = getProvider(args.connectionId) const isClaudeLaunch = !args.connectionId && isClaudeLaunchCommand(args.command) if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { @@ -2030,6 +2052,7 @@ export function registerPtyHandlers( if (startupPromise) { await startupPromise } + await assertFolderWorkspacePtyPathUsable(args.worktreeId) const provider = getProvider(args.connectionId) const isClaudeLaunch = !args.connectionId && isClaudeLaunchCommand(args.command) if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index ac1927617..d0bf84ce9 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -11,16 +11,19 @@ import type { BaseRefSearchResult, Repo, ProjectGroup, + FolderWorkspace, ProjectGroupImportResult, NestedRepoScanResult, BaseRefDefaultResult, SparsePreset } from '../../shared/types' +import type { FolderWorkspacePathStatusRequest } from '../../shared/folder-workspace-path-status' import { isFolderRepo } from '../../shared/repo-kind' import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants' import { normalizeRepoBadgeColor } from '../../shared/repo-badge-color' import { sanitizeRepoIcon } from '../../shared/repo-icon' import { normalizeRepoSourceControlAiOverrides } from '../../shared/source-control-ai' +import { isTuiAgent } from '../../shared/tui-agent-config' import { invalidateAuthorizedRootsCache } from './filesystem-auth' import type { ChildProcess } from 'child_process' import { access, mkdir, readdir, rm } from 'fs/promises' @@ -62,6 +65,11 @@ import { track } from '../telemetry/client' import { getCohortAtEmit } from '../telemetry/cohort-classifier' import type { RepoMethod } from '../../shared/telemetry-events' import { detectRepoIconAndUpstream } from '../repo-icon-autodetect' +import { + assertFolderWorkspacePathUsable, + getFolderWorkspacePathStatus, + getFolderWorkspacePathStatusForPath +} from '../project-groups/folder-workspace-path-status' // Why: `method` answers "which entry point did the user take?", not "what did // they add?" — so the IPC the renderer invoked IS the method. We never send @@ -136,6 +144,7 @@ const GIT_AVAILABILITY_TIMEOUT_MS = 1500 const ProjectGroupCreateArgs = z.object({ name: z.string().min(1), parentPath: z.string().nullable().optional(), + connectionId: z.string().nullable().optional(), parentGroupId: z.string().nullable().optional(), createdFrom: z.enum(['manual', 'folder-scan', 'migration']).optional() }) @@ -160,6 +169,64 @@ const ProjectGroupMoveProjectArgs = z.object({ order: z.number().finite().optional() }) +const FolderWorkspaceLinkedTaskArgs = z + .object({ + provider: z.enum(['github', 'gitlab', 'linear', 'jira']), + type: z.enum(['issue', 'pr', 'mr']), + number: z.number().finite(), + title: z.string().min(1), + url: z.string().min(1), + linearIdentifier: z.string().min(1).optional(), + jiraIdentifier: z.string().min(1).optional(), + repoId: z.string().min(1).optional() + }) + .nullable() + +const FolderWorkspaceCreateArgs = z.object({ + projectGroupId: z.string().min(1), + name: z.string().optional(), + folderPath: z.string().nullable().optional(), + connectionId: z.string().nullable().optional(), + linkedTask: FolderWorkspaceLinkedTaskArgs.optional(), + createdWithAgent: z.string().refine(isTuiAgent).optional(), + pendingFirstAgentMessageRename: z.boolean().optional() +}) + +const FolderWorkspaceUpdateArgs = z.object({ + folderWorkspaceId: z.string().min(1), + updates: z.object({ + name: z.string().optional(), + folderPath: z.string().optional(), + linkedTask: FolderWorkspaceLinkedTaskArgs.optional(), + comment: z.string().optional(), + isArchived: z.boolean().optional(), + isUnread: z.boolean().optional(), + isPinned: z.boolean().optional(), + sortOrder: z.number().finite().optional(), + manualOrder: z.number().finite().optional(), + workspaceStatus: z.string().optional(), + createdWithAgent: z.string().refine(isTuiAgent).optional(), + pendingFirstAgentMessageRename: z.boolean().optional(), + firstAgentMessageRenameError: z.string().nullable().optional(), + lastActivityAt: z.number().finite().optional() + }) +}) + +const FolderWorkspaceSelectorArgs = z.object({ + folderWorkspaceId: z.string().min(1) +}) + +const FolderWorkspacePathStatusArgs = z.discriminatedUnion('scope', [ + z.object({ + scope: z.literal('folder-workspace'), + folderWorkspaceId: z.string().min(1) + }), + z.object({ + scope: z.literal('project-group'), + projectGroupId: z.string().min(1) + }) +]) + const ProjectGroupScanNestedArgs = z.object({ path: z.string().min(1), connectionId: z.string().min(1).optional(), @@ -470,6 +537,11 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v ipcMain.removeHandler('projectGroups:scanNested') ipcMain.removeHandler('projectGroups:cancelNestedScan') ipcMain.removeHandler('projectGroups:importNested') + ipcMain.removeHandler('folderWorkspaces:list') + ipcMain.removeHandler('folderWorkspaces:create') + ipcMain.removeHandler('folderWorkspaces:update') + ipcMain.removeHandler('folderWorkspaces:delete') + ipcMain.removeHandler('folderWorkspaces:getPathStatus') ipcMain.removeHandler('repos:pickFolder') ipcMain.removeHandler('repos:pickDirectory') ipcMain.removeHandler('repos:clone') @@ -495,6 +567,104 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v ipcMain.handle('projectGroups:list', () => store.getProjectGroups()) + ipcMain.handle('folderWorkspaces:list', (): FolderWorkspace[] => store.getFolderWorkspaces()) + + ipcMain.handle('folderWorkspaces:getPathStatus', async (_event, rawArgs: unknown) => { + const args = parseProjectGroupIpcArgs( + FolderWorkspacePathStatusArgs, + rawArgs, + 'invalid_folder_workspace_path_status_args' + ) as FolderWorkspacePathStatusRequest + return getFolderWorkspacePathStatus(store, args, { getSshFilesystemProvider }) + }) + + ipcMain.handle( + 'folderWorkspaces:create', + async (_event, rawArgs: unknown): Promise => { + const args = parseProjectGroupIpcArgs( + FolderWorkspaceCreateArgs, + rawArgs, + 'invalid_folder_workspace_create_args' + ) + const projectGroups = store.getProjectGroups() + const group = projectGroups.find((entry) => entry.id === args.projectGroupId) + const folderPath = + typeof args.folderPath === 'string' && args.folderPath.trim().length > 0 + ? args.folderPath + : group?.parentPath + if (!group || !folderPath) { + throw new Error('folder_workspace_project_group_not_found') + } + const status = await getFolderWorkspacePathStatusForPath( + { + folderPath, + projectGroupId: group.id, + connectionId: args.connectionId ?? group.connectionId ?? null, + projectGroups, + repos: store.getRepos() + }, + { getSshFilesystemProvider } + ) + assertFolderWorkspacePathUsable(status) + const workspace = store.createFolderWorkspace(args) + notifyReposChanged(mainWindow) + return workspace + } + ) + + ipcMain.handle( + 'folderWorkspaces:update', + async (_event, rawArgs: unknown): Promise => { + const args = parseProjectGroupIpcArgs( + FolderWorkspaceUpdateArgs, + rawArgs, + 'invalid_folder_workspace_update_args' + ) + if ( + typeof args.updates.folderPath === 'string' && + args.updates.folderPath.trim().length > 0 + ) { + const workspace = store.getFolderWorkspace(args.folderWorkspaceId) + if (!workspace) { + return null + } + const projectGroups = store.getProjectGroups() + const status = await getFolderWorkspacePathStatusForPath( + { + folderPath: args.updates.folderPath, + projectGroupId: workspace.projectGroupId, + connectionId: + workspace.connectionId ?? + projectGroups.find((entry) => entry.id === workspace.projectGroupId)?.connectionId ?? + null, + projectGroups, + repos: store.getRepos() + }, + { getSshFilesystemProvider } + ) + assertFolderWorkspacePathUsable(status) + } + const updated = store.updateFolderWorkspace(args.folderWorkspaceId, args.updates) + if (updated) { + notifyReposChanged(mainWindow) + } + return updated + } + ) + + ipcMain.handle('folderWorkspaces:delete', (_event, rawArgs: unknown): boolean => { + const args = parseProjectGroupIpcArgs( + FolderWorkspaceSelectorArgs, + rawArgs, + 'invalid_folder_workspace_delete_args' + ) + const deleted = store.removeFolderWorkspace(args.folderWorkspaceId) + if (deleted) { + notifyReposChanged(mainWindow) + } + return deleted + }) + ipcMain.handle('projectGroups:create', (_event, rawArgs: unknown): ProjectGroup => { const args = parseProjectGroupIpcArgs( ProjectGroupCreateArgs, @@ -504,6 +674,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v const group = store.createProjectGroup({ name: args.name, parentPath: args.parentPath ?? null, + connectionId: args.connectionId ?? null, parentGroupId: args.parentGroupId ?? null, createdFrom: args.createdFrom ?? 'manual' }) @@ -598,6 +769,8 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v parentPath: scan.selectedPath, groupName: args.groupName ?? '', mode: args.mode, + connectionId: args.connectionId ?? null, + repoPaths: selection.selectedPaths, createGroup: (input) => store.createProjectGroup(input) }) const results: ProjectGroupImportResult['projects'] = selection.rejectedPaths.map( diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index fd87be32c..4d16b5dc9 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -31,6 +31,7 @@ import { ONBOARDING_FINAL_STEP, ONBOARDING_FLOW_VERSION } from '../shared/constants' +import { folderWorkspaceKey } from '../shared/workspace-scope' import { SshConnectionStore } from './ssh/ssh-connection-store' // Shared mutable state so the electron mock can reference a per-test directory @@ -2144,6 +2145,57 @@ describe('Store', () => { expect(store.getRepo('sibling')?.projectGroupId).toBe(sibling.id) }) + it('adapts flat folder-scan groups into sparse nested folder scopes on load', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [ + makeRepo({ id: 'api', path: '/workspace/platform/api', projectGroupId: 'root' }), + makeRepo({ id: 'web', path: '/workspace/platform/web', projectGroupId: 'root' }), + makeRepo({ + id: 'repo1', + path: '/workspace/platform/packages/shared/repo1', + projectGroupId: 'root' + }), + makeRepo({ + id: 'repo2', + path: '/workspace/platform/packages/shared/repo2', + projectGroupId: 'root' + }) + ], + worktreeMeta: {}, + settings: {}, + ui: {}, + githubCache: { pr: {}, issue: {} }, + projectGroups: [ + { + id: 'root', + name: 'Platform', + parentPath: '/workspace/platform', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + ] + }) + + const store = await createStore() + const groups = store.getProjectGroups() + const shared = groups.find((group) => group.name === 'packages/shared') + + expect(groups.map((group) => [group.name, group.parentGroupId, group.parentPath])).toEqual([ + ['Platform', null, '/workspace/platform'], + ['packages/shared', 'root', '/workspace/platform/packages/shared'] + ]) + expect(store.getRepo('api')?.projectGroupId).toBe('root') + expect(store.getRepo('web')?.projectGroupId).toBe('root') + expect(store.getRepo('repo1')?.projectGroupId).toBe(shared?.id) + expect(store.getRepo('repo2')?.projectGroupId).toBe(shared?.id) + }) + it('creates a project group when persisted group history is very large', async () => { const projectGroups: ProjectGroup[] = Array.from({ length: 130_000 }, (_, index) => ({ id: `group-${index}`, @@ -2525,6 +2577,319 @@ describe('Store', () => { expect(updated.comment).toBe('updated') }) + it('creates and updates folder workspaces from folder-backed project groups', async () => { + const store = await createStore() + const group = store.createProjectGroup({ + name: 'Platform', + parentPath: '/workspace/platform', + createdFrom: 'folder-scan' + }) + const linkedTask = { + provider: 'linear' as const, + type: 'issue' as const, + number: 0, + title: 'Refund fix', + url: 'https://linear.app/acme/issue/ENG-123', + linearIdentifier: 'ENG-123' + } + + const workspace = store.createFolderWorkspace({ + projectGroupId: group.id, + name: 'Refund fix', + linkedTask + }) + const updated = store.updateFolderWorkspace(workspace.id, { + comment: 'Coordinate api and web', + isPinned: true, + lastActivityAt: 123 + }) + + expect(workspace.folderPath).toBe('/workspace/platform') + expect(updated).toMatchObject({ + id: workspace.id, + projectGroupId: group.id, + name: 'Refund fix', + folderPath: '/workspace/platform', + linkedTask, + comment: 'Coordinate api and web', + isPinned: true, + lastActivityAt: 123 + }) + expect(store.getFolderWorkspaces()).toHaveLength(1) + }) + + it('rejects folder workspace creation for non-folder-backed project groups', async () => { + const store = await createStore() + const group = store.createProjectGroup({ name: 'Manual', createdFrom: 'manual' }) + + expect(() => store.createFolderWorkspace({ projectGroupId: group.id })).toThrow( + 'Folder-backed project group not found.' + ) + }) + + it('normalizes persisted folder workspaces and drops orphaned records', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: {}, + githubCache: { pr: {}, issue: {} }, + projectGroups: [ + { + id: 'root', + name: 'Platform', + parentPath: '/workspace/platform', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + ], + folderWorkspaces: [ + { + id: 'fw-1', + projectGroupId: 'root', + name: ' ', + folderPath: '', + comment: 42, + isArchived: true, + isUnread: true, + isPinned: false, + sortOrder: 10, + lastActivityAt: 5, + createdAt: 2, + updatedAt: 3 + }, + { + id: 'orphan', + projectGroupId: 'missing', + name: 'Orphan', + folderPath: '/missing' + } + ] + }) + + const store = await createStore() + + expect(store.getFolderWorkspaces()).toEqual([ + expect.objectContaining({ + id: 'fw-1', + projectGroupId: 'root', + name: 'Untitled workspace', + folderPath: '/workspace/platform', + comment: '', + isArchived: true, + isUnread: true + }) + ]) + }) + + it('backfills folder-scope SSH provenance from unambiguous child repos on load', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [ + makeRepo({ + id: 'api', + path: '/workspace/platform/api', + projectGroupId: 'root', + connectionId: 'ssh-1' + }) + ], + worktreeMeta: {}, + settings: {}, + ui: {}, + githubCache: { pr: {}, issue: {} }, + projectGroups: [ + { + id: 'root', + name: 'Platform', + parentPath: '/workspace/platform', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + ], + folderWorkspaces: [ + { + id: 'fw-1', + projectGroupId: 'root', + name: 'Refund fix', + folderPath: '/workspace/platform', + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 1, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1 + } + ] + }) + + const store = await createStore() + + expect(store.getProjectGroups()[0]).toMatchObject({ id: 'root', connectionId: 'ssh-1' }) + expect(store.getFolderWorkspaces()[0]).toMatchObject({ id: 'fw-1', connectionId: 'ssh-1' }) + }) + + it('backfills folder-scope SSH provenance from grouped repos despite unrelated same-path SSH repos', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [ + makeRepo({ + id: 'api-ssh-1', + path: '/workspace/platform/api', + projectGroupId: 'root', + connectionId: 'ssh-1' + }), + makeRepo({ + id: 'api-ssh-2', + path: '/workspace/platform/api', + projectGroupId: 'other-root', + connectionId: 'ssh-2' + }) + ], + worktreeMeta: {}, + settings: {}, + ui: {}, + githubCache: { pr: {}, issue: {} }, + projectGroups: [ + { + id: 'root', + name: 'Platform', + parentPath: '/workspace/platform', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + }, + { + id: 'other-root', + name: 'Platform other', + parentPath: '/workspace/platform', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 1, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + ], + folderWorkspaces: [ + { + id: 'fw-1', + projectGroupId: 'root', + name: 'Refund fix', + folderPath: '/workspace/platform', + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 1, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1 + } + ] + }) + + const store = await createStore() + + expect(store.getProjectGroups().find((group) => group.id === 'root')).toMatchObject({ + connectionId: 'ssh-1' + }) + expect(store.getFolderWorkspaces()[0]).toMatchObject({ id: 'fw-1', connectionId: 'ssh-1' }) + }) + + it('removes folder workspace metadata and its scoped session state only', async () => { + const store = await createStore() + const group = store.createProjectGroup({ + name: 'Platform', + parentPath: '/workspace/platform', + createdFrom: 'folder-scan' + }) + store.addRepo( + makeRepo({ id: 'api', path: '/workspace/platform/api', projectGroupId: group.id }) + ) + const workspace = store.createFolderWorkspace({ projectGroupId: group.id, name: 'Refund fix' }) + const key = folderWorkspaceKey(workspace.id) + const tab = makeTerminalTab({ id: 'folder-tab', worktreeId: key }) + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + activeWorkspaceKey: key, + activeWorktreeId: key, + activeTabId: tab.id, + tabsByWorktree: { [key]: [tab], 'repo::/wt': [makeTerminalTab({ id: 'repo-tab' })] }, + terminalLayoutsByTabId: { + [tab.id]: { root: null, activeLeafId: null, expandedLeafId: null }, + 'repo-tab': { root: null, activeLeafId: null, expandedLeafId: null } + }, + browserTabsByWorktree: { + [key]: [ + { + id: 'browser-workspace', + worktreeId: key, + url: 'about:blank', + title: 'Blank', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } + ] + }, + browserPagesByWorkspace: { + 'browser-workspace': [ + { + id: 'page-1', + workspaceId: 'browser-workspace', + worktreeId: key, + url: 'about:blank', + title: 'Blank', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } + ] + }, + activeTabIdByWorktree: { [key]: tab.id }, + lastVisitedAtByWorktreeId: { [key]: 10 } + }) + + expect(store.removeFolderWorkspace(workspace.id)).toBe(true) + + const session = store.getWorkspaceSession() + expect(store.getFolderWorkspaces()).toEqual([]) + expect(store.getProjectGroups()).toHaveLength(1) + expect(store.getRepo('api')?.projectGroupId).toBe(group.id) + expect(session.activeWorkspaceKey).toBeNull() + expect(session.activeWorktreeId).toBeNull() + expect(session.activeTabId).toBeNull() + expect(session.tabsByWorktree[key]).toBeUndefined() + expect(session.tabsByWorktree['repo::/wt']).toHaveLength(1) + expect(session.terminalLayoutsByTabId['folder-tab']).toBeUndefined() + expect(session.terminalLayoutsByTabId['repo-tab']).toBeDefined() + expect(session.browserPagesByWorkspace?.['browser-workspace']).toBeUndefined() + }) + // ── 9. Settings: get/update ──────────────────────────────────────── it('updateSettings merges partial updates', async () => { diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 61b35183f..20f0a7a35 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -36,6 +36,7 @@ import type { PersistedState, Repo, ProjectGroup, + FolderWorkspace, SparsePreset, WorktreeMeta, WorktreeLineage, @@ -85,7 +86,10 @@ import { agentHookServer } from './agent-hooks/server' import { pruneLocalTerminalScrollbackBuffers } from '../shared/workspace-session-terminal-buffers' import { pruneWorkspaceSessionBrowserHistory } from '../shared/workspace-session-browser-history' import { getRepoIdFromWorktreeId, getWorktreePathBasenameFromId } from '../shared/worktree-id' -import { normalizeRuntimePathForComparison } from '../shared/cross-platform-path' +import { + isPathInsideOrEqual, + normalizeRuntimePathForComparison +} from '../shared/cross-platform-path' import { normalizeTerminalQuickCommands } from '../shared/terminal-quick-commands' import { normalizeTaskProviderSettings } from '../shared/task-providers' import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../shared/auto-rename-branch-from-work-settings' @@ -121,6 +125,7 @@ import { normalizeProjectGroupName, normalizeProjectGroups } from '../shared/project-groups' +import { createNestedProjectGroupResolver } from './project-groups/nested-repo-import' import { mergeLegacyCommitMessageAiIntoSourceControlAi, normalizeRepoSourceControlAiOverrides, @@ -139,6 +144,11 @@ import { import { normalizeTerminalCursorStyleDefault } from '../shared/terminal-cursor-style-settings' import { normalizeUiLanguage } from '../shared/ui-language' import { normalizeBrowserPageZoomLevel } from '../shared/browser-page-zoom' +import { + normalizeFolderWorkspaceName, + normalizeFolderWorkspaces +} from '../shared/folder-workspaces' +import { folderWorkspaceKey } from '../shared/workspace-scope' import { collectTerminalScrollbackSnapshotRefs, deleteTerminalScrollbackSnapshotSync, @@ -1642,6 +1652,167 @@ function cloneWorkspaceSessionState(session: WorkspaceSessionState): WorkspaceSe return structuredClone(session) } +function removeWorkspaceSessionOwner( + session: WorkspaceSessionState | undefined, + ownerKey: string +): WorkspaceSessionState | undefined { + if (!session) { + return session + } + const next = cloneWorkspaceSessionState(session) + const removedTerminalTabs = next.tabsByWorktree?.[ownerKey] ?? [] + if (next.tabsByWorktree) { + delete next.tabsByWorktree[ownerKey] + } + for (const tab of removedTerminalTabs) { + delete next.terminalLayoutsByTabId[tab.id] + if (next.activeTabId === tab.id) { + next.activeTabId = null + } + } + + if (next.openFilesByWorktree) { + delete next.openFilesByWorktree[ownerKey] + } + if (next.activeFileIdByWorktree) { + delete next.activeFileIdByWorktree[ownerKey] + } + const browserWorkspaces = next.browserTabsByWorktree?.[ownerKey] ?? [] + if (next.browserTabsByWorktree) { + delete next.browserTabsByWorktree[ownerKey] + } + if (next.browserPagesByWorkspace) { + for (const workspace of browserWorkspaces) { + delete next.browserPagesByWorkspace[workspace.id] + } + } + if (next.activeBrowserTabIdByWorktree) { + delete next.activeBrowserTabIdByWorktree[ownerKey] + } + if (next.activeTabTypeByWorktree) { + delete next.activeTabTypeByWorktree[ownerKey] + } + if (next.activeTabIdByWorktree) { + delete next.activeTabIdByWorktree[ownerKey] + } + if (next.unifiedTabs) { + delete next.unifiedTabs[ownerKey] + } + if (next.tabGroups) { + delete next.tabGroups[ownerKey] + } + if (next.tabGroupLayouts) { + delete next.tabGroupLayouts[ownerKey] + } + if (next.activeGroupIdByWorktree) { + delete next.activeGroupIdByWorktree[ownerKey] + } + if (next.lastVisitedAtByWorktreeId) { + delete next.lastVisitedAtByWorktreeId[ownerKey] + } + if (next.defaultTerminalTabsAppliedByWorktreeId) { + delete next.defaultTerminalTabsAppliedByWorktreeId[ownerKey] + } + if (next.sleepingAgentSessionsByPaneKey) { + for (const [paneKey, record] of Object.entries(next.sleepingAgentSessionsByPaneKey)) { + if (record.worktreeId === ownerKey) { + delete next.sleepingAgentSessionsByPaneKey[paneKey] + } + } + } + if (next.activeWorkspaceKey === ownerKey) { + next.activeWorkspaceKey = null + } + if (next.activeWorktreeId === ownerKey) { + next.activeWorktreeId = null + } + next.activeWorktreeIdsOnShutdown = next.activeWorktreeIdsOnShutdown?.filter( + (worktreeId) => worktreeId !== ownerKey + ) + return next +} + +function inferFolderScopeConnectionIdForMigration(args: { + folderPath: string + projectGroupId: string + projectGroups: readonly ProjectGroup[] + repos: readonly Repo[] +}): string | null { + const groupIds = getProjectGroupSubtreeIds(args.projectGroups, args.projectGroupId) + const groupRepos = args.repos.filter( + (repo) => typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId) + ) + const candidateRepos = + groupRepos.length > 0 + ? groupRepos + : args.repos.filter((repo) => isPathInsideOrEqual(args.folderPath, repo.path)) + if (candidateRepos.length === 0) { + return null + } + let hasLocalRepo = false + const connectionIds = new Set() + for (const repo of candidateRepos) { + if (repo.connectionId) { + connectionIds.add(repo.connectionId) + } else { + hasLocalRepo = true + } + } + if (hasLocalRepo || connectionIds.size !== 1) { + return null + } + return [...connectionIds][0] +} + +function backfillFolderScopeConnectionIds(state: PersistedState): { + state: PersistedState + changed: boolean +} { + const groups = state.projectGroups ?? [] + const repos = state.repos ?? [] + let changed = false + const projectGroups = groups.map((group) => { + if (group.connectionId || !group.parentPath) { + return group + } + const connectionId = inferFolderScopeConnectionIdForMigration({ + folderPath: group.parentPath, + projectGroupId: group.id, + projectGroups: groups, + repos + }) + if (!connectionId) { + return group + } + changed = true + return { ...group, connectionId } + }) + const groupsById = new Map(projectGroups.map((group) => [group.id, group])) + const folderWorkspaces = (state.folderWorkspaces ?? []).map((workspace) => { + if (workspace.connectionId) { + return workspace + } + const groupConnectionId = groupsById.get(workspace.projectGroupId)?.connectionId ?? null + const connectionId = + groupConnectionId ?? + inferFolderScopeConnectionIdForMigration({ + folderPath: workspace.folderPath, + projectGroupId: workspace.projectGroupId, + projectGroups, + repos + }) + if (!connectionId) { + return workspace + } + changed = true + return { ...workspace, connectionId } + }) + return { + changed, + state: changed ? { ...state, projectGroups, folderWorkspaces } : state + } +} + function deleteRemovedTerminalScrollbackSnapshots( prior: WorkspaceSessionState | undefined, next: WorkspaceSessionState @@ -1676,6 +1847,7 @@ export class Store { const loaded = this.load() const normalized = normalizePersistedPaneIdentityState(loaded) this.state = normalized.state + const adaptedProjectGroups = this.adaptFlatFolderScanProjectGroups() for (const entry of normalized.migrationUnsupportedEntries) { setMigrationUnsupportedPty(entry) } @@ -1696,7 +1868,7 @@ export class Store { this.state.legacyPaneKeyAliasEntries = entries this.scheduleSave() }) - if (normalized.changed || this.loadNeedsSave) { + if (normalized.changed || this.loadNeedsSave || adaptedProjectGroups) { // Why: upgraded sessions may contain legacy pane:1 leaves. Rewrite them at // the main persistence boundary so older renderer writes cannot revive them. // Other one-shot load migrations also set loadNeedsSave to persist their @@ -1705,6 +1877,86 @@ export class Store { } } + private adaptFlatFolderScanProjectGroups(): boolean { + // Why: older folder imports persisted a real parent path but kept all repos + // flat. Upgrade that shape into v1 sparse folder scopes on load. + const groups = this.state.projectGroups ?? [] + const repos = this.state.repos + if (groups.length === 0 || repos.length === 0) { + return false + } + + let changed = false + let maxOrder = -1 + for (const group of groups) { + maxOrder = Math.max(maxOrder, group.tabOrder) + } + + const childGroupIds = new Set( + groups.flatMap((group) => (group.parentGroupId ? [group.parentGroupId] : [])) + ) + const initialGroupCount = groups.length + for (let groupIndex = 0; groupIndex < initialGroupCount; groupIndex += 1) { + const rootGroup = groups[groupIndex] + if (!rootGroup) { + continue + } + if ( + rootGroup.createdFrom !== 'folder-scan' || + !rootGroup.parentPath || + rootGroup.parentGroupId || + childGroupIds.has(rootGroup.id) + ) { + continue + } + const rootPath = rootGroup.parentPath + const repoCandidates = repos.filter( + (repo) => + !isFolderRepo(repo) && + repo.projectGroupId === rootGroup.id && + isPathInsideOrEqual(rootPath, repo.path) + ) + if (repoCandidates.length < 2) { + continue + } + + const resolver = createNestedProjectGroupResolver({ + parentPath: rootPath, + groupName: rootGroup.name, + mode: 'group', + repoPaths: repoCandidates.map((repo) => repo.path), + createGroup: (input) => { + if (!input.parentGroupId) { + return rootGroup + } + maxOrder += 1 + const group = createProjectGroup({ + ...input, + tabOrder: maxOrder + }) + groups.push(group) + changed = true + return group + } + }) + const nextOrderByGroupId = new Map() + for (const repo of repoCandidates) { + const group = resolver.getGroupForRepo(repo.path) + if (!group) { + continue + } + const nextOrder = nextOrderByGroupId.get(group.id) ?? 0 + nextOrderByGroupId.set(group.id, nextOrder + 1) + if (repo.projectGroupId !== group.id || repo.projectGroupOrder !== nextOrder) { + repo.projectGroupId = group.id + repo.projectGroupOrder = nextOrder + changed = true + } + } + } + return changed + } + // Why (issue #1158): debounced writes fire as often as every 300ms during // active use. The backup ring should capture meaningfully different moments, // not five near-identical snapshots from one burst of store updates. @@ -1995,13 +2247,18 @@ export class Store { if (!parsed.onboarding) { this.loadNeedsSave = true } + const normalizedProjectGroups = normalizeProjectGroups(parsed.projectGroups) result = { ...defaults, ...parsed, featureInteractionTelemetryBuckets: normalizeFeatureInteractionTelemetryBuckets( parsed.featureInteractionTelemetryBuckets ), - projectGroups: normalizeProjectGroups(parsed.projectGroups), + projectGroups: normalizedProjectGroups, + folderWorkspaces: normalizeFolderWorkspaces( + parsed.folderWorkspaces, + normalizedProjectGroups + ), worktreeLineageById: parsed.worktreeLineageById ?? {}, settings: { ...defaults.settings, @@ -2313,11 +2570,15 @@ export class Store { this.loadNeedsSave = true } - result = { + const folderScopeConnectionMigration = backfillFolderScopeConnectionIds({ ...result, repos: clearMissingProjectGroupMemberships(result.repos, result.projectGroups ?? []), workspaceSession: migratedScrollback.session + }) + if (folderScopeConnectionMigration.changed) { + this.loadNeedsSave = true } + result = folderScopeConnectionMigration.state return this.migrateTelemetry(result, fileExistedOnLoad) } @@ -2555,6 +2816,7 @@ export class Store { createProjectGroup(input: { name: string parentPath?: string | null + connectionId?: string | null parentGroupId?: string | null createdFrom: ProjectGroup['createdFrom'] }): ProjectGroup { @@ -2613,6 +2875,165 @@ export class Store { ? { ...repo, projectGroupId: null } : repo ) + for (const workspace of this.state.folderWorkspaces ?? []) { + if (deletedGroupIds.has(workspace.projectGroupId)) { + this.state.workspaceSession = removeWorkspaceSessionOwner( + this.state.workspaceSession, + folderWorkspaceKey(workspace.id) + )! + } + } + this.state.folderWorkspaces = (this.state.folderWorkspaces ?? []).filter( + (workspace) => !deletedGroupIds.has(workspace.projectGroupId) + ) + this.scheduleSave() + return true + } + + getFolderWorkspaces(): FolderWorkspace[] { + return [...(this.state.folderWorkspaces ?? [])].sort( + (left, right) => right.sortOrder - left.sortOrder || left.name.localeCompare(right.name) + ) + } + + getFolderWorkspace(id: string): FolderWorkspace | undefined { + return (this.state.folderWorkspaces ?? []).find((workspace) => workspace.id === id) + } + + createFolderWorkspace(input: { + projectGroupId: string + name?: string + folderPath?: string | null + linkedTask?: FolderWorkspace['linkedTask'] + connectionId?: string | null + createdWithAgent?: FolderWorkspace['createdWithAgent'] + pendingFirstAgentMessageRename?: boolean + }): FolderWorkspace { + const group = (this.state.projectGroups ?? []).find( + (entry) => entry.id === input.projectGroupId + ) + const folderPath = + typeof input.folderPath === 'string' && input.folderPath.trim().length > 0 + ? input.folderPath + : group?.parentPath + if (!group || !folderPath) { + throw new Error('Folder-backed project group not found.') + } + const now = Date.now() + const workspace: FolderWorkspace = { + id: randomUUID(), + projectGroupId: group.id, + name: normalizeFolderWorkspaceName(input.name, `${group.name} workspace`), + folderPath, + connectionId: input.connectionId ?? group.connectionId ?? null, + linkedTask: input.linkedTask ?? null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: now, + ...(input.createdWithAgent ? { createdWithAgent: input.createdWithAgent } : {}), + ...(input.pendingFirstAgentMessageRename === true && input.createdWithAgent + ? { pendingFirstAgentMessageRename: true } + : {}), + lastActivityAt: 0, + createdAt: now, + updatedAt: now + } + this.state.folderWorkspaces = [workspace, ...(this.state.folderWorkspaces ?? [])] + this.scheduleSave() + return workspace + } + + updateFolderWorkspace( + id: string, + updates: Partial< + Pick< + FolderWorkspace, + | 'name' + | 'folderPath' + | 'linkedTask' + | 'comment' + | 'isArchived' + | 'isUnread' + | 'isPinned' + | 'sortOrder' + | 'manualOrder' + | 'workspaceStatus' + | 'createdWithAgent' + | 'pendingFirstAgentMessageRename' + | 'firstAgentMessageRenameError' + | 'lastActivityAt' + > + > + ): FolderWorkspace | null { + const workspace = this.getFolderWorkspace(id) + if (!workspace) { + return null + } + if (updates.name !== undefined) { + workspace.name = normalizeFolderWorkspaceName(updates.name, workspace.name) + } + if (typeof updates.folderPath === 'string' && updates.folderPath.trim().length > 0) { + workspace.folderPath = updates.folderPath + } + if (updates.linkedTask !== undefined) { + workspace.linkedTask = updates.linkedTask + } + if (updates.comment !== undefined) { + workspace.comment = updates.comment + } + if (updates.isArchived !== undefined) { + workspace.isArchived = updates.isArchived + } + if (updates.isUnread !== undefined) { + workspace.isUnread = updates.isUnread + } + if (updates.isPinned !== undefined) { + workspace.isPinned = updates.isPinned + } + if (updates.sortOrder !== undefined && Number.isFinite(updates.sortOrder)) { + workspace.sortOrder = updates.sortOrder + } + if (updates.manualOrder !== undefined) { + if (Number.isFinite(updates.manualOrder)) { + workspace.manualOrder = updates.manualOrder + } else { + delete workspace.manualOrder + } + } + if (updates.workspaceStatus !== undefined) { + workspace.workspaceStatus = updates.workspaceStatus + } + if (updates.createdWithAgent !== undefined) { + workspace.createdWithAgent = updates.createdWithAgent + } + if (updates.pendingFirstAgentMessageRename !== undefined) { + workspace.pendingFirstAgentMessageRename = updates.pendingFirstAgentMessageRename + } + if (updates.firstAgentMessageRenameError !== undefined) { + workspace.firstAgentMessageRenameError = updates.firstAgentMessageRenameError + } + if (updates.lastActivityAt !== undefined && Number.isFinite(updates.lastActivityAt)) { + workspace.lastActivityAt = updates.lastActivityAt + } + workspace.updatedAt = Date.now() + this.scheduleSave() + return workspace + } + + removeFolderWorkspace(id: string): boolean { + const before = this.state.folderWorkspaces?.length ?? 0 + this.state.folderWorkspaces = (this.state.folderWorkspaces ?? []).filter( + (workspace) => workspace.id !== id + ) + if ((this.state.folderWorkspaces?.length ?? 0) === before) { + return false + } + this.state.workspaceSession = removeWorkspaceSessionOwner( + this.state.workspaceSession, + folderWorkspaceKey(id) + )! this.scheduleSave() return true } diff --git a/src/main/project-groups/folder-workspace-path-status.test.ts b/src/main/project-groups/folder-workspace-path-status.test.ts new file mode 100644 index 000000000..fa814f904 --- /dev/null +++ b/src/main/project-groups/folder-workspace-path-status.test.ts @@ -0,0 +1,221 @@ +import { mkdtemp, rm, writeFile } from 'fs/promises' +import { randomUUID } from 'crypto' +import { tmpdir } from 'os' +import { join } from 'path' +import { describe, expect, it, vi } from 'vitest' +import { + getFolderWorkspacePathStatusForPath, + inferFolderWorkspacePathConnection +} from './folder-workspace-path-status' +import type { IFilesystemProvider } from '../providers/types' +import type { ProjectGroup, Repo } from '../../shared/types' + +function makeGroup(overrides: Partial = {}): ProjectGroup { + return { + id: 'group-1', + name: 'Platform', + parentPath: '/workspace/platform', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +function makeRepo(overrides: Partial = {}): Repo { + return { + id: 'repo-1', + path: '/workspace/platform/api', + displayName: 'api', + badgeColor: 'gray', + addedAt: 1, + projectGroupId: 'group-1', + ...overrides + } +} + +describe('folder workspace path status', () => { + it('reports existing local directories and local files', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-folder-status-')) + try { + const filePath = join(root, 'notes.txt') + await writeFile(filePath, 'hello') + + await expect( + getFolderWorkspacePathStatusForPath( + { + folderPath: root, + projectGroupId: 'group-1', + projectGroups: [makeGroup({ parentPath: root })], + repos: [] + }, + { getSshFilesystemProvider: () => undefined } + ) + ).resolves.toEqual({ path: root, exists: true }) + + await expect( + getFolderWorkspacePathStatusForPath( + { + folderPath: filePath, + projectGroupId: 'group-1', + projectGroups: [makeGroup({ parentPath: filePath })], + repos: [] + }, + { getSshFilesystemProvider: () => undefined } + ) + ).resolves.toEqual({ path: filePath, exists: false, reason: 'not-directory' }) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('reports missing local directories', async () => { + const missingPath = join(tmpdir(), `orca-folder-status-missing-${randomUUID()}`) + + await expect( + getFolderWorkspacePathStatusForPath( + { + folderPath: missingPath, + projectGroupId: 'group-1', + projectGroups: [makeGroup({ parentPath: missingPath })], + repos: [] + }, + { getSshFilesystemProvider: () => undefined } + ) + ).resolves.toEqual({ path: missingPath, exists: false, reason: 'missing' }) + }) + + it('routes inferred SSH folder scopes through the SSH filesystem provider', async () => { + const provider = { + stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 1 }) + } as unknown as IFilesystemProvider + + await expect( + getFolderWorkspacePathStatusForPath( + { + folderPath: '/workspace/platform', + projectGroupId: 'group-1', + projectGroups: [makeGroup()], + repos: [makeRepo({ connectionId: 'ssh-1' })] + }, + { getSshFilesystemProvider: () => provider } + ) + ).resolves.toEqual({ path: '/workspace/platform', exists: true }) + expect(provider.stat).toHaveBeenCalledWith('/workspace/platform') + }) + + it('routes explicit SSH folder scopes through SSH without child repos', async () => { + const provider = { + stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 1 }) + } as unknown as IFilesystemProvider + + await expect( + getFolderWorkspacePathStatusForPath( + { + folderPath: '/workspace/platform', + projectGroupId: 'group-1', + connectionId: 'ssh-1', + projectGroups: [makeGroup({ connectionId: 'ssh-1' })], + repos: [] + }, + { getSshFilesystemProvider: () => provider } + ) + ).resolves.toEqual({ path: '/workspace/platform', exists: true }) + expect(provider.stat).toHaveBeenCalledWith('/workspace/platform') + }) + + it('reports unavailable when an inferred SSH provider is missing', async () => { + await expect( + getFolderWorkspacePathStatusForPath( + { + folderPath: '/workspace/platform', + projectGroupId: 'group-1', + projectGroups: [makeGroup()], + repos: [makeRepo({ connectionId: 'ssh-1' })] + }, + { getSshFilesystemProvider: () => undefined } + ) + ).resolves.toEqual({ path: '/workspace/platform', exists: false, reason: 'unavailable' }) + }) + + it('reports ambiguous connection for mixed SSH scopes', () => { + expect( + inferFolderWorkspacePathConnection({ + folderPath: '/workspace/platform', + projectGroupId: 'group-1', + projectGroups: [makeGroup()], + repos: [ + makeRepo({ id: 'repo-1', connectionId: 'ssh-1' }), + makeRepo({ id: 'repo-2', connectionId: 'ssh-2' }) + ] + }) + ).toEqual({ kind: 'ambiguous' }) + }) + + it('reports ambiguous connection for mixed local and SSH scopes', () => { + expect( + inferFolderWorkspacePathConnection({ + folderPath: '/workspace/platform', + projectGroupId: 'group-1', + projectGroups: [makeGroup()], + repos: [ + makeRepo({ id: 'repo-1', connectionId: undefined }), + makeRepo({ id: 'repo-2', connectionId: 'ssh-1' }) + ] + }) + ).toEqual({ kind: 'ambiguous' }) + }) + + it('reports ambiguous connection when explicit SSH scope conflicts with repos', () => { + expect( + inferFolderWorkspacePathConnection({ + folderPath: '/workspace/platform', + projectGroupId: 'group-1', + connectionId: 'ssh-1', + projectGroups: [makeGroup({ connectionId: 'ssh-1' })], + repos: [ + makeRepo({ id: 'repo-1', connectionId: 'ssh-1' }), + makeRepo({ id: 'repo-2', connectionId: 'ssh-2' }) + ] + }) + ).toEqual({ kind: 'ambiguous' }) + }) + + it('keeps explicit SSH scopes isolated from unrelated same-path SSH repos', async () => { + const provider = { + stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 1 }) + } as unknown as IFilesystemProvider + + await expect( + getFolderWorkspacePathStatusForPath( + { + folderPath: '/workspace/platform', + projectGroupId: 'group-1', + connectionId: 'ssh-1', + projectGroups: [ + makeGroup({ id: 'group-1', connectionId: 'ssh-1' }), + makeGroup({ id: 'group-2', connectionId: 'ssh-2' }) + ], + repos: [ + makeRepo({ id: 'repo-1', path: '/workspace/platform/api', connectionId: 'ssh-1' }), + makeRepo({ + id: 'repo-2', + path: '/workspace/platform/api', + projectGroupId: 'group-2', + connectionId: 'ssh-2' + }) + ] + }, + { + getSshFilesystemProvider: (connectionId) => + connectionId === 'ssh-1' ? provider : undefined + } + ) + ).resolves.toEqual({ path: '/workspace/platform', exists: true }) + expect(provider.stat).toHaveBeenCalledWith('/workspace/platform') + }) +}) diff --git a/src/main/project-groups/folder-workspace-path-status.ts b/src/main/project-groups/folder-workspace-path-status.ts new file mode 100644 index 000000000..debe96600 --- /dev/null +++ b/src/main/project-groups/folder-workspace-path-status.ts @@ -0,0 +1,215 @@ +import { stat as statLocalPath } from 'fs/promises' +import { isPathInsideOrEqual } from '../../shared/cross-platform-path' +import type { + FolderWorkspacePathStatus, + FolderWorkspacePathStatusRequest +} from '../../shared/folder-workspace-path-status' +import { getProjectGroupSubtreeIds } from '../../shared/project-groups' +import type { FolderWorkspace, ProjectGroup, Repo } from '../../shared/types' +import type { IFilesystemProvider } from '../providers/types' + +type FolderWorkspacePathStatusStore = { + getRepos: () => Repo[] + getProjectGroups?: () => ProjectGroup[] + getFolderWorkspaces?: () => FolderWorkspace[] +} + +export type FolderWorkspacePathConnectionResolution = + | { kind: 'local' } + | { kind: 'ssh'; connectionId: string } + | { kind: 'ambiguous' } + +type FolderWorkspacePathStatusDeps = { + getSshFilesystemProvider: (connectionId: string) => IFilesystemProvider | undefined +} + +function getFolderScopeCandidateRepos(args: { + folderPath: string + projectGroupId: string + connectionId?: string | null + projectGroups: readonly ProjectGroup[] + repos: readonly Repo[] +}): Repo[] { + const groupIds = getProjectGroupSubtreeIds(args.projectGroups, args.projectGroupId) + const groupRepos = args.repos.filter( + (repo) => typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId) + ) + const pathRepos = args.repos.filter( + (repo) => + !(typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) && + isPathInsideOrEqual(args.folderPath, repo.path) + ) + if (args.connectionId) { + return [ + ...groupRepos, + ...pathRepos.filter((repo) => (repo.connectionId ?? null) === args.connectionId) + ] + } + if (groupRepos.length === 0) { + return pathRepos + } + const groupConnectionIds = new Set(groupRepos.map((repo) => repo.connectionId ?? null)) + return [ + ...groupRepos, + ...pathRepos.filter((repo) => groupConnectionIds.has(repo.connectionId ?? null)) + ] +} + +export function inferFolderWorkspacePathConnection(args: { + folderPath: string + projectGroupId: string + connectionId?: string | null + projectGroups: readonly ProjectGroup[] + repos: readonly Repo[] +}): FolderWorkspacePathConnectionResolution { + const candidateRepos = getFolderScopeCandidateRepos(args) + let hasLocalRepo = false + const connectionIds = new Set() + for (const repo of candidateRepos) { + if (repo.connectionId) { + connectionIds.add(repo.connectionId) + } else { + hasLocalRepo = true + } + } + if (args.connectionId) { + const hasDifferentSshConnection = [...connectionIds].some( + (connectionId) => connectionId !== args.connectionId + ) + if (hasLocalRepo || hasDifferentSshConnection) { + return { kind: 'ambiguous' } + } + return { kind: 'ssh', connectionId: args.connectionId } + } + if (hasLocalRepo && connectionIds.size > 0) { + return { kind: 'ambiguous' } + } + if (connectionIds.size === 0) { + return { kind: 'local' } + } + if (connectionIds.size === 1) { + return { kind: 'ssh', connectionId: [...connectionIds][0] } + } + return { kind: 'ambiguous' } +} + +function pathStatErrorReason(error: unknown): 'missing' | 'unavailable' { + const code = (error as { code?: unknown } | null)?.code + return code === 'ENOENT' || code === 'ENOTDIR' ? 'missing' : 'unavailable' +} + +async function statFolderPath( + path: string, + connection: FolderWorkspacePathConnectionResolution, + deps: FolderWorkspacePathStatusDeps +): Promise { + if (connection.kind === 'ambiguous') { + return { path, exists: false, reason: 'ambiguous-connection' } + } + if (connection.kind === 'ssh') { + const provider = deps.getSshFilesystemProvider(connection.connectionId) + if (!provider) { + return { path, exists: false, reason: 'unavailable' } + } + try { + const stats = await provider.stat(path) + return stats.type === 'directory' + ? { path, exists: true } + : { path, exists: false, reason: 'not-directory' } + } catch (error) { + return { path, exists: false, reason: pathStatErrorReason(error) } + } + } + + try { + const stats = await statLocalPath(path) + return stats.isDirectory() + ? { path, exists: true } + : { path, exists: false, reason: 'not-directory' } + } catch (error) { + return { path, exists: false, reason: pathStatErrorReason(error) } + } +} + +export async function getFolderWorkspacePathStatusForPath( + args: { + folderPath: string + projectGroupId: string + connectionId?: string | null + projectGroups: readonly ProjectGroup[] + repos: readonly Repo[] + }, + deps: FolderWorkspacePathStatusDeps +): Promise { + const connection = inferFolderWorkspacePathConnection(args) + return statFolderPath(args.folderPath, connection, deps) +} + +export function resolveFolderWorkspaceStatusPath(args: { + store: FolderWorkspacePathStatusStore + request: FolderWorkspacePathStatusRequest +}): { folderPath: string; projectGroupId: string; connectionId?: string | null } { + const { request } = args + if (request.scope === 'project-group') { + const group = args.store + .getProjectGroups?.() + .find((entry) => entry.id === request.projectGroupId) + if (!group?.parentPath) { + throw new Error('folder_workspace_path_scope_not_found') + } + return { + folderPath: group.parentPath, + projectGroupId: group.id, + connectionId: group.connectionId ?? null + } + } + + const workspace = args.store + .getFolderWorkspaces?.() + .find((entry) => entry.id === request.folderWorkspaceId) + if (!workspace) { + throw new Error('folder_workspace_path_scope_not_found') + } + const group = args.store + .getProjectGroups?.() + .find((entry) => entry.id === workspace.projectGroupId) + return { + folderPath: workspace.folderPath, + projectGroupId: workspace.projectGroupId, + connectionId: workspace.connectionId ?? group?.connectionId ?? null + } +} + +export async function getFolderWorkspacePathStatus( + store: FolderWorkspacePathStatusStore, + request: FolderWorkspacePathStatusRequest, + deps: FolderWorkspacePathStatusDeps +): Promise { + const scope = resolveFolderWorkspaceStatusPath({ store, request }) + return getFolderWorkspacePathStatusForPath( + { + folderPath: scope.folderPath, + projectGroupId: scope.projectGroupId, + connectionId: scope.connectionId, + projectGroups: store.getProjectGroups?.() ?? [], + repos: store.getRepos() + }, + deps + ) +} + +export function assertFolderWorkspacePathUsable(status: FolderWorkspacePathStatus): void { + if (status.exists) { + return + } + if (status.reason === 'missing') { + throw new Error(`folder_workspace_path_missing:${status.path}`) + } + if (status.reason === 'not-directory') { + throw new Error(`folder_workspace_path_not_directory:${status.path}`) + } + if (status.reason === 'ambiguous-connection') { + throw new Error(`folder_workspace_connection_ambiguous:${status.path}`) + } + throw new Error(`folder_workspace_path_unavailable:${status.path}`) +} diff --git a/src/main/project-groups/nested-repo-import.test.ts b/src/main/project-groups/nested-repo-import.test.ts index e418c9ace..be7a46e81 100644 --- a/src/main/project-groups/nested-repo-import.test.ts +++ b/src/main/project-groups/nested-repo-import.test.ts @@ -6,18 +6,57 @@ import { } from './nested-repo-import' import type { ProjectGroup } from '../../shared/types' +function createGroupRecorder(): { + groups: ProjectGroup[] + createGroup: (input: { + name: string + parentPath?: string | null + connectionId?: string | null + parentGroupId?: string | null + createdFrom: ProjectGroup['createdFrom'] + }) => ProjectGroup +} { + const groups: ProjectGroup[] = [] + return { + groups, + createGroup: (input) => { + const group: ProjectGroup = { + id: `group-${groups.length}`, + name: input.name, + parentPath: input.parentPath ?? null, + connectionId: input.connectionId ?? null, + parentGroupId: input.parentGroupId ?? null, + createdFrom: input.createdFrom, + tabOrder: groups.length, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + groups.push(group) + return group + } + } +} + describe('createNestedProjectGroupResolver', () => { - it('creates one root group for nested repos in grouped imports', () => { + it('creates sparse folder scopes for nested repos in grouped imports', () => { const groups: ProjectGroup[] = [] const resolver = createNestedProjectGroupResolver({ parentPath: '/workspace', groupName: 'workspace', mode: 'group', + repoPaths: [ + '/workspace/gateway-api', + '/workspace/services/payments/api', + '/workspace/services/payments/worker' + ], createGroup: (input) => { const group: ProjectGroup = { id: `group-${groups.length}`, name: input.name, parentPath: input.parentPath ?? null, + connectionId: input.connectionId ?? null, parentGroupId: input.parentGroupId ?? null, createdFrom: input.createdFrom, tabOrder: groups.length, @@ -36,17 +75,70 @@ describe('createNestedProjectGroupResolver', () => { const sibling = resolver.getGroupForRepo('/workspace/services/payments/worker') expect(direct?.name).toBe('workspace') - expect(nested?.name).toBe('workspace') + expect(nested?.name).toBe('services/payments') expect(sibling?.id).toBe(nested?.id) - expect(groups.map((group) => [group.name, group.parentGroupId])).toEqual([['workspace', null]]) + expect(groups.map((group) => [group.name, group.parentGroupId, group.parentPath])).toEqual([ + ['workspace', null, '/workspace'], + ['services/payments', 'group-0', '/workspace/services/payments'] + ]) expect(resolver.getRootGroup()?.id).toBe('group-0') }) + it('skips intermediate folders that only lead to one meaningful child scope', () => { + const { groups, createGroup } = createGroupRecorder() + const resolver = createNestedProjectGroupResolver({ + parentPath: '/workspace/platform', + groupName: 'Platform', + mode: 'group', + repoPaths: [ + '/workspace/platform/api', + '/workspace/platform/web', + '/workspace/platform/packages/shared/repo1', + '/workspace/platform/packages/shared/repo2' + ], + createGroup + }) + + const api = resolver.getGroupForRepo('/workspace/platform/api') + const repo1 = resolver.getGroupForRepo('/workspace/platform/packages/shared/repo1') + const repo2 = resolver.getGroupForRepo('/workspace/platform/packages/shared/repo2') + + expect(api?.name).toBe('Platform') + expect(repo1?.name).toBe('packages/shared') + expect(repo2?.id).toBe(repo1?.id) + expect(groups.map((group) => [group.name, group.parentGroupId, group.parentPath])).toEqual([ + ['Platform', null, '/workspace/platform'], + ['packages/shared', 'group-0', '/workspace/platform/packages/shared'] + ]) + }) + + it('creates a parent folder scope when it has direct repos and nested descendants', () => { + const { groups, createGroup } = createGroupRecorder() + const resolver = createNestedProjectGroupResolver({ + parentPath: '/workspace/platform', + groupName: 'Platform', + mode: 'group', + repoPaths: ['/workspace/platform/services/api', '/workspace/platform/services/jobs/worker'], + createGroup + }) + + const direct = resolver.getGroupForRepo('/workspace/platform/services/api') + const nested = resolver.getGroupForRepo('/workspace/platform/services/jobs/worker') + + expect(direct?.name).toBe('services') + expect(nested?.id).toBe(direct?.id) + expect(groups.map((group) => [group.name, group.parentGroupId, group.parentPath])).toEqual([ + ['Platform', null, '/workspace/platform'], + ['services', 'group-0', '/workspace/platform/services'] + ]) + }) + it('does not create groups for separate imports', () => { const resolver = createNestedProjectGroupResolver({ parentPath: '/workspace', groupName: 'workspace', mode: 'separate', + repoPaths: ['/workspace/services/api', '/workspace/services/worker'], createGroup: () => { throw new Error('should not create a group') } @@ -62,6 +154,7 @@ describe('createNestedProjectGroupResolver', () => { parentPath: '/', groupName: 'root', mode: 'group', + repoPaths: ['/api', '/services/api'], createGroup: (input) => { const group: ProjectGroup = { id: `group-${groups.length}`, @@ -92,6 +185,7 @@ describe('createNestedProjectGroupResolver', () => { parentPath: 'C:\\', groupName: 'C', mode: 'group', + repoPaths: ['C:\\api', 'C:\\services\\api'], createGroup: (input) => { const group: ProjectGroup = { id: `group-${groups.length}`, @@ -116,6 +210,53 @@ describe('createNestedProjectGroupResolver', () => { expect(groups.map((group) => group.parentPath)).toEqual(['C:/']) }) + it('creates sparse folder scopes for Windows repo paths', () => { + const { groups, createGroup } = createGroupRecorder() + const resolver = createNestedProjectGroupResolver({ + parentPath: 'C:\\workspace\\platform', + groupName: 'Platform', + mode: 'group', + repoPaths: [ + 'C:\\workspace\\platform\\apps\\web', + 'C:\\workspace\\platform\\packages\\shared\\repo1', + 'C:\\workspace\\platform\\packages\\shared\\repo2' + ], + createGroup + }) + + const web = resolver.getGroupForRepo('C:\\workspace\\platform\\apps\\web') + const repo1 = resolver.getGroupForRepo('C:\\workspace\\platform\\packages\\shared\\repo1') + + expect(web?.name).toBe('Platform') + expect(repo1?.name).toBe('packages/shared') + expect(groups.map((group) => [group.name, group.parentGroupId, group.parentPath])).toEqual([ + ['Platform', null, 'C:/workspace/platform'], + ['packages/shared', 'group-0', 'C:/workspace/platform/packages/shared'] + ]) + }) + + it('preserves SSH provenance on grouped folder scopes', () => { + const { groups, createGroup } = createGroupRecorder() + const resolver = createNestedProjectGroupResolver({ + parentPath: '/workspace/platform', + groupName: 'Platform', + mode: 'group', + connectionId: 'ssh-1', + repoPaths: [ + '/workspace/platform/packages/shared/repo1', + '/workspace/platform/packages/shared/repo2' + ], + createGroup + }) + + resolver.getGroupForRepo('/workspace/platform/packages/shared/repo1') + + expect(groups.map((group) => [group.name, group.connectionId])).toEqual([ + ['Platform', 'ssh-1'], + ['packages/shared', 'ssh-1'] + ]) + }) + it('falls back to the selected parent folder basename for blank group names', () => { const groups: ProjectGroup[] = [] const resolver = createNestedProjectGroupResolver({ diff --git a/src/main/project-groups/nested-repo-import.ts b/src/main/project-groups/nested-repo-import.ts index 489c062b1..bcaf3ec25 100644 --- a/src/main/project-groups/nested-repo-import.ts +++ b/src/main/project-groups/nested-repo-import.ts @@ -4,12 +4,14 @@ import { isPathInsideOrEqual, isRuntimePathAbsolute, normalizeRuntimePathForComparison, + relativePathInsideRoot, resolveRuntimePath } from '../../shared/cross-platform-path' type CreateGroupInput = { name: string parentPath?: string | null + connectionId?: string | null parentGroupId?: string | null createdFrom: ProjectGroup['createdFrom'] } @@ -25,6 +27,13 @@ export type ResolvedNestedRepoSelection = { rejectedPaths: string[] } +type FolderScope = { + relativePath: string + name: string + folderPath: string + parentRelativePath: string | null +} + function canonicalizeImportPath(path: string): string | null { if (!isRuntimePathAbsolute(path)) { return null @@ -39,16 +48,112 @@ function trimPathSeparators(path: string): string { if (/^\/\/[^/]+\/[^/]+\/?$/.test(path.replace(/\\/g, '/'))) { return path.replace(/\\/g, '/').replace(/\/$/, '') } - return path.replace(/[\\/]+$/g, '') + return path.replace(/\\/g, '/').replace(/\/+$/g, '') +} + +function normalizeRelativePath(value: string): string { + return value.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '') +} + +function getFolderRelativePathForRepo(parentPath: string, repoPath: string): string | null { + const relativePath = relativePathInsideRoot(parentPath, repoPath) + if (relativePath === null || relativePath === '') { + return null + } + const segments = normalizeRelativePath(relativePath).split('/').filter(Boolean) + segments.pop() + return segments.join('/') +} + +function resolveFolderPath(parentPath: string, relativePath: string): string { + return trimPathSeparators(resolveRuntimePath(parentPath, relativePath)) +} + +function getNearestScopePath( + relativePath: string, + scopePaths: { has: (value: string) => boolean } +): string | null { + const segments = normalizeRelativePath(relativePath).split('/').filter(Boolean) + for (let length = segments.length; length > 0; length -= 1) { + const candidate = segments.slice(0, length).join('/') + if (scopePaths.has(candidate)) { + return candidate + } + } + return null +} + +function buildSparseFolderScopes(args: { + parentPath: string + repoPaths: readonly string[] +}): FolderScope[] { + // Why: folder-backed workspaces should expose meaningful launch scopes + // without turning every one-child filesystem segment into sidebar structure. + const folderStats = new Map() + const noteFolder = (relativePath: string, field: 'directRepoCount' | 'totalRepoCount'): void => { + const normalized = normalizeRelativePath(relativePath) + const stats = folderStats.get(normalized) ?? { directRepoCount: 0, totalRepoCount: 0 } + stats[field] += 1 + folderStats.set(normalized, stats) + } + + for (const repoPath of args.repoPaths) { + const folderRelativePath = getFolderRelativePathForRepo(args.parentPath, repoPath) + if (folderRelativePath === null) { + continue + } + noteFolder(folderRelativePath, 'directRepoCount') + const segments = folderRelativePath.split('/').filter(Boolean) + for (let length = 1; length <= segments.length; length += 1) { + noteFolder(segments.slice(0, length).join('/'), 'totalRepoCount') + } + } + + const meaningfulPaths = [...folderStats.entries()] + .filter(([relativePath, stats]) => { + if (!relativePath) { + return false + } + return ( + stats.directRepoCount >= 2 || + (stats.directRepoCount > 0 && stats.totalRepoCount > stats.directRepoCount) + ) + }) + .map(([relativePath]) => relativePath) + .sort( + (left, right) => left.split('/').length - right.split('/').length || left.localeCompare(right) + ) + const meaningfulPathSet = new Set(meaningfulPaths) + + return meaningfulPaths.map((relativePath) => { + const parentRelativePath = + getNearestScopePath(relativePath.split('/').slice(0, -1).join('/'), meaningfulPathSet) ?? null + return { + relativePath, + name: relativePath, + folderPath: resolveFolderPath(args.parentPath, relativePath), + parentRelativePath + } + }) } export function createNestedProjectGroupResolver(args: { parentPath: string groupName: string mode: ProjectGroupImportMode + connectionId?: string | null + repoPaths?: readonly string[] createGroup: (input: CreateGroupInput) => ProjectGroup }): NestedProjectGroupResolver { const createdGroups: ProjectGroup[] = [] + const folderScopes = buildSparseFolderScopes({ + parentPath: args.parentPath, + repoPaths: args.repoPaths ?? [] + }) + const folderScopesByRelativePath = new Map( + folderScopes.map((scope) => [scope.relativePath, scope]) + ) + const folderScopeGroups = new Map() let rootGroup: ProjectGroup | undefined const ensureRootGroup = (): ProjectGroup | undefined => { @@ -62,6 +167,7 @@ export function createNestedProjectGroupResolver(args: { rootGroup = args.createGroup({ name: args.groupName.trim() || fallbackName, parentPath: trimPathSeparators(args.parentPath), + connectionId: args.connectionId ?? null, parentGroupId: null, createdFrom: 'folder-scan' }) @@ -69,8 +175,46 @@ export function createNestedProjectGroupResolver(args: { return rootGroup } + const ensureFolderScopeGroup = (relativePath: string): ProjectGroup | undefined => { + const root = ensureRootGroup() + if (!root) { + return undefined + } + const existing = folderScopeGroups.get(relativePath) + if (existing) { + return existing + } + const scope = folderScopesByRelativePath.get(relativePath) + if (!scope) { + return root + } + const parentGroup = scope.parentRelativePath + ? ensureFolderScopeGroup(scope.parentRelativePath) + : root + const group = args.createGroup({ + name: scope.name, + parentPath: scope.folderPath, + connectionId: args.connectionId ?? null, + parentGroupId: parentGroup?.id ?? root.id, + createdFrom: 'folder-scan' + }) + folderScopeGroups.set(relativePath, group) + createdGroups.push(group) + return group + } + return { - getGroupForRepo: () => ensureRootGroup(), + getGroupForRepo: (repoPath) => { + const root = ensureRootGroup() + if (!root) { + return undefined + } + const folderRelativePath = getFolderRelativePathForRepo(args.parentPath, repoPath) + const scopePath = folderRelativePath + ? getNearestScopePath(folderRelativePath, folderScopesByRelativePath) + : null + return scopePath ? ensureFolderScopeGroup(scopePath) : root + }, getRootGroup: () => rootGroup, getCreatedGroups: () => [...createdGroups] } diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 1c2dda4cb..d78f56d2b 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1,11 +1,14 @@ /* eslint-disable max-lines -- Why: runtime behavior is stateful and cross-cutting, so these tests stay in one file to preserve the end-to-end invariants around handles, waits, and graph sync. */ import { afterEach, describe, expect, it, vi } from 'vitest' import { EventEmitter } from 'events' +import { randomUUID } from 'crypto' import { lstat, mkdir, mkdtemp, rm, writeFile } from 'fs/promises' import { tmpdir } from 'os' import { join } from 'path' import { ipcMain } from 'electron' import type { + FolderWorkspace, + ProjectGroup, TerminalLayoutSnapshot, WorktreeLineage, WorktreeMeta, @@ -506,6 +509,10 @@ const TEST_REPO_ID = 'repo-1' const TEST_REPO_PATH = '/tmp/repo' const TEST_WORKTREE_PATH = '/tmp/worktree-a' const TEST_WORKTREE_ID = `${TEST_REPO_ID}::${TEST_WORKTREE_PATH}` +const TEST_FOLDER_PROJECT_GROUP_ID = 'folder-project-group-1' +const TEST_FOLDER_WORKSPACE_ID = 'folder-workspace-1' +const TEST_FOLDER_WORKSPACE_KEY = `folder:${TEST_FOLDER_WORKSPACE_ID}` +const TEST_FOLDER_WORKSPACE_PATH = '/tmp/platform' const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ const HEADLESS_LEAF_ID = '11111111-1111-4111-8111-111111111111' const HEADLESS_SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222' @@ -623,6 +630,52 @@ function createRuntime(): OrcaRuntimeService { return new OrcaRuntimeService(store) } +function makeFolderProjectGroup(overrides: Partial = {}): ProjectGroup { + return { + id: TEST_FOLDER_PROJECT_GROUP_ID, + name: 'Platform', + parentPath: TEST_FOLDER_WORKSPACE_PATH, + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +function makeFolderWorkspace(overrides: Partial = {}): FolderWorkspace { + return { + ...overrides, + id: overrides.id ?? TEST_FOLDER_WORKSPACE_ID, + projectGroupId: overrides.projectGroupId ?? TEST_FOLDER_PROJECT_GROUP_ID, + name: overrides.name ?? 'Refund fix', + folderPath: overrides.folderPath ?? TEST_FOLDER_WORKSPACE_PATH, + linkedTask: overrides.linkedTask ?? null, + comment: overrides.comment ?? '', + isArchived: overrides.isArchived ?? false, + isUnread: overrides.isUnread ?? false, + isPinned: overrides.isPinned ?? false, + sortOrder: overrides.sortOrder ?? 0, + lastActivityAt: overrides.lastActivityAt ?? 1, + createdAt: overrides.createdAt ?? 1, + updatedAt: overrides.updatedAt ?? 1 + } +} + +function createFolderWorkspaceRuntimeStore( + folderWorkspace: FolderWorkspace = makeFolderWorkspace(), + projectGroup: ProjectGroup = makeFolderProjectGroup() +) { + return { + ...store, + getProjectGroups: () => [projectGroup], + getFolderWorkspaces: () => [folderWorkspace] + } +} + function makeRpcRequest(method: string, params?: unknown): RpcRequest { return { id: 'req-1', authToken: 'tok', method, params } } @@ -3952,6 +4005,86 @@ describe('OrcaRuntimeService', () => { }) }) + it.each([ + { label: 'canonical folder workspace selector', selector: TEST_FOLDER_WORKSPACE_KEY }, + { label: 'id-prefixed folder workspace selector', selector: `id:${TEST_FOLDER_WORKSPACE_KEY}` } + ])('creates background terminal sessions for a $label', async ({ selector }) => { + const folderPath = await mkdtemp(join(tmpdir(), 'orca-runtime-folder-workspace-')) + const spawn = vi.fn().mockResolvedValue({ id: 'pty-folder' }) + const folderWorkspace = makeFolderWorkspace({ folderPath }) + const projectGroup = makeFolderProjectGroup({ parentPath: folderPath }) + const runtime = new OrcaRuntimeService( + createFolderWorkspaceRuntimeStore(folderWorkspace, projectGroup) as never + ) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await expect( + runtime.createTerminal(selector, { + command: 'codex', + title: 'multi-repo worker' + }) + ).resolves.toMatchObject({ + worktreeId: TEST_FOLDER_WORKSPACE_KEY, + title: 'multi-repo worker', + surface: 'background' + }) + + const spawnCall = spawn.mock.calls[0]?.[0] as + | { cwd?: string; env?: Record; worktreeId?: string } + | undefined + const spawnedEnv = spawnCall?.env ?? {} + expect(spawnCall).toMatchObject({ + cwd: folderPath, + worktreeId: TEST_FOLDER_WORKSPACE_KEY + }) + expectStablePaneKeyEnv(spawnedEnv) + expect(spawnedEnv.ORCA_WORKSPACE_ID).toBe(TEST_FOLDER_WORKSPACE_KEY) + expect(spawnedEnv.ORCA_PROJECT_GROUP_ID).toBe(TEST_FOLDER_PROJECT_GROUP_ID) + expect(spawnedEnv.ORCA_WORKSPACE_ROOT).toBe(folderPath) + expect(spawnedEnv.ORCA_WORKTREE_ID).toBe(TEST_FOLDER_WORKSPACE_KEY) + }) + + it('rejects folder workspace terminal creation when the backing path is missing', async () => { + const missingPath = join(tmpdir(), `orca-missing-folder-workspace-${randomUUID()}`) + const spawn = vi.fn().mockResolvedValue({ id: 'pty-folder' }) + const folderWorkspace = makeFolderWorkspace({ folderPath: missingPath }) + const projectGroup = makeFolderProjectGroup({ parentPath: missingPath }) + const runtime = new OrcaRuntimeService( + createFolderWorkspaceRuntimeStore(folderWorkspace, projectGroup) as never + ) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await expect(runtime.createTerminal(TEST_FOLDER_WORKSPACE_KEY)).rejects.toThrow( + 'folder_workspace_path_missing' + ) + expect(spawn).not.toHaveBeenCalled() + }) + + it('rejects folder workspace folderPath updates when the new path is missing', async () => { + const missingPath = join(tmpdir(), `orca-missing-folder-update-${randomUUID()}`) + const folderWorkspace = makeFolderWorkspace() + const runtimeStore = { + ...createFolderWorkspaceRuntimeStore(folderWorkspace), + updateFolderWorkspace: vi.fn() + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + + await expect( + runtime.updateFolderWorkspace(TEST_FOLDER_WORKSPACE_ID, { folderPath: missingPath }) + ).rejects.toThrow('folder_workspace_path_missing') + expect(runtimeStore.updateFolderWorkspace).not.toHaveBeenCalled() + }) + it('enables Claude Agent Teams only for direct Claude launches when configured in-process', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' }) const runtimeStore = { @@ -4166,6 +4299,84 @@ describe('OrcaRuntimeService', () => { }) }) + it('splits folder workspace pty-backed terminal sessions with folder cwd and env', async () => { + const folderPath = await mkdtemp(join(tmpdir(), 'orca-runtime-folder-split-')) + const spawn = vi + .fn() + .mockResolvedValueOnce({ id: 'pty-folder-source' }) + .mockResolvedValueOnce({ id: 'pty-folder-split' }) + const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-folder' }) + const folderWorkspace = makeFolderWorkspace({ folderPath }) + const projectGroup = makeFolderProjectGroup({ parentPath: folderPath }) + const runtime = new OrcaRuntimeService( + createFolderWorkspaceRuntimeStore(folderWorkspace, projectGroup) as never + ) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + + const { handle } = await runtime.createTerminal(TEST_FOLDER_WORKSPACE_KEY) + const sourceCall = spawn.mock.calls[0]?.[0] as + | { cwd?: string; env?: Record; worktreeId?: string } + | undefined + const sourceEnv = sourceCall?.env ?? {} + const sourceLeafId = sourceEnv.ORCA_PANE_KEY.slice(`${sourceEnv.ORCA_TAB_ID}:`.length) + + await expect(runtime.splitTerminal(handle, { direction: 'vertical' })).resolves.toMatchObject({ + handle: expect.stringMatching(/^term_/), + tabId: sourceEnv.ORCA_TAB_ID, + paneRuntimeId: -1 + }) + + const splitCall = spawn.mock.calls[1]?.[0] as + | { cwd?: string; env?: Record; worktreeId?: string } + | undefined + const splitEnv = splitCall?.env ?? {} + const splitLeafId = splitEnv.ORCA_PANE_KEY.slice(`${sourceEnv.ORCA_TAB_ID}:`.length) + expect(sourceCall).toMatchObject({ + cwd: folderPath, + worktreeId: TEST_FOLDER_WORKSPACE_KEY + }) + expect(splitCall).toMatchObject({ + cwd: folderPath, + worktreeId: TEST_FOLDER_WORKSPACE_KEY + }) + expectStablePaneKeyEnv(splitEnv) + expect(splitEnv.ORCA_TAB_ID).toBe(sourceEnv.ORCA_TAB_ID) + expect(splitEnv.ORCA_WORKSPACE_ID).toBe(TEST_FOLDER_WORKSPACE_KEY) + expect(splitEnv.ORCA_PROJECT_GROUP_ID).toBe(TEST_FOLDER_PROJECT_GROUP_ID) + expect(splitEnv.ORCA_WORKSPACE_ROOT).toBe(folderPath) + expect(splitEnv.ORCA_WORKTREE_ID).toBe(TEST_FOLDER_WORKSPACE_KEY) + expect(revealTerminalSession).toHaveBeenLastCalledWith(TEST_FOLDER_WORKSPACE_KEY, { + ptyId: 'pty-folder-split', + title: null, + activate: true, + tabId: sourceEnv.ORCA_TAB_ID, + leafId: splitLeafId, + splitFromLeafId: sourceLeafId, + splitDirection: 'vertical' + }) + }) + it('returns a background handle when inactive tab adoption fails after spawn', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' }) const revealTerminalSession = vi.fn().mockRejectedValue(new Error('Renderer timed out')) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index adeebb784..6f481983e 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -71,6 +71,7 @@ import type { LinearWorkspaceSelection, NestedRepoScanResult, ProjectGroup, + FolderWorkspace, ProjectGroupImportMode, ProjectGroupImportResult, MemorySnapshot, @@ -120,6 +121,11 @@ import { isPathInsideOrEqual, normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { folderWorkspaceKey, parseWorkspaceKey } from '../../shared/workspace-scope' +import type { + FolderWorkspacePathStatus, + FolderWorkspacePathStatusRequest +} from '../../shared/folder-workspace-path-status' import { buildKnownOrcaWorkspaceLayouts, isLegacyRepoForExternalWorktreeVisibility, @@ -487,6 +493,12 @@ import { killAllProcessesForWorktree } from './worktree-teardown' import { MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from './scrollback-limits' import type { IFilesystemProvider, IPtyProvider } from '../providers/types' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { + assertFolderWorkspacePathUsable, + getFolderWorkspacePathStatus, + getFolderWorkspacePathStatusForPath, + inferFolderWorkspacePathConnection +} from '../project-groups/folder-workspace-path-status' import { getSshGitProvider, requireSshGitProvider } from '../providers/ssh-git-dispatch' import { detectRepoIconAndUpstream } from '../repo-icon-autodetect' import { githubAvatarIcon } from '../../shared/repo-icon' @@ -540,6 +552,10 @@ type RuntimeStore = { updateProjectGroup?: Store['updateProjectGroup'] deleteProjectGroup?: Store['deleteProjectGroup'] moveProjectToGroup?: Store['moveProjectToGroup'] + getFolderWorkspaces?: Store['getFolderWorkspaces'] + createFolderWorkspace?: Store['createFolderWorkspace'] + updateFolderWorkspace?: Store['updateFolderWorkspace'] + removeFolderWorkspace?: Store['removeFolderWorkspace'] removeProject?: Store['removeProject'] reorderRepos?: Store['reorderRepos'] getAllWorktreeMeta: Store['getAllWorktreeMeta'] @@ -1194,6 +1210,13 @@ type ResolvedWorktree = Worktree & { git: GitWorktreeInfo } +type TerminalWorkspaceLaunchScope = { + id: string + path: string + connectionId: string | null + folderWorkspace: FolderWorkspace | null +} + type WorktreeLineageInput = { parentWorktree?: string cwdParentWorktree?: string @@ -6487,9 +6510,14 @@ export class OrcaRuntimeService { return this.store?.getProjectGroups?.() ?? [] } + listFolderWorkspaces(): FolderWorkspace[] { + return this.store?.getFolderWorkspaces?.() ?? [] + } + async createProjectGroup(input: { name: string parentPath?: string | null + connectionId?: string | null parentGroupId?: string | null createdFrom?: ProjectGroup['createdFrom'] }): Promise { @@ -6499,6 +6527,7 @@ export class OrcaRuntimeService { const group = this.store.createProjectGroup({ name: input.name, parentPath: input.parentPath ?? null, + connectionId: input.connectionId ?? null, parentGroupId: input.parentGroupId ?? null, createdFrom: input.createdFrom ?? 'manual' }) @@ -6548,6 +6577,118 @@ export class OrcaRuntimeService { return moved } + async createFolderWorkspace(input: { + projectGroupId: string + name?: string + folderPath?: string | null + connectionId?: string | null + linkedTask?: FolderWorkspace['linkedTask'] + createdWithAgent?: FolderWorkspace['createdWithAgent'] + pendingFirstAgentMessageRename?: boolean + }): Promise { + if (!this.store?.createFolderWorkspace) { + throw new Error('runtime_unavailable') + } + const projectGroups = this.store.getProjectGroups?.() ?? [] + const group = projectGroups.find((entry) => entry.id === input.projectGroupId) + const folderPath = + typeof input.folderPath === 'string' && input.folderPath.trim().length > 0 + ? input.folderPath + : group?.parentPath + if (!group || !folderPath) { + throw new Error('folder_workspace_project_group_not_found') + } + const status = await getFolderWorkspacePathStatusForPath( + { + folderPath, + projectGroupId: group.id, + connectionId: input.connectionId ?? group.connectionId ?? null, + projectGroups, + repos: this.store.getRepos() + }, + { getSshFilesystemProvider } + ) + assertFolderWorkspacePathUsable(status) + const workspace = this.store.createFolderWorkspace(input) + this.notifyReposChanged() + return workspace + } + + async getFolderWorkspacePathStatus( + request: FolderWorkspacePathStatusRequest + ): Promise { + if (!this.store) { + throw new Error('runtime_unavailable') + } + return getFolderWorkspacePathStatus(this.store, request, { getSshFilesystemProvider }) + } + + async updateFolderWorkspace( + folderWorkspaceId: string, + updates: Partial< + Pick< + FolderWorkspace, + | 'name' + | 'folderPath' + | 'linkedTask' + | 'comment' + | 'isArchived' + | 'isUnread' + | 'isPinned' + | 'sortOrder' + | 'manualOrder' + | 'workspaceStatus' + | 'createdWithAgent' + | 'pendingFirstAgentMessageRename' + | 'firstAgentMessageRenameError' + | 'lastActivityAt' + > + > + ): Promise { + if (!this.store?.updateFolderWorkspace) { + throw new Error('runtime_unavailable') + } + if (typeof updates.folderPath === 'string' && updates.folderPath.trim().length > 0) { + const workspace = this.store + .getFolderWorkspaces?.() + .find((entry) => entry.id === folderWorkspaceId) + if (!workspace) { + return null + } + const projectGroups = this.store.getProjectGroups?.() ?? [] + const status = await getFolderWorkspacePathStatusForPath( + { + folderPath: updates.folderPath, + projectGroupId: workspace.projectGroupId, + connectionId: + workspace.connectionId ?? + projectGroups.find((entry) => entry.id === workspace.projectGroupId)?.connectionId ?? + null, + projectGroups, + repos: this.store.getRepos() + }, + { getSshFilesystemProvider } + ) + assertFolderWorkspacePathUsable(status) + } + const updated = this.store.updateFolderWorkspace(folderWorkspaceId, updates) + if (updated) { + this.notifyReposChanged() + } + return updated + } + + async deleteFolderWorkspace(folderWorkspaceId: string): Promise<{ deleted: boolean }> { + if (!this.store?.removeFolderWorkspace) { + throw new Error('runtime_unavailable') + } + const deleted = this.store.removeFolderWorkspace(folderWorkspaceId) + if (deleted) { + this.notifyReposChanged() + } + return { deleted } + } + async scanNestedRepos(path: string): Promise { if (!isAbsolute(path)) { throw new Error('Project path must be an absolute path') @@ -6605,6 +6746,8 @@ export class OrcaRuntimeService { parentPath: args.parentPath, groupName: args.groupName, mode: args.mode, + connectionId: null, + repoPaths: selection.selectedPaths, createGroup: (input) => this.store!.createProjectGroup!(input) }) const results: ProjectGroupImportResult['projects'] = selection.rejectedPaths.map( @@ -11147,8 +11290,7 @@ export class OrcaRuntimeService { if (!this.ptyController?.spawn) { throw new Error('runtime_unavailable') } - const worktree = await this.resolveWorktreeSelector(worktreeSelector) - const repo = this.store?.getRepo(worktree.repoId) + const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector) const preAllocatedHandle = this.createPreAllocatedTerminalHandle() // Why: mint tabId in main before spawn so paneKey is known at PTY env // build time. Hook-based agent status (Claude/Codex/Cursor/Gemini) keys @@ -11184,23 +11326,23 @@ export class OrcaRuntimeService { shimBin }).env }) - const env = { - ...baseEnv, - ...agentTeamsPlan?.env, - ORCA_PANE_KEY: paneKey, - ORCA_TAB_ID: tabId, - ORCA_WORKTREE_ID: worktree.id - } + const env = this.buildTerminalWorkspaceEnv( + workspace, + baseEnv, + paneKey, + tabId, + agentTeamsPlan?.env + ) const result = await this.ptyController.spawn({ cols: 120, rows: 40, - cwd: worktree.path, + cwd: workspace.path, command: agentTeamsPlan?.command ?? opts.command, env, envToDelete: agentTeamsPlan?.envToDelete, telemetry: opts.telemetry, - connectionId: repo?.connectionId ?? null, - worktreeId: worktree.id, + connectionId: workspace.connectionId, + worktreeId: workspace.id, preAllocatedHandle, tabId, leafId, @@ -11208,7 +11350,7 @@ export class OrcaRuntimeService { ...(opts.persistHostSessionBinding ? { persistHostSessionBinding: true } : {}) }) this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle) - this.registerPty(result.id, worktree.id, repo?.connectionId ?? null) + this.registerPty(result.id, workspace.id, workspace.connectionId) const pty = this.getOrCreatePtyWorktreeRecord(result.id) if (pty) { if (opts.title) { @@ -11225,7 +11367,7 @@ export class OrcaRuntimeService { } const handle = pty ? this.issuePtyHandle(pty) : preAllocatedHandle if (pty) { - this.publishPtyBackedMobileSessionTerminal(worktree.id, pty, { + this.publishPtyBackedMobileSessionTerminal(workspace.id, pty, { tabId, leafId, title: opts.title ?? null, @@ -11239,7 +11381,7 @@ export class OrcaRuntimeService { // failing here must not strand a live process without returning a handle. // Pass the pre-minted tabId so the renderer adopts under the same id // already baked into the PTY env — keeps paneKey hook attribution intact. - await this.notifier.revealTerminalSession(worktree.id, { + await this.notifier.revealTerminalSession(workspace.id, { ptyId: result.id, title: opts.title ?? null, activate: opts.activate === true, @@ -11251,7 +11393,7 @@ export class OrcaRuntimeService { console.warn(`[terminal-create] failed to create inactive tab for ${result.id}:`, err) } } - return { handle, worktreeId: worktree.id, title: opts.title ?? null, surface } + return { handle, worktreeId: workspace.id, title: opts.title ?? null, surface } } this.assertGraphReady() @@ -11259,7 +11401,7 @@ export class OrcaRuntimeService { // Why: mirrors browserTabCreate — when no worktree is specified, pass // undefined so the renderer uses its current active worktree. const worktreeId = worktreeSelector - ? (await this.resolveWorktreeSelector(worktreeSelector)).id + ? (await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector)).id : undefined const requestId = randomUUID() @@ -11814,29 +11956,23 @@ export class OrcaRuntimeService { throw new Error('terminal_handle_stale') } const direction = opts.direction ?? 'horizontal' - const worktree = await this.resolveWorktreeSelector(`id:${pty.worktreeId}`) - const repo = this.store?.getRepo(worktree.repoId) + const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${pty.worktreeId}`) const leafId = randomUUID() const preAllocatedHandle = this.createPreAllocatedTerminalHandle() const paneKey = makePaneKey(parentTabId, leafId) const result = await this.ptyController.spawn({ cols: 120, rows: 40, - cwd: worktree.path, + cwd: workspace.path, command: opts.command, - env: { - ...opts.env, - ORCA_PANE_KEY: paneKey, - ORCA_TAB_ID: parentTabId, - ORCA_WORKTREE_ID: worktree.id - }, + env: this.buildTerminalWorkspaceEnv(workspace, opts.env ?? {}, paneKey, parentTabId), envToDelete: opts.envToDelete, - connectionId: repo?.connectionId ?? null, - worktreeId: worktree.id, + connectionId: workspace.connectionId, + worktreeId: workspace.id, preAllocatedHandle }) this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle) - this.registerPty(result.id, worktree.id, repo?.connectionId ?? null) + this.registerPty(result.id, workspace.id, workspace.connectionId) const createdPty = this.getOrCreatePtyWorktreeRecord(result.id) if (createdPty) { createdPty.tabId = parentTabId @@ -11844,7 +11980,7 @@ export class OrcaRuntimeService { } try { - await this.notifier?.revealTerminalSession?.(worktree.id, { + await this.notifier?.revealTerminalSession?.(workspace.id, { ptyId: result.id, title: null, activate: opts.activate !== false, @@ -11859,7 +11995,7 @@ export class OrcaRuntimeService { throw error } if (createdPty) { - this.publishPtyBackedMobileSessionTerminal(worktree.id, createdPty, { + this.publishPtyBackedMobileSessionTerminal(workspace.id, createdPty, { tabId: parentTabId, leafId, title: null, @@ -12063,6 +12199,101 @@ export class OrcaRuntimeService { } } + private resolveFolderWorkspaceConnectionId(workspace: FolderWorkspace): string | null { + const repos = this.store?.getRepos() ?? [] + const projectGroups = this.store?.getProjectGroups?.() ?? [] + const connection = inferFolderWorkspacePathConnection({ + folderPath: workspace.folderPath, + projectGroupId: workspace.projectGroupId, + connectionId: workspace.connectionId ?? null, + projectGroups, + repos + }) + if (connection.kind === 'ambiguous') { + // Why: a single PTY can only be spawned on one runtime target; mixed + // child repo connections need an explicit V2 routing decision. + throw new Error('folder_workspace_connection_ambiguous') + } + return connection.kind === 'ssh' ? connection.connectionId : null + } + + private async resolveFolderWorkspaceLaunchScope( + selector: string + ): Promise { + const workspaceSelector = selector.startsWith('id:') ? selector.slice(3) : selector + const parsed = parseWorkspaceKey(workspaceSelector) + if (parsed?.type !== 'folder') { + return null + } + const workspace = this.store + ?.getFolderWorkspaces?.() + .find((entry) => entry.id === parsed.folderWorkspaceId) + if (!workspace) { + throw new Error('selector_not_found') + } + if (!this.store) { + throw new Error('runtime_unavailable') + } + const status = await getFolderWorkspacePathStatus( + this.store, + { scope: 'folder-workspace', folderWorkspaceId: workspace.id }, + { getSshFilesystemProvider } + ) + assertFolderWorkspacePathUsable(status) + return { + id: folderWorkspaceKey(workspace.id), + path: workspace.folderPath, + connectionId: this.resolveFolderWorkspaceConnectionId(workspace), + folderWorkspace: workspace + } + } + + private async resolveTerminalWorkspaceLaunchScope( + selector: string + ): Promise { + const folderScope = await this.resolveFolderWorkspaceLaunchScope(selector) + if (folderScope) { + return folderScope + } + + const workspaceSelector = selector.startsWith('id:') ? selector.slice(3) : selector + const parsed = parseWorkspaceKey(workspaceSelector) + const worktreeSelector = parsed?.type === 'worktree' ? `id:${parsed.worktreeId}` : selector + const worktree = await this.resolveWorktreeSelector(worktreeSelector) + const repo = this.store?.getRepo(worktree.repoId) ?? null + return { + id: worktree.id, + path: worktree.path, + connectionId: repo?.connectionId ?? null, + folderWorkspace: null + } + } + + private buildTerminalWorkspaceEnv( + scope: TerminalWorkspaceLaunchScope, + baseEnv: Record, + paneKey: string, + tabId: string, + agentTeamsEnv?: Record + ): Record { + const env = { + ...baseEnv, + ...agentTeamsEnv, + ORCA_PANE_KEY: paneKey, + ORCA_TAB_ID: tabId, + ORCA_WORKTREE_ID: scope.id + } + if (!scope.folderWorkspace) { + return env + } + return { + ...env, + ORCA_WORKSPACE_ID: scope.id, + ORCA_PROJECT_GROUP_ID: scope.folderWorkspace.projectGroupId, + ORCA_WORKSPACE_ROOT: scope.folderWorkspace.folderPath + } + } + private async resolveWorktreeSelector(selector: string): Promise { const worktrees = await this.listResolvedWorktrees() let candidates: ResolvedWorktree[] @@ -12687,6 +12918,11 @@ export class OrcaRuntimeService { this.notifyWorktreesChanged(repoId) } + notifyFolderWorkspaceChanged(): void { + this.invalidateResolvedWorktreeCache() + this.notifyReposChanged() + } + private recordPtyWorktree( ptyId: string, worktreeId: string, diff --git a/src/main/runtime/rpc/methods/folder-workspace.ts b/src/main/runtime/rpc/methods/folder-workspace.ts new file mode 100644 index 000000000..f305017a7 --- /dev/null +++ b/src/main/runtime/rpc/methods/folder-workspace.ts @@ -0,0 +1,98 @@ +import { z } from 'zod' +import { defineMethod, type RpcMethod } from '../core' +import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' +import { isTuiAgent } from '../../../../shared/tui-agent-config' + +const FolderWorkspaceLinkedTask = z + .object({ + provider: z.enum(['github', 'gitlab', 'linear', 'jira']), + type: z.enum(['issue', 'pr', 'mr']), + number: z.number().finite(), + title: requiredString('Missing linked task title'), + url: requiredString('Missing linked task URL'), + linearIdentifier: OptionalString, + jiraIdentifier: OptionalString, + repoId: OptionalString + }) + .nullable() + +const FolderWorkspaceCreate = z.object({ + projectGroupId: requiredString('Missing project group id'), + name: OptionalString, + folderPath: OptionalString.nullable().optional(), + connectionId: OptionalString.nullable().optional(), + linkedTask: FolderWorkspaceLinkedTask.optional(), + createdWithAgent: z.string().refine(isTuiAgent).optional(), + pendingFirstAgentMessageRename: z.boolean().optional() +}) + +const FolderWorkspaceUpdate = z.object({ + folderWorkspaceId: requiredString('Missing folder workspace id'), + updates: z.object({ + name: OptionalString, + folderPath: OptionalString, + linkedTask: FolderWorkspaceLinkedTask.optional(), + comment: z.string().optional(), + isArchived: z.boolean().optional(), + isUnread: z.boolean().optional(), + isPinned: z.boolean().optional(), + sortOrder: OptionalFiniteNumber, + manualOrder: OptionalFiniteNumber, + workspaceStatus: OptionalString, + createdWithAgent: z.string().refine(isTuiAgent).optional(), + pendingFirstAgentMessageRename: z.boolean().optional(), + firstAgentMessageRenameError: z.string().nullable().optional(), + lastActivityAt: OptionalFiniteNumber + }) +}) + +const FolderWorkspaceSelector = z.object({ + folderWorkspaceId: requiredString('Missing folder workspace id') +}) + +const FolderWorkspacePathStatus = z.discriminatedUnion('scope', [ + z.object({ + scope: z.literal('folder-workspace'), + folderWorkspaceId: requiredString('Missing folder workspace id') + }), + z.object({ + scope: z.literal('project-group'), + projectGroupId: requiredString('Missing project group id') + }) +]) + +export const FOLDER_WORKSPACE_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'folderWorkspace.list', + params: null, + handler: (_params, { runtime }) => ({ + folderWorkspaces: runtime.listFolderWorkspaces() + }) + }), + defineMethod({ + name: 'folderWorkspace.create', + params: FolderWorkspaceCreate, + handler: async (params, { runtime }) => ({ + folderWorkspace: await runtime.createFolderWorkspace(params) + }) + }), + defineMethod({ + name: 'folderWorkspace.update', + params: FolderWorkspaceUpdate, + handler: async (params, { runtime }) => ({ + folderWorkspace: await runtime.updateFolderWorkspace(params.folderWorkspaceId, params.updates) + }) + }), + defineMethod({ + name: 'folderWorkspace.delete', + params: FolderWorkspaceSelector, + handler: async (params, { runtime }) => runtime.deleteFolderWorkspace(params.folderWorkspaceId) + }), + defineMethod({ + name: 'folderWorkspace.getPathStatus', + params: FolderWorkspacePathStatus, + handler: async (params, { runtime }) => ({ + status: await runtime.getFolderWorkspacePathStatus(params) + }) + }) +] diff --git a/src/main/runtime/rpc/methods/repo.test.ts b/src/main/runtime/rpc/methods/repo.test.ts index d6e3cc655..546e3e132 100644 --- a/src/main/runtime/rpc/methods/repo.test.ts +++ b/src/main/runtime/rpc/methods/repo.test.ts @@ -287,7 +287,29 @@ describe('repo RPC methods', () => { createProjectGroup: vi.fn().mockResolvedValue(group), updateProjectGroup: vi.fn().mockResolvedValue({ ...group, name: 'Core' }), deleteProjectGroup: vi.fn().mockResolvedValue({ deleted: true }), - moveProjectToGroup: vi.fn().mockResolvedValue({ id: 'repo-1', projectGroupId: group.id }) + moveProjectToGroup: vi.fn().mockResolvedValue({ id: 'repo-1', projectGroupId: group.id }), + listFolderWorkspaces: vi.fn().mockReturnValue([ + { + id: 'folder-workspace-1', + projectGroupId: group.id, + name: 'Refund fix', + folderPath: '/srv/platform', + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 1, + lastActivityAt: 0, + createdAt: 1, + updatedAt: 1 + } + ]), + createFolderWorkspace: vi.fn().mockResolvedValue({ id: 'folder-workspace-2' }), + updateFolderWorkspace: vi.fn().mockResolvedValue({ id: 'folder-workspace-1', comment: 'x' }), + deleteFolderWorkspace: vi.fn().mockResolvedValue({ deleted: true }), + getFolderWorkspacePathStatus: vi + .fn() + .mockResolvedValue({ path: '/srv/platform', exists: true }) } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS }) @@ -313,6 +335,28 @@ describe('repo RPC methods', () => { order: 2 }) ) + const folderListResponse = await dispatcher.dispatch(makeRequest('folderWorkspace.list')) + await dispatcher.dispatch( + makeRequest('folderWorkspace.create', { + projectGroupId: group.id, + name: 'Refund fix' + }) + ) + await dispatcher.dispatch( + makeRequest('folderWorkspace.update', { + folderWorkspaceId: 'folder-workspace-1', + updates: { comment: 'x' } + }) + ) + await dispatcher.dispatch( + makeRequest('folderWorkspace.delete', { folderWorkspaceId: 'folder-workspace-1' }) + ) + const statusResponse = await dispatcher.dispatch( + makeRequest('folderWorkspace.getPathStatus', { + scope: 'folder-workspace', + folderWorkspaceId: 'folder-workspace-1' + }) + ) expect(runtime.listProjectGroups).toHaveBeenCalled() expect(runtime.createProjectGroup).toHaveBeenCalledWith({ @@ -326,10 +370,33 @@ describe('repo RPC methods', () => { }) expect(runtime.deleteProjectGroup).toHaveBeenCalledWith(group.id) expect(runtime.moveProjectToGroup).toHaveBeenCalledWith('repo-1', group.id, 2) + expect(runtime.listFolderWorkspaces).toHaveBeenCalled() + expect(runtime.createFolderWorkspace).toHaveBeenCalledWith({ + projectGroupId: group.id, + name: 'Refund fix' + }) + expect(runtime.updateFolderWorkspace).toHaveBeenCalledWith('folder-workspace-1', { + comment: 'x' + }) + expect(runtime.deleteFolderWorkspace).toHaveBeenCalledWith('folder-workspace-1') + expect(runtime.getFolderWorkspacePathStatus).toHaveBeenCalledWith({ + scope: 'folder-workspace', + folderWorkspaceId: 'folder-workspace-1' + }) expect(moveResponse).toMatchObject({ ok: true, result: { repo: { id: 'repo-1', projectGroupId: group.id } } }) + expect(folderListResponse).toMatchObject({ + ok: true, + result: { + folderWorkspaces: [expect.objectContaining({ id: 'folder-workspace-1' })] + } + }) + expect(statusResponse).toMatchObject({ + ok: true, + result: { status: { path: '/srv/platform', exists: true } } + }) }) it('allows separate nested-repo imports without a group name', async () => { diff --git a/src/main/runtime/rpc/methods/repo.ts b/src/main/runtime/rpc/methods/repo.ts index 5cf492686..531e0b858 100644 --- a/src/main/runtime/rpc/methods/repo.ts +++ b/src/main/runtime/rpc/methods/repo.ts @@ -4,6 +4,7 @@ import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas import { sanitizeRepoIcon } from '../../../../shared/repo-icon' import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color' import { normalizeRepoSourceControlAiOverrides } from '../../../../shared/source-control-ai' +import { FOLDER_WORKSPACE_METHODS } from './folder-workspace' const RepoSelector = z.object({ repo: requiredString('Missing repo selector') @@ -95,6 +96,7 @@ const RepoReorder = z.object({ const ProjectGroupCreate = z.object({ name: requiredString('Missing group name'), parentPath: OptionalString, + connectionId: OptionalString.nullable().optional(), parentGroupId: OptionalString.nullable().optional(), createdFrom: z.enum(['manual', 'folder-scan', 'migration']).optional() }) @@ -187,6 +189,7 @@ export const REPO_METHODS: RpcMethod[] = [ repo: await runtime.moveProjectToGroup(params.repo, params.groupId ?? null, params.order) }) }), + ...FOLDER_WORKSPACE_METHODS, defineMethod({ name: 'projectGroup.scanNested', params: ProjectGroupScanNested, diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 0837efb2e..549a266b2 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -10,6 +10,10 @@ import type { import type { NativeFileDropPayload } from '../shared/native-file-drop' import type { AppIdentity } from '../shared/app-identity' import type { TerminalPaneSplitSource } from '../shared/feature-education-telemetry' +import type { + FolderWorkspacePathStatus, + FolderWorkspacePathStatusRequest +} from '../shared/folder-workspace-path-status' import type { BaseRefDefaultResult, BaseRefSearchResult, @@ -122,6 +126,7 @@ import type { PRRefreshOutcome, Repo, ProjectGroup, + FolderWorkspace, ProjectGroupImportResult, ProjectGroupImportMode, ShellHydrationFailureReason, @@ -787,6 +792,7 @@ export type PreloadApi = { create: (args: { name: string parentPath?: string | null + connectionId?: string | null parentGroupId?: string | null createdFrom?: ProjectGroup['createdFrom'] }) => Promise @@ -819,6 +825,42 @@ export type PreloadApi = { mode: ProjectGroupImportMode }) => Promise } + folderWorkspaces: { + list: () => Promise + getPathStatus: (args: FolderWorkspacePathStatusRequest) => Promise + create: (args: { + projectGroupId: string + name?: string + folderPath?: string | null + connectionId?: string | null + linkedTask?: FolderWorkspace['linkedTask'] + createdWithAgent?: FolderWorkspace['createdWithAgent'] + pendingFirstAgentMessageRename?: boolean + }) => Promise + update: (args: { + folderWorkspaceId: string + updates: Partial< + Pick< + FolderWorkspace, + | 'name' + | 'folderPath' + | 'linkedTask' + | 'comment' + | 'isArchived' + | 'isUnread' + | 'isPinned' + | 'sortOrder' + | 'manualOrder' + | 'workspaceStatus' + | 'createdWithAgent' + | 'pendingFirstAgentMessageRename' + | 'firstAgentMessageRenameError' + | 'lastActivityAt' + > + > + }) => Promise + delete: (args: { folderWorkspaceId: string }) => Promise + } sparsePresets: { list: (args: { repoId: string }) => Promise save: (args: { diff --git a/src/preload/index.ts b/src/preload/index.ts index 48c93ca15..48a05a4bd 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -532,6 +532,14 @@ const api = { importNested: (args) => ipcRenderer.invoke('projectGroups:importNested', args) } satisfies PreloadApi['projectGroups'], + folderWorkspaces: { + list: () => ipcRenderer.invoke('folderWorkspaces:list'), + getPathStatus: (args) => ipcRenderer.invoke('folderWorkspaces:getPathStatus', args), + create: (args) => ipcRenderer.invoke('folderWorkspaces:create', args), + update: (args) => ipcRenderer.invoke('folderWorkspaces:update', args), + delete: (args) => ipcRenderer.invoke('folderWorkspaces:delete', args) + } satisfies PreloadApi['folderWorkspaces'], + sparsePresets: { list: (args) => ipcRenderer.invoke('sparsePresets:list', args), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index cdf2311ca..f32075604 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -329,6 +329,7 @@ function App(): React.JSX.Element { toggleSidebar: s.toggleSidebar, fetchRepos: s.fetchRepos, fetchProjectGroups: s.fetchProjectGroups, + fetchFolderWorkspaces: s.fetchFolderWorkspaces, fetchAllWorktrees: s.fetchAllWorktrees, fetchWorktreeLineage: s.fetchWorktreeLineage, fetchSettings: s.fetchSettings, @@ -732,6 +733,7 @@ function App(): React.JSX.Element { await actions.fetchSettings() await actions.fetchRepos() await actions.fetchProjectGroups() + await actions.fetchFolderWorkspaces() await actions.fetchAllWorktrees() await actions.fetchWorktreeLineage() const persistedUI = await window.api.ui.get() diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 009da8714..268110e46 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -54,6 +54,10 @@ type NewWorkspaceComposerCardProps = { selectedRepoIsGit: boolean onRepoChange: (value: string) => void primaryActionLabel: string + projectLabel?: string + projectPlaceholder?: string + emptyProjectMessage?: string + showAddProjectButton?: boolean name: string onNameValueChange: (value: string) => void onSmartGitHubItemSelect: (item: GitHubWorkItem) => void @@ -86,10 +90,13 @@ type NewWorkspaceComposerCardProps = { selectedRepoRequiresConnection: boolean selectedRepoConnectInProgress: boolean onConnectSelectedRepo: () => Promise + branchesEnabled?: boolean + setupControlsEnabled?: boolean canUseSparseCheckout: boolean sparsePresets: SparsePreset[] sparseSelectedPresetId: string | null onSparseSelectPreset: (preset: SparsePreset | null) => void + sparseControlsEnabled?: boolean } const SSH_STATUS_LABELS: Record = { @@ -234,6 +241,10 @@ export default function NewWorkspaceComposerCard({ selectedRepoIsGit, onRepoChange, primaryActionLabel, + projectLabel, + projectPlaceholder, + emptyProjectMessage, + showAddProjectButton = true, name, onNameValueChange, onSmartGitHubItemSelect, @@ -265,10 +276,13 @@ export default function NewWorkspaceComposerCard({ selectedRepoRequiresConnection, selectedRepoConnectInProgress, onConnectSelectedRepo, + branchesEnabled = true, + setupControlsEnabled = true, canUseSparseCheckout, sparsePresets, sparseSelectedPresetId, - onSparseSelectPreset + onSparseSelectPreset, + sparseControlsEnabled = true }: NewWorkspaceComposerCardProps): React.JSX.Element { const { isFileDragOver, dragHandlers } = useComposerFileDragOver() const openModal = useAppStore((s) => s.openModal) @@ -402,38 +416,41 @@ export default function NewWorkspaceComposerCard({
- - - - - - {translate('auto.components.NewWorkspaceComposerCard.d6b0a96f32', 'Add project')} - - + {showAddProjectButton ? ( + + + + + + {translate('auto.components.NewWorkspaceComposerCard.d6b0a96f32', 'Add project')} + + + ) : null}
) : eligibleRepos.length === 0 ? (

- {translate( - 'auto.components.NewWorkspaceComposerCard.addProjectBeforeWorkspace', - 'Add a project before creating a workspace.' - )} + {emptyProjectMessage ?? + translate( + 'auto.components.NewWorkspaceComposerCard.addProjectBeforeWorkspace', + 'Add a project before creating a workspace.' + )}

) : null} {selectedRepoRequiresConnection && selectedRepoConnectionId ? ( @@ -522,6 +540,7 @@ export default function NewWorkspaceComposerCard({ disabled={selectedRepoRequiresConnection} disabledPlaceholder="Connect this repo first" textOnly={!selectedRepoIsGit} + branchesEnabled={branchesEnabled} onPlainEnter={() => { // Why: Enter on the workspace name advances focus to the next // field (Agent combobox) rather than submitting, letting the user @@ -669,7 +688,7 @@ export default function NewWorkspaceComposerCard({ />
- {setupConfig ? ( + {setupControlsEnabled && setupConfig ? (
) : null} -
- - - {!canUseSparseCheckout ? ( -

+ {sparseControlsEnabled ? ( +

+
+ + + {!canUseSparseCheckout ? ( +

+ {translate( + 'auto.components.NewWorkspaceComposerCard.cbb47ee0dc', + 'Only available for local Git projects.' + )} +

+ ) : null} +
+ ) : null}
diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 22f5e5662..b2d2b47e8 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -10,6 +10,7 @@ import { type BackgroundMountTerminalWorktreeDetail } from '@/constants/terminal' import { useAppStore } from '../store' +import { folderWorkspaceKey } from '../../../shared/workspace-scope' import { useAllWorktrees } from '../store/selectors' import { getConnectionId } from '../lib/connection-context' import { basename } from '../lib/path' @@ -188,6 +189,17 @@ function Terminal(): React.JSX.Element | null { const mountedWorktreeIdsRef = useRef(new Set()) const measurableBackgroundWorktreeIdsRef = useRef(new Set()) const allWorktrees = useAllWorktrees() + const folderWorkspaces = useAppStore((s) => s.folderWorkspaces) + const workspaceSurfaces = useMemo( + () => [ + ...allWorktrees.map((worktree) => ({ id: worktree.id, path: worktree.path })), + ...folderWorkspaces.map((workspace) => ({ + id: folderWorkspaceKey(workspace.id), + path: workspace.folderPath + })) + ], + [allWorktrees, folderWorkspaces] + ) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) const renderedActiveWorktreeId = activeWorktreeId const activeView = useAppStore((s) => s.activeView) @@ -723,14 +735,14 @@ function Terminal(): React.JSX.Element | null { mountedWorktreeIdsRef.current.add(renderedActiveWorktreeId) } // Prune IDs of worktrees that no longer exist (deleted/removed) - const allWorktreeIds = new Set(allWorktrees.map((wt) => wt.id)) + const allWorktreeIds = new Set(workspaceSurfaces.map((workspace) => workspace.id)) for (const id of mountedWorktreeIdsRef.current) { if (!allWorktreeIds.has(id)) { mountedWorktreeIdsRef.current.delete(id) } } const anyMountedWorktreeHasLayout = computeAnyMountedWorktreeHasLayout( - allWorktrees.map((wt) => wt.id), + workspaceSurfaces.map((workspace) => workspace.id), mountedWorktreeIdsRef.current, layoutByWorktree, groupsByWorktree, @@ -1645,26 +1657,26 @@ function Terminal(): React.JSX.Element | null { can preserve hidden trees without reflowing the active one. Keep a relative anchor here so those panes size to the workspace body rather than some outer ancestor when split groups are enabled. */} - {allWorktrees - .filter((wt) => mountedWorktreeIdsRef.current.has(wt.id)) - .map((worktree) => { - const layout = getEffectiveLayoutForWorktree(worktree.id) + {workspaceSurfaces + .filter((workspace) => mountedWorktreeIdsRef.current.has(workspace.id)) + .map((workspace) => { + const layout = getEffectiveLayoutForWorktree(workspace.id) if (!layout) { return null } // Why: use strict equality with 'terminal' instead of !== 'settings' // so the terminal/browser surface hides on the tasks page too. const isVisible = - activeView === 'terminal' && worktree.id === renderedActiveWorktreeId + activeView === 'terminal' && workspace.id === renderedActiveWorktreeId const shouldMeasureHiddenWorktree = - !isVisible && measurableBackgroundWorktreeIdsRef.current.has(worktree.id) + !isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id) return ( - {allWorktrees - .filter((wt) => mountedWorktreeIdsRef.current.has(wt.id)) - .map((worktree) => { + {workspaceSurfaces + .filter((workspace) => mountedWorktreeIdsRef.current.has(workspace.id)) + .map((workspace) => { // Why: use strict equality with 'terminal' instead of !== 'settings' // so the terminal/browser surface hides on the tasks page too. const isVisible = - activeView === 'terminal' && worktree.id === renderedActiveWorktreeId + activeView === 'terminal' && workspace.id === renderedActiveWorktreeId const shouldMeasureHiddenWorktree = - !isVisible && measurableBackgroundWorktreeIdsRef.current.has(worktree.id) + !isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id) return (
- - {(tabsByWorktree[worktree.id] ?? []).map((tab) => { + + {(tabsByWorktree[workspace.id] ?? []).map((tab) => { const activityTerminalPortal = findActivityTerminalPortal( activityTerminalPortals, - { worktreeId: worktree.id, tabId: tab.id } + { worktreeId: workspace.id, tabId: tab.id } ) const isActivityPortalTab = activityTerminalPortal !== null const isActiveTerminalTab = @@ -1743,8 +1755,8 @@ function Terminal(): React.JSX.Element | null { - {allWorktrees.map((worktree) => { - const browserTabs = browserTabsByWorktree[worktree.id] ?? [] + {workspaceSurfaces.map((workspace) => { + const browserTabs = browserTabsByWorktree[workspace.id] ?? [] // Why: use strict equality with 'terminal' instead of !== 'settings' // so browser panes also hide on the tasks page. const isVisibleWorktree = - activeView === 'terminal' && worktree.id === renderedActiveWorktreeId + activeView === 'terminal' && workspace.id === renderedActiveWorktreeId if (browserTabs.length === 0) { return null } return (
diff --git a/src/renderer/src/components/mobile/MobileHero.tsx b/src/renderer/src/components/mobile/MobileHero.tsx index ea5bb4acb..74a50de9d 100644 --- a/src/renderer/src/components/mobile/MobileHero.tsx +++ b/src/renderer/src/components/mobile/MobileHero.tsx @@ -30,19 +30,40 @@ export function HeroIntro({ onStart }: { onStart: () => void }): React.JSX.Eleme return (
- {translate("auto.components.mobile.MobileHero.5410d55d79", "Orca Mobile")} + + {translate('auto.components.mobile.MobileHero.5410d55d79', 'Orca Mobile')} +
-

{translate("auto.components.mobile.MobileHero.cd4e5e816f", "Your workspaces, in your pocket.")}

+

+ {translate( + 'auto.components.mobile.MobileHero.cd4e5e816f', + 'Your workspaces, in your pocket.' + )} +

- {translate("auto.components.mobile.MobileHero.b4ccce5cb7", "Control Orca from your phone. Check on agents, review changes, and kick off tasks while you're away from your desk.")}

-
- {translate("auto.components.mobile.MobileHero.da1d5e5ed0", "Available on")} + {translate( + 'auto.components.mobile.MobileHero.b4ccce5cb7', + "Control Orca from your phone. Check on agents, review changes, and kick off tasks while you're away from your desk." + )} +

+
+ + {translate('auto.components.mobile.MobileHero.da1d5e5ed0', 'Available on')} + - {translate("auto.components.mobile.MobileHero.711e6f4b47", "iOS")} + {translate('auto.components.mobile.MobileHero.711e6f4b47', 'iOS')} + - {translate("auto.components.mobile.MobileHero.ac1eb64952", "Android")} + {translate('auto.components.mobile.MobileHero.ac1eb64952', 'Android')} +
@@ -73,13 +95,21 @@ export function HeroPaired({ return (
- {translate("auto.components.mobile.MobileHero.5410d55d79", "Orca Mobile")} + + {translate('auto.components.mobile.MobileHero.5410d55d79', 'Orca Mobile')} +

- {devices.length === 1 ? translate("auto.components.mobile.MobileHero.051978a785", "Your phone is paired.") : translate("auto.components.mobile.MobileHero.d0b52871ce", "Your phones are paired.")} + {devices.length === 1 + ? translate('auto.components.mobile.MobileHero.051978a785', 'Your phone is paired.') + : translate('auto.components.mobile.MobileHero.d0b52871ce', 'Your phones are paired.')}

- {translate("auto.components.mobile.MobileHero.266c18c105", "Open Orca Mobile to pick up where you left off, or pair another device.")}

+ {translate( + 'auto.components.mobile.MobileHero.266c18c105', + 'Open Orca Mobile to pick up where you left off, or pair another device.' + )} +

    {devices.map((device) => { const revoking = revokingDeviceIds.includes(device.deviceId) @@ -91,7 +121,7 @@ export function HeroPaired({
    {device.name}
    - {translate("auto.components.mobile.MobileHero.94829abdb1", "Paired")}{' '} + {translate('auto.components.mobile.MobileHero.94829abdb1', 'Paired')}{' '} {new Date(device.pairedAt).toLocaleDateString()}
    @@ -100,8 +130,12 @@ export function HeroPaired({ className="mp-paired-revoke" onClick={() => onRevoke(device.deviceId)} disabled={revoking} - aria-label={translate("auto.components.mobile.MobileHero.34f878d04f", "Revoke {{value0}}", { value0: device.name })} - title={translate("auto.components.mobile.MobileHero.f9cbf4bb53", "Revoke device")} + aria-label={translate( + 'auto.components.mobile.MobileHero.34f878d04f', + 'Revoke {{value0}}', + { value0: device.name } + )} + title={translate('auto.components.mobile.MobileHero.f9cbf4bb53', 'Revoke device')} > @@ -112,7 +146,8 @@ export function HeroPaired({
    + {translate('auto.components.mobile.MobileHero.ff48d9d520', 'Pair another device')} +
@@ -174,11 +209,19 @@ export function HeroFlow({
{stepIdx + 1}
- {translate("auto.components.mobile.MobileHero.92ddfdfa1f", "Step 1 of 2")} + + {translate('auto.components.mobile.MobileHero.92ddfdfa1f', 'Step 1 of 2')} +
-

{translate("auto.components.mobile.MobileHero.0d9b33299e", "Get the app.")}

+

+ {translate('auto.components.mobile.MobileHero.0d9b33299e', 'Get the app.')} +

- {translate("auto.components.mobile.MobileHero.e75647ace0", "Scan the QR with your phone or open the install link to grab Orca Mobile.")}

+ {translate( + 'auto.components.mobile.MobileHero.e75647ace0', + 'Scan the QR with your phone or open the install link to grab Orca Mobile.' + )} +

+ {translate('auto.components.mobile.MobileHero.711e6f4b47', 'iOS')} + + {translate('auto.components.mobile.MobileHero.ac1eb64952', 'Android')} +
+ {translate('auto.components.mobile.MobileHero.aa97420ba4', 'Copy install link')} +
-
- {installQrUrl ? {translate("auto.components.mobile.MobileHero.3241f3c26a", : null} +
+ {installQrUrl ? ( + {translate('auto.components.mobile.MobileHero.3241f3c26a', + ) : null}
@@ -217,19 +274,26 @@ export function HeroFlow({
2
- {translate("auto.components.mobile.MobileHero.3960f5c339", "Step 2 of 2")} + + {translate('auto.components.mobile.MobileHero.3960f5c339', 'Step 2 of 2')} +

- {translate("auto.components.mobile.MobileHero.901c98bb93", "Pair this")} {getDeviceLabel()}. + {translate('auto.components.mobile.MobileHero.901c98bb93', 'Pair this')}{' '} + {getDeviceLabel()}.

- {translate("auto.components.mobile.MobileHero.d1495e5e64", "Open Orca Mobile, tap")}{' '} - {translate("auto.components.mobile.MobileHero.3aa7bb2d8b", "Pair Desktop")} - {translate("auto.components.mobile.MobileHero.2f077ef4eb", ", and scan the code.")} + {translate('auto.components.mobile.MobileHero.d1495e5e64', 'Open Orca Mobile, tap')}{' '} + + {translate('auto.components.mobile.MobileHero.3aa7bb2d8b', 'Pair Desktop')} + + {translate('auto.components.mobile.MobileHero.2f077ef4eb', ', and scan the code.')}

- {translate("auto.components.mobile.MobileHero.dfd2aa9d5d", "Network")} + + {translate('auto.components.mobile.MobileHero.dfd2aa9d5d', 'Network')} + +

+ {translate( + 'auto.components.sidebar.AddRepoNestedImportStep.fb33359f69', + 'Is this a monorepo?' + )} +

+

+ {translate( + 'auto.components.sidebar.AddRepoNestedImportStep.d75170194e', + 'Choose this if these projects belong together. Orca will group them and let you work from the parent folder.' + )} +

+
+
+
+ + + + + + + {translate( + 'auto.components.sidebar.AddRepoNestedImportStep.b20bb7c24f', + 'Keeps these repos together in one group. Best for related repos like microservices.' + )} + +
- ) : null} + onGroupNameChange(event.target.value)} + disabled={isAdding || scanInProgress} + className="h-9 min-w-0" + placeholder={folderName} + /> +
+ - {!isFirstRepoImport ? ( - - ) : null}
diff --git a/src/renderer/src/components/sidebar/FolderWorkspaceComposerDialog.tsx b/src/renderer/src/components/sidebar/FolderWorkspaceComposerDialog.tsx new file mode 100644 index 000000000..ab09a6532 --- /dev/null +++ b/src/renderer/src/components/sidebar/FolderWorkspaceComposerDialog.tsx @@ -0,0 +1,408 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { toast } from 'sonner' +import { useShallow } from 'zustand/react/shallow' +import AgentSettingsDialog from '@/components/agent/AgentSettingsDialog' +import NewWorkspaceComposerCard from '@/components/NewWorkspaceComposerCard' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { useDetectedAgents } from '@/hooks/useDetectedAgents' +import { useAppStore } from '@/store' +import { getLinkedWorkItemProvider, type LinkedWorkItemSummary } from '@/lib/new-workspace' +import { shouldAllowComposerEnterSubmitTarget } from '@/lib/new-workspace-enter-guard' +import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' +import { + pickQuickWorkspaceAgent, + resolveQuickWorkspaceAgentSelection +} from '@/lib/quick-workspace-agent-selection' +import { getSelectedRepoSshGate, isSshConnectInProgress } from '@/lib/new-workspace-ssh-gate' +import { isWorkItemLookupText } from '@/lib/work-item-lookup-text' +import type { + GitHubWorkItem, + GitLabWorkItem, + LinearIssue, + ProjectGroup, + TuiAgent +} from '../../../../shared/types' +import type { SshConnectionStatus } from '../../../../shared/ssh-types' +import { translate } from '@/i18n/i18n' +import { + getFolderSourceRepos, + getLinkedItemDisplayName, + getSmartNameSelection, + toGitHubLinkedWorkItem, + toGitLabLinkedWorkItem, + toLinearLinkedWorkItem +} from './folder-workspace-composer-helpers' +import { useFolderWorkspaceComposerPathStatus } from './folder-workspace-composer-path-status' +import { submitFolderWorkspaceCreate } from './folder-workspace-composer-submit' + +type FolderWorkspaceComposerDialogProps = { + projectGroup: ProjectGroup | null + open: boolean + onOpenChange: (open: boolean) => void +} + +export function FolderWorkspaceComposerDialog({ + projectGroup, + open, + onOpenChange +}: FolderWorkspaceComposerDialogProps): React.JSX.Element { + const { createFolderWorkspace, projectGroups, repos, settings, sshConnectionStates } = + useAppStore( + useShallow((s) => ({ + createFolderWorkspace: s.createFolderWorkspace, + projectGroups: s.projectGroups, + repos: s.repos, + settings: s.settings, + sshConnectionStates: s.sshConnectionStates + })) + ) + const { pathStatusBlocksCreate, pathStatusProjectError } = useFolderWorkspaceComposerPathStatus( + projectGroup, + open + ) + const sourceRepos = useMemo( + () => getFolderSourceRepos(repos, projectGroups, projectGroup), + [projectGroup, projectGroups, repos] + ) + const [repoId, setRepoId] = useState('') + const selectedRepo = sourceRepos.find((repo) => repo.id === repoId) ?? null + const selectedRepoConnectionId = + selectedRepo?.connectionId ?? + (sourceRepos.length === 0 ? (projectGroup?.connectionId ?? null) : null) + const selectedRepoSshState = selectedRepoConnectionId + ? (sshConnectionStates.get(selectedRepoConnectionId) ?? null) + : null + const { selectedRepoSshStatus, selectedRepoRequiresConnection, selectedRepoConnectInProgress } = + getSelectedRepoSshGate({ + connectionId: selectedRepoConnectionId, + status: selectedRepoSshState?.status ?? null + }) + const { detectedIds } = useDetectedAgents(null) + const detectedAgentIds = useMemo(() => (detectedIds ? new Set(detectedIds) : null), [detectedIds]) + const [name, setName] = useState('') + const [note, setNote] = useState('') + const [linkedWorkItem, setLinkedWorkItem] = useState(null) + const [quickAgentOverride, setQuickAgentOverride] = useState( + undefined + ) + const [advancedOpen, setAdvancedOpen] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [agentSettingsOpen, setAgentSettingsOpen] = useState(false) + const lastAutoNameRef = useRef('') + const composerRef = useRef(null) + const nameInputRef = useRef(null) + + useEffect(() => { + if (!open) { + return + } + setRepoId(sourceRepos[0]?.id ?? '') + setName('') + setNote('') + setLinkedWorkItem(null) + setQuickAgentOverride(undefined) + setAdvancedOpen(false) + setSubmitting(false) + lastAutoNameRef.current = '' + }, [open, projectGroup?.id, sourceRepos]) + + const preferredQuickAgent = useMemo( + () => + pickQuickWorkspaceAgent( + settings?.defaultTuiAgent, + detectedAgentIds, + settings?.disabledTuiAgents + ), + [detectedAgentIds, settings?.defaultTuiAgent, settings?.disabledTuiAgents] + ) + const resolvedQuickAgentSelection = resolveQuickWorkspaceAgentSelection({ + quickAgentOverride, + preferredQuickAgent, + detectedAgentIds, + disabledTuiAgents: settings?.disabledTuiAgents + }) + if (resolvedQuickAgentSelection.quickAgentOverride !== quickAgentOverride) { + setQuickAgentOverride(resolvedQuickAgentSelection.quickAgentOverride) + } + const quickAgent = resolvedQuickAgentSelection.quickAgent + + const applyLinkedWorkItem = useCallback( + (item: LinkedWorkItemSummary): void => { + setLinkedWorkItem(item) + const nextName = getLinkedItemDisplayName(item) + if ( + nextName && + (!name.trim() || name === lastAutoNameRef.current || isWorkItemLookupText(name)) + ) { + setName(nextName) + lastAutoNameRef.current = nextName + } + }, + [name] + ) + + const handleRepoChange = useCallback((nextRepoId: string): void => { + setRepoId(nextRepoId) + setLinkedWorkItem((current) => { + const provider = current ? getLinkedWorkItemProvider(current) : null + return provider === 'github' || provider === 'gitlab' ? null : current + }) + }, []) + + const handleSmartGitHubItemSelect = useCallback( + (item: GitHubWorkItem): void => { + applyLinkedWorkItem(toGitHubLinkedWorkItem(item)) + }, + [applyLinkedWorkItem] + ) + + const handleSmartGitLabItemSelect = useCallback( + (item: GitLabWorkItem): void => { + applyLinkedWorkItem(toGitLabLinkedWorkItem(item)) + }, + [applyLinkedWorkItem] + ) + + const handleSmartLinearIssueSelect = useCallback( + (issue: LinearIssue): void => { + applyLinkedWorkItem(toLinearLinkedWorkItem(issue)) + }, + [applyLinkedWorkItem] + ) + + const handleClearSmartNameSelection = useCallback((): void => { + setLinkedWorkItem(null) + if (name === lastAutoNameRef.current) { + setName('') + lastAutoNameRef.current = '' + } + }, [name]) + + const handleQuickAgentChange = useCallback((agent: TuiAgent | null): void => { + setQuickAgentOverride(agent) + }, []) + + const onConnectSelectedRepo = useCallback(async (): Promise => { + if (!selectedRepoConnectionId) { + return + } + const liveStatus = useAppStore + .getState() + .sshConnectionStates.get(selectedRepoConnectionId)?.status + if (liveStatus === 'connected' || isSshConnectInProgress(liveStatus ?? null)) { + return + } + try { + await window.api.ssh.connect({ targetId: selectedRepoConnectionId }) + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : translate( + 'auto.components.sidebar.FolderWorkspaceComposerDialog.connectFailed', + 'Failed to connect to project.' + ) + ) + } + }, [selectedRepoConnectionId]) + + const handleCreate = useCallback(async (): Promise => { + if ( + !projectGroup?.parentPath || + submitting || + pathStatusBlocksCreate || + selectedRepoRequiresConnection + ) { + return + } + setSubmitting(true) + try { + await submitFolderWorkspaceCreate({ + projectGroup, + name, + lastAutoName: lastAutoNameRef.current, + linkedWorkItem, + note, + quickAgent, + autoRenameBranchFromWork: settings?.autoRenameBranchFromWork, + agentCmdOverrides: settings?.agentCmdOverrides, + createFolderWorkspace, + onOpenChange + }) + } finally { + setSubmitting(false) + } + }, [ + createFolderWorkspace, + linkedWorkItem, + name, + note, + onOpenChange, + projectGroup, + quickAgent, + settings?.agentCmdOverrides, + settings?.autoRenameBranchFromWork, + submitting, + pathStatusBlocksCreate, + selectedRepoRequiresConnection + ]) + + useEffect(() => { + if (!open) { + return + } + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key !== 'Enter' && event.key !== 'Escape') { + return + } + const target = event.target + if (!(target instanceof HTMLElement)) { + return + } + if (event.key === 'Escape') { + if ( + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target instanceof HTMLSelectElement || + target.isContentEditable + ) { + event.preventDefault() + target.blur() + return + } + event.preventDefault() + onOpenChange(false) + return + } + if (!isScreenSubmitShortcut(event)) { + return + } + if (!shouldAllowComposerEnterSubmitTarget(target, composerRef.current) || submitting) { + return + } + event.preventDefault() + void handleCreate() + } + window.addEventListener('keydown', onKeyDown, { capture: true }) + return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) + }, [handleCreate, onOpenChange, open, submitting]) + + const smartNameSelection = useMemo(() => getSmartNameSelection(linkedWorkItem), [linkedWorkItem]) + const emptySourceProjectMessage = + sourceRepos.length === 0 + ? translate( + 'auto.components.sidebar.FolderWorkspaceComposerDialog.noRepos', + 'Add a Git project under this folder to attach GitHub or GitLab tasks.' + ) + : null + + return ( + <> + + { + event.preventDefault() + const content = event.currentTarget as HTMLElement + const trigger = content.querySelector( + '[data-repo-combobox-root="true"][role="combobox"]' + ) + trigger?.focus({ preventScroll: true }) + }} + > + + + {translate( + 'auto.components.sidebar.FolderWorkspaceComposerDialog.title', + 'Create Folder Workspace' + )} + + {projectGroup?.parentPath ?? ''} + + {}} + onSmartLinearIssueSelect={handleSmartLinearIssueSelect} + smartNameSelection={smartNameSelection} + onClearSmartNameSelection={handleClearSmartNameSelection} + forkPushWarning={null} + detectedAgentIds={detectedAgentIds} + onOpenAgentSettings={() => setAgentSettingsOpen(true)} + advancedOpen={advancedOpen} + onToggleAdvanced={() => setAdvancedOpen((value) => !value)} + createDisabled={ + submitting || + !projectGroup?.parentPath || + pathStatusBlocksCreate || + selectedRepoRequiresConnection + } + projectError={pathStatusProjectError} + creating={submitting} + onCreate={() => void handleCreate()} + note={note} + onNoteChange={setNote} + setupConfig={null} + requiresExplicitSetupChoice={false} + setupDecision={null} + onSetupDecisionChange={() => {}} + shouldWaitForSetupCheck={false} + resolvedSetupDecision={null} + createError={null} + selectedRepoConnectionId={selectedRepoConnectionId} + selectedRepoSshStatus={selectedRepoSshStatus as SshConnectionStatus | null} + selectedRepoRequiresConnection={selectedRepoRequiresConnection} + selectedRepoConnectInProgress={selectedRepoConnectInProgress} + onConnectSelectedRepo={onConnectSelectedRepo} + branchesEnabled={false} + setupControlsEnabled={false} + canUseSparseCheckout={false} + sparsePresets={[]} + sparseSelectedPresetId={null} + onSparseSelectPreset={() => {}} + sparseControlsEnabled={false} + /> + + + + + ) +} diff --git a/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx index 6dcb03033..6941813e7 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx @@ -157,7 +157,7 @@ describe('WorktreeCard quick actions', () => { expect(markup).toContain('data-worktree-card-meta-row=""') }) - it('renders folder kind in the detailed metadata row', () => { + it('renders folder kind and directory in the detailed metadata row', () => { const markup = renderToStaticMarkup( { expect(markup).toContain('Docs folder') expect(markup).toContain('>Folder') + expect(markup).toContain('>quick-action') + expect(markup).toContain('data-worktree-card-meta-row=""') + }) + + it('renders synthetic folder workspace directory in the detailed metadata row', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('Docs folder') + expect(markup).toContain('>Folder') + expect(markup).toContain('>quick-action') expect(markup).toContain('data-worktree-card-meta-row=""') }) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index 3b3d9f74e..f85c85fa6 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -61,6 +61,7 @@ import { import { DetachedHeadBadge } from '@/components/DetachedHeadBadge' import { getWorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display' import { translate } from '@/i18n/i18n' +import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope' type WorktreeCardProps = { worktree: Worktree @@ -107,6 +108,12 @@ function isWebClient(): boolean { return Boolean((window as unknown as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__) } +function getDirectoryName(folderPath: string): string { + const normalized = folderPath.replace(/[\\/]+$/, '') + const parts = normalized.split(/[\\/]+/) + return parts.at(-1) || normalized || folderPath +} + // Why: the pinned repo icon and the compact inline badge share one chip shell; // keep the box + tooltip identical so both repo cues read as the same affordance. function RepoIdentityChip({ @@ -165,6 +172,8 @@ const WorktreeCard = React.memo(function WorktreeCard({ const openModal = useAppStore((s) => s.openModal) const openTaskPage = useAppStore((s) => s.openTaskPage) const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta) + const deleteFolderWorkspace = useAppStore((s) => s.deleteFolderWorkspace) + const setActiveWorktree = useAppStore((s) => s.setActiveWorktree) const renamingWorktreeId = useAppStore((s) => s.renamingWorktreeId) const setRenamingWorktreeId = useAppStore((s) => s.setRenamingWorktreeId) const fetchHostedReviewForBranch = useAppStore((s) => s.fetchHostedReviewForBranch) @@ -247,7 +256,10 @@ const WorktreeCard = React.memo(function WorktreeCard({ const gitIdentityDisplay = getWorktreeGitIdentityDisplay(worktree) const detachedHeadDisplay = gitIdentityDisplay?.kind === 'detached' ? gitIdentityDisplay : null const branch = gitIdentityDisplay?.kind === 'branch' ? gitIdentityDisplay.branchName : '' - const isFolder = repo ? isFolderRepo(repo) : false + const workspaceScope = parseWorkspaceKey(worktree.id) + const folderWorkspaceId = + workspaceScope?.type === 'folder' ? workspaceScope.folderWorkspaceId : null + const isFolder = repo ? isFolderRepo(repo) : folderWorkspaceId !== null const hostedReviewCacheKey = repo && branch ? getHostedReviewCacheKey(repo.path, branch, settings, repo.id, repo.connectionId) @@ -555,10 +567,27 @@ const WorktreeCard = React.memo(function WorktreeCard({ event.preventDefault() event.stopPropagation() if (showDeleteQuickAction) { + if (folderWorkspaceId) { + void deleteFolderWorkspace(folderWorkspaceId).then((deleted) => { + if ( + deleted && + useAppStore.getState().activeWorktreeId === folderWorkspaceKey(folderWorkspaceId) + ) { + setActiveWorktree(null) + } + }) + return + } runWorktreeDelete(worktree.id) } }, - [showDeleteQuickAction, worktree.id] + [ + deleteFolderWorkspace, + folderWorkspaceId, + setActiveWorktree, + showDeleteQuickAction, + worktree.id + ] ) const handlePendingFirstAgentMessageRenameInfo = useCallback( (event: React.MouseEvent) => { @@ -965,6 +994,17 @@ const WorktreeCard = React.memo(function WorktreeCard({ onBeginEditingConsumed={() => setRenamingWorktreeId(null)} /> + {isFolder && ( + + {repo + ? getRepoKindLabel(repo) + : translate('auto.components.sidebar.WorktreeCard.93aebe4529', 'Folder')} + + )} + {typeof worktree.firstAgentMessageRenameError === 'string' && worktree.firstAgentMessageRenameError.length > 0 && !titleRenaming ? ( @@ -1155,14 +1195,12 @@ const WorktreeCard = React.memo(function WorktreeCard({ )} {isFolder ? ( - - {repo - ? getRepoKindLabel(repo) - : translate('auto.components.sidebar.WorktreeCard.93aebe4529', 'Folder')} - + {getDirectoryName(worktree.path)} + ) : showBranch ? ( {branch} diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx index 665c72946..afde399bb 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx @@ -43,6 +43,7 @@ import { getWorkspaceStatus, getWorkspaceStatusVisualMeta } from './workspace-st import { WorktreeOpenInSubMenu } from './WorktreeOpenInMenu' import { ProjectGroupNameDialog } from './ProjectGroupNameDialog' import { translate } from '@/i18n/i18n' +import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope' type Props = { worktree: Worktree @@ -217,6 +218,8 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ const projectGroups = useAppStore((s) => s.projectGroups) const createProjectGroup = useAppStore((s) => s.createProjectGroup) const moveProjectToGroup = useAppStore((s) => s.moveProjectToGroup) + const deleteFolderWorkspace = useAppStore((s) => s.deleteFolderWorkspace) + const setActiveWorktree = useAppStore((s) => s.setActiveWorktree) const repo = useRepoById(worktree.repoId) const deleteState = useAppStore((s) => s.deleteStateByWorktreeId[worktree.id]) const [menuOpen, setMenuOpen] = useState(false) @@ -238,6 +241,9 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ const contextMenuOpenedAtRef = useRef(null) const activeContextWorktrees = menuOpen ? contextWorktrees : effectiveSelectedWorktrees const isMultiContext = activeContextWorktrees.length > 1 + const workspaceScope = parseWorkspaceKey(worktree.id) + const folderWorkspaceId = + workspaceScope?.type === 'folder' ? workspaceScope.folderWorkspaceId : null const sleepableWorktrees = useMemo( () => activeContextWorktrees.filter((item) => @@ -408,13 +414,33 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ restoreSidebarPosition() return } + if (folderWorkspaceId) { + void deleteFolderWorkspace(folderWorkspaceId).then((deleted) => { + if ( + deleted && + useAppStore.getState().activeWorktreeId === folderWorkspaceKey(folderWorkspaceId) + ) { + setActiveWorktree(null) + } + }) + restoreSidebarPosition() + return + } // Why delegate to runWorktreeDelete: keeps the delete-vs-project-removal // decision tree (and its rationale) in one place shared with command // surfaces and the memory popover's inline Delete action. runWorktreeDelete(worktree.id) restoreSidebarPosition() }, 50) - }, [batchDeleteWorktrees, isMultiContext, setMenuOpenState, worktree.id]) + }, [ + batchDeleteWorktrees, + deleteFolderWorkspace, + folderWorkspaceId, + isMultiContext, + setActiveWorktree, + setMenuOpenState, + worktree.id + ]) const handleOpenParent = useCallback(() => { if (validParentWorktreeId) { @@ -703,12 +729,17 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ ? translate('auto.components.sidebar.WorktreeContextMenu.b42391d8bf', 'Deleting…') : isMultiContext ? deleteLabel - : removesProject + : folderWorkspaceId ? translate( - 'auto.components.sidebar.WorktreeContextMenu.f5ac91531d', - 'Remove Project from Orca' + 'auto.components.sidebar.WorktreeContextMenu.250de158fd', + 'Remove Workspace' ) - : translate('auto.components.sidebar.WorktreeContextMenu.f4475537d8', 'Delete')} + : removesProject + ? translate( + 'auto.components.sidebar.WorktreeContextMenu.f5ac91531d', + 'Remove Project from Orca' + ) + : translate('auto.components.sidebar.WorktreeContextMenu.f4475537d8', 'Delete')} diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index f3b13f5c5..dbbd52fdf 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -12,6 +12,7 @@ import { Eye, FolderInput, FolderPlus, + FolderX, Plus, Shapes, SlidersHorizontal, @@ -27,6 +28,8 @@ import { useWorktreeMap } from '@/store/selectors' import WorktreeCard from './WorktreeCard' +import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree' +import { FolderWorkspaceComposerDialog } from './FolderWorkspaceComposerDialog' import { PendingWorktreeRow } from './PendingWorktreeRow' import { SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT } from './WorktreeCardAgents' import { Button } from '@/components/ui/button' @@ -45,6 +48,7 @@ import { cn } from '@/lib/utils' import type { Worktree, Repo, + FolderWorkspace, ProjectGroup, ProjectOrderBy, WorktreeLineage, @@ -110,6 +114,11 @@ import { type VirtualizedScrollAnchor } from '@/hooks/useVirtualizedScrollAnchor' import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { useFolderWorkspacePathStatusCacheExpiryTick } from '@/lib/folder-workspace-path-status-cache-expiry' +import { + getFolderWorkspacePathStatusDescription, + getFolderWorkspacePathStatusTitle +} from '@/lib/folder-workspace-path-status' import { getShortcutPlatform } from '@/lib/shortcut-platform' import { SCROLL_TO_CURRENT_WORKSPACE_REVEAL_REQUEST_EVENT, @@ -192,10 +201,21 @@ import { buildImportedWorktreesCardCandidates } from './imported-worktrees-card- import { WORKTREE_SECTION_HEADER_PADDING_LEFT, getProjectGroupHeaderPaddingLeft, - getWorktreeCardContentIndent + getWorktreeCardContentIndent, + getWorktreeCardSurfaceInset } from './worktree-list-indentation' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import { + isConfirmedStaleFolderPathStatus, + type FolderWorkspacePathStatus +} from '../../../../shared/folder-workspace-path-status' +import { + getFolderWorkspaceRevealGroupKeys, + getKnownSidebarWorktreeById, + sidebarWorkspaceStillExists +} from './worktree-list-folder-reveal' export { getScrollTopToRevealBounds, @@ -355,10 +375,6 @@ function getWorktreeVisibilityMenuLabel(repo: Repo): string { } const SIDEBAR_POINTER_DRAG_THRESHOLD_PX = 4 -// Why: nested child worktree cards sit inside the parent card body. Preserve -// the legacy lineage surface offset instead of using the full sidebar tree step. -const NESTED_LINEAGE_CARD_INDENT = 14 - type VirtualizedWorktreeViewportProps = { rows: Row[] activeWorktreeId: string | null @@ -379,11 +395,13 @@ type VirtualizedWorktreeViewportProps = { handleRemoveProjectFromGroup: (repo: Repo) => void handleRenameProjectGroup: (groupId: string, currentName: string) => void handleDeleteProjectGroup: (groupId: string, groupName: string) => void + handleCreateFolderWorkspace: (projectGroup: ProjectGroup) => void activeModal: string pendingRevealWorktree: PendingSidebarWorktreeReveal | null clearPendingRevealWorktreeId: () => void agentSendTargetWorktreeId: string | null worktrees: Worktree[] + folderWorkspaces: readonly FolderWorkspace[] selectedWorktreeIds: ReadonlySet selectedWorktrees: readonly Worktree[] onSelectionGesture: (event: React.MouseEvent, worktreeId: string) => boolean @@ -438,6 +456,7 @@ type VirtualizedWorktreeViewportProps = { } type WorktreeItemRow = Extract +type FolderWorkspaceItemRow = Extract function formatSectionActivityLabel(count: number, label: string): string { return `${count} ${label}${count === 1 ? '' : 's'}` @@ -465,6 +484,41 @@ function SectionMetricsBadge({ count }: { count: number }): React.JSX.Element { ) } +function FolderPathStatusIndicator({ + status +}: { + status: FolderWorkspacePathStatus | null | undefined +}): React.JSX.Element | null { + const title = getFolderWorkspacePathStatusTitle(status) + if (!status || status.exists || !title) { + return null + } + const destructive = isConfirmedStaleFolderPathStatus(status) + return ( + + + + + + + +
+
{title}
+
+ {getFolderWorkspacePathStatusDescription(status)} +
+
+
+
+ ) +} + type WorktreeRowDragState = { draggingWorktreeId: string | null sourceGroupKey: string | null @@ -594,6 +648,9 @@ export function renderRowContainsWorktree(row: RenderRow, worktreeId: string | n if (worktreeId === null) { return false } + if (row.type === 'folder-workspace') { + return folderWorkspaceKey(row.folderWorkspace.id) === worktreeId + } if (row.type === 'lineage-group') { return row.rows.some((item) => item.worktree.id === worktreeId) } @@ -648,6 +705,9 @@ export function getRenderRowKey(row: RenderRow): string { if (row.type === 'pending-creation') { return `pending:${row.creationId}` } + if (row.type === 'folder-workspace') { + return `folder-workspace:${row.folderWorkspace.id}` + } return `wt:${row.worktree.id}` } @@ -661,7 +721,11 @@ export function getWorktreeDragGroups(rows: Row[]): WorktreeDragGroup[] { groups.push({ key: current.key, worktreeIds: current.ids }) continue } - if (row.type === 'imported-worktrees-card' || row.type === 'pending-creation') { + if ( + row.type === 'imported-worktrees-card' || + row.type === 'pending-creation' || + row.type === 'folder-workspace' + ) { continue } if (!current) { @@ -725,11 +789,13 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp handleRemoveProjectFromGroup, handleRenameProjectGroup, handleDeleteProjectGroup, + handleCreateFolderWorkspace, activeModal, pendingRevealWorktree, clearPendingRevealWorktreeId, agentSendTargetWorktreeId, worktrees, + folderWorkspaces, selectedWorktreeIds, selectedWorktrees, onSelectionGesture, @@ -1000,6 +1066,82 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp [renderRows, activeWorktreeId] ) const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) + const { + folderWorkspacePathStatuses, + fetchFolderWorkspacePathStatus, + getFolderWorkspacePathStatusCacheKey, + getFreshFolderWorkspacePathStatus, + activeRuntimeEnvironmentId + } = useAppStore( + useShallow((s) => ({ + folderWorkspacePathStatuses: s.folderWorkspacePathStatuses, + fetchFolderWorkspacePathStatus: s.fetchFolderWorkspacePathStatus, + getFolderWorkspacePathStatusCacheKey: s.getFolderWorkspacePathStatusCacheKey, + getFreshFolderWorkspacePathStatus: s.getFreshFolderWorkspacePathStatus, + activeRuntimeEnvironmentId: s.settings?.activeRuntimeEnvironmentId ?? null + })) + ) + const folderPathStatusRepoMembershipKey = useMemo( + () => + allRepoIds + .map((repoId) => { + const repo = repoMap.get(repoId) + return `${repoId}:${repo?.path ?? ''}:${repo?.projectGroupId ?? ''}:${repo?.connectionId ?? ''}` + }) + .join('\0'), + [allRepoIds, repoMap] + ) + const folderPathStatusSshConnectionKey = useMemo( + () => + [...sshConnectionStates.entries()] + .map(([connectionId, state]) => `${connectionId}:${state.status}`) + .sort() + .join('\0'), + [sshConnectionStates] + ) + const folderPathStatusCacheExpiryTick = useFolderWorkspacePathStatusCacheExpiryTick( + folderWorkspacePathStatuses + ) + useEffect(() => { + const requests = new Map[0]>() + for (const group of projectGroups) { + if (group.parentPath) { + const request = { scope: 'project-group' as const, projectGroupId: group.id } + requests.set(getFolderWorkspacePathStatusCacheKey(request), request) + } + } + for (const workspace of folderWorkspaces) { + const request = { scope: 'folder-workspace' as const, folderWorkspaceId: workspace.id } + requests.set(getFolderWorkspacePathStatusCacheKey(request), request) + } + for (const request of requests.values()) { + void fetchFolderWorkspacePathStatus(request, { force: true }) + } + }, [ + activeRuntimeEnvironmentId, + fetchFolderWorkspacePathStatus, + folderPathStatusRepoMembershipKey, + folderPathStatusSshConnectionKey, + folderWorkspaces, + getFolderWorkspacePathStatusCacheKey, + projectGroups + ]) + const getCachedFolderWorkspacePathStatus = useCallback( + (request: Parameters[0]) => { + const cacheKey = getFolderWorkspacePathStatusCacheKey(request) + // Why: expired negative statuses should not keep disabling folder + // workspaces while a fresh status request is in flight. + void folderWorkspacePathStatuses[cacheKey] + void folderPathStatusCacheExpiryTick + return getFreshFolderWorkspacePathStatus(request) + }, + [ + folderWorkspacePathStatuses, + folderPathStatusCacheExpiryTick, + getFolderWorkspacePathStatusCacheKey, + getFreshFolderWorkspacePathStatus + ] + ) const renderRowsRef = useRef(renderRows) renderRowsRef.current = renderRows const getVirtualItemKey = useCallback( @@ -1153,53 +1295,66 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp } if (agentSendTargetWorktreeId !== pendingRevealWorktree.worktreeId) { - const targetWorktree = worktrees.find((w) => w.id === pendingRevealWorktree.worktreeId) - if (targetWorktree && !targetWorktree.isPinned) { - const seen = new Set() - let current: Worktree | undefined = targetWorktree - while (current && !seen.has(current.id)) { - seen.add(current.id) - const lineage = worktreeLineageById[current.id] - const parent = lineage ? worktreeMap.get(lineage.parentWorktreeId) : undefined - if ( - !lineage || - !parent || - current.instanceId !== lineage.worktreeInstanceId || - parent.instanceId !== lineage.parentWorktreeInstanceId - ) { - break - } - const lineageGroupKey = getLineageGroupKey(parent.id) - if (collapsedGroups.has(lineageGroupKey)) { - toggleGroup(lineageGroupKey) - } - current = parent - } - } - - if (targetWorktree?.isPinned) { - // Why: pinned worktrees live in the dedicated "Pinned" section regardless - // of their PR-status / project group. Only uncollapse the Pinned header - // itself — expanding the underlying status group would be surprising since - // the user intentionally collapsed it. - if (collapsedGroups.has(PINNED_GROUP_KEY)) { - toggleGroup(PINNED_GROUP_KEY) - } - } else if (targetWorktree) { - const groupKeys = getGroupKeysForWorktree( - groupBy, - targetWorktree, - repoMap, - prCache, - workspaceStatuses, - settings, - projectGroups - ) - for (const groupKey of groupKeys) { + const folderGroupKeys = getFolderWorkspaceRevealGroupKeys( + pendingRevealWorktree.worktreeId, + folderWorkspaces, + projectGroups + ) + if (folderGroupKeys.length > 0) { + for (const groupKey of folderGroupKeys) { if (collapsedGroups.has(groupKey)) { toggleGroup(groupKey) } } + } else { + const targetWorktree = worktrees.find((w) => w.id === pendingRevealWorktree.worktreeId) + if (targetWorktree && !targetWorktree.isPinned) { + const seen = new Set() + let current: Worktree | undefined = targetWorktree + while (current && !seen.has(current.id)) { + seen.add(current.id) + const lineage = worktreeLineageById[current.id] + const parent = lineage ? worktreeMap.get(lineage.parentWorktreeId) : undefined + if ( + !lineage || + !parent || + current.instanceId !== lineage.worktreeInstanceId || + parent.instanceId !== lineage.parentWorktreeInstanceId + ) { + break + } + const lineageGroupKey = getLineageGroupKey(parent.id) + if (collapsedGroups.has(lineageGroupKey)) { + toggleGroup(lineageGroupKey) + } + current = parent + } + } + + if (targetWorktree?.isPinned) { + // Why: pinned worktrees live in the dedicated "Pinned" section regardless + // of their PR-status / project group. Only uncollapse the Pinned header + // itself — expanding the underlying status group would be surprising since + // the user intentionally collapsed it. + if (collapsedGroups.has(PINNED_GROUP_KEY)) { + toggleGroup(PINNED_GROUP_KEY) + } + } else if (targetWorktree) { + const groupKeys = getGroupKeysForWorktree( + groupBy, + targetWorktree, + repoMap, + prCache, + workspaceStatuses, + settings, + projectGroups + ) + for (const groupKey of groupKeys) { + if (collapsedGroups.has(groupKey)) { + toggleGroup(groupKey) + } + } + } } } @@ -1208,8 +1363,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp if (cancelled) { return } - const targetWorktreeStillExists = worktrees.some( - (worktree) => worktree.id === pendingRevealWorktree.worktreeId + const targetWorktreeStillExists = sidebarWorkspaceStillExists( + pendingRevealWorktree.worktreeId, + worktrees, + folderWorkspaces ) const targetIndex = renderRows.findIndex((row) => renderRowContainsWorktree(row, pendingRevealWorktree.worktreeId) @@ -1294,6 +1451,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp agentSendTargetWorktreeId, groupBy, worktrees, + folderWorkspaces, repoMap, prCache, worktreeLineageById, @@ -2778,6 +2936,20 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp : null }) : null + const projectGroupPathStatus = + isProjectGroupHeader && + row.projectGroup && + 'parentPath' in row.projectGroup && + row.projectGroup.parentPath + ? getCachedFolderWorkspacePathStatus({ + scope: 'project-group', + projectGroupId: row.projectGroup.id + }) + : null + const folderWorkspaceCreateDisabled = + projectGroupPathStatus?.exists === false && + (isConfirmedStaleFolderPathStatus(projectGroupPathStatus) || + projectGroupPathStatus.reason === 'ambiguous-connection') const projectGroupDepth = row.projectGroupDepth ?? 0 // Why: non-project section headers like "All" are labels for the // flat list, so they should not reserve project hierarchy indent. @@ -2906,6 +3078,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp {row.label}
+
@@ -2983,6 +3156,61 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ) : null} + {isProjectGroupHeader && + !row.repo && + row.projectGroup && + 'parentPath' in row.projectGroup && + row.projectGroup.parentPath ? ( + + + + + + {projectGroupPathStatus?.exists === false + ? getFolderWorkspacePathStatusDescription(projectGroupPathStatus) + : translate( + 'auto.components.sidebar.WorktreeList.bd37a57ac8', + 'Create workspace for {{value0}}', + { value0: row.label } + )} + + + ) : null} + {row.repo && groupBy === 'repo' ? ( @@ -3220,7 +3448,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp // still owns the project/group inset inside each card surface. const paddingDepth = nested ? Math.max(0, itemRow.depth - 1) : itemRow.depth const nestedCardPaddingLeft = nested - ? Math.max(0, itemRow.depth) * NESTED_LINEAGE_CARD_INDENT + ? getWorktreeCardSurfaceInset({ + isGrouped: true, + groupDepth: itemRow.depth + }) : 0 const inheritedCardContentIndent = getWorktreeCardContentIndent({ isGrouped: groupBy !== 'none', @@ -3234,7 +3465,16 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp groupDepth: itemRow.groupDepth, lineageDepth: paddingDepth }) - const cardContentIndent = nested ? inheritedCardContentIndent : paddingLeft + const surfaceInset = nested + ? nestedCardPaddingLeft + : getWorktreeCardSurfaceInset({ + isGrouped: groupBy !== 'none', + groupDepth: itemRow.groupDepth + }) + const cardContentIndent = Math.max( + 0, + (nested ? inheritedCardContentIndent : paddingLeft) - surfaceInset + ) const worktreeDragGroupKey = groupKeyByWorktreeId.get(itemRow.worktree.id) const worktreeDragGroupIndex = groupIndexByWorktreeId.get(itemRow.worktree.id) const revealHighlightTone = @@ -3271,8 +3511,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp nested ? undefined : handleWorktreeRowPointerDown(event, itemRow.worktree.id) } style={{ - paddingLeft: - nested && nestedCardPaddingLeft > 0 ? `${nestedCardPaddingLeft}px` : undefined + paddingLeft: surfaceInset > 0 ? `${surfaceInset}px` : undefined }} > handleWorktreeRowPointerDown(event, folderWorktree.id)} + > +
0 ? { paddingLeft: surfaceInset } : undefined} + > + +
+ +
+
+ + ) + } + const itemWorkspaceStatus = groupBy === 'workspace-status' ? getWorkspaceStatus(row.worktree, workspaceStatuses) @@ -3873,6 +4179,7 @@ const WorktreeList = React.memo(function WorktreeList({ // header order from the sorted visible worktree stream instead. const repos = useAppStore((s) => s.repos) const projectGroups = useAppStore((s) => s.projectGroups ?? EMPTY_PROJECT_GROUPS) + const folderWorkspaces = useAppStore((s) => s.folderWorkspaces) const effectiveCollapsedGroups = useMemo(() => { if (!agentSendTargetWorktreeId) { return collapsedGroups @@ -3994,7 +4301,8 @@ const WorktreeList = React.memo(function WorktreeList({ projectGroups, placeholderRepoIds, importedWorktreesByRepo, - pendingCreations + pendingCreations, + folderWorkspaces ), [ groupBy, @@ -4011,7 +4319,8 @@ const WorktreeList = React.memo(function WorktreeList({ projectGroups, placeholderRepoIds, importedWorktreesByRepo, - pendingCreations + pendingCreations, + folderWorkspaces ] ) // Why: status headers change during wake (inactive -> active). Key only on @@ -4025,9 +4334,15 @@ const WorktreeList = React.memo(function WorktreeList({ // positions when grouping is active. const renderedWorktrees = useMemo( () => - rows - .filter((r): r is Extract => r.type === 'item') - .map((r) => r.worktree), + rows.flatMap((row) => { + if (row.type === 'item') { + return [row.worktree] + } + if (row.type === 'folder-workspace') { + return [folderWorkspaceToWorktree(row.folderWorkspace)] + } + return [] + }), [rows] ) const renderedWorktreeIds = useMemo( @@ -4214,6 +4529,9 @@ const WorktreeList = React.memo(function WorktreeList({ useState(null) const [projectGroupDeleteDialog, setProjectGroupDeleteDialog] = useState(null) + const [folderWorkspaceCreateGroup, setFolderWorkspaceCreateGroup] = useState( + null + ) const handleCreateGroupFromRepo = useCallback((repo: Repo) => { setProjectGroupNameDialog({ type: 'create-from-repo', repo }) @@ -4338,6 +4656,13 @@ const WorktreeList = React.memo(function WorktreeList({ projectGroupDeleteDialog ]) + const handleCreateFolderWorkspace = useCallback((projectGroup: ProjectGroup) => { + if (!projectGroup.parentPath) { + return + } + setFolderWorkspaceCreateGroup(projectGroup) + }, []) + const moveWorktreeToStatus = useCallback( (worktreeId: string, status: WorkspaceStatus) => { const current = worktreeMap.get(worktreeId) @@ -4550,11 +4875,15 @@ const WorktreeList = React.memo(function WorktreeList({ if (!activeWorktreeId) { return } - const activeWorktree = worktreeMap.get(activeWorktreeId) + const activeWorktree = getKnownSidebarWorktreeById( + activeWorktreeId, + worktreeMap, + folderWorkspaces + ) if (!activeWorktree || activeWorktree.isArchived) { return } - if (!worktrees.some((worktree) => worktree.id === activeWorktreeId)) { + if (!renderedWorktreeIds.includes(activeWorktreeId)) { // Why: the toolbar action promises to reveal the current workspace; when // sidebar filters hide it, relax those filters before queuing the reveal. clearFilters() @@ -4565,7 +4894,14 @@ const WorktreeList = React.memo(function WorktreeList({ beginRename: detail?.beginRename === true }) }, - [activeWorktreeId, clearFilters, revealWorktreeInSidebar, worktreeMap, worktrees] + [ + activeWorktreeId, + clearFilters, + folderWorkspaces, + renderedWorktreeIds, + revealWorktreeInSidebar, + worktreeMap + ] ) useEffect(() => { @@ -4668,6 +5004,15 @@ const WorktreeList = React.memo(function WorktreeList({ }} onConfirm={handleConfirmDeleteProjectGroup} /> + { + if (!open) { + setFolderWorkspaceCreateGroup(null) + } + }} + /> + isGitRepoKind(repo) && + ((typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) || + isPathInsideOrEqual(folderPath, repo.path)) + ) +} + +export function toFolderWorkspaceLinkedTask( + item: LinkedWorkItemSummary | null +): FolderWorkspace['linkedTask'] { + if (!item) { + return null + } + const provider = getLinkedWorkItemProvider(item) + return { + provider, + type: item.type, + number: item.number, + title: item.title, + url: item.url, + ...(item.linearIdentifier ? { linearIdentifier: item.linearIdentifier } : {}), + ...(item.jiraIdentifier ? { jiraIdentifier: item.jiraIdentifier } : {}), + ...(item.repoId ? { repoId: item.repoId } : {}) + } +} + +export function getSmartNameSelection( + linkedWorkItem: LinkedWorkItemSummary | null +): SmartWorkspaceNameSelection | null { + if (!linkedWorkItem) { + return null + } + const provider = getLinkedWorkItemProvider(linkedWorkItem) + const kind: SmartWorkspaceNameSelection['kind'] = + provider === 'linear' + ? 'linear' + : provider === 'jira' + ? 'jira' + : provider === 'gitlab' + ? linkedWorkItem.type === 'mr' + ? 'gitlab-mr' + : 'gitlab-issue' + : linkedWorkItem.type === 'pr' + ? 'github-pr' + : 'github-issue' + return { + kind, + label: + provider === 'linear' || provider === 'jira' || linkedWorkItem.number === 0 + ? linkedWorkItem.title + : `#${linkedWorkItem.number} ${linkedWorkItem.title}`, + url: linkedWorkItem.url + } +} + +export function getLinkedItemDisplayName(item: LinkedWorkItemSummary): string | null { + return getLinkedWorkItemWorkspaceName(item)?.displayName ?? (item.title.trim() || null) +} + +export function toGitHubLinkedWorkItem(item: GitHubWorkItem): LinkedWorkItemSummary { + return { + type: item.type, + provider: 'github', + number: item.number, + title: item.title, + url: item.url, + repoId: item.repoId + } +} + +export function toGitLabLinkedWorkItem(item: GitLabWorkItem): LinkedWorkItemSummary { + return { + type: item.type, + provider: 'gitlab', + number: item.number, + title: item.title, + url: item.url, + repoId: item.repoId + } +} + +export function toLinearLinkedWorkItem(issue: LinearIssue): LinkedWorkItemSummary { + return buildLinearIssueLinkedWorkItem(issue) +} diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-path-status.test.tsx b/src/renderer/src/components/sidebar/folder-workspace-composer-path-status.test.tsx new file mode 100644 index 000000000..9fc3db07d --- /dev/null +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-path-status.test.tsx @@ -0,0 +1,182 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ProjectGroup } from '../../../../shared/types' +import { useAppStore } from '@/store' +import { useFolderWorkspaceComposerPathStatus } from './folder-workspace-composer-path-status' + +const initialState = useAppStore.getInitialState() + +const projectGroup: ProjectGroup = { + id: 'group-1', + name: 'Platform', + parentPath: '/workspace/platform', + connectionId: null, + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 +} +const projectGroupRequestSnapshot = '/workspace/platform\0group-1\0\0\0' + +let root: Root | null = null +let container: HTMLDivElement | null = null + +function HookProbe(): null { + const result = useFolderWorkspaceComposerPathStatus(projectGroup, true) + ;( + globalThis as { __folderWorkspaceComposerPathStatusResult?: typeof result } + ).__folderWorkspaceComposerPathStatusResult = result + return null +} + +describe('useFolderWorkspaceComposerPathStatus', () => { + afterEach(() => { + vi.useRealTimers() + act(() => { + root?.unmount() + }) + root = null + container?.remove() + container = null + delete (globalThis as { __folderWorkspaceComposerPathStatusResult?: unknown }) + .__folderWorkspaceComposerPathStatusResult + useAppStore.setState(initialState, true) + }) + + it('does not block creation with an expired negative path status', () => { + vi.useFakeTimers() + vi.setSystemTime(20_000) + const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id } + const cacheKey = useAppStore.getState().getFolderWorkspacePathStatusCacheKey(request) + const fetchFolderWorkspacePathStatus = vi.fn() + useAppStore.setState({ + projectGroups: [projectGroup], + fetchFolderWorkspacePathStatus, + folderWorkspacePathStatuses: { + [cacheKey]: { + status: { + path: '/workspace/platform', + exists: false, + reason: 'missing' + }, + checkedAt: 0, + requestSnapshot: projectGroupRequestSnapshot + } + } + }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + act(() => { + root?.render() + }) + + expect( + ( + globalThis as { + __folderWorkspaceComposerPathStatusResult?: { pathStatusBlocksCreate: boolean } + } + ).__folderWorkspaceComposerPathStatusResult?.pathStatusBlocksCreate + ).toBe(false) + expect( + ( + globalThis as { + __folderWorkspaceComposerPathStatusResult?: { pathStatusProjectError: string | null } + } + ).__folderWorkspaceComposerPathStatusResult?.pathStatusProjectError + ).toBeNull() + expect(fetchFolderWorkspacePathStatus).toHaveBeenCalledWith(request, { force: true }) + }) + + it('does not block creation for an unavailable path status', () => { + vi.useFakeTimers() + vi.setSystemTime(20_000) + const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id } + const cacheKey = useAppStore.getState().getFolderWorkspacePathStatusCacheKey(request) + useAppStore.setState({ + projectGroups: [projectGroup], + fetchFolderWorkspacePathStatus: vi.fn(), + folderWorkspacePathStatuses: { + [cacheKey]: { + status: { + path: '/workspace/platform', + exists: false, + reason: 'unavailable' + }, + checkedAt: 20_000, + requestSnapshot: projectGroupRequestSnapshot + } + } + }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + act(() => { + root?.render() + }) + + expect( + ( + globalThis as { + __folderWorkspaceComposerPathStatusResult?: { pathStatusBlocksCreate: boolean } + } + ).__folderWorkspaceComposerPathStatusResult?.pathStatusBlocksCreate + ).toBe(false) + }) + + it('rerenders when a cached blocking path status expires', () => { + vi.useFakeTimers() + vi.setSystemTime(20_000) + const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id } + const cacheKey = useAppStore.getState().getFolderWorkspacePathStatusCacheKey(request) + useAppStore.setState({ + projectGroups: [projectGroup], + fetchFolderWorkspacePathStatus: vi.fn(), + folderWorkspacePathStatuses: { + [cacheKey]: { + status: { + path: '/workspace/platform', + exists: false, + reason: 'missing' + }, + checkedAt: 20_000, + requestSnapshot: projectGroupRequestSnapshot + } + } + }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + act(() => { + root?.render() + }) + expect( + ( + globalThis as { + __folderWorkspaceComposerPathStatusResult?: { pathStatusBlocksCreate: boolean } + } + ).__folderWorkspaceComposerPathStatusResult?.pathStatusBlocksCreate + ).toBe(true) + + act(() => { + vi.advanceTimersByTime(10_001) + }) + + expect( + ( + globalThis as { + __folderWorkspaceComposerPathStatusResult?: { pathStatusBlocksCreate: boolean } + } + ).__folderWorkspaceComposerPathStatusResult?.pathStatusBlocksCreate + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-path-status.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-path-status.ts new file mode 100644 index 000000000..f972ea8b6 --- /dev/null +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-path-status.ts @@ -0,0 +1,71 @@ +import { useEffect, useMemo } from 'react' +import { useShallow } from 'zustand/react/shallow' +import { useAppStore } from '@/store' +import { useFolderWorkspacePathStatusCacheExpiryTick } from '@/lib/folder-workspace-path-status-cache-expiry' +import { + getFolderWorkspacePathStatusDescription, + getFolderWorkspacePathStatusTitle +} from '@/lib/folder-workspace-path-status' +import { isConfirmedStaleFolderPathStatus } from '../../../../shared/folder-workspace-path-status' +import type { ProjectGroup } from '../../../../shared/types' + +export function useFolderWorkspaceComposerPathStatus( + projectGroup: ProjectGroup | null, + open: boolean +): { + pathStatusBlocksCreate: boolean + pathStatusProjectError: string | null +} { + const { + folderWorkspacePathStatuses, + fetchFolderWorkspacePathStatus, + getFolderWorkspacePathStatusCacheKey, + getFreshFolderWorkspacePathStatus + } = useAppStore( + useShallow((s) => ({ + folderWorkspacePathStatuses: s.folderWorkspacePathStatuses, + fetchFolderWorkspacePathStatus: s.fetchFolderWorkspacePathStatus, + getFolderWorkspacePathStatusCacheKey: s.getFolderWorkspacePathStatusCacheKey, + getFreshFolderWorkspacePathStatus: s.getFreshFolderWorkspacePathStatus + })) + ) + const pathStatusRequest = useMemo( + () => + projectGroup ? { scope: 'project-group' as const, projectGroupId: projectGroup.id } : null, + [projectGroup] + ) + const cacheExpiryTick = useFolderWorkspacePathStatusCacheExpiryTick(folderWorkspacePathStatuses) + const pathStatus = useMemo(() => { + if (!pathStatusRequest) { + return null + } + const cacheKey = getFolderWorkspacePathStatusCacheKey(pathStatusRequest) + // Why: subscribe to cache writes, but only let the TTL-aware accessor decide + // whether a cached negative status is still authoritative. + void folderWorkspacePathStatuses[cacheKey] + void cacheExpiryTick + return getFreshFolderWorkspacePathStatus(pathStatusRequest) + }, [ + folderWorkspacePathStatuses, + cacheExpiryTick, + getFolderWorkspacePathStatusCacheKey, + getFreshFolderWorkspacePathStatus, + pathStatusRequest + ]) + + useEffect(() => { + if (!open || !pathStatusRequest) { + return + } + void fetchFolderWorkspacePathStatus(pathStatusRequest, { force: true }) + }, [fetchFolderWorkspacePathStatus, open, pathStatusRequest]) + + const pathStatusBlocksCreate = + pathStatus?.exists === false && + (isConfirmedStaleFolderPathStatus(pathStatus) || pathStatus.reason === 'ambiguous-connection') + const title = pathStatus?.exists === false ? getFolderWorkspacePathStatusTitle(pathStatus) : null + const pathStatusProjectError = + title && pathStatus ? `${title}. ${getFolderWorkspacePathStatusDescription(pathStatus)}` : null + + return { pathStatusBlocksCreate, pathStatusProjectError } +} diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts new file mode 100644 index 000000000..8bddb367c --- /dev/null +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts @@ -0,0 +1,100 @@ +import { + CLIENT_PLATFORM, + buildAgentPromptWithContext, + type LinkedWorkItemSummary +} from '@/lib/new-workspace' +import { getLinkedWorkItemPromptContext } from '@/lib/linked-work-item-context' +import { buildAgentStartupPlan } from '@/lib/tui-agent-startup' +import { tuiAgentToAgentKind } from '@/lib/telemetry' +import { activateAndRevealFolderWorkspace } from '@/lib/worktree-activation' +import { isWorkItemLookupText } from '@/lib/work-item-lookup-text' +import type { FolderWorkspace, ProjectGroup, TuiAgent } from '../../../../shared/types' +import { + getLinkedItemDisplayName, + toFolderWorkspaceLinkedTask +} from './folder-workspace-composer-helpers' + +type FolderWorkspaceCreateInput = { + projectGroupId: string + name: string + linkedTask: FolderWorkspace['linkedTask'] + createdWithAgent?: TuiAgent + pendingFirstAgentMessageRename?: boolean +} + +type SubmitFolderWorkspaceCreateParams = { + projectGroup: ProjectGroup + name: string + lastAutoName: string + linkedWorkItem: LinkedWorkItemSummary | null + note: string + quickAgent: TuiAgent | null + autoRenameBranchFromWork: boolean | undefined + agentCmdOverrides: Record | undefined + createFolderWorkspace: (input: FolderWorkspaceCreateInput) => Promise + onOpenChange: (open: boolean) => void +} + +export async function submitFolderWorkspaceCreate({ + projectGroup, + name, + lastAutoName, + linkedWorkItem, + note, + quickAgent, + autoRenameBranchFromWork, + agentCmdOverrides, + createFolderWorkspace, + onOpenChange +}: SubmitFolderWorkspaceCreateParams): Promise { + const linkedName = linkedWorkItem ? getLinkedItemDisplayName(linkedWorkItem) : null + const nameIsAutoManaged = !name.trim() || name === lastAutoName || isWorkItemLookupText(name) + const workspaceName = + nameIsAutoManaged && linkedName + ? linkedName + : name.trim() || linkedName || `${projectGroup.name} workspace` + const pendingFirstAgentMessageRename = + autoRenameBranchFromWork === true && !name.trim() && !linkedWorkItem && Boolean(quickAgent) + + const workspace = await createFolderWorkspace({ + projectGroupId: projectGroup.id, + name: workspaceName, + linkedTask: toFolderWorkspaceLinkedTask(linkedWorkItem), + ...(quickAgent ? { createdWithAgent: quickAgent } : {}), + ...(pendingFirstAgentMessageRename ? { pendingFirstAgentMessageRename: true } : {}) + }) + if (!workspace) { + return + } + + const linkedPromptContext = getLinkedWorkItemPromptContext(linkedWorkItem) + const startupPrompt = buildAgentPromptWithContext( + note, + [], + linkedPromptContext.linkedUrls, + linkedPromptContext.linkedContextBlocks + ) + const startupPlan = quickAgent + ? buildAgentStartupPlan({ + agent: quickAgent, + prompt: startupPrompt, + cmdOverrides: agentCmdOverrides ?? {}, + platform: CLIENT_PLATFORM, + allowEmptyPromptLaunch: true + }) + : null + const startup = + quickAgent && startupPlan + ? { + command: startupPlan.launchCommand, + ...(startupPlan.env ? { env: startupPlan.env } : {}), + telemetry: { + agent_kind: tuiAgentToAgentKind(quickAgent), + launch_source: 'sidebar' as const, + request_kind: 'new' as const + } + } + : undefined + activateAndRevealFolderWorkspace(workspace.id, startup ? { startup } : undefined) + onOpenChange(false) +} diff --git a/src/renderer/src/components/sidebar/worktree-drag-units.ts b/src/renderer/src/components/sidebar/worktree-drag-units.ts index 29a8bc209..75c1091b8 100644 --- a/src/renderer/src/components/sidebar/worktree-drag-units.ts +++ b/src/renderer/src/components/sidebar/worktree-drag-units.ts @@ -10,6 +10,7 @@ type WorktreeDragUnitRow = | { type: 'item'; worktree: { id: string }; depth: number } | { type: 'imported-worktrees-card' } | { type: 'pending-creation' } + | { type: 'folder-workspace' } export function getWorktreeDragUnitGroups( rows: readonly WorktreeDragUnitRow[] @@ -27,7 +28,11 @@ export function getWorktreeDragUnitGroups( }) continue } - if (row.type === 'imported-worktrees-card' || row.type === 'pending-creation') { + if ( + row.type === 'imported-worktrees-card' || + row.type === 'pending-creation' || + row.type === 'folder-workspace' + ) { continue } if (!current) { diff --git a/src/renderer/src/components/sidebar/worktree-list-folder-reveal.test.ts b/src/renderer/src/components/sidebar/worktree-list-folder-reveal.test.ts new file mode 100644 index 000000000..9b472ab7b --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list-folder-reveal.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest' +import type { FolderWorkspace, ProjectGroup, Worktree } from '../../../../shared/types' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import { + getFolderWorkspaceRevealGroupKeys, + getKnownSidebarWorktreeById, + sidebarWorkspaceStillExists +} from './worktree-list-folder-reveal' +import { getProjectGroupHeaderKey } from './worktree-list-groups' + +function makeFolderWorkspace(overrides: Partial = {}): FolderWorkspace { + return { + id: 'folder-workspace-1', + projectGroupId: 'group-child', + name: 'Refund workflow', + folderPath: '/workspace/platform', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 1, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +function makeProjectGroup(overrides: Partial): ProjectGroup { + return { + id: 'group-1', + name: 'Platform', + parentPath: '/workspace/platform', + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 1, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +function makeWorktree(id: string): Worktree { + return { + id, + repoId: 'repo-1', + path: `/workspace/repo/${id}`, + displayName: id, + branch: id, + head: 'abc123', + isBare: false, + isMainWorktree: false, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 1, + lastActivityAt: 1 + } +} + +describe('worktree list folder reveal', () => { + it('resolves synthetic folder workspace ids as known sidebar worktrees', () => { + const folderWorkspace = makeFolderWorkspace() + const folderWorktree = getKnownSidebarWorktreeById( + folderWorkspaceKey(folderWorkspace.id), + new Map(), + [folderWorkspace] + ) + + expect(folderWorktree).toMatchObject({ + id: folderWorkspaceKey(folderWorkspace.id), + displayName: folderWorkspace.name, + path: folderWorkspace.folderPath + }) + }) + + it('keeps pending reveals alive for folder workspaces missing from raw git worktrees', () => { + const folderWorkspace = makeFolderWorkspace() + const gitWorktree = makeWorktree('git-worktree-1') + + expect( + sidebarWorkspaceStillExists( + folderWorkspaceKey(folderWorkspace.id), + [gitWorktree], + [folderWorkspace] + ) + ).toBe(true) + expect(sidebarWorkspaceStillExists('missing-worktree', [gitWorktree], [folderWorkspace])).toBe( + false + ) + }) + + it('returns project group keys from root to nested folder workspace owner', () => { + const root = makeProjectGroup({ id: 'group-root', name: 'Company' }) + const child = makeProjectGroup({ + id: 'group-child', + name: 'Platform', + parentGroupId: root.id + }) + const folderWorkspace = makeFolderWorkspace({ projectGroupId: child.id }) + + expect( + getFolderWorkspaceRevealGroupKeys( + folderWorkspaceKey(folderWorkspace.id), + [folderWorkspace], + [child, root] + ) + ).toEqual([getProjectGroupHeaderKey(root.id), getProjectGroupHeaderKey(child.id)]) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list-folder-reveal.ts b/src/renderer/src/components/sidebar/worktree-list-folder-reveal.ts new file mode 100644 index 000000000..21c70db93 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list-folder-reveal.ts @@ -0,0 +1,65 @@ +import type { FolderWorkspace, ProjectGroup, Worktree } from '../../../../shared/types' +import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { getProjectGroupHeaderKey } from './worktree-list-groups' + +function findFolderWorkspaceByKey( + worktreeId: string, + folderWorkspaces: readonly FolderWorkspace[] +): FolderWorkspace | null { + const scope = parseWorkspaceKey(worktreeId) + if (scope?.type !== 'folder') { + return null + } + return folderWorkspaces.find((workspace) => workspace.id === scope.folderWorkspaceId) ?? null +} + +export function getKnownSidebarWorktreeById( + worktreeId: string, + worktreeMap: ReadonlyMap, + folderWorkspaces: readonly FolderWorkspace[] +): Worktree | null { + const worktree = worktreeMap.get(worktreeId) + if (worktree) { + return worktree + } + const folderWorkspace = findFolderWorkspaceByKey(worktreeId, folderWorkspaces) + return folderWorkspace ? folderWorkspaceToWorktree(folderWorkspace) : null +} + +export function sidebarWorkspaceStillExists( + worktreeId: string, + worktrees: readonly Worktree[], + folderWorkspaces: readonly FolderWorkspace[] +): boolean { + if (worktrees.some((worktree) => worktree.id === worktreeId)) { + return true + } + return findFolderWorkspaceByKey(worktreeId, folderWorkspaces) !== null +} + +export function getFolderWorkspaceRevealGroupKeys( + worktreeId: string, + folderWorkspaces: readonly FolderWorkspace[], + projectGroups: readonly ProjectGroup[] +): string[] { + const folderWorkspace = findFolderWorkspaceByKey(worktreeId, folderWorkspaces) + if (!folderWorkspace) { + return [] + } + + const groupsById = new Map(projectGroups.map((group) => [group.id, group])) + const keys: string[] = [] + const seen = new Set() + let groupId: string | null = folderWorkspace.projectGroupId + while (groupId && !seen.has(groupId)) { + seen.add(groupId) + const group = groupsById.get(groupId) + if (!group) { + break + } + keys.unshift(getProjectGroupHeaderKey(group.id)) + groupId = group.parentGroupId + } + return keys +} diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts index 6b9ae31c9..e3abac92b 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts @@ -14,6 +14,7 @@ import { } from './worktree-list-groups' import type { DetectedWorktree, + FolderWorkspace, Repo, ProjectGroup, Worktree, @@ -1314,6 +1315,209 @@ describe('project groups', () => { expect(rows[0]).toMatchObject({ count: 1 }) }) + it('renders folder workspaces under their owning folder-backed Project Group', () => { + const group: ProjectGroup = { + id: 'group-root', + name: 'Platform', + parentPath: '/monorepo', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + const folderWorkspace: FolderWorkspace = { + id: 'folder-workspace-1', + projectGroupId: group.id, + name: 'Refund fix', + folderPath: '/monorepo', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 10, + lastActivityAt: 0, + createdAt: 1, + updatedAt: 1 + } + + const rows = buildRows( + 'repo', + [], + new Map(), + null, + new Set(), + undefined, + undefined, + undefined, + undefined, + undefined, + false, + undefined, + [group], + new Set(), + new Map(), + [], + [folderWorkspace] + ) + + expect(rows).toMatchObject([ + { + type: 'header', + key: 'project-group:group-root', + count: 1 + }, + { + type: 'folder-workspace', + folderWorkspace: { id: 'folder-workspace-1' }, + projectGroup: { id: 'group-root' }, + groupDepth: 1 + } + ]) + }) + + it('preserves nested Project Group depth for folder workspace rows', () => { + const rootGroup: ProjectGroup = { + id: 'group-root', + name: 'Platform', + parentPath: '/monorepo', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + const childGroup: ProjectGroup = { + id: 'group-shared', + name: 'packages/shared', + parentPath: '/monorepo/packages/shared', + parentGroupId: rootGroup.id, + createdFrom: 'folder-scan', + tabOrder: 1, + isCollapsed: false, + color: null, + createdAt: 2, + updatedAt: 2 + } + const folderWorkspace: FolderWorkspace = { + id: 'folder-workspace-nested', + projectGroupId: childGroup.id, + name: 'Shared package work', + folderPath: '/monorepo/packages/shared', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 10, + lastActivityAt: 0, + createdAt: 3, + updatedAt: 3 + } + + const rows = buildRows( + 'repo', + [], + new Map(), + null, + new Set(), + undefined, + undefined, + undefined, + undefined, + undefined, + false, + undefined, + [rootGroup, childGroup], + new Set(), + new Map(), + [], + [folderWorkspace] + ) + + expect(rows).toMatchObject([ + { + type: 'header', + key: 'project-group:group-root', + projectGroupDepth: 0 + }, + { + type: 'header', + key: 'project-group:group-shared', + projectGroupDepth: 1 + }, + { + type: 'folder-workspace', + folderWorkspace: { id: 'folder-workspace-nested' }, + groupDepth: 2 + } + ]) + }) + + it('does not render folder workspaces under non-folder Project Groups', () => { + const group: ProjectGroup = { + id: 'group-manual', + name: 'Manual', + parentPath: null, + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + const folderWorkspace: FolderWorkspace = { + id: 'folder-workspace-1', + projectGroupId: group.id, + name: 'Hidden', + folderPath: '/monorepo', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 10, + lastActivityAt: 0, + createdAt: 1, + updatedAt: 1 + } + + const rows = buildRows( + 'repo', + [], + new Map(), + null, + new Set(), + undefined, + undefined, + undefined, + undefined, + undefined, + false, + undefined, + [group], + new Set(), + new Map(), + [], + [folderWorkspace] + ) + + expect(rows).toMatchObject([ + { + type: 'header', + key: 'project-group:group-manual', + count: 0 + } + ]) + expect(rows.some((row) => row.type === 'folder-workspace')).toBe(false) + }) + it('renders imported repos under nested Project Groups before worktree rows load', () => { const rootGroup: ProjectGroup = { id: 'group-root', diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts index 8fdbd6b85..9312f0685 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts @@ -3,6 +3,7 @@ import { CircleX, FolderTree, List, Pin } from 'lucide-react' import type React from 'react' import type { DetectedWorktree, + FolderWorkspace, Repo, ProjectGroup, ProjectOrderBy, @@ -10,7 +11,7 @@ import type { WorktreeLineage, WorkspaceStatusDefinition } from '../../../../shared/types' -import { branchName } from '@/lib/git-utils' +import { branchName } from '../../lib/git-utils' import { getWorkspaceStatus, getWorkspaceStatusFromGroupKey, @@ -23,11 +24,11 @@ import { ConductorReviewIcon } from './workspace-status-icons' import { cloneDefaultWorkspaceStatuses } from '../../../../shared/workspace-statuses' -import type { AppState } from '@/store/types' -import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from '@/store/slices/github-cache-key' +import type { AppState } from '../../store/types' +import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from '../../store/slices/github-cache-key' import { UNGROUPED_PROJECT_GROUP_KEY } from '../../../../shared/project-groups' -import { getRepoDisplayLabelsByPath } from '@/lib/repo-display-labels' -import { translate } from '@/i18n/i18n' +import { getRepoDisplayLabelsByPath } from '../../lib/repo-display-labels' +import { translate } from '../../i18n/i18n' export { branchName } @@ -78,13 +79,27 @@ export type PendingCreationRow = { repo: Repo | undefined } +export type FolderWorkspaceRow = { + type: 'folder-workspace' + key: string + folderWorkspace: FolderWorkspace + projectGroup: ProjectGroup + depth: number + groupDepth: number +} + /** Minimal shape buildRows needs for an in-flight create. Deliberately not the * full PendingWorktreeCreation: row identity depends only on which creates * exist and their repo, so callers can subscribe on this stable shape and keep * progress-field churn (phase/loaderVisible) from rebuilding the whole list. */ export type PendingCreationRef = { creationId: string; repoId: string } -export type Row = GroupHeaderRow | WorktreeRow | ImportedWorktreesCardRow | PendingCreationRow +export type Row = + | GroupHeaderRow + | WorktreeRow + | ImportedWorktreesCardRow + | PendingCreationRow + | FolderWorkspaceRow function buildPendingCreationRow( creation: PendingCreationRef, @@ -533,7 +548,8 @@ export function buildRows( projectGroups: readonly ProjectGroup[] = [], placeholderRepoIds: ReadonlySet = new Set(), importedWorktreesByRepo: ReadonlyMap = new Map(), - pendingCreations: readonly PendingCreationRef[] = [] + pendingCreations: readonly PendingCreationRef[] = [], + folderWorkspaces: readonly FolderWorkspace[] = [] ): Row[] { const result: Row[] = [] @@ -798,6 +814,23 @@ export function buildRows( } const projectGroupsById = new Map(projectGroups.map((group) => [group.id, group])) + const folderWorkspacesByProjectGroupId = new Map() + for (const workspace of folderWorkspaces) { + const group = projectGroupsById.get(workspace.projectGroupId) + if (!group?.parentPath) { + continue + } + const list = folderWorkspacesByProjectGroupId.get(workspace.projectGroupId) ?? [] + list.push(workspace) + folderWorkspacesByProjectGroupId.set(workspace.projectGroupId, list) + } + for (const list of folderWorkspacesByProjectGroupId.values()) { + list.sort((left, right) => { + const leftOrder = left.manualOrder ?? left.sortOrder + const rightOrder = right.manualOrder ?? right.sortOrder + return rightOrder - leftOrder || left.name.localeCompare(right.name) + }) + } const childGroupsByParentId = new Map() for (const group of projectGroups) { const parentId = @@ -814,10 +847,11 @@ export function buildRows( const getProjectGroupSubtreeCount = (groupId: string): number => { const directCount = groupByProjectGroupId.get(groupId)?.length ?? 0 + const folderWorkspaceCount = folderWorkspacesByProjectGroupId.get(groupId)?.length ?? 0 const children = childGroupsByParentId.get(groupId) ?? [] return children.reduce( (count, child) => count + getProjectGroupSubtreeCount(child.id), - directCount + directCount + folderWorkspaceCount ) } @@ -836,6 +870,16 @@ export function buildRows( projectGroupDepth: depth }) if (!collapsedGroups.has(key)) { + for (const folderWorkspace of folderWorkspacesByProjectGroupId.get(projectGroup.id) ?? []) { + result.push({ + type: 'folder-workspace', + key: `folder-workspace:${folderWorkspace.id}`, + folderWorkspace, + projectGroup, + depth: 0, + groupDepth: depth + 1 + }) + } appendOrderedGroups(withRepoSectionDisplayLabels(repoEntries), depth + 1) for (const childGroup of childGroups) { appendProjectGroup(childGroup, depth + 1) diff --git a/src/renderer/src/components/sidebar/worktree-list-indentation.test.ts b/src/renderer/src/components/sidebar/worktree-list-indentation.test.ts index 8c0dde83b..8ce4f34dc 100644 --- a/src/renderer/src/components/sidebar/worktree-list-indentation.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-indentation.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest' import { WORKTREE_SECTION_HEADER_PADDING_LEFT, getProjectGroupHeaderPaddingLeft, - getWorktreeCardContentIndent + getWorktreeCardContentIndent, + getWorktreeCardSurfaceInset } from './worktree-list-indentation' describe('worktree list indentation', () => { @@ -40,4 +41,13 @@ describe('worktree list indentation', () => { it('aligns flat section headers with top-level project headers', () => { expect(WORKTREE_SECTION_HEADER_PADDING_LEFT).toBe(getProjectGroupHeaderPaddingLeft(0)) }) + + it('keeps root repo cards flush but insets cards inside project groups', () => { + expect(getWorktreeCardSurfaceInset({ isGrouped: true, groupDepth: 0 })).toBe(0) + expect(getWorktreeCardSurfaceInset({ isGrouped: true, groupDepth: 1 })).toBe(14) + }) + + it('does not inset card surfaces outside grouped views', () => { + expect(getWorktreeCardSurfaceInset({ isGrouped: false, groupDepth: 4 })).toBe(0) + }) }) diff --git a/src/renderer/src/components/sidebar/worktree-list-indentation.ts b/src/renderer/src/components/sidebar/worktree-list-indentation.ts index 6bfc3dc7e..e327e197f 100644 --- a/src/renderer/src/components/sidebar/worktree-list-indentation.ts +++ b/src/renderer/src/components/sidebar/worktree-list-indentation.ts @@ -2,6 +2,9 @@ export const SIDEBAR_TREE_INDENT = 18 // Why: project-grouped cards need to read as children even after the card // surface inset is subtracted, while lineage rows keep the base tree step. const PROJECT_WORKTREE_CARD_EXTRA_INDENT = 2 +// Why: grouped workspace cards should move their surface inward without using +// the full tree step, preserving the existing compact child-card rhythm. +const GROUPED_WORKTREE_CARD_SURFACE_INDENT = 14 export const PROJECT_GROUP_HEADER_BASE_PADDING = 10 // Why: workspace/status headers and project headers occupy the same sidebar // row role, so their titles should not shift when switching grouping modes. @@ -29,3 +32,10 @@ export function getWorktreeCardContentIndent(args: { const projectCardIndent = args.isGrouped ? PROJECT_WORKTREE_CARD_EXTRA_INDENT : 0 return (groupSteps + clampDepth(args.lineageDepth)) * SIDEBAR_TREE_INDENT + projectCardIndent } + +export function getWorktreeCardSurfaceInset(args: { + isGrouped: boolean + groupDepth: number +}): number { + return args.isGrouped ? clampDepth(args.groupDepth) * GROUPED_WORKTREE_CARD_SURFACE_INDENT : 0 +} diff --git a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts index 89394356b..04c011409 100644 --- a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts @@ -7,6 +7,7 @@ export const GROUP_HEADER_ROW_HEIGHT = 28 const SECONDARY_GROUP_HEADER_TOP_MARGIN = 4 const IMPORTED_WORKTREES_LINE_ROW_HEIGHT = 36 const PENDING_CREATION_ROW_HEIGHT = 56 +const FOLDER_WORKSPACE_ROW_HEIGHT = 64 type WorktreeItemRow = Extract export type RenderRow = Row | { type: 'lineage-group'; key: string; rows: WorktreeItemRow[] } @@ -50,6 +51,9 @@ export function estimateRenderRowSize( if (row?.type === 'pending-creation') { return PENDING_CREATION_ROW_HEIGHT } + if (row?.type === 'folder-workspace') { + return FOLDER_WORKSPACE_ROW_HEIGHT + } return 116 } diff --git a/src/renderer/src/components/stats/ClaudeUsagePane.tsx b/src/renderer/src/components/stats/ClaudeUsagePane.tsx index 6ba9b533b..d7fb5d00b 100644 --- a/src/renderer/src/components/stats/ClaudeUsagePane.tsx +++ b/src/renderer/src/components/stats/ClaudeUsagePane.tsx @@ -31,8 +31,14 @@ import { translate } from '@/i18n/i18n' const RANGE_OPTIONS: ClaudeUsageRange[] = ['7d', '30d', '90d', 'all'] const SCOPE_OPTIONS: { value: ClaudeUsageScope; label: string }[] = [ - { value: 'orca', label: translate("auto.components.stats.ClaudeUsagePane.4f8368c272", "Orca worktrees only") }, - { value: 'all', label: translate("auto.components.stats.ClaudeUsagePane.5ce4842c2c", "All local Claude usage") } + { + value: 'orca', + label: translate('auto.components.stats.ClaudeUsagePane.4f8368c272', 'Orca worktrees only') + }, + { + value: 'all', + label: translate('auto.components.stats.ClaudeUsagePane.5ce4842c2c', 'All local Claude usage') + } ] const RANGE_LABELS: Record = { '7d': 'Last 7 days', @@ -108,15 +114,27 @@ export function ClaudeUsagePane(): React.JSX.Element {
-

{translate("auto.components.stats.ClaudeUsagePane.6afacbee37", "Claude Usage Tracking")}

+

+ {translate( + 'auto.components.stats.ClaudeUsagePane.6afacbee37', + 'Claude Usage Tracking' + )} +

- {translate("auto.components.stats.ClaudeUsagePane.0cb1a36d7d", "Reads local Claude usage logs to show token, model, and session stats.")}

+ {translate( + 'auto.components.stats.ClaudeUsagePane.0cb1a36d7d', + 'Reads local Claude usage logs to show token, model, and session stats.' + )} +

- {translate("auto.components.stats.ClaudeUsagePane.dd29209b21", "Filters")} + {translate('auto.components.stats.ClaudeUsagePane.dd29209b21', 'Filters')} + - {translate("auto.components.stats.ClaudeUsagePane.f61cffb9c8", "Scope")} + + {translate('auto.components.stats.ClaudeUsagePane.f61cffb9c8', 'Scope')} + void setClaudeUsageScope(value as ClaudeUsageScope)} @@ -174,7 +210,9 @@ export function ClaudeUsagePane(): React.JSX.Element { ))} - {translate("auto.components.stats.ClaudeUsagePane.505be9aac4", "Range")} + + {translate('auto.components.stats.ClaudeUsagePane.505be9aac4', 'Range')} + void setClaudeUsageRange(value as ClaudeUsageRange)} @@ -195,20 +233,27 @@ export function ClaudeUsagePane(): React.JSX.Element { size="icon-xs" onClick={() => void refreshClaudeUsage()} disabled={scanState.isScanning} - aria-label={translate("auto.components.stats.ClaudeUsagePane.c5b9b344d0", "Refresh Claude usage")} + aria-label={translate( + 'auto.components.stats.ClaudeUsagePane.c5b9b344d0', + 'Refresh Claude usage' + )} > - {translate("auto.components.stats.ClaudeUsagePane.8d18bbb771", "Refresh")} + {translate('auto.components.stats.ClaudeUsagePane.8d18bbb771', 'Refresh')} +
) : ( <>
} /> } /> } /> } /> } /> 0 ? `${Math.round((summary.zeroCacheReadTurns / summary.turns) * 100)}%` @@ -268,28 +323,41 @@ export function ClaudeUsagePane(): React.JSX.Element { icon={} /> } /> } />

- {translate("auto.components.stats.ClaudeUsagePane.51ae85fa00", "Cache reuse rate is calculated as cache read tokens / (input tokens + cache read tokens).")}

+ {translate( + 'auto.components.stats.ClaudeUsagePane.51ae85fa00', + 'Cache reuse rate is calculated as cache read tokens / (input tokens + cache read tokens).' + )} +

-

{translate("auto.components.stats.ClaudeUsagePane.0f394c24e3", "By model")}

+

+ {translate('auto.components.stats.ClaudeUsagePane.0f394c24e3', 'By model')} +

- {translate("auto.components.stats.ClaudeUsagePane.c3fdbc5474", "Top model:")}{' '} - {summary?.topModel ?? translate("auto.components.stats.ClaudeUsagePane.7765a4c3e1", "n/a")} + {translate('auto.components.stats.ClaudeUsagePane.c3fdbc5474', 'Top model:')}{' '} + {summary?.topModel ?? + translate('auto.components.stats.ClaudeUsagePane.7765a4c3e1', 'n/a')}

@@ -302,8 +370,10 @@ export function ClaudeUsagePane(): React.JSX.Element {
- {row.sessions} {translate("auto.components.stats.ClaudeUsagePane.02a046792e", "sessions •")} {row.turns}{' '} - {translate("auto.components.stats.ClaudeUsagePane.32176e1d44", "turns")} + {row.sessions}{' '} + {translate('auto.components.stats.ClaudeUsagePane.02a046792e', 'sessions •')}{' '} + {row.turns}{' '} + {translate('auto.components.stats.ClaudeUsagePane.32176e1d44', 'turns')}
))} @@ -312,10 +382,13 @@ export function ClaudeUsagePane(): React.JSX.Element {
-

{translate("auto.components.stats.ClaudeUsagePane.7dc9e5613b", "By project")}

+

+ {translate('auto.components.stats.ClaudeUsagePane.7dc9e5613b', 'By project')} +

- {translate("auto.components.stats.ClaudeUsagePane.f97435845c", "Top project:")}{' '} - {summary?.topProject ?? translate("auto.components.stats.ClaudeUsagePane.7765a4c3e1", "n/a")} + {translate('auto.components.stats.ClaudeUsagePane.f97435845c', 'Top project:')}{' '} + {summary?.topProject ?? + translate('auto.components.stats.ClaudeUsagePane.7765a4c3e1', 'n/a')}

@@ -328,8 +401,10 @@ export function ClaudeUsagePane(): React.JSX.Element {
- {row.sessions} {translate("auto.components.stats.ClaudeUsagePane.02a046792e", "sessions •")} {row.turns}{' '} - {translate("auto.components.stats.ClaudeUsagePane.32176e1d44", "turns")} + {row.sessions}{' '} + {translate('auto.components.stats.ClaudeUsagePane.02a046792e', 'sessions •')}{' '} + {row.turns}{' '} + {translate('auto.components.stats.ClaudeUsagePane.32176e1d44', 'turns')}
))} @@ -339,25 +414,41 @@ export function ClaudeUsagePane(): React.JSX.Element {
-

{translate("auto.components.stats.ClaudeUsagePane.7e76c84153", "Recent sessions")}

+

+ {translate('auto.components.stats.ClaudeUsagePane.7e76c84153', 'Recent sessions')} +

- {translate("auto.components.stats.ClaudeUsagePane.abfc4a4943", "Cache reuse rate:")}{' '} + {translate('auto.components.stats.ClaudeUsagePane.abfc4a4943', 'Cache reuse rate:')}{' '} {summary?.cacheReuseRate !== null && summary?.cacheReuseRate !== undefined ? `${Math.round(summary.cacheReuseRate * 100)}%` - : translate("auto.components.stats.ClaudeUsagePane.7765a4c3e1", "n/a")} + : translate('auto.components.stats.ClaudeUsagePane.7765a4c3e1', 'n/a')}

- - - - - - - + + + + + + + @@ -367,7 +458,10 @@ export function ClaudeUsagePane(): React.JSX.Element { {formatSessionTime(row.lastActiveAt)} - +
{translate("auto.components.stats.ClaudeUsagePane.01476891c7", "Last active")}{translate("auto.components.stats.ClaudeUsagePane.c17bed0416", "Project")}{translate("auto.components.stats.ClaudeUsagePane.1afc25eb06", "Model")}{translate("auto.components.stats.ClaudeUsagePane.0f03975d59", "Turns")}{translate("auto.components.stats.ClaudeUsagePane.faf3444859", "Input")}{translate("auto.components.stats.ClaudeUsagePane.a8b7487ff7", "Output")}{translate("auto.components.stats.ClaudeUsagePane.21ea00bfa8", "Cache")} + {translate('auto.components.stats.ClaudeUsagePane.01476891c7', 'Last active')} + + {translate('auto.components.stats.ClaudeUsagePane.c17bed0416', 'Project')} + + {translate('auto.components.stats.ClaudeUsagePane.1afc25eb06', 'Model')} + + {translate('auto.components.stats.ClaudeUsagePane.0f03975d59', 'Turns')} + + {translate('auto.components.stats.ClaudeUsagePane.faf3444859', 'Input')} + + {translate('auto.components.stats.ClaudeUsagePane.a8b7487ff7', 'Output')} + + {translate('auto.components.stats.ClaudeUsagePane.21ea00bfa8', 'Cache')} +
{row.projectLabel}{row.model ?? translate("auto.components.stats.ClaudeUsagePane.cfe2282ffa", "Unknown")} + {row.model ?? + translate('auto.components.stats.ClaudeUsagePane.cfe2282ffa', 'Unknown')} + {row.turns} {formatTokens(row.inputTokens)} diff --git a/src/renderer/src/components/stats/CodexUsagePane.tsx b/src/renderer/src/components/stats/CodexUsagePane.tsx index e6612a921..58f46830e 100644 --- a/src/renderer/src/components/stats/CodexUsagePane.tsx +++ b/src/renderer/src/components/stats/CodexUsagePane.tsx @@ -30,8 +30,14 @@ import { translate } from '@/i18n/i18n' const RANGE_OPTIONS: CodexUsageRange[] = ['7d', '30d', '90d', 'all'] const SCOPE_OPTIONS: { value: CodexUsageScope; label: string }[] = [ - { value: 'orca', label: translate("auto.components.stats.CodexUsagePane.201766b754", "Orca worktrees only") }, - { value: 'all', label: translate("auto.components.stats.CodexUsagePane.4fe8820098", "All local Codex usage") } + { + value: 'orca', + label: translate('auto.components.stats.CodexUsagePane.201766b754', 'Orca worktrees only') + }, + { + value: 'all', + label: translate('auto.components.stats.CodexUsagePane.4fe8820098', 'All local Codex usage') + } ] const RANGE_LABELS: Record = { '7d': 'Last 7 days', @@ -107,15 +113,24 @@ export function CodexUsagePane(): React.JSX.Element {
-

{translate("auto.components.stats.CodexUsagePane.408210470c", "Codex Usage Tracking")}

+

+ {translate('auto.components.stats.CodexUsagePane.408210470c', 'Codex Usage Tracking')} +

- {translate("auto.components.stats.CodexUsagePane.13badcd8f2", "Reads local Codex usage logs to show token, model, and session stats.")}

+ {translate( + 'auto.components.stats.CodexUsagePane.13badcd8f2', + 'Reads local Codex usage logs to show token, model, and session stats.' + )} +

- {translate("auto.components.stats.CodexUsagePane.1af1a39b2f", "Filters")} + {translate('auto.components.stats.CodexUsagePane.1af1a39b2f', 'Filters')} + - {translate("auto.components.stats.CodexUsagePane.6d68e8399a", "Scope")} + + {translate('auto.components.stats.CodexUsagePane.6d68e8399a', 'Scope')} + void setCodexUsageScope(value as CodexUsageScope)} @@ -179,7 +212,9 @@ export function CodexUsagePane(): React.JSX.Element { ))} - {translate("auto.components.stats.CodexUsagePane.89162e019b", "Range")} + + {translate('auto.components.stats.CodexUsagePane.89162e019b', 'Range')} + void setCodexUsageRange(value as CodexUsageRange)} @@ -200,20 +235,27 @@ export function CodexUsagePane(): React.JSX.Element { size="icon-xs" onClick={() => void refreshCodexUsage()} disabled={scanState.isScanning} - aria-label={translate("auto.components.stats.CodexUsagePane.ec4d270e2c", "Refresh Codex usage")} + aria-label={translate( + 'auto.components.stats.CodexUsagePane.ec4d270e2c', + 'Refresh Codex usage' + )} > - {translate("auto.components.stats.CodexUsagePane.3022cda443", "Refresh")} + {translate('auto.components.stats.CodexUsagePane.3022cda443', 'Refresh')} +
) : ( <>
} /> } /> } /> } /> } /> } />

- {translate("auto.components.stats.CodexUsagePane.94ac1f1ee7", "Reasoning tokens are shown for visibility, but cost is calculated from uncached input, cached input, and output only.")}

+ {translate( + 'auto.components.stats.CodexUsagePane.94ac1f1ee7', + 'Reasoning tokens are shown for visibility, but cost is calculated from uncached input, cached input, and output only.' + )} +

-

{translate("auto.components.stats.CodexUsagePane.5a0d1d69cd", "By model")}

+

+ {translate('auto.components.stats.CodexUsagePane.5a0d1d69cd', 'By model')} +

- {translate("auto.components.stats.CodexUsagePane.95d2d89285", "Top model:")}{' '} - {summary?.topModel ?? translate("auto.components.stats.CodexUsagePane.ae255c3dba", "n/a")} + {translate('auto.components.stats.CodexUsagePane.95d2d89285', 'Top model:')}{' '} + {summary?.topModel ?? + translate('auto.components.stats.CodexUsagePane.ae255c3dba', 'n/a')}

@@ -289,10 +351,12 @@ export function CodexUsagePane(): React.JSX.Element {
- {row.sessions} {translate("auto.components.stats.CodexUsagePane.bf1bf2f674", "sessions •")} {row.events}{' '} - {translate("auto.components.stats.CodexUsagePane.79a69522a5", "events")} + {row.sessions}{' '} + {translate('auto.components.stats.CodexUsagePane.bf1bf2f674', 'sessions •')}{' '} + {row.events}{' '} + {translate('auto.components.stats.CodexUsagePane.79a69522a5', 'events')} {row.hasInferredPricing - ? ` ${translate("auto.components.stats.CodexUsagePane.247c93ca92", "• inferred pricing")}` + ? ` ${translate('auto.components.stats.CodexUsagePane.247c93ca92', '• inferred pricing')}` : ''}
@@ -302,10 +366,13 @@ export function CodexUsagePane(): React.JSX.Element {
-

{translate("auto.components.stats.CodexUsagePane.b98718aaab", "By project")}

+

+ {translate('auto.components.stats.CodexUsagePane.b98718aaab', 'By project')} +

- {translate("auto.components.stats.CodexUsagePane.829ee743f2", "Top project:")}{' '} - {summary?.topProject ?? translate("auto.components.stats.CodexUsagePane.ae255c3dba", "n/a")} + {translate('auto.components.stats.CodexUsagePane.829ee743f2', 'Top project:')}{' '} + {summary?.topProject ?? + translate('auto.components.stats.CodexUsagePane.ae255c3dba', 'n/a')}

@@ -318,8 +385,10 @@ export function CodexUsagePane(): React.JSX.Element {
- {row.sessions} {translate("auto.components.stats.CodexUsagePane.bf1bf2f674", "sessions •")} {row.events}{' '} - {translate("auto.components.stats.CodexUsagePane.79a69522a5", "events")} + {row.sessions}{' '} + {translate('auto.components.stats.CodexUsagePane.bf1bf2f674', 'sessions •')}{' '} + {row.events}{' '} + {translate('auto.components.stats.CodexUsagePane.79a69522a5', 'events')}
))} @@ -329,21 +398,41 @@ export function CodexUsagePane(): React.JSX.Element {
-

{translate("auto.components.stats.CodexUsagePane.0cb0983c07", "Recent sessions")}

+

+ {translate('auto.components.stats.CodexUsagePane.0cb0983c07', 'Recent sessions')} +

- {translate("auto.components.stats.CodexUsagePane.0bd8655475", "Most recent local Codex sessions in this scope.")}

+ {translate( + 'auto.components.stats.CodexUsagePane.0bd8655475', + 'Most recent local Codex sessions in this scope.' + )} +

- - - - - - - + + + + + + + @@ -354,7 +443,8 @@ export function CodexUsagePane(): React.JSX.Element { diff --git a/src/renderer/src/components/stats/OpenCodeUsagePane.tsx b/src/renderer/src/components/stats/OpenCodeUsagePane.tsx index e40fe5419..b21dfb5db 100644 --- a/src/renderer/src/components/stats/OpenCodeUsagePane.tsx +++ b/src/renderer/src/components/stats/OpenCodeUsagePane.tsx @@ -32,8 +32,17 @@ import { translate } from '@/i18n/i18n' const RANGE_OPTIONS: OpenCodeUsageRange[] = ['7d', '30d', '90d', 'all'] const SCOPE_OPTIONS: { value: OpenCodeUsageScope; label: string }[] = [ - { value: 'orca', label: translate("auto.components.stats.OpenCodeUsagePane.e04c58327c", "Orca worktrees only") }, - { value: 'all', label: translate("auto.components.stats.OpenCodeUsagePane.144a6050e9", "All local OpenCode usage") } + { + value: 'orca', + label: translate('auto.components.stats.OpenCodeUsagePane.e04c58327c', 'Orca worktrees only') + }, + { + value: 'all', + label: translate( + 'auto.components.stats.OpenCodeUsagePane.144a6050e9', + 'All local OpenCode usage' + ) + } ] const RANGE_LABELS: Record = { '7d': 'Last 7 days', @@ -109,15 +118,27 @@ export function OpenCodeUsagePane(): React.JSX.Element {
-

{translate("auto.components.stats.OpenCodeUsagePane.bea80ceae0", "OpenCode Usage Tracking")}

+

+ {translate( + 'auto.components.stats.OpenCodeUsagePane.bea80ceae0', + 'OpenCode Usage Tracking' + )} +

- {translate("auto.components.stats.OpenCodeUsagePane.b8b3522436", "Reads local OpenCode usage logs to show token, model, and session stats.")}

+ {translate( + 'auto.components.stats.OpenCodeUsagePane.b8b3522436', + 'Reads local OpenCode usage logs to show token, model, and session stats.' + )} +

- {translate("auto.components.stats.OpenCodeUsagePane.01583b30aa", "Filters")} + {translate('auto.components.stats.OpenCodeUsagePane.01583b30aa', 'Filters')} + - {translate("auto.components.stats.OpenCodeUsagePane.40d283c837", "Scope")} + + {translate('auto.components.stats.OpenCodeUsagePane.40d283c837', 'Scope')} + void setOpenCodeUsageScope(value as OpenCodeUsageScope)} @@ -178,7 +223,9 @@ export function OpenCodeUsagePane(): React.JSX.Element { ))} - {translate("auto.components.stats.OpenCodeUsagePane.b5ed5c9fd0", "Range")} + + {translate('auto.components.stats.OpenCodeUsagePane.b5ed5c9fd0', 'Range')} + void setOpenCodeUsageRange(value as OpenCodeUsageRange)} @@ -199,20 +246,27 @@ export function OpenCodeUsagePane(): React.JSX.Element { size="icon-xs" onClick={() => void refreshOpenCodeUsage()} disabled={scanState.isScanning} - aria-label={translate("auto.components.stats.OpenCodeUsagePane.bed558df0b", "Refresh OpenCode usage")} + aria-label={translate( + 'auto.components.stats.OpenCodeUsagePane.bed558df0b', + 'Refresh OpenCode usage' + )} > - {translate("auto.components.stats.OpenCodeUsagePane.603cd138dc", "Refresh")} + {translate('auto.components.stats.OpenCodeUsagePane.603cd138dc', 'Refresh')} +
) : ( <>
} /> } /> } /> } /> } /> } />

- {translate("auto.components.stats.OpenCodeUsagePane.e5bb23d85e", "Cost comes from the local OpenCode database when the assistant message recorded one.")}

+ {translate( + 'auto.components.stats.OpenCodeUsagePane.e5bb23d85e', + 'Cost comes from the local OpenCode database when the assistant message recorded one.' + )} +

-

{translate("auto.components.stats.OpenCodeUsagePane.040c044d39", "By model")}

+

+ {translate('auto.components.stats.OpenCodeUsagePane.040c044d39', 'By model')} +

- {translate("auto.components.stats.OpenCodeUsagePane.a15206a63a", "Top model:")}{' '} - {summary?.topModel ?? translate("auto.components.stats.OpenCodeUsagePane.8095a63426", "n/a")} + {translate('auto.components.stats.OpenCodeUsagePane.a15206a63a', 'Top model:')}{' '} + {summary?.topModel ?? + translate('auto.components.stats.OpenCodeUsagePane.8095a63426', 'n/a')}

@@ -288,9 +371,16 @@ export function OpenCodeUsagePane(): React.JSX.Element {
- {row.sessions} {translate("auto.components.stats.OpenCodeUsagePane.bc0cb89901", "sessions •")} {row.events}{' '} - {translate("auto.components.stats.OpenCodeUsagePane.1e5d410df0", "events")} - {row.estimatedCostUsd !== null ? ` • ${formatCost(row.estimatedCostUsd)}` : ''} + {row.sessions}{' '} + {translate( + 'auto.components.stats.OpenCodeUsagePane.bc0cb89901', + 'sessions •' + )}{' '} + {row.events}{' '} + {translate('auto.components.stats.OpenCodeUsagePane.1e5d410df0', 'events')} + {row.estimatedCostUsd !== null + ? ` • ${formatCost(row.estimatedCostUsd)}` + : ''}
))} @@ -299,10 +389,13 @@ export function OpenCodeUsagePane(): React.JSX.Element {
-

{translate("auto.components.stats.OpenCodeUsagePane.0f0a1684bb", "By project")}

+

+ {translate('auto.components.stats.OpenCodeUsagePane.0f0a1684bb', 'By project')} +

- {translate("auto.components.stats.OpenCodeUsagePane.048ffe4d65", "Top project:")}{' '} - {summary?.topProject ?? translate("auto.components.stats.OpenCodeUsagePane.8095a63426", "n/a")} + {translate('auto.components.stats.OpenCodeUsagePane.048ffe4d65', 'Top project:')}{' '} + {summary?.topProject ?? + translate('auto.components.stats.OpenCodeUsagePane.8095a63426', 'n/a')}

@@ -315,8 +408,13 @@ export function OpenCodeUsagePane(): React.JSX.Element {
- {row.sessions} {translate("auto.components.stats.OpenCodeUsagePane.bc0cb89901", "sessions •")} {row.events}{' '} - {translate("auto.components.stats.OpenCodeUsagePane.1e5d410df0", "events")} + {row.sessions}{' '} + {translate( + 'auto.components.stats.OpenCodeUsagePane.bc0cb89901', + 'sessions •' + )}{' '} + {row.events}{' '} + {translate('auto.components.stats.OpenCodeUsagePane.1e5d410df0', 'events')}
))} @@ -326,21 +424,44 @@ export function OpenCodeUsagePane(): React.JSX.Element {
-

{translate("auto.components.stats.OpenCodeUsagePane.4799177b1c", "Recent sessions")}

+

+ {translate('auto.components.stats.OpenCodeUsagePane.4799177b1c', 'Recent sessions')} +

- {translate("auto.components.stats.OpenCodeUsagePane.81817a641a", "Most recent local OpenCode sessions in this scope.")}

+ {translate( + 'auto.components.stats.OpenCodeUsagePane.81817a641a', + 'Most recent local OpenCode sessions in this scope.' + )} +

{translate("auto.components.stats.CodexUsagePane.0c36b100be", "Last active")}{translate("auto.components.stats.CodexUsagePane.1a65900aea", "Project")}{translate("auto.components.stats.CodexUsagePane.c2478bcc3c", "Model")}{translate("auto.components.stats.CodexUsagePane.bd0822ca47", "Events")}{translate("auto.components.stats.CodexUsagePane.3acc582214", "Input")}{translate("auto.components.stats.CodexUsagePane.bbd20344b8", "Output")}{translate("auto.components.stats.CodexUsagePane.e0b988599d", "Total")} + {translate('auto.components.stats.CodexUsagePane.0c36b100be', 'Last active')} + + {translate('auto.components.stats.CodexUsagePane.1a65900aea', 'Project')} + + {translate('auto.components.stats.CodexUsagePane.c2478bcc3c', 'Model')} + + {translate('auto.components.stats.CodexUsagePane.bd0822ca47', 'Events')} + + {translate('auto.components.stats.CodexUsagePane.3acc582214', 'Input')} + + {translate('auto.components.stats.CodexUsagePane.bbd20344b8', 'Output')} + + {translate('auto.components.stats.CodexUsagePane.e0b988599d', 'Total')} +
{row.projectLabel} - {row.model ?? translate("auto.components.stats.CodexUsagePane.bf6cf2d4dd", "Unknown")} + {row.model ?? + translate('auto.components.stats.CodexUsagePane.bf6cf2d4dd', 'Unknown')} {row.hasInferredPricing ? ' *' : ''} {row.events}
- - - - - - - + + + + + + + @@ -350,7 +471,13 @@ export function OpenCodeUsagePane(): React.JSX.Element { {formatSessionTime(row.lastActiveAt)} - +
{translate("auto.components.stats.OpenCodeUsagePane.d97bdf6e27", "Last active")}{translate("auto.components.stats.OpenCodeUsagePane.a4738de041", "Project")}{translate("auto.components.stats.OpenCodeUsagePane.08c78441b7", "Model")}{translate("auto.components.stats.OpenCodeUsagePane.d416f5cf92", "Events")}{translate("auto.components.stats.OpenCodeUsagePane.0f2f266c9d", "Input")}{translate("auto.components.stats.OpenCodeUsagePane.dfc4513657", "Output")}{translate("auto.components.stats.OpenCodeUsagePane.349f7c3f5c", "Total")} + {translate( + 'auto.components.stats.OpenCodeUsagePane.d97bdf6e27', + 'Last active' + )} + + {translate('auto.components.stats.OpenCodeUsagePane.a4738de041', 'Project')} + + {translate('auto.components.stats.OpenCodeUsagePane.08c78441b7', 'Model')} + + {translate('auto.components.stats.OpenCodeUsagePane.d416f5cf92', 'Events')} + + {translate('auto.components.stats.OpenCodeUsagePane.0f2f266c9d', 'Input')} + + {translate('auto.components.stats.OpenCodeUsagePane.dfc4513657', 'Output')} + + {translate('auto.components.stats.OpenCodeUsagePane.349f7c3f5c', 'Total')} +
{row.projectLabel}{row.model ?? translate("auto.components.stats.OpenCodeUsagePane.362231082f", "Unknown")} + {row.model ?? + translate( + 'auto.components.stats.OpenCodeUsagePane.362231082f', + 'Unknown' + )} + {row.events} {formatTokens(row.inputTokens)} diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index cf2fd6cdd..f9caa5d90 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -8,10 +8,12 @@ import { } from '@/lib/agent-status' import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' import { useAppStore } from '@/store' -import { getRepoMapFromState, getWorktreeMapFromState } from '@/store/selectors' +import { getWorktreeMapFromState } from '@/store/selectors' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' import type { PtyBufferSnapshot, PtyConnectResult } from './pty-transport' import { createIpcPtyTransport } from './pty-transport' import { createRemoteRuntimePtyTransport } from './remote-runtime-pty-transport' +import { getConnectionId } from '@/lib/connection-context' import { shouldSeedCacheTimerOnInitialTitle } from './cache-timer-seeding' import type { PtyConnectionDeps } from './pty-connection-types' import { safeFit } from '@/lib/pane-manager/pane-tree-ops' @@ -1414,19 +1416,31 @@ export function connectPanePty( // callbacks to the correct Orca pane without resolving worktrees from cwd. // The key matches the `${tabId}:${leafId}` composite used for cacheTimerByKey // and agentStatusByPaneKey. Treat it as opaque outside Orca. + const state = useAppStore.getState() + const parsedWorkspaceKey = parseWorkspaceKey(deps.worktreeId) + const folderWorkspace = + parsedWorkspaceKey?.type === 'folder' + ? state.folderWorkspaces.find( + (workspace) => workspace.id === parsedWorkspaceKey.folderWorkspaceId + ) + : null + const workspaceEnv: Record = { ORCA_WORKSPACE_ID: deps.worktreeId } + if (folderWorkspace) { + workspaceEnv.ORCA_PROJECT_GROUP_ID = folderWorkspace.projectGroupId + workspaceEnv.ORCA_WORKSPACE_ROOT = folderWorkspace.folderPath + } const paneEnv = { ...paneStartup?.env, + ...workspaceEnv, ORCA_PANE_KEY: cacheKey, ORCA_TAB_ID: deps.tabId, ORCA_WORKTREE_ID: deps.worktreeId } - // Why: remote repos route PTY spawn through the SSH provider. Resolve the - // repo's connectionId from the store so the transport passes it to pty:spawn. - const state = useAppStore.getState() + // Why: folder workspaces can inherit their SSH target from child repos, so + // use the shared resolver instead of only looking up repo-backed worktrees. const worktree = getWorktreeMapFromState(state).get(deps.worktreeId) - const repo = worktree ? getRepoMapFromState(state).get(worktree.repoId) : null - const connectionId = repo?.connectionId ?? null + const connectionId = getConnectionId(deps.worktreeId) ?? null const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find((t) => t.id === deps.tabId) const shellOverride = tab?.shellOverride const isNativeWindowsConpty = isLocalNativeWindowsPty({ diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 32e2a0256..ff5e13f38 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -776,6 +776,7 @@ export function useIpcEvents(): void { if (event.type === 'reposChanged') { const state = useAppStore.getState() void state.fetchProjectGroups() + void state.fetchFolderWorkspaces() void state.fetchRepos() return } @@ -839,6 +840,7 @@ export function useIpcEvents(): void { } const state = useAppStore.getState() void state.fetchProjectGroups() + void state.fetchFolderWorkspaces() void state.fetchRepos() }) ) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index b57afc414..3aa1f5c4c 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3074,7 +3074,17 @@ "8db50afe1a": "Import repositories from folder", "5b2e6fe3c8": "Import separately", "cf9d382ca1": "Import", - "220dd32d83": "Scanning..." + "220dd32d83": "Scanning...", + "fb33359f69": "Is this a monorepo?", + "d75170194e": "Choose this if these projects belong together. Orca will group them and let you work from the parent folder.", + "39d51212cc": "Monorepo name", + "e907ec8935": "What is a monorepo name?", + "aa0247680d": "No, import separately", + "a0bc4d1f8e": "Yes, import as monorepo", + "8401a7a0d0": "1 repository", + "d4f1df62ef": "{{value0}} repositories", + "b4263a2ac4": "Found {{value0}} in {{value1}}.", + "24eda6c8b2": "Scanning... {{value0}}" }, "AddRepoRemoteStep": { "5b205b5281": "Stop scan", @@ -3554,7 +3564,8 @@ "f50603c6b2": "Mark Unread", "8dacff1fe0": "Mark Read", "3baa7d6507": "Pin", - "697d0f6e1b": "Unpin" + "697d0f6e1b": "Unpin", + "250de158fd": "Remove Workspace" }, "WorktreeList": { "d880ea0744": "Create a group and move this project into it.", @@ -3588,6 +3599,7 @@ "c1f4a31623": "Show {{value0}} child workspaces", "e97297cb75": "Hide {{value0}} child workspace", "0cd15956d4": "Hide {{value0}} child workspaces", + "bd37a57ac8": "Create workspace for {{value0}}", "b667b59632": "Some projects could not be removed from Orca", "f94466bc39": "{{value0}} of {{value1}} contained project{{value2}} remained after deleting the group.", "groupDeleteFailed": "Failed to delete group", @@ -3795,6 +3807,15 @@ }, "index": { "b826a98b6f": "busy" + }, + "FolderWorkspaceComposerDialog": { + "connectFailed": "Failed to connect to project.", + "noRepos": "Add a Git project under this folder to attach GitHub or GitLab tasks.", + "title": "Create Folder Workspace", + "createStart": "Create & Start Agent", + "create": "Create Workspace", + "sourceProject": "Task Source", + "chooseSourceProject": "Choose task source" } }, "shared": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 335225ad6..2b241f59a 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -3074,7 +3074,17 @@ "8db50afe1a": "Importar repositorios desde la carpeta", "5b2e6fe3c8": "Importar por separado", "cf9d382ca1": "Importar", - "220dd32d83": "Exploración..." + "220dd32d83": "Exploración...", + "fb33359f69": "¿Es esto un monorepo?", + "d75170194e": "Elige esto si estos proyectos pertenecen juntos. Orca los agrupará y te permitirá trabajar desde la carpeta principal.", + "39d51212cc": "Nombre del monorepo", + "e907ec8935": "¿Qué es un nombre de monorepo?", + "aa0247680d": "No, importar por separado", + "a0bc4d1f8e": "Sí, importar como monorepo", + "8401a7a0d0": "1 repositorio", + "d4f1df62ef": "{{value0}} repositorios", + "b4263a2ac4": "Se encontraron {{value0}} en {{value1}}.", + "24eda6c8b2": "Explorando... {{value0}}" }, "AddRepoRemoteStep": { "5b205b5281": "Detener escaneo", @@ -3554,7 +3564,8 @@ "f50603c6b2": "Marcar como no leído", "8dacff1fe0": "Marcar como leído", "3baa7d6507": "Alfiler", - "697d0f6e1b": "Desprender" + "697d0f6e1b": "Desprender", + "250de158fd": "Remove Workspace" }, "WorktreeList": { "d880ea0744": "Crea un grupo y mueve este proyecto a él.", @@ -3588,6 +3599,7 @@ "c1f4a31623": "Mostrar {{value0}} espacios de trabajo secundarios", "e97297cb75": "Ocultar {{value0}} espacio de trabajo secundario", "0cd15956d4": "Ocultar {{value0}} espacios de trabajo secundarios", + "bd37a57ac8": "Create workspace for {{value0}}", "b667b59632": "Some projects could not be removed from Orca", "f94466bc39": "{{value0}} of {{value1}} contained project{{value2}} remained after deleting the group.", "groupDeleteFailed": "Failed to delete group", @@ -3795,6 +3807,15 @@ }, "index": { "b826a98b6f": "ocupado" + }, + "FolderWorkspaceComposerDialog": { + "connectFailed": "Failed to connect to project.", + "noRepos": "Add a Git project under this folder to attach GitHub or GitLab tasks.", + "title": "Create Folder Workspace", + "createStart": "Create & Start Agent", + "create": "Create Workspace", + "sourceProject": "Task Source", + "chooseSourceProject": "Choose task source" } }, "shared": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 10345d2dd..9d09ce974 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -3055,7 +3055,17 @@ "8db50afe1a": "フォルダーからリポジトリをインポートする", "5b2e6fe3c8": "個別にインポート", "cf9d382ca1": "輸入", - "220dd32d83": "走査..." + "220dd32d83": "走査...", + "fb33359f69": "これはモノレポですか?", + "d75170194e": "これらのプロジェクトがまとまっている場合は、これを選択してください。Orca がグループ化し、親フォルダーから作業できるようにします。", + "39d51212cc": "モノレポ名", + "e907ec8935": "モノレポ名とは何ですか?", + "aa0247680d": "いいえ、個別にインポート", + "a0bc4d1f8e": "はい、モノレポとしてインポート", + "8401a7a0d0": "1 個のリポジトリ", + "d4f1df62ef": "{{value0}} 個のリポジトリ", + "b4263a2ac4": "{{value1}} で {{value0}} が見つかりました。", + "24eda6c8b2": "スキャン中... {{value0}}" }, "AddRepoRemoteStep": { "5b205b5281": "スキャンの停止", @@ -3535,7 +3545,8 @@ "f50603c6b2": "未読としてマークする", "8dacff1fe0": "既読マークを付ける", "3baa7d6507": "ピン", - "697d0f6e1b": "固定を解除する" + "697d0f6e1b": "固定を解除する", + "250de158fd": "Remove Workspace" }, "WorktreeList": { "d880ea0744": "グループを作成し、このプロジェクトをそのグループに移動します。", @@ -3569,6 +3580,7 @@ "c1f4a31623": "{{value0}} 個の子ワークスペースを表示", "e97297cb75": "{{value0}} 個の子ワークスペースを非表示", "0cd15956d4": "{{value0}} 個の子ワークスペースを非表示", + "bd37a57ac8": "Create workspace for {{value0}}", "b667b59632": "Some projects could not be removed from Orca", "f94466bc39": "{{value0}} of {{value1}} contained project{{value2}} remained after deleting the group.", "groupDeleteFailed": "Failed to delete group", @@ -3795,6 +3807,15 @@ } } } + }, + "FolderWorkspaceComposerDialog": { + "connectFailed": "Failed to connect to project.", + "noRepos": "Add a Git project under this folder to attach GitHub or GitLab tasks.", + "title": "Create Folder Workspace", + "createStart": "Create & Start Agent", + "create": "Create Workspace", + "sourceProject": "Task Source", + "chooseSourceProject": "Choose task source" } }, "shared": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 878af6453..bdaa1af36 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3055,7 +3055,17 @@ "8db50afe1a": "폴더에서 저장소 가져오기", "5b2e6fe3c8": "별도로 가져오기", "cf9d382ca1": "수입", - "220dd32d83": "스캐닝..." + "220dd32d83": "스캐닝...", + "fb33359f69": "이 폴더가 모노레포인가요?", + "d75170194e": "이 프로젝트들이 함께 속한다면 이것을 선택하세요. Orca가 그룹으로 묶고 상위 폴더에서 작업할 수 있게 합니다.", + "39d51212cc": "모노레포 이름", + "e907ec8935": "모노레포 이름이란 무엇인가요?", + "aa0247680d": "아니요, 별도로 가져오기", + "a0bc4d1f8e": "예, 모노레포로 가져오기", + "8401a7a0d0": "저장소 1개", + "d4f1df62ef": "저장소 {{value0}}개", + "b4263a2ac4": "{{value1}}에서 {{value0}}을(를) 찾았습니다.", + "24eda6c8b2": "스캔 중... {{value0}}" }, "AddRepoRemoteStep": { "5b205b5281": "스캔 중지", @@ -3535,7 +3545,8 @@ "f50603c6b2": "읽지 않은 것으로 표시", "8dacff1fe0": "마크 리드", "3baa7d6507": "핀", - "697d0f6e1b": "고정 해제" + "697d0f6e1b": "고정 해제", + "250de158fd": "Remove Workspace" }, "WorktreeList": { "d880ea0744": "그룹을 만들고 이 프로젝트를 그룹으로 이동하세요.", @@ -3569,6 +3580,7 @@ "c1f4a31623": "{{value0}}개 하위 워크스페이스 표시", "e97297cb75": "{{value0}}개 하위 워크스페이스 숨기기", "0cd15956d4": "{{value0}}개 하위 워크스페이스 숨기기", + "bd37a57ac8": "Create workspace for {{value0}}", "b667b59632": "Some projects could not be removed from Orca", "f94466bc39": "{{value0}} of {{value1}} contained project{{value2}} remained after deleting the group.", "groupDeleteFailed": "Failed to delete group", @@ -3795,6 +3807,15 @@ } } } + }, + "FolderWorkspaceComposerDialog": { + "connectFailed": "Failed to connect to project.", + "noRepos": "Add a Git project under this folder to attach GitHub or GitLab tasks.", + "title": "Create Folder Workspace", + "createStart": "Create & Start Agent", + "create": "Create Workspace", + "sourceProject": "Task Source", + "chooseSourceProject": "Choose task source" } }, "shared": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 9d9ed6079..cf83bcf75 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -3055,7 +3055,17 @@ "8db50afe1a": "从文件夹导入存储库", "5b2e6fe3c8": "单独导入", "cf9d382ca1": "导入", - "220dd32d83": "扫描..." + "220dd32d83": "扫描...", + "fb33359f69": "这是 Monorepo 吗?", + "d75170194e": "如果这些项目属于同一组,请选择此项。Orca 会将它们分组,并让你从父文件夹开始工作。", + "39d51212cc": "Monorepo 名称", + "e907ec8935": "什么是 Monorepo 名称?", + "aa0247680d": "否,单独导入", + "a0bc4d1f8e": "是,作为 Monorepo 导入", + "8401a7a0d0": "1 个仓库", + "d4f1df62ef": "{{value0}} 个仓库", + "b4263a2ac4": "在 {{value1}} 中找到 {{value0}}。", + "24eda6c8b2": "正在扫描... {{value0}}" }, "AddRepoRemoteStep": { "5b205b5281": "停止扫描", @@ -3535,7 +3545,8 @@ "f50603c6b2": "标记为未读", "8dacff1fe0": "马克·里德", "3baa7d6507": "别针", - "697d0f6e1b": "取消固定" + "697d0f6e1b": "取消固定", + "250de158fd": "Remove Workspace" }, "WorktreeList": { "d880ea0744": "创建一个组并将该项目移入其中。", @@ -3569,6 +3580,7 @@ "c1f4a31623": "显示 {{value0}} 个子工作区", "e97297cb75": "隐藏 {{value0}} 个子工作区", "0cd15956d4": "隐藏 {{value0}} 个子工作区", + "bd37a57ac8": "Create workspace for {{value0}}", "b667b59632": "Some projects could not be removed from Orca", "f94466bc39": "{{value0}} of {{value1}} contained project{{value2}} remained after deleting the group.", "groupDeleteFailed": "Failed to delete group", @@ -3795,6 +3807,15 @@ } } } + }, + "FolderWorkspaceComposerDialog": { + "connectFailed": "Failed to connect to project.", + "noRepos": "Add a Git project under this folder to attach GitHub or GitLab tasks.", + "title": "Create Folder Workspace", + "createStart": "Create & Start Agent", + "create": "Create Workspace", + "sourceProject": "Task Source", + "chooseSourceProject": "Choose task source" } }, "shared": { diff --git a/src/renderer/src/lib/connection-context.test.ts b/src/renderer/src/lib/connection-context.test.ts index dba7fbbb0..3606cbb74 100644 --- a/src/renderer/src/lib/connection-context.test.ts +++ b/src/renderer/src/lib/connection-context.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest' import type { Repo } from '../../../shared/types' import { useAppStore } from '@/store' import { getConnectionId } from './connection-context' +import { folderWorkspaceKey } from '../../../shared/workspace-scope' const initialState = useAppStore.getInitialState() @@ -51,4 +52,262 @@ describe('getConnectionId', () => { expect(getConnectionId('repo-missing::/tmp/repo-feature')).toBeUndefined() }) + + it('resolves SSH targets for folder workspaces from repos in the folder scope', () => { + useAppStore.setState({ + folderWorkspaces: [ + { + id: 'folder-workspace-1', + projectGroupId: 'group-1', + name: 'Platform workspace', + folderPath: '/home/neil/platform', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 1, + lastActivityAt: 0, + createdAt: 1, + updatedAt: 1 + } + ], + projectGroups: [ + { + id: 'group-1', + name: 'Platform', + parentPath: '/home/neil/platform', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + ], + repos: [ + makeRepo({ + id: 'repo-ssh', + path: '/home/neil/platform/api', + projectGroupId: 'group-1', + connectionId: 'ssh-1' + }) + ], + worktreesByRepo: {} + }) + + expect(getConnectionId(folderWorkspaceKey('folder-workspace-1'))).toBe('ssh-1') + }) + + it('resolves SSH targets for repo-less folder workspaces from persisted scope provenance', () => { + useAppStore.setState({ + folderWorkspaces: [ + { + id: 'folder-workspace-1', + projectGroupId: 'group-1', + name: 'Platform workspace', + folderPath: '/home/neil/platform', + connectionId: 'ssh-1', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 1, + lastActivityAt: 0, + createdAt: 1, + updatedAt: 1 + } + ], + projectGroups: [ + { + id: 'group-1', + name: 'Platform', + parentPath: '/home/neil/platform', + connectionId: 'ssh-1', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + ], + repos: [], + worktreesByRepo: {} + }) + + expect(getConnectionId(folderWorkspaceKey('folder-workspace-1'))).toBe('ssh-1') + }) + + it('returns undefined when persisted folder workspace provenance conflicts with child repos', () => { + useAppStore.setState({ + folderWorkspaces: [ + { + id: 'folder-workspace-1', + projectGroupId: 'group-1', + name: 'Platform workspace', + folderPath: '/home/neil/platform', + connectionId: 'ssh-1', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 1, + lastActivityAt: 0, + createdAt: 1, + updatedAt: 1 + } + ], + projectGroups: [ + { + id: 'group-1', + name: 'Platform', + parentPath: '/home/neil/platform', + connectionId: 'ssh-1', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + ], + repos: [ + makeRepo({ + id: 'repo-ssh', + path: '/home/neil/platform/api', + projectGroupId: 'group-1', + connectionId: 'ssh-2' + }) + ], + worktreesByRepo: {} + }) + + expect(getConnectionId(folderWorkspaceKey('folder-workspace-1'))).toBeUndefined() + }) + + it('returns undefined for folder workspaces with mixed local and SSH repos', () => { + useAppStore.setState({ + folderWorkspaces: [ + { + id: 'folder-workspace-1', + projectGroupId: 'group-1', + name: 'Platform workspace', + folderPath: '/home/neil/platform', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 1, + lastActivityAt: 0, + createdAt: 1, + updatedAt: 1 + } + ], + projectGroups: [ + { + id: 'group-1', + name: 'Platform', + parentPath: '/home/neil/platform', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + ], + repos: [ + makeRepo({ + id: 'repo-local', + path: '/home/neil/platform/web', + projectGroupId: 'group-1' + }), + makeRepo({ + id: 'repo-ssh', + path: '/home/neil/platform/api', + projectGroupId: 'group-1', + connectionId: 'ssh-1' + }) + ], + worktreesByRepo: {} + }) + + expect(getConnectionId(folderWorkspaceKey('folder-workspace-1'))).toBeUndefined() + }) + + it('keeps explicit folder workspace provenance isolated from unrelated same-path SSH repos', () => { + useAppStore.setState({ + folderWorkspaces: [ + { + id: 'folder-workspace-1', + projectGroupId: 'group-1', + name: 'Platform workspace', + folderPath: '/home/neil/platform', + connectionId: 'ssh-1', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 1, + lastActivityAt: 0, + createdAt: 1, + updatedAt: 1 + } + ], + projectGroups: [ + { + id: 'group-1', + name: 'Platform', + parentPath: '/home/neil/platform', + connectionId: 'ssh-1', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + }, + { + id: 'group-2', + name: 'Platform copy', + parentPath: '/home/neil/platform', + connectionId: 'ssh-2', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 1, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + ], + repos: [ + makeRepo({ + id: 'repo-ssh-1', + path: '/home/neil/platform/api', + projectGroupId: 'group-1', + connectionId: 'ssh-1' + }), + makeRepo({ + id: 'repo-ssh-2', + path: '/home/neil/platform/api', + projectGroupId: 'group-2', + connectionId: 'ssh-2' + }) + ], + worktreesByRepo: {} + }) + + expect(getConnectionId(folderWorkspaceKey('folder-workspace-1'))).toBe('ssh-1') + }) }) diff --git a/src/renderer/src/lib/connection-context.ts b/src/renderer/src/lib/connection-context.ts index 280d02824..b44425d2c 100644 --- a/src/renderer/src/lib/connection-context.ts +++ b/src/renderer/src/lib/connection-context.ts @@ -1,5 +1,7 @@ import { useAppStore } from '@/store' import { getRepoIdFromWorktreeId } from '../../../shared/worktree-id' +import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { getFolderWorkspaceConnectionId } from './folder-workspace-connection' /** * Resolve the SSH connectionId for a worktree. Returns null for local repos, @@ -10,6 +12,13 @@ export function getConnectionId(worktreeId: string | null): string | null | unde if (!worktreeId) { return null } + const parsedWorkspaceKey = parseWorkspaceKey(worktreeId) + if (parsedWorkspaceKey?.type === 'folder') { + return getFolderWorkspaceConnectionId( + useAppStore.getState(), + parsedWorkspaceKey.folderWorkspaceId + ) + } const state = useAppStore.getState() const allWorktrees = Object.values(state.worktreesByRepo ?? {}).flat() const worktree = allWorktrees.find((w) => w.id === worktreeId) diff --git a/src/renderer/src/lib/folder-workspace-connection.ts b/src/renderer/src/lib/folder-workspace-connection.ts new file mode 100644 index 000000000..83388651f --- /dev/null +++ b/src/renderer/src/lib/folder-workspace-connection.ts @@ -0,0 +1,92 @@ +import type { FolderWorkspace, ProjectGroup, Repo } from '../../../shared/types' +import { isPathInsideOrEqual } from '../../../shared/cross-platform-path' +import { getProjectGroupSubtreeIds } from '../../../shared/project-groups' + +export type FolderWorkspaceConnectionState = { + folderWorkspaces: FolderWorkspace[] + projectGroups: ProjectGroup[] + repos: Repo[] +} + +function getFolderScopeCandidateRepos(args: { + folderPath: string + projectGroupId: string + connectionId?: string | null + projectGroups: readonly ProjectGroup[] + repos: readonly Repo[] +}): Repo[] { + const groupIds = getProjectGroupSubtreeIds(args.projectGroups, args.projectGroupId) + const groupRepos = args.repos.filter( + (repo) => typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId) + ) + const pathRepos = args.repos.filter( + (repo) => + !(typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) && + isPathInsideOrEqual(args.folderPath, repo.path) + ) + if (args.connectionId) { + return [ + ...groupRepos, + ...pathRepos.filter((repo) => (repo.connectionId ?? null) === args.connectionId) + ] + } + if (groupRepos.length === 0) { + return pathRepos + } + const groupConnectionIds = new Set(groupRepos.map((repo) => repo.connectionId ?? null)) + return [ + ...groupRepos, + ...pathRepos.filter((repo) => groupConnectionIds.has(repo.connectionId ?? null)) + ] +} + +export function getFolderWorkspaceConnectionId( + state: FolderWorkspaceConnectionState, + folderWorkspaceId: string +): string | null | undefined { + const workspace = state.folderWorkspaces.find((entry) => entry.id === folderWorkspaceId) + if (!workspace) { + return undefined + } + const group = state.projectGroups.find((entry) => entry.id === workspace.projectGroupId) + const scopeConnectionId = workspace.connectionId ?? group?.connectionId ?? null + + const candidateRepos = getFolderScopeCandidateRepos({ + folderPath: workspace.folderPath, + projectGroupId: workspace.projectGroupId, + connectionId: scopeConnectionId, + projectGroups: state.projectGroups, + repos: state.repos + }) + let hasLocalRepo = false + const connectionIds = new Set() + for (const repo of candidateRepos) { + if (repo.connectionId) { + connectionIds.add(repo.connectionId) + } else { + hasLocalRepo = true + } + } + if (scopeConnectionId) { + const hasDifferentSshConnection = [...connectionIds].some( + (connectionId) => connectionId !== scopeConnectionId + ) + if (hasLocalRepo || hasDifferentSshConnection) { + return undefined + } + return scopeConnectionId + } + if (candidateRepos.length === 0) { + return null + } + if (hasLocalRepo && connectionIds.size > 0) { + return undefined + } + if (connectionIds.size === 0) { + return null + } + if (connectionIds.size === 1) { + return [...connectionIds][0] + } + return undefined +} diff --git a/src/renderer/src/lib/folder-workspace-path-status-cache-expiry.ts b/src/renderer/src/lib/folder-workspace-path-status-cache-expiry.ts new file mode 100644 index 000000000..513a44d80 --- /dev/null +++ b/src/renderer/src/lib/folder-workspace-path-status-cache-expiry.ts @@ -0,0 +1,32 @@ +import { useEffect, useState } from 'react' +import { FOLDER_WORKSPACE_PATH_STATUS_TTL_MS } from '../../../shared/folder-workspace-path-status' + +type FolderWorkspacePathStatusCacheClockEntry = { + checkedAt: number +} + +export function useFolderWorkspacePathStatusCacheExpiryTick( + entries: Record +): number { + const [tick, setTick] = useState(0) + + useEffect(() => { + const now = Date.now() + let nextDelayMs = Number.POSITIVE_INFINITY + for (const entry of Object.values(entries)) { + const delayMs = entry.checkedAt + FOLDER_WORKSPACE_PATH_STATUS_TTL_MS - now + if (delayMs > 0) { + nextDelayMs = Math.min(nextDelayMs, delayMs) + } + } + if (!Number.isFinite(nextDelayMs)) { + return + } + // Why: TTL freshness is derived from Date.now(), so subscribers need one + // clock tick when the oldest cached status stops being authoritative. + const timeout = window.setTimeout(() => setTick((value) => value + 1), nextDelayMs + 1) + return () => window.clearTimeout(timeout) + }, [entries, tick]) + + return tick +} diff --git a/src/renderer/src/lib/folder-workspace-path-status.ts b/src/renderer/src/lib/folder-workspace-path-status.ts new file mode 100644 index 000000000..0a2eda79f --- /dev/null +++ b/src/renderer/src/lib/folder-workspace-path-status.ts @@ -0,0 +1,80 @@ +import type { FolderWorkspacePathStatus } from '../../../shared/folder-workspace-path-status' +import { blocksFolderWorkspaceActivation } from '../../../shared/folder-workspace-path-status' + +export function getFolderWorkspacePathStatusTitle( + status: FolderWorkspacePathStatus | null | undefined +): string | null { + if (!status || status.exists) { + return null + } + switch (status.reason) { + case 'missing': + return 'Folder not found' + case 'not-directory': + return 'Path is not a folder' + case 'ambiguous-connection': + return 'Cannot determine connection' + case 'unavailable': + default: + return 'Cannot check folder' + } +} + +export function getFolderWorkspacePathStatusDescription( + status: FolderWorkspacePathStatus | null | undefined +): string | null { + if (!status || status.exists) { + return null + } + switch (status.reason) { + case 'missing': + return `Orca cannot find ${status.path}. Remove and re-import this folder workspace.` + case 'not-directory': + return `${status.path} exists, but it is not a folder.` + case 'ambiguous-connection': + return 'Orca cannot tell which SSH connection owns this folder scope.' + case 'unavailable': + default: + return 'Orca cannot verify this folder right now. Check the runtime or SSH connection and try again.' + } +} + +export function formatFolderWorkspaceCreateError(error: unknown): { + title: string + description: string +} { + const message = error instanceof Error ? error.message : String(error) + const path = message.includes(':') ? message.slice(message.indexOf(':') + 1) : '' + if (message.startsWith('folder_workspace_path_missing:')) { + return { + title: 'Folder not found', + description: `Orca cannot find ${path}. Remove and re-import the folder.` + } + } + if (message.startsWith('folder_workspace_path_not_directory:')) { + return { + title: 'Path is not a folder', + description: `${path} exists, but it is not a folder.` + } + } + if (message.startsWith('folder_workspace_connection_ambiguous:')) { + return { + title: 'Cannot determine connection', + description: 'Orca cannot tell which SSH connection owns this folder scope.' + } + } + if (message.startsWith('folder_workspace_path_unavailable:')) { + return { + title: 'Cannot check folder', + description: + 'Orca cannot verify this folder right now. Check the runtime or SSH connection and try again.' + } + } + return { title: 'Failed to create folder workspace', description: message } +} + +export function folderWorkspaceActivationBlocked( + status: FolderWorkspacePathStatus | null | undefined +): boolean { + return blocksFolderWorkspaceActivation(status) +} diff --git a/src/renderer/src/lib/new-workspace.ts b/src/renderer/src/lib/new-workspace.ts index 00f19695f..2402b68e8 100644 --- a/src/renderer/src/lib/new-workspace.ts +++ b/src/renderer/src/lib/new-workspace.ts @@ -6,7 +6,8 @@ import { } from '@/runtime/runtime-terminal-inspection' import type { AgentStartupPlan } from '@/lib/tui-agent-startup' import { isShellProcess } from '@/lib/tui-agent-startup' -import type { OrcaHooks, TaskViewPresetId } from '../../../shared/types' +import type { LinkedWorkItemContext } from '@/lib/linked-work-item-context' +import type { FolderWorkspaceLinkedTask, OrcaHooks, TaskViewPresetId } from '../../../shared/types' import { resolveHookCommandSourcePolicy } from '../../../shared/hook-command-source-policy' import { isExpectedAgentProcess } from '../../../shared/agent-process-recognition' import { slugifyForWorkspaceName } from '../../../shared/workspace-name' @@ -48,16 +49,11 @@ export const CLIENT_PLATFORM: NodeJS.Platform = navigator.userAgent.includes('Wi export { getLinkedWorkItemProvider, isGitLabIssueUrl } from './linked-work-item-provider' -export type LinkedWorkItemSummary = { - type: 'issue' | 'pr' | 'mr' - provider?: 'github' | 'gitlab' | 'linear' | 'jira' - number: number - title: string - url: string - linearIdentifier?: string +export type LinkedWorkItemSummary = Omit & { + provider?: FolderWorkspaceLinkedTask['provider'] linearWorkspaceId?: string linearOrganizationUrlKey?: string - jiraIdentifier?: string + linkedContext?: LinkedWorkItemContext } // Why: when a repo has no `orca.yaml` issueCommand and no per-user override, diff --git a/src/renderer/src/lib/sidebar-worktree-activation.test.ts b/src/renderer/src/lib/sidebar-worktree-activation.test.ts index 851034f9f..4cd7f4c67 100644 --- a/src/renderer/src/lib/sidebar-worktree-activation.test.ts +++ b/src/renderer/src/lib/sidebar-worktree-activation.test.ts @@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => { openFiles: [] as { worktreeId: string }[] } const activateAndRevealWorktree = vi.fn() + const activateAndRevealFolderWorkspace = vi.fn() const markInputQuietSchedulerInput = vi.fn() const pendingCallbacks: (() => void)[] = [] const pendingCancels: ReturnType[] = [] @@ -26,6 +27,7 @@ const mocks = vi.hoisted(() => { }) return { activateAndRevealWorktree, + activateAndRevealFolderWorkspace, markInputQuietSchedulerInput, pendingCallbacks, pendingCancels, @@ -41,6 +43,7 @@ vi.mock('@/store', () => ({ })) vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealFolderWorkspace: mocks.activateAndRevealFolderWorkspace, activateAndRevealWorktree: mocks.activateAndRevealWorktree })) @@ -59,6 +62,7 @@ describe('sidebar worktree activation', () => { delete (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ cancelPendingSidebarWorktreeActivation() mocks.activateAndRevealWorktree.mockClear() + mocks.activateAndRevealFolderWorkspace.mockClear() mocks.markInputQuietSchedulerInput.mockClear() mocks.scheduleAfterInputQuiet.mockClear() mocks.pendingCallbacks.length = 0 @@ -91,6 +95,14 @@ describe('sidebar worktree activation', () => { expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-live') }) + it('routes folder workspace activation through the guarded folder path', () => { + activateWorktreeFromSidebar('folder:folder-workspace-1') + + expect(mocks.activateAndRevealFolderWorkspace).toHaveBeenCalledWith('folder-workspace-1') + expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() + expect(mocks.scheduleAfterInputQuiet).not.toHaveBeenCalled() + }) + it('does not defer slept workspace activation in the web client', () => { ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true mocks.state.tabsByWorktree = { 'wt-web-slept': [{ id: 'tab-1' }] } diff --git a/src/renderer/src/lib/sidebar-worktree-activation.ts b/src/renderer/src/lib/sidebar-worktree-activation.ts index ba0d160b2..fe726b619 100644 --- a/src/renderer/src/lib/sidebar-worktree-activation.ts +++ b/src/renderer/src/lib/sidebar-worktree-activation.ts @@ -1,7 +1,11 @@ import { useAppStore } from '@/store' -import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { + activateAndRevealFolderWorkspace, + activateAndRevealWorktree +} from '@/lib/worktree-activation' import { tabHasLivePty } from '@/lib/tab-has-live-pty' import { markInputQuietSchedulerInput, scheduleAfterInputQuiet } from '@/lib/input-quiet-scheduler' +import { parseWorkspaceKey } from '../../../shared/workspace-scope' const SLEPT_WORKTREE_ACTIVATION_INPUT_QUIET_MS = 450 const SLEPT_WORKTREE_ACTIVATION_IDLE_TIMEOUT_MS = 120 @@ -37,6 +41,11 @@ function shouldDeferSidebarWorktreeActivation(worktreeId: string): boolean { export function activateWorktreeFromSidebar(worktreeId: string): void { cancelPendingSidebarWorktreeActivation() + const workspaceScope = parseWorkspaceKey(worktreeId) + if (workspaceScope?.type === 'folder') { + activateAndRevealFolderWorkspace(workspaceScope.folderWorkspaceId) + return + } const activate = (): void => { if (pendingSidebarWorktreeActivation?.worktreeId === worktreeId) { diff --git a/src/renderer/src/lib/workspace-session-browser-history.test.ts b/src/renderer/src/lib/workspace-session-browser-history.test.ts index a5dabf661..505110ac4 100644 --- a/src/renderer/src/lib/workspace-session-browser-history.test.ts +++ b/src/renderer/src/lib/workspace-session-browser-history.test.ts @@ -6,6 +6,7 @@ import { buildWorkspaceSessionPayload, type WorkspaceSessionSnapshot } from './w function createSnapshot(browserUrlHistory: BrowserHistoryEntry[]): WorkspaceSessionSnapshot { return { activeRepoId: null, + activeWorkspaceKey: null, activeWorktreeId: null, activeTabId: null, tabsByWorktree: {}, diff --git a/src/renderer/src/lib/workspace-session-editor-drafts.test.ts b/src/renderer/src/lib/workspace-session-editor-drafts.test.ts index 5e7691e8c..88ce7a68d 100644 --- a/src/renderer/src/lib/workspace-session-editor-drafts.test.ts +++ b/src/renderer/src/lib/workspace-session-editor-drafts.test.ts @@ -7,6 +7,7 @@ function createSnapshot( ): WorkspaceSessionSnapshot { return { activeRepoId: 'repo-1', + activeWorkspaceKey: 'worktree:wt-1', activeWorktreeId: 'wt-1', activeTabId: 'tab-1', tabsByWorktree: {}, diff --git a/src/renderer/src/lib/workspace-session-liveness.test.ts b/src/renderer/src/lib/workspace-session-liveness.test.ts index d6bcd3a59..db06aaf0e 100644 --- a/src/renderer/src/lib/workspace-session-liveness.test.ts +++ b/src/renderer/src/lib/workspace-session-liveness.test.ts @@ -6,6 +6,7 @@ function createSnapshot( ): WorkspaceSessionSnapshot { return { activeRepoId: 'repo-1', + activeWorkspaceKey: 'worktree:wt-1', activeWorktreeId: 'wt-1', activeTabId: 'tab-1', tabsByWorktree: {}, diff --git a/src/renderer/src/lib/workspace-session-relevant-fields.test.ts b/src/renderer/src/lib/workspace-session-relevant-fields.test.ts index d9ab05f5b..84b874a09 100644 --- a/src/renderer/src/lib/workspace-session-relevant-fields.test.ts +++ b/src/renderer/src/lib/workspace-session-relevant-fields.test.ts @@ -6,6 +6,7 @@ describe('SESSION_RELEVANT_FIELDS', () => { // A snapshot field omitted here would persist stale data after that field changes. const fixture: Record = { activeRepoId: true, + activeWorkspaceKey: true, activeWorktreeId: true, activeTabId: true, tabsByWorktree: true, diff --git a/src/renderer/src/lib/workspace-session.ts b/src/renderer/src/lib/workspace-session.ts index 2954cbc85..88cb8ca5e 100644 --- a/src/renderer/src/lib/workspace-session.ts +++ b/src/renderer/src/lib/workspace-session.ts @@ -33,6 +33,7 @@ export function shouldPersistWorkspaceSession( export type WorkspaceSessionSnapshot = Pick< AppState, | 'activeRepoId' + | 'activeWorkspaceKey' | 'activeWorktreeId' | 'activeTabId' | 'tabsByWorktree' @@ -70,6 +71,7 @@ export type WorkspaceSessionSnapshot = Pick< // time, preventing the gate from silently going stale. export const SESSION_RELEVANT_FIELDS = [ 'activeRepoId', + 'activeWorkspaceKey', 'activeWorktreeId', 'activeTabId', 'tabsByWorktree', @@ -333,6 +335,7 @@ export function buildWorkspaceSessionPayload( const payload = { activeRepoId: snapshot.activeRepoId, + activeWorkspaceKey: snapshot.activeWorkspaceKey, activeWorktreeId: snapshot.activeWorktreeId, activeTabId: snapshot.activeTabId, tabsByWorktree: buildSanitizedTabsByWorktree(snapshot.tabsByWorktree), diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index 7b55b68cc..b4cf01806 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -1,5 +1,6 @@ /* eslint-disable max-lines -- Why: worktree activation is a single ordered flow spanning startup, setup, issue commands, and default tabs; splitting it would obscure sequencing guarantees. */ import type { + FolderWorkspace, SetupSplitDirection, TuiAgent, Worktree, @@ -37,6 +38,13 @@ import { } from '../../../shared/tui-agent-launch-defaults' import { isTuiAgent } from '../../../shared/tui-agent-config' import { resumeSleepingAgentSessionsForWorktree } from '@/lib/resume-sleeping-agent-session' +import { folderWorkspaceKey } from '../../../shared/workspace-scope' +import { + folderWorkspaceActivationBlocked, + getFolderWorkspacePathStatusDescription, + getFolderWorkspacePathStatusTitle +} from './folder-workspace-path-status' +import { toast } from 'sonner' /** Telemetry payload threaded from the launch site to `pty:spawn`. Main * fires `agent_started` only after the spawn succeeds — see @@ -121,6 +129,71 @@ export type ActivateAndRevealResult = { primaryTabId: string | null } +function ensureFolderWorkspaceInitialTerminal( + folderWorkspace: FolderWorkspace, + startup?: WorktreeStartupPayload +): string | null { + const state = useAppStore.getState() + const workspaceKey = folderWorkspaceKey(folderWorkspace.id) + const primaryTabId = ensureWorktreeHasInitialTerminal( + state, + workspaceKey, + startup, + undefined, + undefined, + undefined + ) + return primaryTabId +} + +export function activateAndRevealFolderWorkspace( + folderWorkspaceId: string, + opts?: { + sidebarRevealBehavior?: PendingSidebarWorktreeReveal['behavior'] + startup?: WorktreeStartupPayload + } +): ActivateAndRevealResult | false { + const state = useAppStore.getState() + const folderWorkspace = state.folderWorkspaces.find( + (workspace) => workspace.id === folderWorkspaceId + ) + if (!folderWorkspace) { + return false + } + const pathStatus = state.getFreshFolderWorkspacePathStatus({ + scope: 'folder-workspace', + folderWorkspaceId + }) + if (folderWorkspaceActivationBlocked(pathStatus)) { + toast.error(getFolderWorkspacePathStatusTitle(pathStatus) ?? 'Cannot open folder workspace', { + description: getFolderWorkspacePathStatusDescription(pathStatus) ?? folderWorkspace.folderPath + }) + return false + } + + if (state.activeView !== 'terminal') { + state.setActiveView('terminal') + } + + state.setActiveFolderWorkspace(folderWorkspaceId) + + const workspaceKey = folderWorkspaceKey(folderWorkspaceId) + state.markWorktreeVisited(workspaceKey) + if (!state.isNavigatingHistory) { + state.recordWorktreeVisit(workspaceKey) + } + resumeSleepingAgentSessionsForWorktree(workspaceKey) + const primaryTabId = ensureFolderWorkspaceInitialTerminal(folderWorkspace, opts?.startup) + + if (opts?.sidebarRevealBehavior) { + state.revealWorktreeInSidebar(workspaceKey, { behavior: opts.sidebarRevealBehavior }) + } else { + state.revealWorktreeInSidebar(workspaceKey) + } + + return { primaryTabId } +} + function buildCreatedAgentReopenStartup(worktree: Worktree): WorktreeStartupPayload | undefined { const agent = worktree.createdWithAgent if (!isTuiAgent(agent)) { diff --git a/src/renderer/src/store/slices/browser.ts b/src/renderer/src/store/slices/browser.ts index 19c4de9fb..a9c4a07a7 100644 --- a/src/renderer/src/store/slices/browser.ts +++ b/src/renderer/src/store/slices/browser.ts @@ -14,6 +14,7 @@ import type { } from '../../../../shared/types' import { GRAB_BUDGET, type BrowserPageAnnotation } from '../../../../shared/browser-grab-types' import { FLOATING_TERMINAL_WORKTREE_ID, ORCA_BROWSER_BLANK_URL } from '../../../../shared/constants' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import { redactKagiSessionToken } from '../../../../shared/browser-url' import { MAX_BROWSER_HISTORY_ENTRIES, @@ -1412,6 +1413,9 @@ export const createBrowserSlice: StateCreator = .map((worktree) => worktree.id) ) validWorktreeIdsForCleanup.add(FLOATING_TERMINAL_WORKTREE_ID) + for (const workspace of currentState.folderWorkspaces) { + validWorktreeIdsForCleanup.add(folderWorkspaceKey(workspace.id)) + } // Why: mirror closeBrowserTab's contract — reducers are pure, imperative // side effects bracket them. Compute dropped workspaces first, destroy @@ -1441,6 +1445,9 @@ export const createBrowserSlice: StateCreator = .map((worktree) => worktree.id) ) validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID) + for (const workspace of s.folderWorkspaces) { + validWorktreeIds.add(folderWorkspaceKey(workspace.id)) + } const browserTabsByWorktree: Record = {} const browserPagesByWorkspace: Record = {} diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index 98a0b0444..5e6532f4c 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -31,6 +31,7 @@ import type { } from '../../../../shared/types' import { stripCredentialsFromMessage } from '../../../../shared/git-remote-error' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import type { RemoteOpKind } from '@/components/right-sidebar/source-control-primary-action' import { shouldForcePushWithLeaseForUpstream } from '../../../../shared/git-upstream-status' import { @@ -3873,6 +3874,9 @@ export const createEditorSlice: StateCreator = (s .map((w) => w.id) ) validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID) + for (const workspace of s.folderWorkspaces) { + validWorktreeIds.add(folderWorkspaceKey(workspace.id)) + } const openFiles: OpenFile[] = [] const editorDrafts: Record = {} diff --git a/src/renderer/src/store/slices/repos-project-groups.test.ts b/src/renderer/src/store/slices/repos-project-groups.test.ts index 07f9e8b90..589ec56dc 100644 --- a/src/renderer/src/store/slices/repos-project-groups.test.ts +++ b/src/renderer/src/store/slices/repos-project-groups.test.ts @@ -1,11 +1,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { createTestStore } from './store-test-helpers' -import type { NestedRepoScanResult, Repo, ProjectGroup } from '../../../../shared/types' +import type { + NestedRepoScanResult, + Repo, + ProjectGroup, + FolderWorkspace +} from '../../../../shared/types' import { createCompatibleRuntimeStatusResponseIfNeeded, type RuntimeEnvironmentCallRequest } from '../../runtime/runtime-compatibility-test-fixture' import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import type { SshConnectionState } from '../../../../shared/ssh-types' const remoteRepo: Repo = { id: 'remote-repo', @@ -39,9 +46,23 @@ const projectGroupsImportNested = vi.fn() const projectGroupsScanNested = vi.fn() const projectGroupsCancelNestedScan = vi.fn() const projectGroupsOnNestedScanProgress = vi.fn() +const folderWorkspacesList = vi.fn() +const folderWorkspacesGetPathStatus = vi.fn() +const folderWorkspacesCreate = vi.fn() +const folderWorkspacesUpdate = vi.fn() +const folderWorkspacesDelete = vi.fn() const runtimeEnvironmentCall = vi.fn() const runtimeEnvironmentTransportCall = vi.fn() +function makeSshConnectionState(status: SshConnectionState['status']): SshConnectionState { + return { + targetId: 'ssh-1', + status, + error: null, + reconnectAttempt: 0 + } +} + beforeEach(() => { clearRuntimeCompatibilityCacheForTests() reposList.mockReset() @@ -57,6 +78,12 @@ beforeEach(() => { projectGroupsCancelNestedScan.mockReset() projectGroupsOnNestedScanProgress.mockReset() projectGroupsOnNestedScanProgress.mockReturnValue(vi.fn()) + folderWorkspacesList.mockReset() + folderWorkspacesGetPathStatus.mockReset() + folderWorkspacesGetPathStatus.mockResolvedValue({ path: '/workspace/platform', exists: true }) + folderWorkspacesCreate.mockReset() + folderWorkspacesUpdate.mockReset() + folderWorkspacesDelete.mockReset() runtimeEnvironmentCall.mockReset() runtimeEnvironmentTransportCall.mockReset() runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { @@ -79,6 +106,13 @@ beforeEach(() => { onNestedScanProgress: projectGroupsOnNestedScanProgress, importNested: projectGroupsImportNested }, + folderWorkspaces: { + list: folderWorkspacesList, + getPathStatus: folderWorkspacesGetPathStatus, + create: folderWorkspacesCreate, + update: folderWorkspacesUpdate, + delete: folderWorkspacesDelete + }, runtimeEnvironments: { call: runtimeEnvironmentTransportCall } } }) @@ -99,6 +133,358 @@ describe('project group store routing', () => { expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) + it('creates, updates, and deletes local folder workspaces', async () => { + const linkedTask: FolderWorkspace['linkedTask'] = { + provider: 'linear', + type: 'issue', + number: 0, + title: 'Refund fix', + url: 'https://linear.app/acme/issue/ENG-123', + linearIdentifier: 'ENG-123' + } + const folderWorkspace: FolderWorkspace = { + id: 'folder-workspace-1', + projectGroupId: projectGroup.id, + name: 'Refund fix', + folderPath: '/workspace/platform', + linkedTask, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 1, + lastActivityAt: 0, + createdAt: 1, + updatedAt: 1 + } + folderWorkspacesCreate.mockResolvedValue(folderWorkspace) + folderWorkspacesUpdate.mockResolvedValue({ ...folderWorkspace, comment: 'Ready' }) + folderWorkspacesDelete.mockResolvedValue(true) + const store = createTestStore() + + await expect( + store.getState().createFolderWorkspace({ + projectGroupId: projectGroup.id, + name: 'Refund fix', + linkedTask + }) + ).resolves.toEqual(folderWorkspace) + await expect( + store.getState().updateFolderWorkspace(folderWorkspace.id, { comment: 'Ready' }) + ).resolves.toBe(true) + await expect(store.getState().deleteFolderWorkspace(folderWorkspace.id)).resolves.toBe(true) + + expect(folderWorkspacesCreate).toHaveBeenCalledWith({ + projectGroupId: projectGroup.id, + name: 'Refund fix', + linkedTask + }) + expect(folderWorkspacesUpdate).toHaveBeenCalledWith({ + folderWorkspaceId: folderWorkspace.id, + updates: { comment: 'Ready' } + }) + expect(folderWorkspacesDelete).toHaveBeenCalledWith({ + folderWorkspaceId: folderWorkspace.id + }) + expect(store.getState().folderWorkspaces).toEqual([]) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('caches local folder workspace path status by scope', async () => { + const folderGroup = { ...projectGroup, parentPath: '/workspace/platform' } + folderWorkspacesGetPathStatus.mockResolvedValue({ + path: '/workspace/platform', + exists: false, + reason: 'missing' + }) + const store = createTestStore() + store.setState({ projectGroups: [folderGroup] }) + + await expect( + store.getState().fetchFolderWorkspacePathStatus({ + scope: 'project-group', + projectGroupId: folderGroup.id + }) + ).resolves.toEqual({ + path: '/workspace/platform', + exists: false, + reason: 'missing' + }) + + const cacheKey = store.getState().getFolderWorkspacePathStatusCacheKey({ + scope: 'project-group', + projectGroupId: folderGroup.id + }) + expect(store.getState().folderWorkspacePathStatuses[cacheKey]?.status).toEqual({ + path: '/workspace/platform', + exists: false, + reason: 'missing' + }) + expect(folderWorkspacesGetPathStatus).toHaveBeenCalledTimes(1) + }) + + it('ignores stale folder path status responses after a group path changes', async () => { + let resolveStatus: (status: { path: string; exists: boolean }) => void = () => {} + folderWorkspacesGetPathStatus.mockImplementation( + () => + new Promise((resolve) => { + resolveStatus = resolve + }) + ) + const store = createTestStore() + store.setState({ + projectGroups: [{ ...projectGroup, parentPath: '/workspace/old-platform' }] + }) + const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id } + const statusPromise = store.getState().fetchFolderWorkspacePathStatus(request) + + store.setState({ + projectGroups: [{ ...projectGroup, parentPath: '/workspace/new-platform' }] + }) + resolveStatus({ path: '/workspace/old-platform', exists: true }) + await statusPromise + + const cacheKey = store.getState().getFolderWorkspacePathStatusCacheKey(request) + expect(store.getState().folderWorkspacePathStatuses[cacheKey]).toBeUndefined() + }) + + it('ignores stale folder path status responses after repo ownership changes', async () => { + let resolveStatus: (status: { path: string; exists: boolean }) => void = () => {} + folderWorkspacesGetPathStatus.mockImplementation( + () => + new Promise((resolve) => { + resolveStatus = resolve + }) + ) + const store = createTestStore() + store.setState({ + projectGroups: [{ ...projectGroup, parentPath: '/workspace/platform' }], + repos: [{ ...remoteRepo, id: 'local-repo', path: '/workspace/platform/api' }] + }) + const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id } + const statusPromise = store.getState().fetchFolderWorkspacePathStatus(request) + + store.setState({ + repos: [ + { + ...remoteRepo, + id: 'ssh-repo', + path: '/workspace/platform/api', + connectionId: 'ssh-1' + } + ] + }) + resolveStatus({ path: '/workspace/platform', exists: true }) + await statusPromise + + const cacheKey = store.getState().getFolderWorkspacePathStatusCacheKey(request) + expect(store.getState().folderWorkspacePathStatuses[cacheKey]).toBeUndefined() + }) + + it('treats expired folder path status cache entries as unknown', async () => { + vi.useFakeTimers() + try { + const store = createTestStore() + store.setState({ + projectGroups: [{ ...projectGroup, parentPath: '/workspace/platform' }] + }) + const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id } + await store.getState().fetchFolderWorkspacePathStatus(request) + + expect(store.getState().getFreshFolderWorkspacePathStatus(request)).toEqual({ + path: '/workspace/platform', + exists: true + }) + + vi.setSystemTime(Date.now() + 10_001) + + expect(store.getState().getFreshFolderWorkspacePathStatus(request)).toBeNull() + } finally { + vi.useRealTimers() + } + }) + + it('treats current-state mismatched folder path cache entries as unknown', async () => { + const store = createTestStore() + store.setState({ + projectGroups: [ + { ...projectGroup, parentPath: '/workspace/platform', connectionId: 'ssh-1' } + ], + sshConnectionStates: new Map([['ssh-1', makeSshConnectionState('connected')]]) + }) + const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id } + await store.getState().fetchFolderWorkspacePathStatus(request) + + expect(store.getState().getFreshFolderWorkspacePathStatus(request)).toEqual({ + path: '/workspace/platform', + exists: true + }) + + store.setState({ + sshConnectionStates: new Map([['ssh-1', makeSshConnectionState('disconnected')]]) + }) + + expect(store.getState().getFreshFolderWorkspacePathStatus(request)).toBeNull() + }) + + it('ignores stale folder path status responses after SSH connection state changes', async () => { + const resolvers: ((status: { path: string; exists: boolean; reason?: string }) => void)[] = [] + folderWorkspacesGetPathStatus.mockImplementation( + () => + new Promise((resolve) => { + resolvers.push(resolve) + }) + ) + const store = createTestStore() + store.setState({ + projectGroups: [ + { ...projectGroup, parentPath: '/workspace/platform', connectionId: 'ssh-1' } + ], + sshConnectionStates: new Map([['ssh-1', makeSshConnectionState('connected')]]) + }) + const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id } + const connectedStatusPromise = store.getState().fetchFolderWorkspacePathStatus(request) + + store.setState({ + sshConnectionStates: new Map([['ssh-1', makeSshConnectionState('disconnected')]]) + }) + const disconnectedStatusPromise = store + .getState() + .fetchFolderWorkspacePathStatus(request, { force: true }) + + resolvers[1]?.({ + path: '/workspace/platform', + exists: false, + reason: 'unavailable' + }) + await disconnectedStatusPromise + resolvers[0]?.({ path: '/workspace/platform', exists: true }) + await connectedStatusPromise + + const cacheKey = store.getState().getFolderWorkspacePathStatusCacheKey(request) + expect(store.getState().folderWorkspacePathStatuses[cacheKey]?.status).toEqual({ + path: '/workspace/platform', + exists: false, + reason: 'unavailable' + }) + }) + + it('purges renderer session state when deleting a local folder workspace', async () => { + const folderWorkspace: FolderWorkspace = { + id: 'folder-workspace-1', + projectGroupId: projectGroup.id, + name: 'Refund fix', + folderPath: '/workspace/platform', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 1, + lastActivityAt: 0, + createdAt: 1, + updatedAt: 1 + } + const workspaceKey = folderWorkspaceKey(folderWorkspace.id) + folderWorkspacesDelete.mockResolvedValue(true) + const store = createTestStore() + store.setState({ + folderWorkspaces: [folderWorkspace], + activeWorktreeId: workspaceKey, + activeWorkspaceKey: workspaceKey, + activeTabId: 'terminal-tab-1', + activeBrowserTabId: 'browser-tab-1', + activeTabType: 'browser', + tabsByWorktree: { + [workspaceKey]: [ + { + id: 'terminal-tab-1', + worktreeId: workspaceKey, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId: 'pty-1' + } + ] + }, + terminalLayoutsByTabId: { + 'terminal-tab-1': { + root: { type: 'leaf', leafId: 'leaf-1' }, + activeLeafId: 'leaf-1', + expandedLeafId: null + } + }, + browserTabsByWorktree: { + [workspaceKey]: [ + { + id: 'browser-tab-1', + worktreeId: workspaceKey, + url: 'https://example.com', + title: 'Example', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } + ] + }, + browserPagesByWorkspace: { + 'browser-tab-1': [ + { + id: 'page-1', + workspaceId: 'browser-tab-1', + worktreeId: workspaceKey, + url: 'https://example.com', + title: 'Example', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } + ] + }, + openFiles: [ + { + id: 'file-1', + worktreeId: workspaceKey, + filePath: '/workspace/platform/notes.md', + relativePath: 'notes.md', + language: 'markdown', + isDirty: true, + isPreview: false, + mode: 'edit' + } + ], + editorDrafts: { 'file-1': 'draft' }, + activeFileIdByWorktree: { [workspaceKey]: 'file-1' }, + activeTabTypeByWorktree: { [workspaceKey]: 'browser' }, + activeBrowserTabIdByWorktree: { [workspaceKey]: 'browser-tab-1' }, + lastVisitedAtByWorktreeId: { [workspaceKey]: 10 } + }) + + await expect(store.getState().deleteFolderWorkspace(folderWorkspace.id)).resolves.toBe(true) + + const state = store.getState() + expect(state.folderWorkspaces).toEqual([]) + expect(state.activeWorktreeId).toBeNull() + expect(state.activeWorkspaceKey).toBeNull() + expect(state.tabsByWorktree[workspaceKey]).toBeUndefined() + expect(state.terminalLayoutsByTabId['terminal-tab-1']).toBeUndefined() + expect(state.browserTabsByWorktree[workspaceKey]).toBeUndefined() + expect(state.browserPagesByWorkspace['browser-tab-1']).toBeUndefined() + expect(state.openFiles).toEqual([]) + expect(state.editorDrafts).toEqual({}) + expect(state.activeFileIdByWorktree[workspaceKey]).toBeUndefined() + expect(state.activeBrowserTabIdByWorktree[workspaceKey]).toBeUndefined() + expect(state.lastVisitedAtByWorktreeId[workspaceKey]).toBeUndefined() + }) + it('refreshes local repos and groups after importing nested repos', async () => { const importedRepo: Repo = { ...remoteRepo, @@ -116,6 +502,7 @@ describe('project group store routing', () => { } projectGroupsImportNested.mockResolvedValue(result) projectGroupsList.mockResolvedValue([projectGroup]) + folderWorkspacesList.mockResolvedValue([]) reposList.mockResolvedValue([importedRepo]) const store = createTestStore() @@ -135,6 +522,7 @@ describe('project group store routing', () => { mode: 'group' }) expect(projectGroupsList).toHaveBeenCalled() + expect(folderWorkspacesList).toHaveBeenCalled() expect(reposList).toHaveBeenCalled() expect(store.getState().projectGroups).toEqual([projectGroup]) expect(store.getState().repos).toEqual([importedRepo]) @@ -286,10 +674,26 @@ describe('project group store routing', () => { name: 'Tools', tabOrder: 1 } + const childWorkspace: FolderWorkspace = { + id: 'folder-workspace-1', + projectGroupId: childGroup.id, + name: 'Shared cleanup', + folderPath: '/workspace/platform/shared', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 1, + lastActivityAt: 0, + createdAt: 1, + updatedAt: 1 + } projectGroupsDelete.mockResolvedValue(true) const store = createTestStore() store.setState({ projectGroups: [projectGroup, childGroup, siblingGroup], + folderWorkspaces: [childWorkspace], repos: [ { ...remoteRepo, id: 'direct', projectGroupId: projectGroup.id }, { ...remoteRepo, id: 'nested', projectGroupId: childGroup.id }, @@ -300,6 +704,7 @@ describe('project group store routing', () => { await expect(store.getState().deleteProjectGroup(projectGroup.id)).resolves.toBe(true) expect(store.getState().projectGroups.map((group) => group.id)).toEqual([siblingGroup.id]) + expect(store.getState().folderWorkspaces).toEqual([]) expect(store.getState().repos).toMatchObject([ { id: 'direct', projectGroupId: null }, { id: 'nested', projectGroupId: null }, diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 4d9b9c151..cee1ffa9a 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -8,22 +8,31 @@ import type { AppState } from '../types' import type { Repo, ProjectGroup, + FolderWorkspace, ProjectGroupImportResult, NestedRepoScanResult } from '../../../../shared/types' +import { + FOLDER_WORKSPACE_PATH_STATUS_TTL_MS, + type FolderWorkspacePathStatus, + type FolderWorkspacePathStatusRequest +} from '../../../../shared/folder-workspace-path-status' import { isGitRepoKind } from '../../../../shared/repo-kind' import { sanitizeRepoIcon } from '../../../../shared/repo-icon' import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color' import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups' +import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path' import { selectProjectGroupRemovalTargets } from './project-group-removal-targets' import { getRepoIdFromWorktreeId } from './worktree-helpers' import { reconcileFetchedRepos } from './repo-identity-reconcile' import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector' -import { buildDismissedOnboardingFolderAgentStartup } from '@/lib/onboarding-folder-agent-startup' -import { markOnboardingProjectAdded } from '@/lib/onboarding-project-checklist' -import { filterSetupScriptPromptDismissalsToValidRepos } from '@/lib/setup-script-prompt' -import { translate } from '@/i18n/i18n' +import { buildDismissedOnboardingFolderAgentStartup } from '../../lib/onboarding-folder-agent-startup' +import { markOnboardingProjectAdded } from '../../lib/onboarding-project-checklist' +import { filterSetupScriptPromptDismissalsToValidRepos } from '../../lib/setup-script-prompt' +import { translate } from '../../i18n/i18n' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import { formatFolderWorkspaceCreateError } from '../../lib/folder-workspace-path-status' const ERROR_TOAST_DURATION = 60_000 @@ -52,6 +61,12 @@ type NestedRepoScanControls = { onProgress?: (scan: NestedRepoScanResult) => void } +export type FolderWorkspacePathStatusCacheEntry = { + status: FolderWorkspacePathStatus + checkedAt: number + requestSnapshot: string +} + export type DeleteProjectGroupWithContainedProjectsOptions = { removeContainedProjects: boolean } @@ -133,12 +148,122 @@ function getKnownRepoWorktreeIds(state: AppState, projectId: string): string[] { return [...ids] } +function getFolderWorkspacePathStatusScopeKey(request: FolderWorkspacePathStatusRequest): string { + return request.scope === 'project-group' + ? `project-group:${request.projectGroupId}` + : `folder-workspace:${request.folderWorkspaceId}` +} + +function getRuntimeTargetCachePrefix(state: AppState): string { + const target = getActiveRuntimeTarget(state.settings) + return target.kind === 'local' ? 'local' : `environment:${target.environmentId}` +} + +function getFolderWorkspaceStatusRequestSnapshot( + state: Pick, + request: FolderWorkspacePathStatusRequest +): string | null { + const scope = + request.scope === 'project-group' + ? state.projectGroups.find((group) => group.id === request.projectGroupId) + : state.folderWorkspaces.find((workspace) => workspace.id === request.folderWorkspaceId) + const projectGroup = + request.scope === 'project-group' + ? scope && 'parentPath' in scope + ? scope + : null + : scope && 'projectGroupId' in scope + ? state.projectGroups.find((group) => group.id === scope.projectGroupId) + : null + const folderPath = + request.scope === 'project-group' + ? scope && 'parentPath' in scope + ? scope.parentPath + : null + : scope && 'folderPath' in scope + ? scope.folderPath + : null + const projectGroupId = + request.scope === 'project-group' + ? request.projectGroupId + : scope && 'projectGroupId' in scope + ? scope.projectGroupId + : null + const scopeConnectionId = + request.scope === 'project-group' + ? scope && 'parentPath' in scope + ? scope.connectionId + : null + : scope && 'folderPath' in scope + ? (scope.connectionId ?? projectGroup?.connectionId) + : null + if (!folderPath || !projectGroupId) { + return null + } + const groupIds = getProjectGroupSubtreeIds(state.projectGroups, projectGroupId) + const candidateRepos = state.repos.filter( + (repo) => + (typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) || + isPathInsideOrEqual(folderPath, repo.path) + ) + const relevantConnectionIds = new Set() + if (scopeConnectionId) { + relevantConnectionIds.add(scopeConnectionId) + } + for (const repo of candidateRepos) { + if (repo.connectionId) { + relevantConnectionIds.add(repo.connectionId) + } + } + const sshFingerprint = [...relevantConnectionIds] + .map( + (connectionId) => + `${connectionId}:${state.sshConnectionStates.get(connectionId)?.status ?? 'missing'}` + ) + .sort() + .join('|') + const repoFingerprint = candidateRepos + .map( + (repo) => `${repo.id}:${repo.path}:${repo.projectGroupId ?? ''}:${repo.connectionId ?? ''}` + ) + .sort() + .join('|') + return [ + folderPath, + projectGroupId, + scopeConnectionId ?? '', + sshFingerprint, + repoFingerprint + ].join('\0') +} + +function getFreshFolderWorkspacePathStatusFromCache(args: { + entry: FolderWorkspacePathStatusCacheEntry | undefined + requestSnapshot: string | null +}): FolderWorkspacePathStatus | null { + const { entry, requestSnapshot } = args + if (!entry || requestSnapshot === null || entry.requestSnapshot !== requestSnapshot) { + return null + } + return Date.now() - entry.checkedAt < FOLDER_WORKSPACE_PATH_STATUS_TTL_MS ? entry.status : null +} + +function getFolderWorkspacePathStatusRequestSnapshotForRead( + state: AppState, + request: FolderWorkspacePathStatusRequest +): string | null { + return getFolderWorkspaceStatusRequestSnapshot(state, request) +} + export type RepoSlice = { repos: Repo[] projectGroups: ProjectGroup[] + folderWorkspaces: FolderWorkspace[] + folderWorkspacePathStatuses: Record activeRepoId: string | null fetchRepos: () => Promise fetchProjectGroups: () => Promise + fetchFolderWorkspaces: () => Promise addRepo: () => Promise addRepoPath: (path: string, kind?: 'git' | 'folder') => Promise addNonGitFolder: (path: string) => Promise @@ -157,6 +282,46 @@ export type RepoSlice = { mode: 'group' | 'separate' }) => Promise createProjectGroup: (name: string) => Promise + createFolderWorkspace: (args: { + projectGroupId: string + name?: string + folderPath?: string | null + connectionId?: string | null + linkedTask?: FolderWorkspace['linkedTask'] + createdWithAgent?: FolderWorkspace['createdWithAgent'] + pendingFirstAgentMessageRename?: boolean + }) => Promise + getFolderWorkspacePathStatusCacheKey: (request: FolderWorkspacePathStatusRequest) => string + getFreshFolderWorkspacePathStatus: ( + request: FolderWorkspacePathStatusRequest + ) => FolderWorkspacePathStatus | null + fetchFolderWorkspacePathStatus: ( + request: FolderWorkspacePathStatusRequest, + options?: { force?: boolean } + ) => Promise + updateFolderWorkspace: ( + folderWorkspaceId: string, + updates: Partial< + Pick< + FolderWorkspace, + | 'name' + | 'folderPath' + | 'linkedTask' + | 'comment' + | 'isArchived' + | 'isUnread' + | 'isPinned' + | 'sortOrder' + | 'manualOrder' + | 'workspaceStatus' + | 'createdWithAgent' + | 'pendingFirstAgentMessageRename' + | 'firstAgentMessageRenameError' + | 'lastActivityAt' + > + > + ) => Promise + deleteFolderWorkspace: (folderWorkspaceId: string) => Promise updateProjectGroup: ( groupId: string, updates: Partial> @@ -180,6 +345,8 @@ export type RepoSlice = { export const createRepoSlice: StateCreator = (set, get) => ({ repos: [], projectGroups: [], + folderWorkspaces: [], + folderWorkspacePathStatuses: {}, activeRepoId: null, fetchRepos: async () => { @@ -204,6 +371,7 @@ export const createRepoSlice: StateCreator = (set, const reconciledRepos = reconcileFetchedRepos(s.repos, repos) return { repos: reconciledRepos, + folderWorkspacePathStatuses: {}, activeRepoId: s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null, filterRepoIds: s.filterRepoIds.filter((projectId) => validRepoIds.has(projectId)), setupScriptPromptDismissedRepoIds: filterSetupScriptPromptDismissalsToValidRepos( @@ -233,12 +401,84 @@ export const createRepoSlice: StateCreator = (set, } ) ).groups - set({ projectGroups }) + set({ projectGroups, folderWorkspacePathStatuses: {} }) } catch (err) { console.error('Failed to fetch project groups:', err) } }, + fetchFolderWorkspaces: async () => { + try { + const target = getActiveRuntimeTarget(get().settings) + const folderWorkspaces = + target.kind === 'local' + ? await window.api.folderWorkspaces.list() + : ( + await callRuntimeRpc<{ folderWorkspaces: FolderWorkspace[] }>( + target, + 'folderWorkspace.list', + undefined, + { timeoutMs: 15_000 } + ) + ).folderWorkspaces + set({ folderWorkspaces, folderWorkspacePathStatuses: {} }) + } catch (err) { + console.error('Failed to fetch folder workspaces:', err) + } + }, + + getFolderWorkspacePathStatusCacheKey: (request) => + `${getRuntimeTargetCachePrefix(get())}:${getFolderWorkspacePathStatusScopeKey(request)}`, + + getFreshFolderWorkspacePathStatus: (request) => { + const state = get() + const cacheKey = get().getFolderWorkspacePathStatusCacheKey(request) + const cached = state.folderWorkspacePathStatuses[cacheKey] + const requestSnapshot = getFolderWorkspacePathStatusRequestSnapshotForRead(state, request) + return getFreshFolderWorkspacePathStatusFromCache({ entry: cached, requestSnapshot }) + }, + + fetchFolderWorkspacePathStatus: async (request, options) => { + const cacheKey = get().getFolderWorkspacePathStatusCacheKey(request) + const requestSnapshot = getFolderWorkspaceStatusRequestSnapshot(get(), request) + const cached = get().folderWorkspacePathStatuses[cacheKey] + const freshCachedStatus = getFreshFolderWorkspacePathStatusFromCache({ + entry: cached, + requestSnapshot + }) + if (!options?.force && freshCachedStatus) { + return freshCachedStatus + } + try { + const target = getActiveRuntimeTarget(get().settings) + const status = + target.kind === 'local' + ? await window.api.folderWorkspaces.getPathStatus(request) + : ( + await callRuntimeRpc<{ status: FolderWorkspacePathStatus }>( + target, + 'folderWorkspace.getPathStatus', + request, + { timeoutMs: 15_000 } + ) + ).status + set((state) => ({ + folderWorkspacePathStatuses: + requestSnapshot !== null && + getFolderWorkspaceStatusRequestSnapshot(state, request) === requestSnapshot + ? { + ...state.folderWorkspacePathStatuses, + [cacheKey]: { status, checkedAt: Date.now(), requestSnapshot } + } + : state.folderWorkspacePathStatuses + })) + return status + } catch (err) { + console.error('Failed to fetch folder workspace path status:', err) + return null + } + }, + scanNestedRepos: async (path, connectionId, controls) => { try { const target = getActiveRuntimeTarget(get().settings) @@ -311,7 +551,9 @@ export const createRepoSlice: StateCreator = (set, { timeoutMs: 60_000 } ) await get().fetchProjectGroups() + await get().fetchFolderWorkspaces() await get().fetchRepos() + set({ folderWorkspacePathStatuses: {} }) return result } catch (err) { console.error('Failed to import nested repos:', err) @@ -342,7 +584,7 @@ export const createRepoSlice: StateCreator = (set, { timeoutMs: 15_000 } ) ).group - set((s) => ({ projectGroups: [...s.projectGroups, group] })) + set((s) => ({ projectGroups: [...s.projectGroups, group], folderWorkspacePathStatuses: {} })) return group } catch (err) { console.error('Failed to create project group:', err) @@ -350,6 +592,95 @@ export const createRepoSlice: StateCreator = (set, } }, + createFolderWorkspace: async (args) => { + try { + const target = getActiveRuntimeTarget(get().settings) + const workspace = + target.kind === 'local' + ? await window.api.folderWorkspaces.create(args) + : ( + await callRuntimeRpc<{ folderWorkspace: FolderWorkspace }>( + target, + 'folderWorkspace.create', + args, + { timeoutMs: 15_000 } + ) + ).folderWorkspace + set((s) => ({ + folderWorkspaces: [workspace, ...s.folderWorkspaces], + folderWorkspacePathStatuses: {} + })) + return workspace + } catch (err) { + console.error('Failed to create folder workspace:', err) + const { title, description } = formatFolderWorkspaceCreateError(err) + toast.error(title, { description, duration: ERROR_TOAST_DURATION }) + return null + } + }, + + updateFolderWorkspace: async (folderWorkspaceId, updates) => { + try { + const target = getActiveRuntimeTarget(get().settings) + const updated = + target.kind === 'local' + ? await window.api.folderWorkspaces.update({ folderWorkspaceId, updates }) + : ( + await callRuntimeRpc<{ folderWorkspace: FolderWorkspace | null }>( + target, + 'folderWorkspace.update', + { folderWorkspaceId, updates }, + { timeoutMs: 15_000 } + ) + ).folderWorkspace + if (!updated) { + return false + } + set((s) => ({ + folderWorkspaces: s.folderWorkspaces.map((workspace) => + workspace.id === folderWorkspaceId ? updated : workspace + ), + folderWorkspacePathStatuses: {} + })) + return true + } catch (err) { + console.error('Failed to update folder workspace:', err) + return false + } + }, + + deleteFolderWorkspace: async (folderWorkspaceId) => { + try { + const target = getActiveRuntimeTarget(get().settings) + const deleted = + target.kind === 'local' + ? await window.api.folderWorkspaces.delete({ folderWorkspaceId }) + : ( + await callRuntimeRpc<{ deleted: boolean }>( + target, + 'folderWorkspace.delete', + { folderWorkspaceId }, + { timeoutMs: 15_000 } + ) + ).deleted + if (!deleted) { + return false + } + const workspaceKey = folderWorkspaceKey(folderWorkspaceId) + set((s) => ({ + folderWorkspaces: s.folderWorkspaces.filter( + (workspace) => workspace.id !== folderWorkspaceId + ), + folderWorkspacePathStatuses: {} + })) + get().purgeWorktreeTerminalState([workspaceKey]) + return true + } catch (err) { + console.error('Failed to delete folder workspace:', err) + return false + } + }, + updateProjectGroup: async (groupId, updates) => { try { const target = getActiveRuntimeTarget(get().settings) @@ -368,7 +699,8 @@ export const createRepoSlice: StateCreator = (set, return false } set((s) => ({ - projectGroups: s.projectGroups.map((group) => (group.id === groupId ? updated : group)) + projectGroups: s.projectGroups.map((group) => (group.id === groupId ? updated : group)), + folderWorkspacePathStatuses: {} })) return true } catch (err) { @@ -398,11 +730,15 @@ export const createRepoSlice: StateCreator = (set, const deletedGroupIds = getProjectGroupSubtreeIds(s.projectGroups, groupId) return { projectGroups: s.projectGroups.filter((group) => !deletedGroupIds.has(group.id)), + folderWorkspaces: s.folderWorkspaces.filter( + (workspace) => !deletedGroupIds.has(workspace.projectGroupId) + ), repos: s.repos.map((repo) => repo.projectGroupId && deletedGroupIds.has(repo.projectGroupId) ? { ...repo, projectGroupId: null } : repo - ) + ), + folderWorkspacePathStatuses: {} } }) return true @@ -498,7 +834,10 @@ export const createRepoSlice: StateCreator = (set, if (!moved) { return false } - set((s) => ({ repos: s.repos.map((repo) => (repo.id === projectId ? moved : repo)) })) + set((s) => ({ + repos: s.repos.map((repo) => (repo.id === projectId ? moved : repo)), + folderWorkspacePathStatuses: {} + })) return true } catch (err) { console.error('Failed to move repo to group:', err) @@ -548,7 +887,7 @@ export const createRepoSlice: StateCreator = (set, if (s.repos.some((r) => r.id === repo.id)) { return s } - return { repos: [...s.repos, repo] } + return { repos: [...s.repos, repo], folderWorkspacePathStatuses: {} } }) if (alreadyAdded) { toast.info(translate('auto.store.slices.repos.a8e4b3af5b', 'Project already added'), { @@ -751,6 +1090,7 @@ export const createRepoSlice: StateCreator = (set, activeFileId: activeFileCleared ? null : s.activeFileId, activeTabType: activeFileCleared ? 'terminal' : s.activeTabType, lastVisitedAtByWorktreeId: nextLastVisitedAtByWorktreeId, + folderWorkspacePathStatuses: {}, sortEpoch: s.sortEpoch + 1, // Why: removing the last repo while in settings leaves activeView as // 'settings', which renders an empty settings pane instead of Landing. @@ -760,6 +1100,7 @@ export const createRepoSlice: StateCreator = (set, ? { activeView: 'terminal' as const, activeWorktreeId: null, + activeWorkspaceKey: null, activeRepoId: null } : {}) @@ -807,7 +1148,8 @@ export const createRepoSlice: StateCreator = (set, ...updatesWithoutSourceControlAi, ...(sourceControlAi !== undefined ? { sourceControlAi } : {}) } - }) + }), + folderWorkspacePathStatuses: {} })) return true } catch (err) { @@ -849,7 +1191,7 @@ export const createRepoSlice: StateCreator = (set, // Caller passed a non-permutation — refuse to apply locally. return } - set({ repos: next }) + set({ repos: next, folderWorkspacePathStatuses: {} }) try { const target = getActiveRuntimeTarget(get().settings) const result = diff --git a/src/renderer/src/store/slices/settings.ts b/src/renderer/src/store/slices/settings.ts index 9ac3f5fc4..f1fb06940 100644 --- a/src/renderer/src/store/slices/settings.ts +++ b/src/renderer/src/store/slices/settings.ts @@ -53,6 +53,7 @@ function runtimeScopedStateReset(): Partial { return { repos: [], projectGroups: [], + folderWorkspaces: [], activeRepoId: null, sparsePresetsByRepo: {}, sparsePresetsLoadingByRepo: {}, @@ -62,6 +63,7 @@ function runtimeScopedStateReset(): Partial { detectedWorktreesByRepo: {}, worktreeLineageById: {}, activeWorktreeId: null, + activeWorkspaceKey: null, deleteStateByWorktreeId: {}, baseStatusByWorktreeId: {}, remoteBranchConflictByWorktreeId: {}, diff --git a/src/renderer/src/store/slices/tabs.ts b/src/renderer/src/store/slices/tabs.ts index d50973ccf..07058b5a5 100644 --- a/src/renderer/src/store/slices/tabs.ts +++ b/src/renderer/src/store/slices/tabs.ts @@ -29,6 +29,7 @@ import { buildHydratedTabState, pruneTabGroupLayoutForGroups } from './tabs-hydr import { buildOrphanTerminalCleanupPatch, getOrphanTerminalIds } from './terminal-orphan-helpers' import { createBrowserUuid } from '@/lib/browser-uuid' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' export type TabSplitDirection = 'left' | 'right' | 'up' | 'down' @@ -855,6 +856,7 @@ export const createTabsSlice: StateCreator = (set, ...(shouldDeactivateWorktree ? { activeWorktreeId: null, + activeWorkspaceKey: null, activeTabId: null, activeBrowserTabId: null, activeFileId: null, @@ -1818,6 +1820,9 @@ export const createTabsSlice: StateCreator = (set, .map((w) => w.id) ) validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID) + for (const workspace of state.folderWorkspaces) { + validWorktreeIds.add(folderWorkspaceKey(workspace.id)) + } set(buildHydratedTabState(session, validWorktreeIds)) } }) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index c549ca7d1..69e28d1b4 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -8,9 +8,15 @@ import type { TerminalTab, TuiAgent, Worktree, + WorkspaceKey, WorkspaceSessionState } from '../../../../shared/types' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { + folderWorkspaceKey, + parseWorkspaceKey, + worktreeWorkspaceKey +} from '../../../../shared/workspace-scope' import { deriveGeneratedTabTitle } from '../../../../shared/agent-tab-title' import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' import { isValidHostTerminalTabId, isValidTerminalTabId } from '../../../../shared/terminal-tab-id' @@ -39,6 +45,7 @@ import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-cl import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' import { createBrowserUuid } from '@/lib/browser-uuid' +import { getFolderWorkspaceConnectionId } from '@/lib/folder-workspace-connection' import { hasWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' import { sanitizeTerminalLayoutPaneTitles } from '@/lib/terminal-pane-title-sanitization' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' @@ -171,9 +178,16 @@ function resolveCreatedTabShellOverride( } function worktreeUsesWslPath( - state: Pick, + state: Pick, worktreeId: string ): boolean { + const parsed = parseWorkspaceKey(worktreeId) + if (parsed?.type === 'folder') { + const folderWorkspace = state.folderWorkspaces.find( + (workspace) => workspace.id === parsed.folderWorkspaceId + ) + return folderWorkspace ? isWslUncPath(folderWorkspace.folderPath) : false + } const worktree = Object.values(state.worktreesByRepo) .flat() .find((entry) => entry.id === worktreeId) @@ -181,9 +195,13 @@ function worktreeUsesWslPath( } export function worktreeUsesRemoteConnection( - state: Pick, + state: Pick, worktreeId: string ): boolean { + const parsedWorkspaceKey = parseWorkspaceKey(worktreeId) + if (parsedWorkspaceKey?.type === 'folder') { + return Boolean(getFolderWorkspaceConnectionId(state, parsedWorkspaceKey.folderWorkspaceId)) + } const directRepoId = getRepoIdFromWorktreeId(worktreeId) const directRepo = state.repos.find((repo) => repo.id === directRepoId) if (directRepo) { @@ -1968,7 +1986,14 @@ export const createTerminalSlice: StateCreator // its tabs still use the normal terminal session pipeline so daemon PTYs // can survive app restart just like workspace terminals. validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID) + for (const workspace of s.folderWorkspaces) { + validWorktreeIds.add(folderWorkspaceKey(workspace.id)) + } for (const worktreeId of Object.keys(session.tabsByWorktree)) { + const parsedWorkspaceKey = parseWorkspaceKey(worktreeId) + if (parsedWorkspaceKey?.type === 'folder') { + continue + } if (!validWorktreeIds.has(worktreeId)) { const repoId = getRepoIdFromWorktreeId(worktreeId) // Why (#1158): an empty/missing list can mean degraded hydration; a @@ -2041,6 +2066,14 @@ export const createTerminalSlice: StateCreator session.activeWorktreeId && validWorktreeIds.has(session.activeWorktreeId) ? session.activeWorktreeId : null + const activeWorkspaceKey: WorkspaceKey | null = + session.activeWorkspaceKey && validWorktreeIds.has(session.activeWorkspaceKey) + ? session.activeWorkspaceKey + : activeWorktreeId + ? parseWorkspaceKey(activeWorktreeId) + ? (activeWorktreeId as WorkspaceKey) + : worktreeWorkspaceKey(activeWorktreeId) + : null const activeTabId = session.activeTabId && validTabIds.has(session.activeTabId) ? session.activeTabId : null const activeRepoId = @@ -2193,6 +2226,7 @@ export const createTerminalSlice: StateCreator return { activeRepoId, activeWorktreeId, + activeWorkspaceKey, activeTabId, activeTabIdByWorktree, tabsByWorktree, diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index 7efbd51bc..aae2de85d 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -15,7 +15,8 @@ import type { WorktreeBaseStatusEvent, WorktreeLineage, WorktreeRemoteBranchConflictEvent, - WorktreeMeta + WorktreeMeta, + WorkspaceKey } from '../../../../shared/types' import type { TerminalGitHubPRLink } from '@/lib/terminal-github-pr-link-detector' import type { @@ -41,6 +42,7 @@ export type WorktreeSlice = { detectedWorktreesByRepo: Record worktreeLineageById: Record activeWorktreeId: string | null + activeWorkspaceKey: WorkspaceKey | null /** * In-flight / failed background worktree creations, keyed by a renderer * `creationId`. Kept separate from `worktreesByRepo` on purpose — a real @@ -209,6 +211,7 @@ export type WorktreeSlice = { */ seedActiveWorktreeLastVisitedIfMissing: () => void setActiveWorktree: (worktreeId: string | null) => void + setActiveFolderWorkspace: (folderWorkspaceId: string) => void setRenamingWorktreeId: (worktreeId: string | null) => void allWorktrees: () => Worktree[] getKnownWorktreeById: (worktreeId: string) => Worktree | DetectedWorktree | undefined diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 941bc50d3..53ef24c0f 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -7,6 +7,7 @@ import { create } from 'zustand' import type { AppState } from '../types' import type { DetectedWorktreeListResult, + FolderWorkspace, LocalBaseRefRefreshResult, Worktree, WorktreeLineage @@ -89,6 +90,7 @@ import { unregisterPersistentWebview } from '../../components/browser-pane/webview-registry' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' function resetRemoteRuntimeMocks() { clearRuntimeCompatibilityCacheForTests() @@ -200,6 +202,45 @@ function makeLineage(overrides: Partial = {}): WorktreeLineage } } +function makeFolderWorkspace(overrides: Partial = {}): FolderWorkspace { + return { + ...overrides, + id: overrides.id ?? 'folder-1', + projectGroupId: overrides.projectGroupId ?? 'group-1', + name: overrides.name ?? 'platform workspace', + folderPath: overrides.folderPath ?? '/work/platform', + linkedTask: overrides.linkedTask ?? null, + comment: overrides.comment ?? '', + isArchived: overrides.isArchived ?? false, + isUnread: overrides.isUnread ?? false, + isPinned: overrides.isPinned ?? false, + sortOrder: overrides.sortOrder ?? 0, + manualOrder: overrides.manualOrder ?? 0, + lastActivityAt: overrides.lastActivityAt ?? 0, + createdAt: overrides.createdAt ?? 0, + updatedAt: overrides.updatedAt ?? 0, + workspaceStatus: overrides.workspaceStatus ?? 'active' + } +} + +describe('folder workspace lookups', () => { + it('returns a stable synthetic worktree for repeated folder workspace lookups', () => { + const store = createTestStore() + const folderWorkspace = makeFolderWorkspace() + store.setState({ folderWorkspaces: [folderWorkspace] } as Partial) + + const first = store.getState().getKnownWorktreeById(folderWorkspaceKey(folderWorkspace.id)) + const second = store.getState().getKnownWorktreeById(folderWorkspaceKey(folderWorkspace.id)) + + expect(second).toBe(first) + expect(first).toMatchObject({ + id: folderWorkspaceKey(folderWorkspace.id), + displayName: folderWorkspace.name, + path: folderWorkspace.folderPath + }) + }) +}) + describe('setActiveWorktree focus handling', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index c50416597..155d8f13d 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -7,6 +7,7 @@ import type { TerminalPaneLayoutNode, LocalBaseRefRefreshResult, ForceDeleteWorktreeBranchResult, + FolderWorkspace, GitHubPrStartPoint, Worktree, WorkspaceVisibleTabType, @@ -40,6 +41,12 @@ import { markInputQuietSchedulerInput, scheduleAfterInputQuiet } from '@/lib/inp import { showLocalBaseRefUpdateSuggestionToast } from '@/components/sidebar/local-base-ref-suggestion-toast' import { translate } from '@/i18n/i18n' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { + folderWorkspaceKey, + parseWorkspaceKey, + worktreeWorkspaceKey +} from '../../../../shared/workspace-scope' +import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree' export type { WorktreeSlice, WorktreeDeleteState } from './worktree-helpers' // Why: old runtime servers only have `worktree.list`; preserve the large-list @@ -50,6 +57,7 @@ const ACTIVE_WORKTREE_TERMINAL_PREP_INPUT_QUIET_MS = 450 const ACTIVE_WORKTREE_TERMINAL_PREP_IDLE_TIMEOUT_MS = 180 const pendingActivationTerminalPrepCancels = new Map void>() const detachedHeadAutoDerivedDisplayNames = new Map() +const folderWorkspaceWorktreeCache = new WeakMap() function countTerminalLayoutLeaves(node: TerminalPaneLayoutNode | null | undefined): number { if (!node) { @@ -387,9 +395,25 @@ function applyDetectedWorktreeUpdates( } function findKnownWorktreeById( - state: Pick, + state: Pick, worktreeId: string ): Worktree | DetectedWorktreeListResult['worktrees'][number] | undefined { + const workspaceScope = parseWorkspaceKey(worktreeId) + if (workspaceScope?.type === 'folder') { + const folderWorkspace = state.folderWorkspaces.find( + (workspace) => workspace.id === workspaceScope.folderWorkspaceId + ) + if (!folderWorkspace) { + return undefined + } + const cached = folderWorkspaceWorktreeCache.get(folderWorkspace) + if (cached) { + return cached + } + const worktree = folderWorkspaceToWorktree(folderWorkspace) + folderWorkspaceWorktreeCache.set(folderWorkspace, worktree) + return worktree + } const visible = findWorktreeById(state.worktreesByRepo, worktreeId) if (visible) { return visible @@ -403,6 +427,84 @@ function findKnownWorktreeById( return undefined } +function getFolderWorkspaceMetaUpdates( + updates: Partial +): Partial< + Pick< + FolderWorkspace, + | 'name' + | 'comment' + | 'isArchived' + | 'isUnread' + | 'isPinned' + | 'sortOrder' + | 'manualOrder' + | 'lastActivityAt' + | 'workspaceStatus' + | 'createdWithAgent' + | 'pendingFirstAgentMessageRename' + | 'firstAgentMessageRenameError' + > +> { + const next: Partial< + Pick< + FolderWorkspace, + | 'name' + | 'comment' + | 'isArchived' + | 'isUnread' + | 'isPinned' + | 'sortOrder' + | 'manualOrder' + | 'lastActivityAt' + | 'workspaceStatus' + | 'createdWithAgent' + | 'pendingFirstAgentMessageRename' + | 'firstAgentMessageRenameError' + > + > = {} + if (updates.displayName !== undefined) { + next.name = updates.displayName + next.pendingFirstAgentMessageRename = false + next.firstAgentMessageRenameError = null + } + if (updates.comment !== undefined) { + next.comment = updates.comment + next.lastActivityAt = Date.now() + } + if (updates.isArchived !== undefined) { + next.isArchived = updates.isArchived + } + if (updates.isUnread !== undefined) { + next.isUnread = updates.isUnread + } + if (updates.isPinned !== undefined) { + next.isPinned = updates.isPinned + } + if (updates.sortOrder !== undefined) { + next.sortOrder = updates.sortOrder + } + if (updates.manualOrder !== undefined) { + next.manualOrder = updates.manualOrder + } + if (updates.lastActivityAt !== undefined) { + next.lastActivityAt = updates.lastActivityAt + } + if (updates.workspaceStatus !== undefined) { + next.workspaceStatus = updates.workspaceStatus + } + if (updates.createdWithAgent !== undefined) { + next.createdWithAgent = updates.createdWithAgent + } + if (updates.pendingFirstAgentMessageRename !== undefined) { + next.pendingFirstAgentMessageRename = updates.pendingFirstAgentMessageRename + } + if (updates.firstAgentMessageRenameError !== undefined) { + next.firstAgentMessageRenameError = updates.firstAgentMessageRenameError + } + return next +} + function isRuntimeSelectorNotFoundError(error: unknown): boolean { if ( error && @@ -585,11 +687,15 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial() + const doomedBrowserWorkspaceIds = new Set() const removedFileIds = new Set() for (const id of worktreeIdSet) { for (const tab of s.tabsByWorktree[id] ?? []) { doomedTabIds.add(tab.id) } + for (const workspace of s.browserTabsByWorktree[id] ?? []) { + doomedBrowserWorkspaceIds.add(workspace.id) + } } for (const file of s.openFiles) { if (worktreeIdSet.has(file.worktreeId)) { @@ -641,6 +747,17 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial(obj: Record): Record => { + let changed = false + const out = { ...obj } + for (const workspaceId of doomedBrowserWorkspaceIds) { + if (workspaceId in out) { + delete out[workspaceId] + changed = true + } + } + return changed ? out : obj + } const omitByFileId = (obj: Record): Record => { let changed = false const out = { ...obj } @@ -694,6 +811,7 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial detectedWorktreesByRepo: {}, worktreeLineageById: {}, activeWorktreeId: null, + activeWorkspaceKey: null, pendingWorktreeCreations: {}, activePendingCreationId: null, renamingWorktreeId: null, @@ -1649,6 +1770,14 @@ export const createWorktreeSlice: StateCreator if (shouldApplyUpdate && !shouldApplyUpdate(existingWorktree)) { return } + const workspaceScope = parseWorkspaceKey(worktreeId) + if (workspaceScope?.type === 'folder') { + const folderUpdates = getFolderWorkspaceMetaUpdates(updates) + if (Object.keys(folderUpdates).length > 0) { + await get().updateFolderWorkspace(workspaceScope.folderWorkspaceId, folderUpdates) + } + return + } // Why: manual PR linking only supplies the PR number. Resolve the PR head // branch here so Push targets the review branch, but don't repeat that // network lookup for no-op linkedPR metadata saves. @@ -1863,7 +1992,12 @@ export const createWorktreeSlice: StateCreator if (!current || current.isPinned === isPinned) { continue } - updates.set(worktreeId, { isPinned }) + const workspaceScope = parseWorkspaceKey(worktreeId) + if (workspaceScope?.type === 'folder') { + void get().updateWorktreeMeta(worktreeId, { isPinned }) + } else { + updates.set(worktreeId, { isPinned }) + } if (revealWorktreeId === null) { revealWorktreeId = worktreeId } @@ -2200,6 +2334,7 @@ export const createWorktreeSlice: StateCreator if (!worktreeId) { return { activeWorktreeId: null, + activeWorkspaceKey: null, // Why: activating any real worktree (or clearing it) must dismiss the // background-creation panel so the user isn't stranded on it. activePendingCreationId: null @@ -2420,6 +2555,7 @@ export const createWorktreeSlice: StateCreator return { activeWorktreeId: worktreeId, + activeWorkspaceKey: worktreeWorkspaceKey(worktreeId), activePendingCreationId: null, activeFileId, activeBrowserTabId, @@ -2512,6 +2648,115 @@ export const createWorktreeSlice: StateCreator } }, + setActiveFolderWorkspace: (folderWorkspaceId) => { + const workspaceKey = folderWorkspaceKey(folderWorkspaceId) + const workspace = get().folderWorkspaces.find((entry) => entry.id === folderWorkspaceId) + if (!workspace) { + return + } + if (shouldDeferActivationTerminalPrep()) { + markInputQuietSchedulerInput() + } + if (get().activeWorktreeId !== workspaceKey) { + moveFocusToRendererBeforeFocusedWebviewHidden() + } + const reconciledActiveTabId = + get().reconcileWorktreeTabModel(workspaceKey).activeRenderableTabId + set((s) => { + const restoredFileId = s.activeFileIdByWorktree[workspaceKey] ?? null + const restoredBrowserTabId = s.activeBrowserTabIdByWorktree[workspaceKey] ?? null + const restoredTabType = s.activeTabTypeByWorktree[workspaceKey] ?? 'terminal' + const activeGroupId = + s.activeGroupIdByWorktree[workspaceKey] ?? s.groupsByWorktree[workspaceKey]?.[0]?.id ?? null + const activeGroup = activeGroupId + ? ((s.groupsByWorktree[workspaceKey] ?? []).find((group) => group.id === activeGroupId) ?? + null) + : null + const activeUnifiedTabId = reconciledActiveTabId ?? activeGroup?.activeTabId ?? null + const activeUnifiedTab = + activeUnifiedTabId != null + ? ((s.unifiedTabsByWorktree[workspaceKey] ?? []).find( + (tab) => + tab.id === activeUnifiedTabId && (!activeGroup || tab.groupId === activeGroup.id) + ) ?? null) + : null + const fileStillOpen = restoredFileId + ? s.openFiles.some((file) => file.id === restoredFileId && file.worktreeId === workspaceKey) + : false + const browserTabs = s.browserTabsByWorktree[workspaceKey] ?? [] + const browserTabStillOpen = restoredBrowserTabId + ? browserTabs.some((tab) => tab.id === restoredBrowserTabId) + : false + const worktreeTabs = s.tabsByWorktree[workspaceKey] ?? [] + const restoredTabId = s.activeTabIdByWorktree[workspaceKey] ?? null + const tabStillExists = restoredTabId + ? worktreeTabs.some((tab) => tab.id === restoredTabId) + : false + const activeFileId = + activeUnifiedTab?.contentType === 'editor' || + activeUnifiedTab?.contentType === 'diff' || + activeUnifiedTab?.contentType === 'conflict-review' + ? activeUnifiedTab.entityId + : fileStillOpen + ? restoredFileId + : null + const activeBrowserTabId = + activeUnifiedTab?.contentType === 'browser' + ? activeUnifiedTab.entityId + : browserTabStillOpen + ? restoredBrowserTabId + : (browserTabs[0]?.id ?? null) + const activeTabType = + activeUnifiedTab?.contentType === 'terminal' + ? 'terminal' + : activeUnifiedTab?.contentType === 'browser' + ? 'browser' + : activeUnifiedTab + ? 'editor' + : restoredTabType === 'browser' && browserTabStillOpen + ? 'browser' + : restoredTabType === 'editor' && fileStillOpen + ? 'editor' + : fileStillOpen + ? 'editor' + : browserTabs.length > 0 + ? 'browser' + : 'terminal' + const activeTabId = + activeUnifiedTab?.contentType === 'terminal' + ? activeUnifiedTab.entityId + : tabStillExists + ? restoredTabId + : (worktreeTabs[0]?.id ?? null) + const nextEverActivated = s.everActivatedWorktreeIds.has(workspaceKey) + ? s.everActivatedWorktreeIds + : new Set([...s.everActivatedWorktreeIds, workspaceKey]) + return { + activeRepoId: null, + activeWorktreeId: workspaceKey, + activeWorkspaceKey: workspaceKey, + activePendingCreationId: null, + activeFileId, + activeBrowserTabId, + activeTabType, + activeTabTypeByWorktree: + s.activeTabTypeByWorktree[workspaceKey] === activeTabType + ? s.activeTabTypeByWorktree + : { ...s.activeTabTypeByWorktree, [workspaceKey]: activeTabType }, + activeTabId, + everActivatedWorktreeIds: nextEverActivated, + folderWorkspaces: workspace.isUnread + ? s.folderWorkspaces.map((entry) => + entry.id === folderWorkspaceId ? { ...entry, isUnread: false } : entry + ) + : s.folderWorkspaces + } + }) + if (workspace.isUnread) { + void get().updateFolderWorkspace(folderWorkspaceId, { isUnread: false }) + } + }, + allWorktrees: () => Object.values(get().worktreesByRepo).flat(), getKnownWorktreeById: (worktreeId) => findKnownWorktreeById(get(), worktreeId), diff --git a/src/shared/constants.ts b/src/shared/constants.ts index f744a92b2..fc4149281 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -382,6 +382,7 @@ export function getDefaultPersistedState(homedir: string): PersistedState { schemaVersion: SCHEMA_VERSION, repos: [], projectGroups: [], + folderWorkspaces: [], sparsePresetsByRepo: {}, worktreeMeta: {}, worktreeLineageById: {}, diff --git a/src/shared/folder-workspace-path-status.ts b/src/shared/folder-workspace-path-status.ts new file mode 100644 index 000000000..ac7cbf7f8 --- /dev/null +++ b/src/shared/folder-workspace-path-status.ts @@ -0,0 +1,34 @@ +export type FolderWorkspacePathStatusReason = + | 'missing' + | 'not-directory' + | 'unavailable' + | 'ambiguous-connection' + +export const FOLDER_WORKSPACE_PATH_STATUS_TTL_MS = 10_000 + +export type FolderWorkspacePathStatusRequest = + | { scope: 'folder-workspace'; folderWorkspaceId: string } + | { scope: 'project-group'; projectGroupId: string } + +export type FolderWorkspacePathStatus = { + path: string + exists: boolean + reason?: FolderWorkspacePathStatusReason +} + +export function isConfirmedStaleFolderPathStatus( + status: FolderWorkspacePathStatus | null | undefined +): boolean { + return ( + status?.exists === false && (status.reason === 'missing' || status.reason === 'not-directory') + ) +} + +export function blocksFolderWorkspaceActivation( + status: FolderWorkspacePathStatus | null | undefined +): boolean { + return ( + isConfirmedStaleFolderPathStatus(status) || + (status?.exists === false && status.reason === 'ambiguous-connection') + ) +} diff --git a/src/shared/folder-workspace-worktree.test.ts b/src/shared/folder-workspace-worktree.test.ts new file mode 100644 index 000000000..05aab2f8e --- /dev/null +++ b/src/shared/folder-workspace-worktree.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest' +import type { FolderWorkspace } from './types' +import { folderWorkspaceToWorktree } from './folder-workspace-worktree' + +function makeFolderWorkspace(overrides: Partial = {}): FolderWorkspace { + return { + ...overrides, + id: overrides.id ?? 'folder-workspace-1', + projectGroupId: overrides.projectGroupId ?? 'group-1', + name: overrides.name ?? 'Refund fix', + folderPath: overrides.folderPath ?? '/workspace/platform', + linkedTask: overrides.linkedTask ?? null, + comment: overrides.comment ?? '', + isArchived: overrides.isArchived ?? false, + isUnread: overrides.isUnread ?? false, + isPinned: overrides.isPinned ?? false, + sortOrder: overrides.sortOrder ?? 1, + manualOrder: overrides.manualOrder, + workspaceStatus: overrides.workspaceStatus, + lastActivityAt: overrides.lastActivityAt ?? 2, + createdAt: overrides.createdAt ?? 3, + updatedAt: overrides.updatedAt ?? 4 + } +} + +describe('folderWorkspaceToWorktree', () => { + it('projects attached issue tasks without creating linked PR metadata', () => { + const githubIssue = folderWorkspaceToWorktree( + makeFolderWorkspace({ + linkedTask: { + provider: 'github', + type: 'issue', + number: 42, + title: 'Refund flow fails', + url: 'https://github.com/acme/app/issues/42' + } + }) + ) + const gitlabIssue = folderWorkspaceToWorktree( + makeFolderWorkspace({ + linkedTask: { + provider: 'gitlab', + type: 'issue', + number: 7, + title: 'Import fails', + url: 'https://gitlab.com/acme/app/-/issues/7' + } + }) + ) + + expect(githubIssue).toMatchObject({ + linkedIssue: 42, + linkedPR: null, + linkedGitLabMR: null, + linkedGitLabIssue: null + }) + expect(gitlabIssue).toMatchObject({ + linkedIssue: null, + linkedPR: null, + linkedGitLabMR: null, + linkedGitLabIssue: 7 + }) + }) + + it('projects Linear tasks by identifier', () => { + const worktree = folderWorkspaceToWorktree( + makeFolderWorkspace({ + linkedTask: { + provider: 'linear', + type: 'issue', + number: 0, + title: 'Polish folder workspaces', + url: 'https://linear.app/acme/issue/ENG-123', + linearIdentifier: 'ENG-123' + } + }) + ) + + expect(worktree.linkedLinearIssue).toBe('ENG-123') + expect(worktree.linkedPR).toBeNull() + expect(worktree.linkedGitLabMR).toBeNull() + }) + + it('projects first-message rename state for folder workspace cards', () => { + const worktree = folderWorkspaceToWorktree( + makeFolderWorkspace({ + createdWithAgent: 'codex', + pendingFirstAgentMessageRename: true, + firstAgentMessageRenameError: 'No model configured' + }) + ) + + expect(worktree).toMatchObject({ + createdWithAgent: 'codex', + pendingFirstAgentMessageRename: true, + firstAgentMessageRenameError: 'No model configured' + }) + }) + + it('keeps review-style tasks attached only to the folder workspace record', () => { + const githubPr = folderWorkspaceToWorktree( + makeFolderWorkspace({ + linkedTask: { + provider: 'github', + type: 'pr', + number: 99, + title: 'Feature branch', + url: 'https://github.com/acme/app/pull/99' + } + }) + ) + const gitlabMr = folderWorkspaceToWorktree( + makeFolderWorkspace({ + linkedTask: { + provider: 'gitlab', + type: 'mr', + number: 12, + title: 'Feature branch', + url: 'https://gitlab.com/acme/app/-/merge_requests/12' + } + }) + ) + + expect(githubPr.linkedPR).toBeNull() + expect(githubPr.linkedIssue).toBeNull() + expect(gitlabMr.linkedGitLabMR).toBeNull() + expect(gitlabMr.linkedGitLabIssue).toBeNull() + }) +}) diff --git a/src/shared/folder-workspace-worktree.ts b/src/shared/folder-workspace-worktree.ts new file mode 100644 index 000000000..efc38b08a --- /dev/null +++ b/src/shared/folder-workspace-worktree.ts @@ -0,0 +1,37 @@ +import type { FolderWorkspace, Worktree } from './types' +import { folderWorkspaceKey } from './workspace-scope' + +export function folderWorkspaceToWorktree(folderWorkspace: FolderWorkspace): Worktree { + const linkedTask = folderWorkspace.linkedTask + return { + id: folderWorkspaceKey(folderWorkspace.id), + repoId: `folder-workspace:${folderWorkspace.projectGroupId}`, + displayName: folderWorkspace.name, + comment: folderWorkspace.comment, + linkedIssue: + linkedTask?.provider === 'github' && linkedTask.type === 'issue' ? linkedTask.number : null, + linkedPR: null, + linkedLinearIssue: + linkedTask?.provider === 'linear' ? (linkedTask.linearIdentifier ?? null) : null, + linkedGitLabMR: null, + linkedGitLabIssue: + linkedTask?.provider === 'gitlab' && linkedTask.type === 'issue' ? linkedTask.number : null, + isArchived: folderWorkspace.isArchived, + isUnread: folderWorkspace.isUnread, + isPinned: folderWorkspace.isPinned, + sortOrder: folderWorkspace.sortOrder, + manualOrder: folderWorkspace.manualOrder, + lastActivityAt: folderWorkspace.lastActivityAt, + createdAt: folderWorkspace.createdAt, + createdWithAgent: folderWorkspace.createdWithAgent, + pendingFirstAgentMessageRename: folderWorkspace.pendingFirstAgentMessageRename, + firstAgentMessageRenameError: folderWorkspace.firstAgentMessageRenameError, + workspaceStatus: folderWorkspace.workspaceStatus, + path: folderWorkspace.folderPath, + head: '', + branch: '', + isBare: false, + isSparse: false, + isMainWorktree: false + } +} diff --git a/src/shared/folder-workspaces.ts b/src/shared/folder-workspaces.ts new file mode 100644 index 000000000..81594ee2c --- /dev/null +++ b/src/shared/folder-workspaces.ts @@ -0,0 +1,144 @@ +import type { FolderWorkspace, FolderWorkspaceLinkedTask, ProjectGroup } from './types' +import { isTuiAgent } from './tui-agent-config' + +export function normalizeFolderWorkspaceName( + name: string | null | undefined, + fallback = 'Untitled workspace' +): string { + const trimmed = typeof name === 'string' ? name.trim() : '' + return trimmed.length > 0 ? trimmed : fallback +} + +export function normalizeFolderWorkspaceLinkedTask( + value: unknown +): FolderWorkspaceLinkedTask | null { + if (!value || typeof value !== 'object') { + return null + } + const raw = value as Partial + if ( + raw.provider !== 'github' && + raw.provider !== 'gitlab' && + raw.provider !== 'linear' && + raw.provider !== 'jira' + ) { + return null + } + if (raw.type !== 'issue' && raw.type !== 'pr' && raw.type !== 'mr') { + return null + } + if ( + typeof raw.number !== 'number' || + !Number.isFinite(raw.number) || + typeof raw.title !== 'string' || + raw.title.trim().length === 0 || + typeof raw.url !== 'string' || + raw.url.trim().length === 0 + ) { + return null + } + return { + provider: raw.provider, + type: raw.type, + number: raw.number, + title: raw.title.trim(), + url: raw.url.trim(), + ...(typeof raw.linearIdentifier === 'string' && raw.linearIdentifier.trim().length > 0 + ? { linearIdentifier: raw.linearIdentifier.trim() } + : {}), + ...(typeof raw.jiraIdentifier === 'string' && raw.jiraIdentifier.trim().length > 0 + ? { jiraIdentifier: raw.jiraIdentifier.trim() } + : {}), + ...(typeof raw.repoId === 'string' && raw.repoId.trim().length > 0 + ? { repoId: raw.repoId.trim() } + : {}) + } +} + +export function normalizeFolderWorkspaces( + value: unknown, + projectGroups: readonly ProjectGroup[] +): FolderWorkspace[] { + if (!Array.isArray(value)) { + return [] + } + const folderGroups = new Map() + for (const group of projectGroups) { + if (group.parentPath) { + folderGroups.set(group.id, group) + } + } + + const workspaces: FolderWorkspace[] = [] + const seen = new Set() + for (const candidate of value) { + if (!candidate || typeof candidate !== 'object') { + continue + } + const raw = candidate as Partial + if ( + typeof raw.id !== 'string' || + raw.id.trim().length === 0 || + seen.has(raw.id) || + typeof raw.projectGroupId !== 'string' || + !folderGroups.has(raw.projectGroupId) + ) { + continue + } + const group = folderGroups.get(raw.projectGroupId) + const folderPath = + typeof raw.folderPath === 'string' && raw.folderPath.trim().length > 0 + ? raw.folderPath + : group?.parentPath + if (!folderPath) { + continue + } + const now = Date.now() + seen.add(raw.id) + workspaces.push({ + id: raw.id, + projectGroupId: raw.projectGroupId, + name: normalizeFolderWorkspaceName(raw.name), + folderPath, + connectionId: + typeof raw.connectionId === 'string' + ? raw.connectionId + : raw.connectionId === null + ? null + : (group?.connectionId ?? null), + linkedTask: normalizeFolderWorkspaceLinkedTask(raw.linkedTask), + comment: typeof raw.comment === 'string' ? raw.comment : '', + isArchived: raw.isArchived === true, + isUnread: raw.isUnread === true, + isPinned: raw.isPinned === true, + sortOrder: + typeof raw.sortOrder === 'number' && Number.isFinite(raw.sortOrder) ? raw.sortOrder : now, + ...(typeof raw.manualOrder === 'number' && Number.isFinite(raw.manualOrder) + ? { manualOrder: raw.manualOrder } + : {}), + ...(typeof raw.workspaceStatus === 'string' && raw.workspaceStatus.trim().length > 0 + ? { workspaceStatus: raw.workspaceStatus } + : {}), + ...(isTuiAgent(raw.createdWithAgent) ? { createdWithAgent: raw.createdWithAgent } : {}), + ...(raw.pendingFirstAgentMessageRename === true + ? { pendingFirstAgentMessageRename: true } + : {}), + ...(typeof raw.firstAgentMessageRenameError === 'string' + ? { firstAgentMessageRenameError: raw.firstAgentMessageRenameError } + : raw.firstAgentMessageRenameError === null + ? { firstAgentMessageRenameError: null } + : {}), + lastActivityAt: + typeof raw.lastActivityAt === 'number' && Number.isFinite(raw.lastActivityAt) + ? raw.lastActivityAt + : 0, + createdAt: + typeof raw.createdAt === 'number' && Number.isFinite(raw.createdAt) ? raw.createdAt : now, + updatedAt: + typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt) ? raw.updatedAt : now + }) + } + return workspaces.sort( + (left, right) => right.sortOrder - left.sortOrder || left.name.localeCompare(right.name) + ) +} diff --git a/src/shared/project-groups.ts b/src/shared/project-groups.ts index 4a208c685..fd6ef19ae 100644 --- a/src/shared/project-groups.ts +++ b/src/shared/project-groups.ts @@ -18,6 +18,7 @@ export function normalizeProjectGroupName(name: string, fallback = 'Untitled gro export function createProjectGroup(input: { name: string parentPath?: string | null + connectionId?: string | null parentGroupId?: string | null createdFrom: ProjectGroupCreatedFrom tabOrder: number @@ -28,6 +29,7 @@ export function createProjectGroup(input: { id: createProjectGroupId(), name: normalizeProjectGroupName(input.name), parentPath: input.parentPath ?? null, + connectionId: input.connectionId ?? null, parentGroupId: input.parentGroupId ?? null, createdFrom: input.createdFrom, tabOrder: input.tabOrder, @@ -58,6 +60,12 @@ export function normalizeProjectGroups(value: unknown): ProjectGroup[] { id: raw.id, name: normalizeProjectGroupName(typeof raw.name === 'string' ? raw.name : ''), parentPath: typeof raw.parentPath === 'string' ? raw.parentPath : null, + connectionId: + typeof raw.connectionId === 'string' + ? raw.connectionId + : raw.connectionId === null + ? null + : null, parentGroupId: typeof raw.parentGroupId === 'string' ? raw.parentGroupId : null, createdFrom: raw.createdFrom === 'manual' || diff --git a/src/shared/types.ts b/src/shared/types.ts index f50b39f63..cde79b12f 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -136,6 +136,8 @@ export type ProjectGroup = { id: string name: string parentPath: string | null + /** SSH target ID for folder-backed groups imported from a remote root. */ + connectionId?: string | null parentGroupId: string | null createdFrom: ProjectGroupCreatedFrom tabOrder: number @@ -145,6 +147,47 @@ export type ProjectGroup = { updatedAt: number } +export type WorkspaceScope = + | { type: 'worktree'; worktreeId: string } + | { type: 'folder'; folderWorkspaceId: string } + +export type WorkspaceKey = `worktree:${string}` | `folder:${string}` + +export type FolderWorkspace = { + id: string + projectGroupId: string + name: string + folderPath: string + /** SSH target ID for folder workspaces whose folder path lives remotely. */ + connectionId?: string | null + linkedTask: FolderWorkspaceLinkedTask | null + comment: string + isArchived: boolean + isUnread: boolean + isPinned: boolean + sortOrder: number + /** User-authored sidebar ordering. Higher values render earlier in Manual sort. */ + manualOrder?: number + workspaceStatus?: WorkspaceStatus + createdWithAgent?: TuiAgent + pendingFirstAgentMessageRename?: boolean + firstAgentMessageRenameError?: string | null + lastActivityAt: number + createdAt: number + updatedAt: number +} + +export type FolderWorkspaceLinkedTask = { + provider: 'github' | 'gitlab' | 'linear' | 'jira' + type: 'issue' | 'pr' | 'mr' + number: number + title: string + url: string + linearIdentifier?: string + jiraIdentifier?: string + repoId?: string +} + export type NestedRepoScanOptions = { maxDepth?: number maxRepos?: number @@ -699,8 +742,11 @@ export type PersistedOpenFile = { export type WorkspaceSessionState = { activeRepoId: string | null + /** Scope-aware active owner for folder workspaces. Legacy worktree UI still reads activeWorktreeId. */ + activeWorkspaceKey?: WorkspaceKey | null activeWorktreeId: string | null activeTabId: string | null + /** Keys may be legacy raw worktree IDs or canonical WorkspaceKey values. */ tabsByWorktree: Record terminalLayoutsByTabId: Record /** Worktree IDs that had at least one tab with a live PTY at shutdown. @@ -2890,6 +2936,7 @@ export type PersistedState = { schemaVersion: number repos: Repo[] projectGroups: ProjectGroup[] + folderWorkspaces: FolderWorkspace[] /** Sparse-checkout presets keyed by repoId. Empty record on first launch; * presets are managed from the new-workspace composer and repo settings. */ sparsePresetsByRepo: Record diff --git a/src/shared/workspace-scope.ts b/src/shared/workspace-scope.ts new file mode 100644 index 000000000..289657226 --- /dev/null +++ b/src/shared/workspace-scope.ts @@ -0,0 +1,31 @@ +import type { WorkspaceKey, WorkspaceScope } from './types' + +export function worktreeWorkspaceKey(worktreeId: string): WorkspaceKey { + return `worktree:${worktreeId}` +} + +export function folderWorkspaceKey(folderWorkspaceId: string): WorkspaceKey { + return `folder:${folderWorkspaceId}` +} + +export function workspaceKeyFromScope(scope: WorkspaceScope): WorkspaceKey { + return scope.type === 'worktree' + ? worktreeWorkspaceKey(scope.worktreeId) + : folderWorkspaceKey(scope.folderWorkspaceId) +} + +export function parseWorkspaceKey(value: string): WorkspaceScope | null { + if (value.startsWith('worktree:')) { + const worktreeId = value.slice('worktree:'.length) + return worktreeId.length > 0 ? { type: 'worktree', worktreeId } : null + } + if (value.startsWith('folder:')) { + const folderWorkspaceId = value.slice('folder:'.length) + return folderWorkspaceId.length > 0 ? { type: 'folder', folderWorkspaceId } : null + } + return null +} + +export function isWorkspaceKey(value: string): value is WorkspaceKey { + return parseWorkspaceKey(value) !== null +} diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index 4c935d18c..20fb3f84c 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -14,12 +14,14 @@ import type { TabGroupLayoutNode, TerminalPaneLayoutNode, TuiAgent, + WorkspaceKey, WorkspaceSessionState } from './types' import { isValidTerminalTabId } from './terminal-tab-id' import { isTuiAgent } from './tui-agent-config' import { normalizeBrowserHistoryEntries } from './workspace-session-browser-history' import { normalizeAgentProviderSession, RESUMABLE_TUI_AGENTS } from './agent-session-resume' +import { isWorkspaceKey } from './workspace-scope' // ─── Terminal pane layout (recursive) ─────────────────────────────── @@ -28,6 +30,9 @@ const terminalTabIdSchema = z .string() .min(1) .refine(isValidTerminalTabId, 'terminal tab id must not contain ":"') +const workspaceKeySchema = z.custom( + (value) => typeof value === 'string' && isWorkspaceKey(value) +) // Why: z.lazy + type annotation keeps the recursive inference working without // forcing zod to resolve the whole tree at definition time. @@ -262,6 +267,7 @@ const browserHistoryEntriesSchema = z export const workspaceSessionStateSchema: z.ZodType = z.object({ activeRepoId: z.string().nullable(), + activeWorkspaceKey: workspaceKeySchema.nullable().optional(), activeWorktreeId: z.string().nullable(), activeTabId: z.string().nullable(), tabsByWorktree: z.record(z.string(), z.array(terminalTabSchema)),