From deb6a71b296cf03de5dd29be96b3678232ba5f64 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:41:52 -0700 Subject: [PATCH] Show folder workspaces in the Cmd+J jump palette (#28175) * Show folder workspaces in the Cmd+J jump palette Cmd+J only listed git worktrees, so folder workspaces could not be found or opened from it. The palette now includes folder workspaces alongside git worktrees and renames the section from "Worktrees" to "Workspaces" to match. - Folder rows are searchable by name, comment, and attached GitHub item, and follow the sidebar's host scope. - A blocked activation (for example, a folder path that no longer exists) keeps the palette open and shows only the path error, instead of closing or showing a second generic error. - Folder path-status caching and sidebar reveal are keyed by execution host, so folders with the same id on different hosts no longer share cache entries or reveal the wrong group. * Consolidate runtime target imports in folder-path-status `getRuntimeTargetHostId` now comes from the same module as `runtimeTargetForOwnerEnvironment`, so both are imported from `runtime/runtime-client-target` instead of splitting them across two modules. * Wake sleeping folder workspaces from Cmd+J Picking a sleeping folder workspace from the jump palette did not restart its terminal or restore its Files explorer view. Folder and git worktree activation now share one code path in workspace-activation.ts, so both wake the same way. - Wake a sleeping folder the same way as a worktree: bump the tab generation and tag the pending spawn when no PTY is live, including when the workspace is already selected. - Restore the remembered right-sidebar explorer view for folders. - Skip the GitHub PR refresh for folder workspaces, which have no PR. - Clear unread only on the folder entry whose execution host matches the activation. * Reveal folder workspaces on their catalog owner host Cmd+J activation revealed folder workspaces against the focused runtime server's host, so a folder owned by a local or SSH project could be revealed under an unrelated server. The reveal host now comes from the folder workspace's catalog owner, and an explicit caller host still wins. Add a regression test covering local and SSH owners while another runtime environment is focused. * Keep shared workspace activation in the existing layout writer --- .../WorktreeJumpPalette.recent-tabs.test.tsx | 4 +- .../components/WorktreeJumpPalette.test.tsx | 261 +++++++++++++++++- .../cmd-j/palette-host-badge.test.ts | 24 +- .../components/cmd-j/palette-host-badge.ts | 14 +- .../folder-workspace-composer-expiry.test.tsx | 15 +- ...er-workspace-composer-path-status.test.tsx | 10 +- .../navigation/folder-reveal.test.ts | 33 +++ .../worktree-list/navigation/folder-reveal.ts | 21 +- .../navigation/pending-reveal-inputs.ts | 3 +- .../components/tab-bar/TabBarCreateEntry.tsx | 3 + .../open-tab-selection-routing.test.ts | 13 + .../tab-bar/open-tab-selection-routing.ts | 10 + ...ree-jump-palette-browser-ownership.test.ts | 2 +- .../use-worktree-jump-palette-controller.ts | 4 +- .../use-worktree-jump-palette-filter.ts | 17 +- .../use-worktree-jump-palette-list-entries.ts | 4 +- .../use-worktree-jump-palette-open-tabs.ts | 14 +- ...e-worktree-jump-palette-project-targets.ts | 6 +- ...worktree-jump-palette-selection-actions.ts | 16 +- .../use-worktree-jump-palette-store-state.ts | 14 +- .../use-worktree-jump-palette-worktrees.ts | 41 +-- ...ee-jump-palette-browser-simulator-rows.tsx | 5 +- .../worktree-jump-palette-create-worktree.ts | 20 +- .../worktree-jump-palette-document-index.ts | 8 +- ...jump-palette-interleaved-sections.test.tsx | 6 +- ...ktree-jump-palette-project-action-rows.tsx | 7 +- .../worktree-jump-palette-surface.tsx | 4 +- .../worktree-jump-palette-test-fixtures.ts | 20 ++ ...orktree-jump-palette-workspace-tab-row.tsx | 3 +- .../worktree-jump-palette-worktree-row.tsx | 9 +- src/renderer/src/i18n/locales/en.json | 8 +- src/renderer/src/i18n/locales/es.json | 8 +- src/renderer/src/i18n/locales/fr.json | 8 +- src/renderer/src/i18n/locales/ja.json | 8 +- src/renderer/src/i18n/locales/ko.json | 8 +- src/renderer/src/i18n/locales/zh.json | 8 +- .../browser-page-palette-activation.test.ts | 35 ++- .../lib/browser-page-palette-activation.ts | 9 +- .../github-work-item-workspace-attachment.ts | 5 + .../simulator-tab-palette-activation.test.ts | 29 +- .../lib/simulator-tab-palette-activation.ts | 7 +- .../workspace-activation-path-gate.test.ts | 170 ++++++++++++ ...space-tab-palette-activation.store.test.ts | 2 +- .../workspace-tab-palette-activation.test.ts | 27 +- .../lib/workspace-tab-palette-activation.ts | 9 +- src/renderer/src/lib/worktree-activation.ts | 49 +--- .../lib/worktree-initial-terminal-seeding.ts | 28 ++ .../worktree-palette-attachment-urls.test.ts | 26 ++ .../folder-path-status-actions.ts | 7 +- .../folder-path-status-ownership.test.ts | 148 ++++++++++ .../folder-workspaces/folder-path-status.ts | 182 ++++++------ .../store/slices/repos-project-groups.test.ts | 6 +- .../workspace-activation-preparation.test.ts | 130 +++++++++ .../session/set-active-folder-workspace.ts | 67 +---- .../worktrees/session/set-active-worktree.ts | 22 +- .../e2e/folder-workspace-palette-wake.spec.ts | 104 +++++++ .../e2e/worktree-jump-palette-filter.spec.ts | 2 +- ...ree-jump-palette-folder-workspaces.spec.ts | 73 +++++ 58 files changed, 1411 insertions(+), 385 deletions(-) create mode 100644 src/renderer/src/store/folder-workspaces/folder-path-status-ownership.test.ts create mode 100644 src/renderer/src/store/slices/workspace-activation-preparation.test.ts create mode 100644 tests/e2e/folder-workspace-palette-wake.spec.ts create mode 100644 tests/e2e/worktree-jump-palette-folder-workspaces.spec.ts diff --git a/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx b/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx index 0571cead58f..2e37a290ffc 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx @@ -257,7 +257,7 @@ describe('WorktreeJumpPalette recent chats & terminals', () => { expect(rows[0].startsWith(encodePaletteIdentity(['workspace-tab']))).toBe(true) expect(rows.some((id) => id.startsWith(encodePaletteIdentity(['worktree'])))).toBe(true) expect(testContainer.textContent).toContain('Recent Chats & Terminals') - expect(testContainer.textContent).toContain('Recent Worktrees') + expect(testContainer.textContent).toContain('Recent Workspaces') }) it('keeps duplicate persisted tab ids as separate recent rows and digit targets', async () => { @@ -284,7 +284,7 @@ describe('WorktreeJumpPalette recent chats & terminals', () => { await renderPalette(makeManyTabState(12)) expect(getTabRowIds()).toHaveLength(6) - expect(testContainer.textContent).toContain('Recent Worktrees') + expect(testContainer.textContent).toContain('Recent Workspaces') // Why: the worktree section shrinks against the recent rows so the list holds at 10 total — // it must never uncap, not even for the frame before the order snapshot lands. expect(getWorktreeRows().length).toBeLessThanOrEqual(4) diff --git a/src/renderer/src/components/WorktreeJumpPalette.test.tsx b/src/renderer/src/components/WorktreeJumpPalette.test.tsx index cfe5de95832..4fbe2eac965 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.test.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.test.tsx @@ -8,14 +8,24 @@ import type * as ReactI18Next from 'react-i18next' import { useAppStore } from '@/store' import type { AppState } from '@/store/types' import { encodePaletteIdentity } from '@/lib/palette-match/palette-ranking' +import type { activateAndRevealWorkspace as ActivateWorkspace } from '@/lib/worktree-activation' +import type { WorkspaceTabPaletteActivationResult } from '@/lib/workspace-tab-palette-activation' +import { toast } from 'sonner' import WorktreeJumpPalette from './WorktreeJumpPalette' -import { makeRepo, makeWorktree } from './worktree-jump-palette-test-fixtures' +import { + makeFolderWorkspace, + makeGroup, + makeRepo, + makeTerminalTab, + makeUnifiedTab, + makeWorktree +} from './worktree-jump-palette-test-fixtures' -const { activateAndRevealWorktree } = vi.hoisted(() => ({ - activateAndRevealWorktree: vi.fn(() => false) +const { activateAndRevealWorkspace } = vi.hoisted(() => ({ + activateAndRevealWorkspace: vi.fn(() => ({ primaryTabId: null })) })) -vi.mock('@/lib/worktree-activation', () => ({ activateAndRevealWorktree })) +vi.mock('@/lib/worktree-activation', () => ({ activateAndRevealWorkspace })) vi.mock('react-i18next', async (importOriginal) => { const actual = await importOriginal() @@ -49,14 +59,12 @@ vi.mock('@/components/repo/RepoBadgeLabel', () => ({ RepoBadgeMark: () => })) -vi.mock('@/components/cmd-j/palette-host-badge', () => ({ - getPaletteHostBadge: () => null -})) - // Why: activation reaches into window.api and the whole worktree-reveal path; the palette's own // contract is which result it hands over, so stub the boundary and assert on that. const { activateWorkspaceTabPaletteResult } = vi.hoisted(() => ({ - activateWorkspaceTabPaletteResult: vi.fn((_result: unknown) => ({ status: 'activated' }) as const) + activateWorkspaceTabPaletteResult: vi.fn( + (_result: unknown): WorkspaceTabPaletteActivationResult => ({ status: 'activated' }) + ) })) vi.mock('@/lib/workspace-tab-palette-activation', () => ({ activateWorkspaceTabPaletteResult: (result: unknown) => activateWorkspaceTabPaletteResult(result) @@ -193,7 +201,9 @@ describe('WorktreeJumpPalette', () => { beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true setCommandQuery = null - activateAndRevealWorktree.mockClear() + activateAndRevealWorkspace.mockReset().mockReturnValue({ primaryTabId: null }) + activateWorkspaceTabPaletteResult.mockReset().mockReturnValue({ status: 'activated' }) + vi.mocked(toast.error).mockClear() useAppStore.setState(initialAppState, true) testContainer = document.createElement('div') document.body.appendChild(testContainer) @@ -208,6 +218,231 @@ describe('WorktreeJumpPalette', () => { useAppStore.setState(initialAppState, true) }) + it('lists actual folder workspaces with no repositories or active terminals', async () => { + await renderPalette({ + repos: [], + worktreesByRepo: {}, + folderWorkspaces: [makeFolderWorkspace()], + showSleepingWorkspaces: false, + hideDefaultBranchWorkspace: true, + hideDetachedHeadWorkspaces: true + }) + + expect(getWorktreeRows()).toHaveLength(1) + expect(getWorktreeRows()[0]).toContain('Design workspace') + expect(testContainer.textContent).not.toContain('Loading') + }) + + it('orders folders and git worktrees together by visit recency', async () => { + await renderPalette({ + worktreesByRepo: { 'repo-1': [makeWorktree('git', 'Git workspace')] }, + folderWorkspaces: [makeFolderWorkspace()], + showSleepingWorkspaces: true, + lastVisitedAtByWorktreeId: { 'local|folder:folder-1': 20, git: 10 } + }) + + expect(getWorktreeRows()).toHaveLength(2) + expect(getWorktreeRows()[0]).toContain('Design workspace') + expect(getWorktreeRows()[1]).toContain('Git workspace') + }) + + it('searches folder names and comments but never returns archived folders', async () => { + await renderPalette({ + worktreesByRepo: {}, + folderWorkspaces: [ + makeFolderWorkspace({ comment: 'Prototype checkout flow' }), + makeFolderWorkspace({ id: 'archived', name: 'Archived design', isArchived: true }) + ] + }) + + await act(async () => setCommandQuery?.('design')) + await flushEffects() + expect(getWorktreeRows()).toHaveLength(1) + expect(getWorktreeRows()[0]).toContain('Design workspace') + await act(async () => setCommandQuery?.('checkout')) + await flushEffects() + expect(getWorktreeRows()[0]).toContain('Design workspace') + expect(getWorktreeRows()[0]).toContain('Prototype checkout flow') + }) + + it('excludes only the current host when folder ids collide', async () => { + await renderPalette({ + worktreesByRepo: {}, + folderWorkspaces: [ + makeFolderWorkspace({ name: 'Local design', executionHostId: 'local' }), + makeFolderWorkspace({ name: 'Remote design', executionHostId: 'runtime:builder' }) + ], + activeWorktreeId: 'folder:folder-1', + activeWorkspaceExecutionHostId: 'local' + }) + expect(getWorktreeRows()).toHaveLength(1) + expect(getWorktreeRows()[0]).toContain('Remote design') + + await act(async () => setCommandQuery?.('design')) + await flushEffects() + expect(getWorktreeRows()).toHaveLength(2) + expect(getWorktreeRows().find((row) => row.includes('Local design'))).toContain('Current') + const remote = testContainer.querySelector( + `[data-command-item="${encodePaletteIdentity(['worktree', 'runtime:builder|folder:folder-1'])}"]` + ) + await act(async () => fireEvent.click(remote!)) + expect(activateAndRevealWorkspace).toHaveBeenCalledWith('folder:folder-1', { + navigationIntent: 'user-open', + executionHostId: 'runtime:builder' + }) + }) + + it('filters remote folders by host and searches their visible host badge without a repo', async () => { + await renderPalette({ + repos: [], + worktreesByRepo: {}, + folderWorkspaces: [ + makeFolderWorkspace(), + makeFolderWorkspace({ id: 'remote', name: 'Remote design', executionHostId: 'ssh:box' }) + ], + sshTargetLabels: new Map([['box', 'Builder']]), + visibleWorkspaceHostIds: ['ssh:box'] + }) + + expect(getWorktreeRows()).toHaveLength(1) + expect(getWorktreeRows()[0]).toContain('Remote design') + expect(getWorktreeRows()[0]).toContain('Builder') + await act(async () => setCommandQuery?.('Builder')) + await flushEffects() + expect(getWorktreeRows()).toHaveLength(1) + expect(getWorktreeRows()[0]).toContain('Remote design') + expect(getWorktreeRows()[0]).toContain('Builder') + }) + + it('excludes folders when a repository filter is selected', async () => { + await renderPalette({ + worktreesByRepo: { 'repo-1': [makeWorktree('git', 'Git workspace')] }, + folderWorkspaces: [makeFolderWorkspace()], + showSleepingWorkspaces: true, + filterRepoIds: ['repo-1'] + }) + + expect(getWorktreeRows()).toHaveLength(1) + expect(getWorktreeRows()[0]).toContain('Git workspace') + await act(async () => setCommandQuery?.('Design')) + await flushEffects() + expect(getWorktreeRows()).toEqual([]) + }) + + it('includes hosts owned only by folders in the host registry', async () => { + await renderPalette({ + repos: [], + worktreesByRepo: {}, + folderWorkspaces: [makeFolderWorkspace({ executionHostId: 'runtime:builder' })], + visibleWorkspaceHostIds: ['runtime:builder'] + }) + + expect(getWorktreeRows()).toHaveLength(1) + expect(getWorktreeRows()[0]).toContain('builder') + }) + + it('refreshes the folder catalog while the palette is open', async () => { + await renderPalette({ repos: [], worktreesByRepo: {}, folderWorkspaces: [] }) + await act(async () => { + useAppStore.setState({ folderWorkspaces: [makeFolderWorkspace()] }) + }) + expect(getWorktreeRows()).toHaveLength(1) + expect(getWorktreeRows()[0]).toContain('Design workspace') + await act(async () => useAppStore.setState({ folderWorkspaces: [] })) + expect(getWorktreeRows()).toEqual([]) + }) + + it('keeps the palette open when folder activation is blocked', async () => { + await renderPalette({ worktreesByRepo: {}, folderWorkspaces: [makeFolderWorkspace()] }) + activateAndRevealWorkspace.mockReturnValue(false) + const row = testContainer.querySelector( + `[data-command-item="${encodePaletteIdentity(['worktree', 'local|folder:folder-1'])}"]` + ) + + await act(async () => fireEvent.click(row!)) + expect(testContainer.querySelector('[data-command-dialog]')).not.toBeNull() + expect(useAppStore.getState().activeModal).toBe('worktree-palette') + }) + + it.each([true, false])( + 'routes an attached folder from a GitHub number (activation %s)', + async (opens) => { + await renderPalette({ + worktreesByRepo: {}, + folderWorkspaces: [ + makeFolderWorkspace({ + linkedItems: [ + { + provider: 'github', + type: 'issue', + number: 42, + title: 'Checkout', + repoId: 'repo-1', + url: 'https://github.com/acme/product/issues/42' + } + ] + }) + ] + }) + activateAndRevealWorkspace.mockReturnValue(opens ? { primaryTabId: null } : false) + await act(async () => setCommandQuery?.('#42')) + await flushEffects() + const create = testContainer.querySelector( + '[data-command-item="__create_worktree__"]' + ) + await act(async () => fireEvent.click(create!)) + + expect(activateAndRevealWorkspace).toHaveBeenCalledWith('folder:folder-1', { + executionHostId: 'local' + }) + expect(useAppStore.getState().activeModal).toBe(opens ? 'none' : 'worktree-palette') + } + ) + + it('includes open terminal tabs from folder workspaces in search', async () => { + const workspaceId = 'folder:folder-1' + await renderPalette({ + worktreesByRepo: {}, + folderWorkspaces: [makeFolderWorkspace()], + tabsByWorktree: { [workspaceId]: [makeTerminalTab('term-1', workspaceId, 'Research chat')] }, + unifiedTabsByWorktree: { + [workspaceId]: [makeUnifiedTab('tab-1', workspaceId, 'term-1', 'Research chat')] + }, + groupsByWorktree: { [workspaceId]: [makeGroup(workspaceId, ['tab-1'])] }, + activeGroupIdByWorktree: { [workspaceId]: `group-${workspaceId}` } + }) + + await act(async () => setCommandQuery?.('Research')) + await flushEffects() + expect(testContainer.textContent).toContain('Research chat') + expect(testContainer.textContent).toContain('Design workspace') + }) + + it('keeps a blocked folder tab jump open without a second error', async () => { + const workspaceId = 'folder:folder-1' + await renderPalette({ + worktreesByRepo: {}, + folderWorkspaces: [makeFolderWorkspace()], + tabsByWorktree: { [workspaceId]: [makeTerminalTab('term-1', workspaceId, 'Research chat')] }, + unifiedTabsByWorktree: { + [workspaceId]: [makeUnifiedTab('tab-1', workspaceId, 'term-1', 'Research chat')] + }, + groupsByWorktree: { [workspaceId]: [makeGroup(workspaceId, ['tab-1'])] } + }) + activateWorkspaceTabPaletteResult.mockReturnValue({ status: 'blocked' }) + await act(async () => setCommandQuery?.('Research')) + await flushEffects() + const row = testContainer.querySelector( + `[data-command-item^="${encodePaletteIdentity(['workspace-tab'])}"]` + ) + expect(row).not.toBeNull() + await act(async () => fireEvent.click(row!)) + + expect(activateWorkspaceTabPaletteResult).toHaveBeenCalled() + expect(testContainer.querySelector('[data-command-dialog]')).not.toBeNull() + expect(toast.error).not.toHaveBeenCalled() + }) + it('keeps every inactive main workspace visible when sleeping workspaces are hidden', async () => { const defaultBranch = makeWorktree('default-branch', 'Default branch workspace', { isMainWorktree: true, @@ -420,7 +655,7 @@ describe('WorktreeJumpPalette', () => { // The first row names ITS OWN host — the wrong-host open is gone. await act(async () => fireEvent.click(rows[0]!)) - expect(activateAndRevealWorktree).toHaveBeenLastCalledWith('shared', { + expect(activateAndRevealWorkspace).toHaveBeenLastCalledWith('shared', { navigationIntent: 'user-open', executionHostId: 'local' }) @@ -443,7 +678,7 @@ describe('WorktreeJumpPalette', () => { expect(rows).toHaveLength(2) await act(async () => fireEvent.click(rows[1]!)) - expect(activateAndRevealWorktree).toHaveBeenLastCalledWith('shared', { + expect(activateAndRevealWorkspace).toHaveBeenLastCalledWith('shared', { navigationIntent: 'user-open', executionHostId: 'ssh:box' }) @@ -488,7 +723,7 @@ describe('WorktreeJumpPalette', () => { expect(hubBRow).not.toBeNull() await act(async () => fireEvent.click(hubARow!)) - expect(activateAndRevealWorktree).toHaveBeenLastCalledWith('shared-runtime', { + expect(activateAndRevealWorkspace).toHaveBeenLastCalledWith('shared-runtime', { navigationIntent: 'user-open', executionHostId: 'runtime:hub-a' }) diff --git a/src/renderer/src/components/cmd-j/palette-host-badge.test.ts b/src/renderer/src/components/cmd-j/palette-host-badge.test.ts index 586c3d54641..efe01ed6699 100644 --- a/src/renderer/src/components/cmd-j/palette-host-badge.test.ts +++ b/src/renderer/src/components/cmd-j/palette-host-badge.test.ts @@ -20,7 +20,7 @@ describe('getPaletteHostBadge', () => { settings: { activeRuntimeEnvironmentId: null } }) - expect(getPaletteHostBadge({ connectionId: null }, hosts)).toBeNull() + expect(getPaletteHostBadge('local', hosts)).toBeNull() }) it('returns null when the only non-local host is configured but disconnected', () => { @@ -32,7 +32,7 @@ describe('getPaletteHostBadge', () => { // No connection state -> the SSH host is 'disconnected', so there's nothing // live to disambiguate from and rows stay badge-free. - expect(getPaletteHostBadge({ connectionId: null }, hosts)).toBeNull() + expect(getPaletteHostBadge('local', hosts)).toBeNull() }) it('badges a disconnected host anyway when a host filter is applied', () => { @@ -44,7 +44,7 @@ describe('getPaletteHostBadge', () => { // Why: with a host filter on, the badge is the only thing explaining which // rows survived, so liveness must not suppress it. - expect(getPaletteHostBadge({ connectionId: 'ssh-1' }, hosts, true)).toEqual({ + expect(getPaletteHostBadge('ssh:ssh-1', hosts, true)).toEqual({ hostId: 'ssh:ssh-1', label: 'Builder' }) @@ -58,7 +58,7 @@ describe('getPaletteHostBadge', () => { settings: { activeRuntimeEnvironmentId: null } }) - expect(getPaletteHostBadge({ connectionId: null }, hosts)).toEqual({ + expect(getPaletteHostBadge('local', hosts)).toEqual({ hostId: 'local', label: LOCAL_HOST_LABEL }) @@ -72,7 +72,7 @@ describe('getPaletteHostBadge', () => { settings: { activeRuntimeEnvironmentId: null } }) - expect(getPaletteHostBadge({ connectionId: 'ssh-1' }, hosts)).toEqual({ + expect(getPaletteHostBadge('ssh:ssh-1', hosts)).toEqual({ hostId: 'ssh:ssh-1', label: 'Builder' }) @@ -104,7 +104,7 @@ describe('getPaletteHostBadge', () => { ]) }) - expect(getPaletteHostBadge({ executionHostId: 'runtime:env-1' }, hosts)).toEqual({ + expect(getPaletteHostBadge('runtime:env-1', hosts)).toEqual({ hostId: 'runtime:env-1', label: 'env-1' }) @@ -117,10 +117,10 @@ describe('getPaletteHostBadge', () => { settings: { activeRuntimeEnvironmentId: null } }) - expect(getPaletteHostBadge({ connectionId: null }, hosts)).toBeNull() + expect(getPaletteHostBadge('local', hosts)).toBeNull() }) - it('maps repos with no executionHostId/connectionId to local', () => { + it('labels an explicitly local workspace', () => { const hosts = buildSidebarHostOptions({ repos: [{ connectionId: 'ssh-1' }], sshTargetLabels: new Map([['ssh-1', 'Builder']]), @@ -128,13 +128,13 @@ describe('getPaletteHostBadge', () => { settings: { activeRuntimeEnvironmentId: null } }) - expect(getPaletteHostBadge({}, hosts)).toEqual({ + expect(getPaletteHostBadge('local', hosts)).toEqual({ hostId: 'local', label: LOCAL_HOST_LABEL }) }) - it('returns null when the repo is missing', () => { + it('returns null when the host is unknown', () => { const hosts = buildSidebarHostOptions({ repos: [{ connectionId: 'ssh-1' }], sshTargetLabels: new Map([['ssh-1', 'Builder']]), @@ -154,8 +154,8 @@ it.each(['connecting', 'blocked', 'disconnected', 'error'] as const)( sshTargetLabels: new Map(), settings: { activeRuntimeEnvironmentId: null } }).map((host) => (host.kind === 'runtime' ? { ...host, health } : host)) - expect(getPaletteHostBadge({ connectionId: null }, hosts)).toBeNull() - expect(getPaletteHostBadge({ executionHostId: 'runtime:env-1' }, hosts, true)).toEqual({ + expect(getPaletteHostBadge('local', hosts)).toBeNull() + expect(getPaletteHostBadge('runtime:env-1', hosts, true)).toEqual({ hostId: 'runtime:env-1', label: 'env-1' }) diff --git a/src/renderer/src/components/cmd-j/palette-host-badge.ts b/src/renderer/src/components/cmd-j/palette-host-badge.ts index 01a9e33d3ac..cd22181f7af 100644 --- a/src/renderer/src/components/cmd-j/palette-host-badge.ts +++ b/src/renderer/src/components/cmd-j/palette-host-badge.ts @@ -1,9 +1,4 @@ -import type { Repo } from '../../../../shared/repo-types' -import { - getRepoExecutionHostId, - LOCAL_EXECUTION_HOST_ID, - type ExecutionHostId -} from '../../../../shared/execution-host' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host' import type { SidebarHostOption } from '../sidebar/sidebar-host-options' export type PaletteHostBadge = { @@ -22,19 +17,18 @@ function hasActiveRemoteHost(hostOptions: readonly SidebarHostOption[]): boolean } export function getPaletteHostBadge( - repo: Pick | null | undefined, + hostId: ExecutionHostId | null | undefined, hostOptions: readonly SidebarHostOption[], // Why: with a host filter applied the badge is the only thing explaining which // rows survived, so it must show even when every remote is disconnected. alwaysShowHostLabel = false ): PaletteHostBadge | null { - if (!repo || (!alwaysShowHostLabel && !hasActiveRemoteHost(hostOptions))) { + if (!alwaysShowHostLabel && !hasActiveRemoteHost(hostOptions)) { return null } - const hostId = getRepoExecutionHostId(repo) const host = hostOptions.find((option) => option.id === hostId) if (!host) { return null } - return { hostId, label: host.label } + return { hostId: host.id, label: host.label } } diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-expiry.test.tsx b/src/renderer/src/components/sidebar/folder-workspace-composer-expiry.test.tsx index 1361252d5ff..08f5847911c 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-expiry.test.tsx +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-expiry.test.tsx @@ -176,7 +176,9 @@ describe('folder composer cache expiry requests', () => { ) it('forces the initial check even when a matching fresh result is already cached', async () => { - const snapshot = getFolderWorkspaceStatusRequestSnapshot(useAppStore.getState(), request) + const snapshot = getFolderWorkspaceStatusRequestSnapshot(useAppStore.getState(), request, { + runtimeEnvironmentId: null + }) expect(snapshot).not.toBeNull() useAppStore.setState({ folderWorkspacePathStatuses: { @@ -226,7 +228,9 @@ describe('folder composer cache expiry requests', () => { }) it('keeps a real pending check pending when fresh cache is removed after unrelated expiry', async () => { - const snapshot = getFolderWorkspaceStatusRequestSnapshot(useAppStore.getState(), request) + const snapshot = getFolderWorkspaceStatusRequestSnapshot(useAppStore.getState(), request, { + runtimeEnvironmentId: null + }) expect(snapshot).not.toBeNull() useAppStore.setState((state) => ({ folderWorkspacePathStatuses: { @@ -251,7 +255,12 @@ describe('folder composer cache expiry requests', () => { it('uses the selected runtime cache entry instead of a same-group local entry', async () => { const fetchStatus = vi.fn().mockResolvedValue(null) - const snapshot = getFolderWorkspaceStatusRequestSnapshot(useAppStore.getState(), request) + useAppStore.setState({ + projectGroups: [group, { ...group, executionHostId: 'runtime:env-1' }] + }) + const snapshot = getFolderWorkspaceStatusRequestSnapshot(useAppStore.getState(), request, { + runtimeEnvironmentId: 'env-1' + }) expect(snapshot).not.toBeNull() const makeEntry = (checkedAt: number): FolderWorkspacePathStatusCacheEntry => ({ checkedAt, 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 index 959f447928c..d771fa99d57 100644 --- 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 @@ -5,6 +5,7 @@ import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' import type { ProjectGroup } from '../../../../shared/project-group-types' import { useAppStore } from '@/store' +import { getFolderWorkspaceStatusRequestSnapshot } from '@/store/folder-workspaces/folder-path-status' import { useFolderWorkspaceComposerPathStatus } from './folder-workspace-composer-path-status' const initialState = useAppStore.getInitialState() @@ -22,7 +23,14 @@ const projectGroup: ProjectGroup = { createdAt: 1, updatedAt: 1 } -const projectGroupRequestSnapshot = '/workspace/platform\0group-1\0\0\0' +const projectGroupRequestSnapshot = getFolderWorkspaceStatusRequestSnapshot( + { ...initialState, projectGroups: [projectGroup] }, + { scope: 'project-group', projectGroupId: projectGroup.id }, + { runtimeEnvironmentId: null } +) +if (projectGroupRequestSnapshot === null) { + throw new Error('Project group fixture must have a path status snapshot') +} let root: Root | null = null let container: HTMLDivElement | null = null diff --git a/src/renderer/src/components/sidebar/worktree-list/navigation/folder-reveal.test.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/folder-reveal.test.ts index fd51f5e0fd9..37b001543df 100644 --- a/src/renderer/src/components/sidebar/worktree-list/navigation/folder-reveal.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list/navigation/folder-reveal.test.ts @@ -118,6 +118,39 @@ describe('worktree list folder reveal', () => { ) ).toEqual([getProjectGroupHeaderKey(root.id), getProjectGroupHeaderKey(child.id)]) }) + + it.each(['local', 'ssh:builder', 'runtime:builder'] as const)( + 'expands only the %s owner when folder and group ids repeat across hosts', + (executionHostId) => { + const otherHostId = executionHostId === 'local' ? 'runtime:other' : 'local' + const root = makeProjectGroup({ id: 'selected-root', executionHostId }) + const child = makeProjectGroup({ + id: 'group-child', + parentGroupId: root.id, + executionHostId + }) + const otherRoot = makeProjectGroup({ id: 'other-root', executionHostId: otherHostId }) + const otherChild = makeProjectGroup({ + id: child.id, + parentGroupId: otherRoot.id, + executionHostId: otherHostId + }) + const folderWorkspace = makeFolderWorkspace({ executionHostId }) + + expect( + getFolderWorkspaceRevealGroupKeys( + folderWorkspaceKey(folderWorkspace.id), + [{ ...folderWorkspace, executionHostId: otherHostId }, folderWorkspace], + [root, child, otherRoot, otherChild], + { executionHostId, defaultHostId: 'local' } + ) + ).toEqual([ + getProjectGroupHeaderKey(root.id), + getProjectGroupHeaderKey(child.id), + `host:${executionHostId}` + ]) + } + ) }) describe('reveal keys under non-repo grouping', () => { diff --git a/src/renderer/src/components/sidebar/worktree-list/navigation/folder-reveal.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/folder-reveal.ts index 18e28d1d4a9..01efe54d7fd 100644 --- a/src/renderer/src/components/sidebar/worktree-list/navigation/folder-reveal.ts +++ b/src/renderer/src/components/sidebar/worktree-list/navigation/folder-reveal.ts @@ -14,7 +14,10 @@ import { getExecutionHostIdFromWorktreeHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity' import { getProjectGroupHeaderKey } from '../grouping/group-keys' -import type { ExecutionHostId } from '../../../../../../shared/execution-host' +import { + getRepoExecutionHostId, + type ExecutionHostId +} from '../../../../../../shared/execution-host' import { getFolderWorkspaceLaneKey } from '../grouping/folder-workspace-lanes' import type { WorktreeGroupBy } from '../grouping/row-types' import { getFolderWorkspaceHostId } from '../../folder-workspace-host-id' @@ -88,14 +91,26 @@ export function getFolderWorkspaceRevealGroupKeys( workspaceStatuses?: readonly WorkspaceStatusDefinition[] defaultHostId?: ExecutionHostId hostScopedGroups?: boolean + executionHostId?: ExecutionHostId } ): string[] { - const folderWorkspace = findFolderWorkspaceByKey(worktreeId, folderWorkspaces) + const folderWorkspace = findFolderWorkspaceByKey( + worktreeId, + folderWorkspaces, + options?.executionHostId + ) if (!folderWorkspace) { return [] } - const groupsById = new Map(projectGroups.map((group) => [group.id, group])) + const groupsById = new Map( + projectGroups + .filter( + (group) => + !options?.executionHostId || getRepoExecutionHostId(group) === options.executionHostId + ) + .map((group) => [group.id, group]) + ) const keys: string[] = [] const seen = new Set() let groupId: string | null = folderWorkspace.projectGroupId diff --git a/src/renderer/src/components/sidebar/worktree-list/navigation/pending-reveal-inputs.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/pending-reveal-inputs.ts index 09d38d20ccf..465e6928d9d 100644 --- a/src/renderer/src/components/sidebar/worktree-list/navigation/pending-reveal-inputs.ts +++ b/src/renderer/src/components/sidebar/worktree-list/navigation/pending-reveal-inputs.ts @@ -63,7 +63,8 @@ export function expandGroupsForWorktreeReveal( groupBy: args.groupBy, workspaceStatuses: args.workspaceStatuses, defaultHostId: args.defaultHostId, - hostScopedGroups + hostScopedGroups, + executionHostId } ) if (folderGroupKeys.length > 0) { diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx index 0c1562513b8..aaf0c74210c 100644 --- a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx @@ -211,6 +211,9 @@ function TabBarCreateEntrySession({ } if (selectedOption.kind === 'tab') { const outcome = activateOpenTabSearchResult(selectedOption.option) + if (outcome.status === 'blocked') { + return + } if (outcome.status === 'failed') { setSwitchError(outcome.message) return diff --git a/src/renderer/src/components/tab-bar/open-tab-selection-routing.test.ts b/src/renderer/src/components/tab-bar/open-tab-selection-routing.test.ts index 5ab615702d1..ce3b754a8bc 100644 --- a/src/renderer/src/components/tab-bar/open-tab-selection-routing.test.ts +++ b/src/renderer/src/components/tab-bar/open-tab-selection-routing.test.ts @@ -167,6 +167,19 @@ describe('activateOpenTabSearchResult', () => { }) }) + it.each([terminalResult, browserResult, simulatorResult])( + 'preserves a blocked $source jump without scheduling focus', + (result) => { + mocks.activateWorkspaceTab.mockReturnValue({ status: 'blocked' }) + mocks.activateBrowserPage.mockReturnValue({ status: 'blocked' }) + mocks.activateSimulatorTab.mockReturnValue({ status: 'blocked' }) + + expect(activateOpenTabSearchResult(result)).toEqual({ status: 'blocked' }) + expect(mocks.focusTerminalTabSurface).not.toHaveBeenCalled() + expect(mocks.requestBrowserFocus).not.toHaveBeenCalled() + } + ) + it('reports a missing worktree distinguishably from a stale tab', () => { mocks.activateWorkspaceTab.mockReturnValue({ status: 'failed', reason: 'missing-worktree' }) mocks.activateBrowserPage.mockReturnValue({ status: 'failed', reason: 'missing-worktree' }) diff --git a/src/renderer/src/components/tab-bar/open-tab-selection-routing.ts b/src/renderer/src/components/tab-bar/open-tab-selection-routing.ts index 3c5aa14cd0b..88e8c8d3981 100644 --- a/src/renderer/src/components/tab-bar/open-tab-selection-routing.ts +++ b/src/renderer/src/components/tab-bar/open-tab-selection-routing.ts @@ -11,6 +11,7 @@ import type { OpenTabSearchResult } from './open-tab-search' export type OpenTabSelectionOutcome = | { status: 'activated'; focus: (() => void) | null } + | { status: 'blocked' } | { status: 'failed'; message: string } // Every source distinguishes a dead workspace from a target that went stale @@ -36,6 +37,9 @@ export function activateOpenTabSearchResult(result: OpenTabSearchResult): OpenTa workspaceId: result.workspaceId, worktreeId: result.worktreeId }) + if (activation.status === 'blocked') { + return activation + } if (activation.status === 'failed') { return failed( activation.reason, @@ -58,6 +62,9 @@ export function activateOpenTabSearchResult(result: OpenTabSearchResult): OpenTa tabId: result.tabId, worktreeId: result.worktreeId }) + if (activation.status === 'blocked') { + return activation + } if (activation.status === 'failed') { return failed( activation.reason, @@ -78,6 +85,9 @@ export function activateOpenTabSearchResult(result: OpenTabSearchResult): OpenTa tabId: result.tabId, worktreeId: result.worktreeId }) + if (activation.status === 'blocked') { + return activation + } if (activation.status === 'failed') { return failed( activation.reason, diff --git a/src/renderer/src/components/use-worktree-jump-palette-browser-ownership.test.ts b/src/renderer/src/components/use-worktree-jump-palette-browser-ownership.test.ts index c8aea613f03..bb5909556aa 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-browser-ownership.test.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-browser-ownership.test.ts @@ -46,7 +46,7 @@ it('keeps same-id browser results on their owner with recency, and follows owner // The store holds {key, result}; the hook takes the unwrapped result. workspacePortScan: null, paletteStatusInputsActive: true, - allWorktrees: worktrees, + workspaces: worktrees, browserSortedWorktrees: worktrees, repoMap: new Map(), repoByHostIdentity: new Map(), diff --git a/src/renderer/src/components/use-worktree-jump-palette-controller.ts b/src/renderer/src/components/use-worktree-jump-palette-controller.ts index 97d3391e011..a6cec2fe1f0 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-controller.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-controller.ts @@ -32,7 +32,7 @@ export function useWorktreeJumpPaletteController({ () => ({ query: localState.paletteSearchQuery, agentStatus: storeState.agentStatusByPaneKey, - worktrees: storeState.allWorktrees, + worktrees: storeState.workspaces, browserPages: storeState.browserPagesByWorkspace, browserWorkspaces: storeState.browserTabsByWorktree, openFiles: storeState.openFiles, @@ -44,7 +44,7 @@ export function useWorktreeJumpPaletteController({ [ localState.paletteSearchQuery, storeState.agentStatusByPaneKey, - storeState.allWorktrees, + storeState.workspaces, storeState.browserPagesByWorkspace, storeState.browserTabsByWorktree, storeState.openFiles, diff --git a/src/renderer/src/components/use-worktree-jump-palette-filter.ts b/src/renderer/src/components/use-worktree-jump-palette-filter.ts index c9eefe0ed7a..a7f9d4d1683 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-filter.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-filter.ts @@ -7,6 +7,7 @@ import { isPaletteFilterActive } from '@/components/cmd-j/palette-filter' import { getRepoHostIdentity } from '@/store/slices/repo-host-identity' +import { getPaletteWorktreeExecutionHostId } from '@/lib/palette-repo-resolution' import { getHostDisplayLabelOverrides } from '../../../shared/host-setting-overrides' import { getSettingsFocusedExecutionHostId } from '../../../shared/execution-host' import type { WorktreeJumpPaletteLocalState } from './use-worktree-jump-palette-local-state' @@ -20,7 +21,7 @@ type WorktreeJumpPaletteFilterInput = Pick< | 'sshConnectionStates' | 'runtimeEnvironments' | 'runtimeStatusByEnvironmentId' - | 'allWorktrees' + | 'workspaces' | 'projects' | 'projectHostSetups' | 'projectGroups' @@ -34,7 +35,7 @@ export function useWorktreeJumpPaletteFilter({ sshConnectionStates, runtimeEnvironments, runtimeStatusByEnvironmentId, - allWorktrees, + workspaces, projects, projectHostSetups, projectGroups, @@ -49,7 +50,12 @@ export function useWorktreeJumpPaletteFilter({ const hostOptions = useMemo( () => buildSidebarHostOptions({ - repos, + repos: [ + ...repos, + ...workspaces.map((workspace) => ({ + executionHostId: getPaletteWorktreeExecutionHostId(workspace) + })) + ], sshTargetLabels, sshConnectionStates, settings, @@ -59,6 +65,7 @@ export function useWorktreeJumpPaletteFilter({ }), [ repos, + workspaces, sshTargetLabels, sshConnectionStates, settings, @@ -73,13 +80,13 @@ export function useWorktreeJumpPaletteFilter({ () => buildPaletteFilterModel({ repos, - worktrees: allWorktrees, + worktrees: workspaces, hostOptions, projects, projectHostSetups, defaultHostId }), - [allWorktrees, defaultHostId, hostOptions, projectHostSetups, projects, repos] + [workspaces, defaultHostId, hostOptions, projectHostSetups, projects, repos] ) const filterActive = isPaletteFilterActive(filter) const hostFilterActive = filter.hostIds.length > 0 diff --git a/src/renderer/src/components/use-worktree-jump-palette-list-entries.ts b/src/renderer/src/components/use-worktree-jump-palette-list-entries.ts index 074794c148f..a09759c9818 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-list-entries.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-list-entries.ts @@ -83,10 +83,10 @@ export function useWorktreeJumpPaletteListEntries({ id: `__header_worktrees__${idSuffix}`, type: 'section-header', label: hasQuery - ? translate('auto.components.WorktreeJumpPalette.worktreesHeader', 'Worktrees') + ? translate('auto.components.WorktreeJumpPalette.worktreesHeader', 'Workspaces') : translate( 'auto.components.WorktreeJumpPalette.recentWorktreesHeader', - 'Recent Worktrees' + 'Recent Workspaces' ) }) } diff --git a/src/renderer/src/components/use-worktree-jump-palette-open-tabs.ts b/src/renderer/src/components/use-worktree-jump-palette-open-tabs.ts index d6c62711670..eb13e3967b9 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-open-tabs.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-open-tabs.ts @@ -49,7 +49,7 @@ type WorktreeJumpPaletteOpenTabsInput = WorktreeJumpPaletteStoreState & export function useWorktreeJumpPaletteOpenTabs({ paletteStatusInputsActive, browserSortedWorktrees, - allWorktrees, + workspaces, repoMap, repoByHostIdentity, worktreeOrder, @@ -87,7 +87,7 @@ export function useWorktreeJumpPaletteOpenTabs({ } return buildSearchableBrowserPages({ worktrees: browserSortedWorktrees, - ownershipWorktrees: allWorktrees, + ownershipWorktrees: workspaces, repoMap, repoMapByHostIdentity: repoByHostIdentity, worktreeOrder, @@ -105,7 +105,7 @@ export function useWorktreeJumpPaletteOpenTabs({ activeTabType, activeWorktreeId, activeWorkspaceExecutionHostId, - allWorktrees, + workspaces, browserPagesByWorkspace, browserTabsByWorktree, browserSortedWorktrees, @@ -127,7 +127,7 @@ export function useWorktreeJumpPaletteOpenTabs({ } return buildSearchableSimulatorTabs({ worktrees: browserSortedWorktrees, - ownershipWorktrees: allWorktrees, + ownershipWorktrees: workspaces, repoMap, repoMapByHostIdentity: repoByHostIdentity, worktreeOrder, @@ -144,7 +144,7 @@ export function useWorktreeJumpPaletteOpenTabs({ activeTabType, activeWorktreeId, activeWorkspaceExecutionHostId, - allWorktrees, + workspaces, browserSortedWorktrees, groupsByWorktree, repoByHostIdentity, @@ -165,7 +165,7 @@ export function useWorktreeJumpPaletteOpenTabs({ } return buildSearchableWorkspaceTabs({ worktrees: browserSortedWorktrees, - ownershipWorktrees: allWorktrees, + ownershipWorktrees: workspaces, repoMap, repoMapByHostIdentity: repoByHostIdentity, worktreeOrder, @@ -200,7 +200,7 @@ export function useWorktreeJumpPaletteOpenTabs({ activeTabTypeByWorktree, activeWorktreeId, activeWorkspaceExecutionHostId, - allWorktrees, + workspaces, agentStatusByPaneKey, browserSortedWorktrees, groupsByWorktree, diff --git a/src/renderer/src/components/use-worktree-jump-palette-project-targets.ts b/src/renderer/src/components/use-worktree-jump-palette-project-targets.ts index 9db8c7e9c3b..6304959d38a 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-project-targets.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-project-targets.ts @@ -24,7 +24,7 @@ type WorktreeJumpPaletteProjectTargetsInput = WorktreeJumpPaletteStoreState & export function useWorktreeJumpPaletteProjectTargets({ settingsSections, pluginCommands, - allWorktrees, + workspaces, repos, worktreesByRepo, detectedWorktreesByRepo, @@ -53,7 +53,7 @@ export function useWorktreeJumpPaletteProjectTargets({ ) const renderableProjectRepoIds = useMemo(() => { const ids = new Set() - for (const worktree of allWorktrees) { + for (const worktree of workspaces) { if (!worktree.isArchived) { ids.add(worktree.repoId) } @@ -75,7 +75,7 @@ export function useWorktreeJumpPaletteProjectTargets({ } return ids }, [ - allWorktrees, + workspaces, detectedWorktreesByRepo, pendingWorktreeCreations, repos, diff --git a/src/renderer/src/components/use-worktree-jump-palette-selection-actions.ts b/src/renderer/src/components/use-worktree-jump-palette-selection-actions.ts index b8ca94caca7..6e663c1fe6b 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-selection-actions.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-selection-actions.ts @@ -3,7 +3,7 @@ import { toast } from 'sonner' import { activateBrowserPagePaletteResult } from '@/lib/browser-page-palette-activation' import { activateSimulatorTabPaletteResult } from '@/lib/simulator-tab-palette-activation' import { activateWorkspaceTabPaletteResult } from '@/lib/workspace-tab-palette-activation' -import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { activateAndRevealWorkspace } from '@/lib/worktree-activation' import { queueWorkspaceActivationTerminalFocus } from '@/lib/workspace-activation-terminal-focus' import type { BrowserPaletteSearchResult } from '@/lib/browser-palette-search' import type { SimulatorPaletteSearchResult } from '@/lib/simulator-palette-search' @@ -65,10 +65,13 @@ export function useWorktreeJumpPaletteSelectionActions({ ) return } - const activation = activateAndRevealWorktree(worktree.id, { + const activation = activateAndRevealWorkspace(worktree.id, { navigationIntent: 'user-open', ...(executionHostId ? { executionHostId } : {}) }) + if (!activation) { + return + } recordFeatureInteraction('cmd-j-workspace-open') skipRestoreFocusRef.current = true closeModal() @@ -83,6 +86,9 @@ export function useWorktreeJumpPaletteSelectionActions({ const handleSelectBrowserPage = useCallback( (result: BrowserPaletteSearchResult) => { const activation = activateBrowserPagePaletteResult(result) + if (activation.status === 'blocked') { + return + } if (activation.status === 'failed') { toast.error( activation.reason === 'missing-page' @@ -109,6 +115,9 @@ export function useWorktreeJumpPaletteSelectionActions({ const handleSelectSimulatorTab = useCallback( (result: SimulatorPaletteSearchResult) => { const activation = activateSimulatorTabPaletteResult(result) + if (activation.status === 'blocked') { + return + } if (activation.status === 'failed') { toast.error( activation.reason === 'missing-tab' @@ -133,6 +142,9 @@ export function useWorktreeJumpPaletteSelectionActions({ const handleSelectWorkspaceTab = useCallback( (result: WorkspaceTabPaletteSearchResult) => { const activation = activateWorkspaceTabPaletteResult(result) + if (activation.status === 'blocked') { + return + } if (activation.status === 'failed') { toast.error( activation.reason === 'missing-worktree' diff --git a/src/renderer/src/components/use-worktree-jump-palette-store-state.ts b/src/renderer/src/components/use-worktree-jump-palette-store-state.ts index 21b0bc67f42..e9bfd270e24 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-store-state.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-store-state.ts @@ -5,6 +5,7 @@ import { useAppStore } from '@/store' import { usePluginCommands } from '@/store/plugin-panels' import { useSettingsNavigationMetadata } from '@/hooks/useSettingsNavigationMetadata' import { dedupePaletteWorktrees } from '@/lib/palette-repo-resolution' +import { getFolderWorkspaceWorktree } from '@/store/slices/worktrees/listing/detected-worktree-meta' import { selectPaletteIndexStatusSnapshot, selectPaletteStatusInputs @@ -29,9 +30,14 @@ export function useWorktreeJumpPaletteStoreState({ const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction) const revealSidebarRow = useAppStore((state) => state.revealSidebarRow) const worktreesByRepo = useAppStore((state) => state.worktreesByRepo) - const allWorktrees = useMemo( - () => dedupePaletteWorktrees(Object.values(worktreesByRepo).flat()), - [worktreesByRepo] + const folderWorkspaces = useAppStore((state) => state.folderWorkspaces) + const workspaces = useMemo( + () => + dedupePaletteWorktrees([ + ...Object.values(worktreesByRepo).flat(), + ...folderWorkspaces.map(getFolderWorkspaceWorktree) + ]), + [folderWorkspaces, worktreesByRepo] ) const repos = useAppStore((state) => state.repos) const projectGroups = useAppStore((state) => state.projectGroups) @@ -126,7 +132,7 @@ export function useWorktreeJumpPaletteStoreState({ recordFeatureInteraction, revealSidebarRow, worktreesByRepo, - allWorktrees, + workspaces, repos, projectGroups, projects, diff --git a/src/renderer/src/components/use-worktree-jump-palette-worktrees.ts b/src/renderer/src/components/use-worktree-jump-palette-worktrees.ts index 348e6b82b10..335f8a0ea9c 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-worktrees.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-worktrees.ts @@ -22,6 +22,7 @@ import { isWorkspaceFromOtherDevice } from '@/components/sidebar/workspace-creator-visibility' import type { Worktree } from '../../../shared/worktree/types' +import { parseWorkspaceKey } from '../../../shared/workspace-scope' import { EMPTY_SORTED_WORKTREES } from './worktree-jump-palette-model' import { buildWorktreeJumpPaletteDocumentIndex } from './worktree-jump-palette-document-index' import { buildWorktreeJumpPaletteWorktreeMaps } from './worktree-jump-palette-worktree-maps' @@ -34,7 +35,7 @@ export function useWorktreeJumpPaletteWorktrees({ worktreesByRepo, agentStatusByPaneKey, tabsByWorktree, - allWorktrees, + workspaces, filterPredicate, hideDefaultBranchWorkspace, hideAutomationGeneratedWorkspaces, @@ -90,13 +91,23 @@ export function useWorktreeJumpPaletteWorktrees({ ) const emptyQueryVisibleWorktrees = useMemo( () => - allWorktrees.filter((worktree) => { + workspaces.filter((worktree) => { if (worktree.isArchived) { return false } if (filterPredicate && !filterPredicate.matchesWorktree(worktree)) { return false } + if ( + hideWorkspacesFromOtherDevices && + isWorkspaceFromOtherDevice(worktree, pairedDeviceIdsByEnvironment) + ) { + return false + } + // Folder rows follow the sidebar's host/device scope, without git or sleeping filters. + if (parseWorkspaceKey(worktree.id)?.type === 'folder') { + return true + } if ( hideDefaultBranchWorkspace && isDefaultBranchWorkspace(worktree, repoMap.get(worktree.repoId)) @@ -112,12 +123,6 @@ export function useWorktreeJumpPaletteWorktrees({ if (hideDetachedHeadWorkspaces && isDetachedHeadWorkspace(worktree)) { return false } - if ( - hideWorkspacesFromOtherDevices && - isWorkspaceFromOtherDevice(worktree, pairedDeviceIdsByEnvironment) - ) { - return false - } if ( !showSleepingWorkspaces && !isSleepingSweepExemptWorkspace(worktree, alwaysShowDefaultBranchWorkspace) && @@ -135,7 +140,7 @@ export function useWorktreeJumpPaletteWorktrees({ return true }), [ - allWorktrees, + workspaces, alwaysShowDefaultBranchWorkspace, browserTabsByWorktree, filterPredicate, @@ -171,18 +176,16 @@ export function useWorktreeJumpPaletteWorktrees({ const searchScopeWorktrees = useMemo(() => { const scope = getWorktreePaletteSearchScope({ hasQuery, - allWorktrees, + allWorktrees: workspaces, emptyQueryWorktrees: switchableWorktreesForRows }) return hasQuery && filterPredicate ? scope.filter(filterPredicate.matchesWorktree) : scope - }, [allWorktrees, filterPredicate, hasQuery, switchableWorktreesForRows]) + }, [workspaces, filterPredicate, hasQuery, switchableWorktreesForRows]) const browserSortedWorktrees = useMemo(() => { if (!paletteStatusInputsActive) { return EMPTY_SORTED_WORKTREES } - const scope = filterPredicate - ? allWorktrees.filter(filterPredicate.matchesWorktree) - : allWorktrees + const scope = filterPredicate ? workspaces.filter(filterPredicate.matchesWorktree) : workspaces return sortWorktreesSmart( scope, tabsByWorktree, @@ -195,7 +198,7 @@ export function useWorktreeJumpPaletteWorktrees({ ) }, [ paletteStatusInputsActive, - allWorktrees, + workspaces, filterPredicate, tabsByWorktree, repoMap, @@ -229,18 +232,18 @@ export function useWorktreeJumpPaletteWorktrees({ const checksReviewByWorktree = useMemo( () => buildWorktreeChecksReviewIndex({ - worktrees: allWorktrees, + worktrees: workspaces, repoByHostIdentity, prCache, hostedReviewCache, settings }), - [allWorktrees, hostedReviewCache, prCache, repoByHostIdentity, settings] + [workspaces, hostedReviewCache, prCache, repoByHostIdentity, settings] ) const worktreeDocuments = useMemo( () => buildWorktreeJumpPaletteDocumentIndex({ - worktrees: allWorktrees, + worktrees: workspaces, repoMap, repoByHostIdentity, hostOptions, @@ -251,7 +254,7 @@ export function useWorktreeJumpPaletteWorktrees({ checksReviewByWorktree }), [ - allWorktrees, + workspaces, checksReviewByWorktree, hostFilterActive, hostOptions, diff --git a/src/renderer/src/components/worktree-jump-palette-browser-simulator-rows.tsx b/src/renderer/src/components/worktree-jump-palette-browser-simulator-rows.tsx index 4a3acb10f11..b8f091ef9ea 100644 --- a/src/renderer/src/components/worktree-jump-palette-browser-simulator-rows.tsx +++ b/src/renderer/src/components/worktree-jump-palette-browser-simulator-rows.tsx @@ -15,6 +15,7 @@ import { import { formatPaletteSessionAge } from '@/components/cmd-j/palette-session-age' import { resolvePaletteRepoForWorktree } from '@/lib/palette-repo-resolution' import { BrowserFavicon } from '@/components/browser-favicon' +import { getRepoExecutionHostId } from '../../../shared/execution-host' export function WorktreeJumpPaletteSimulatorRow({ entry, @@ -36,7 +37,7 @@ export function WorktreeJumpPaletteSimulatorRow({ : undefined const simulatorRepoName = simulatorRepo?.displayName ?? result.repoName const simulatorHostBadge = getPaletteHostBadge( - simulatorRepo, + result.executionHostId ?? (simulatorRepo && getRepoExecutionHostId(simulatorRepo)), controller.hostOptions, controller.hostFilterActive ) @@ -131,7 +132,7 @@ export function WorktreeJumpPaletteBrowserRow({ : undefined const browserRepoName = browserRepo?.displayName ?? result.repoName const browserHostBadge = getPaletteHostBadge( - browserRepo, + result.executionHostId ?? (browserRepo && getRepoExecutionHostId(browserRepo)), controller.hostOptions, controller.hostFilterActive ) diff --git a/src/renderer/src/components/worktree-jump-palette-create-worktree.ts b/src/renderer/src/components/worktree-jump-palette-create-worktree.ts index b04a341f581..10e148fbdab 100644 --- a/src/renderer/src/components/worktree-jump-palette-create-worktree.ts +++ b/src/renderer/src/components/worktree-jump-palette-create-worktree.ts @@ -8,7 +8,7 @@ import { type LinkedWorkItemSummary } from '@/lib/new-workspace' import { lookupGitHubWorkItemForSource } from '@/lib/github-work-item-source-lookup' -import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { activateAndRevealWorkspace } from '@/lib/worktree-activation' import { queueWorkspaceActivationTerminalFocus } from '@/lib/workspace-activation-terminal-focus' import { getRepoMapFromState } from '@/store/selectors' import { useAppStore } from '@/store' @@ -29,7 +29,7 @@ import type { WorktreeJumpPaletteTaskUrl } from './use-worktree-jump-palette-tas type WorktreeJumpPaletteCreateWorktreeInput = Pick< WorktreeJumpPaletteStoreState, - 'allWorktrees' | 'closeModal' | 'openModal' | 'recordFeatureInteraction' + 'workspaces' | 'closeModal' | 'openModal' | 'recordFeatureInteraction' > & Pick & Pick< @@ -55,7 +55,7 @@ type WorktreeJumpPaletteCreateWorktreeInput = Pick< > export function createWorktreeJumpPaletteWorktreeHandler({ - allWorktrees, + workspaces, closeModal, createLookupGuard, createWorktreeName, @@ -211,7 +211,7 @@ export function createWorktreeJumpPaletteWorktreeHandler({ if (ghNumber !== null) { const state = useAppStore.getState() - const matches = allWorktrees.filter( + const matches = workspaces.filter( (worktree) => !worktree.isArchived && worktreeMatchesGitHubNumber( @@ -226,12 +226,16 @@ export function createWorktreeJumpPaletteWorktreeHandler({ const activeMatch = matches.find((worktree) => worktree.repoId === state.activeRepoId) ?? matches[0] if (activeMatch) { + const executionHostId = getPaletteWorktreeExecutionHostId(activeMatch) + const activation = activateAndRevealWorkspace( + activeMatch.id, + executionHostId ? { executionHostId } : {} + ) + if (!activation) { + return + } skipRestoreFocusRef.current = true closeModal() - const activation = activateAndRevealWorktree( - activeMatch.id, - activeMatch.hostId ? { executionHostId: activeMatch.hostId } : {} - ) if (!queueWorkspaceActivationTerminalFocus(activeMatch.id, activation)) { focusFallbackSurface() } diff --git a/src/renderer/src/components/worktree-jump-palette-document-index.ts b/src/renderer/src/components/worktree-jump-palette-document-index.ts index 1dc70ec5efc..1eeb3eba7dd 100644 --- a/src/renderer/src/components/worktree-jump-palette-document-index.ts +++ b/src/renderer/src/components/worktree-jump-palette-document-index.ts @@ -4,6 +4,7 @@ import { getWorkspacePortsByWorktreeId } from '@/lib/workspace-port-groups' import { buildWorktreePaletteDocuments } from '@/lib/worktree-palette-document' import { getPaletteWorktreeIdentity, + getPaletteWorktreeExecutionHostId, resolvePaletteRepoForWorktree } from '@/lib/palette-repo-resolution' import type { PaletteDocument } from '@/lib/palette-match/palette-document' @@ -12,6 +13,7 @@ import type { Repo } from '../../../shared/repo-types' import type { Worktree } from '../../../shared/worktree/types' import type { WorkspacePortScanResult } from '../../../shared/workspace-ports' import type { HostedReviewInfo } from '../../../shared/hosted-review' +import { getRepoExecutionHostId } from '../../../shared/execution-host' export function buildWorktreeJumpPaletteDocumentIndex({ worktrees, @@ -37,7 +39,11 @@ export function buildWorktreeJumpPaletteDocumentIndex({ const hostLabelByWorktreeId = new Map() for (const worktree of worktrees) { const repo = resolvePaletteRepoForWorktree(worktree, repoMap, repoByHostIdentity) - const badge = getPaletteHostBadge(repo, hostOptions, hostFilterActive) + const badge = getPaletteHostBadge( + getPaletteWorktreeExecutionHostId(worktree) ?? (repo && getRepoExecutionHostId(repo)), + hostOptions, + hostFilterActive + ) if (badge) { hostLabelByWorktreeId.set(getPaletteWorktreeIdentity(worktree), badge.label) } diff --git a/src/renderer/src/components/worktree-jump-palette-interleaved-sections.test.tsx b/src/renderer/src/components/worktree-jump-palette-interleaved-sections.test.tsx index b7c438d653b..966f9701211 100644 --- a/src/renderer/src/components/worktree-jump-palette-interleaved-sections.test.tsx +++ b/src/renderer/src/components/worktree-jump-palette-interleaved-sections.test.tsx @@ -314,7 +314,7 @@ function perfTabsPaletteProps(count: number): Partial { /** Each primary row paired with the section header rendered above it, in DOM order. */ function getPrimaryRowsBySectionHeader(): { header: string; rowId: string }[] { - const headerLabels = new Set(['Open Tabs', 'Worktrees']) + const headerLabels = new Set(['Open Tabs', 'Workspaces']) const pairs: { header: string; rowId: string }[] = [] let header = '' for (const node of testContainer.querySelectorAll( @@ -366,7 +366,7 @@ describe('WorktreeJumpPalette interleaved primary sections', () => { expect(rows.filter((row) => isWorkspaceTabItemId(row.rowId))).toHaveLength(8) expect(rows.filter((row) => isWorktreeItemId(row.rowId))).toHaveLength(5) for (const { header, rowId } of rows) { - expect(header).toBe(isWorkspaceTabItemId(rowId) ? 'Open Tabs' : 'Worktrees') + expect(header).toBe(isWorkspaceTabItemId(rowId) ? 'Open Tabs' : 'Workspaces') } }) @@ -420,7 +420,7 @@ describe('WorktreeJumpPalette interleaved primary sections', () => { const rows = getPrimaryRowsBySectionHeader() expect(rows).toEqual([{ header: 'Open Tabs', rowId: workspaceTabItemId('wt-tabs', 'tab-0') }]) expect(testContainer.textContent).toContain('Open Tabs') - expect(testContainer.textContent).not.toContain('Worktrees') + expect(testContainer.textContent).not.toContain('Workspaces') }) it('puts the tab title on the left and the worktree name in the badge rail', async () => { diff --git a/src/renderer/src/components/worktree-jump-palette-project-action-rows.tsx b/src/renderer/src/components/worktree-jump-palette-project-action-rows.tsx index 3b8a8187a84..ab29278c519 100644 --- a/src/renderer/src/components/worktree-jump-palette-project-action-rows.tsx +++ b/src/renderer/src/components/worktree-jump-palette-project-action-rows.tsx @@ -12,6 +12,7 @@ import type { } from './worktree-jump-palette-model' import type { WorktreeJumpPaletteController } from './use-worktree-jump-palette-controller' import { PaletteHostBadgeChip } from './worktree-jump-palette-primitives' +import { getRepoExecutionHostId } from '../../../shared/execution-host' export function WorktreeJumpPaletteProjectRow({ entry, @@ -25,7 +26,11 @@ export function WorktreeJumpPaletteProjectRow({ const result = entry.result const isProject = result.kind === 'project' const hostBadge = isProject - ? getPaletteHostBadge(result.repo, controller.hostOptions, controller.hostFilterActive) + ? getPaletteHostBadge( + getRepoExecutionHostId(result.repo), + controller.hostOptions, + controller.hostFilterActive + ) : null const badgeLabel = isProject ? translate('auto.components.WorktreeJumpPalette.projectBadge', 'Project') diff --git a/src/renderer/src/components/worktree-jump-palette-surface.tsx b/src/renderer/src/components/worktree-jump-palette-surface.tsx index 29e4962e3df..54f3765f66d 100644 --- a/src/renderer/src/components/worktree-jump-palette-surface.tsx +++ b/src/renderer/src/components/worktree-jump-palette-surface.tsx @@ -32,7 +32,7 @@ export function WorktreeJumpPaletteSurface({ title={translate('auto.components.WorktreeJumpPalette.4ee378034d', 'Jump to...')} description={translate( 'auto.components.WorktreeJumpPalette.2770f02910', - 'Search chats, terminals, worktrees, settings, and actions' + 'Search chats, terminals, workspaces, settings, and actions' )} overlayClassName="bg-black/55 backdrop-blur-[2px]" contentClassName="top-[min(10%,4rem)] w-[900px] max-w-[96vw] max-h-[min(90vh,calc(100vh-1.5rem))] overflow-hidden rounded-xl border border-border/70 bg-background/96 shadow-[0_26px_84px_rgba(0,0,0,0.32)] backdrop-blur-xl" @@ -55,7 +55,7 @@ export function WorktreeJumpPaletteSurface({ ref={controller.inputRef} placeholder={translate( 'auto.components.WorktreeJumpPalette.27f10cca63', - 'Search chats, terminals, worktrees, settings, and actions...' + 'Search chats, terminals, workspaces, settings, and actions...' )} value={controller.query} onValueChange={controller.emojiInput.handleValueChange} diff --git a/src/renderer/src/components/worktree-jump-palette-test-fixtures.ts b/src/renderer/src/components/worktree-jump-palette-test-fixtures.ts index dad45ebc01d..7e3dc666759 100644 --- a/src/renderer/src/components/worktree-jump-palette-test-fixtures.ts +++ b/src/renderer/src/components/worktree-jump-palette-test-fixtures.ts @@ -2,6 +2,7 @@ import type { Repo } from '../../../shared/repo-types' import type { Tab, TabGroup } from '../../../shared/tab-types' import type { TerminalTab } from '../../../shared/terminal-tab-types' import type { Worktree } from '../../../shared/worktree/types' +import type { FolderWorkspace } from '../../../shared/folder-workspace-types' import type { AgentStatusEntry, AgentStatusState } from '../../../shared/agent-status-types' import { makePaneKey } from '../../../shared/stable-pane-id' import type { AppState } from '@/store/types' @@ -45,6 +46,25 @@ export function makeWorktree( } } +export function makeFolderWorkspace(overrides: Partial = {}): FolderWorkspace { + return { + id: 'folder-1', + projectGroupId: 'group-1', + name: 'Design workspace', + folderPath: '/tmp/design', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + createdAt: 0, + updatedAt: 0, + ...overrides + } +} + export function makeTerminalTab(id: string, worktreeId: string, title: string): TerminalTab { return { id, diff --git a/src/renderer/src/components/worktree-jump-palette-workspace-tab-row.tsx b/src/renderer/src/components/worktree-jump-palette-workspace-tab-row.tsx index 0763300eca7..3a7a1383526 100644 --- a/src/renderer/src/components/worktree-jump-palette-workspace-tab-row.tsx +++ b/src/renderer/src/components/worktree-jump-palette-workspace-tab-row.tsx @@ -17,6 +17,7 @@ import { import { formatPaletteSessionAge } from '@/components/cmd-j/palette-session-age' import { resolvePaletteRepoForWorktree } from '@/lib/palette-repo-resolution' import { isEditorTabContentType } from '@/store/slices/editor/tabs/editor-tab-content-type' +import { getRepoExecutionHostId } from '../../../shared/execution-host' export function WorktreeJumpPaletteWorkspaceTabRow({ entry, @@ -38,7 +39,7 @@ export function WorktreeJumpPaletteWorkspaceTabRow({ : undefined const workspaceTabRepoName = workspaceTabRepo?.displayName ?? result.repoName const workspaceTabHostBadge = getPaletteHostBadge( - workspaceTabRepo, + result.executionHostId ?? (workspaceTabRepo && getRepoExecutionHostId(workspaceTabRepo)), controller.hostOptions, controller.hostFilterActive ) diff --git a/src/renderer/src/components/worktree-jump-palette-worktree-row.tsx b/src/renderer/src/components/worktree-jump-palette-worktree-row.tsx index 80610a700ca..4dc648401b2 100644 --- a/src/renderer/src/components/worktree-jump-palette-worktree-row.tsx +++ b/src/renderer/src/components/worktree-jump-palette-worktree-row.tsx @@ -10,9 +10,10 @@ import { } from '@/lib/worktree-default-display-name' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' -import { isRuntimeOwnedSshTargetId } from '../../../shared/execution-host' +import { getRepoExecutionHostId, isRuntimeOwnedSshTargetId } from '../../../shared/execution-host' import { isPaletteCurrentWorktree, + getPaletteWorktreeExecutionHostId, resolvePaletteRepoForWorktree } from '@/lib/palette-repo-resolution' import { formatPaletteSessionAge } from '@/components/cmd-j/palette-session-age' @@ -61,7 +62,11 @@ export function WorktreeJumpPaletteWorktreeRow({ ? (sshConnectionStates.get(sshConnectionId)?.status ?? 'disconnected') : null const isSshDisconnected = sshStatus != null && sshStatus !== 'connected' - const hostBadge = getPaletteHostBadge(repo, hostOptions, hostFilterActive) + const hostBadge = getPaletteHostBadge( + getPaletteWorktreeExecutionHostId(worktree) ?? (repo && getRepoExecutionHostId(repo)), + hostOptions, + hostFilterActive + ) return ( ({ activateAndRevealWorktree: vi.fn() })) +const mocks = vi.hoisted(() => ({ activateAndRevealWorkspace: vi.fn() })) vi.mock('./worktree-activation', () => ({ - activateAndRevealWorktree: mocks.activateAndRevealWorktree + activateAndRevealWorkspace: mocks.activateAndRevealWorkspace })) import { activateBrowserPagePaletteResult } from './browser-page-palette-activation' @@ -145,7 +145,7 @@ const target = { pageId: 'page-1', workspaceId: 'ws-1', worktreeId: 'wt-1' } describe('activateBrowserPagePaletteResult', () => { beforeEach(() => { vi.clearAllMocks() - mocks.activateAndRevealWorktree.mockReturnValue(true) + mocks.activateAndRevealWorkspace.mockReturnValue(true) seedStore() }) @@ -157,7 +157,7 @@ describe('activateBrowserPagePaletteResult', () => { }) const state = useAppStore.getState() - expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', {}) + expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith('wt-1', {}) expect(state.activeBrowserTabId).toBe('ws-1') expect(state.activeTabType).toBe('browser') expect(state.browserTabsByWorktree['wt-1'][0].activePageId).toBe('page-1') @@ -170,7 +170,7 @@ describe('activateBrowserPagePaletteResult', () => { activateBrowserPagePaletteResult(target) - expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', { + expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith('wt-1', { executionHostId: 'ssh:host-1' }) }) @@ -190,7 +190,7 @@ describe('activateBrowserPagePaletteResult', () => { expect( activateBrowserPagePaletteResult({ ...target, executionHostId: 'runtime:paired-host' }).status ).toBe('activated') - expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', { + expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith('wt-1', { executionHostId: 'runtime:paired-host' }) }) @@ -218,7 +218,7 @@ describe('activateBrowserPagePaletteResult', () => { reason: 'missing-tab' }) expect(useAppStore.getState()).toBe(before) - expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() + expect(mocks.activateAndRevealWorkspace).not.toHaveBeenCalled() }) it('keeps browser workspaces with distinct unified tabs in multiple groups activatable', () => { @@ -251,7 +251,7 @@ describe('activateBrowserPagePaletteResult', () => { expect( activateBrowserPagePaletteResult({ pageId: 'page-1', workspaceId: 'ws-1', worktreeId }) ).toMatchObject({ status: 'activated' }) - expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith(worktreeId, { + expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith(worktreeId, { executionHostId: 'ssh:host-1' }) }) @@ -264,7 +264,7 @@ describe('activateBrowserPagePaletteResult', () => { }) // Why: the real activation drops the page, so a post-activation lookup would // resolve the wrong target — the pre-capture is what keeps this correct. - mocks.activateAndRevealWorktree.mockImplementation(() => { + mocks.activateAndRevealWorkspace.mockImplementation(() => { useAppStore.setState({ browserPagesByWorkspace: {} }) return true }) @@ -288,7 +288,7 @@ describe('activateBrowserPagePaletteResult', () => { reason: 'missing-page' }) - expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() + expect(mocks.activateAndRevealWorkspace).not.toHaveBeenCalled() }) // A live page in a dead workspace is a different story than a dead page. @@ -299,7 +299,7 @@ describe('activateBrowserPagePaletteResult', () => { status: 'failed', reason: 'missing-worktree' }) - expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() + expect(mocks.activateAndRevealWorkspace).not.toHaveBeenCalled() }) // Deleting a worktree purges its browser workspaces and pages too, so the @@ -315,16 +315,13 @@ describe('activateBrowserPagePaletteResult', () => { status: 'failed', reason: 'missing-worktree' }) - expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() + expect(mocks.activateAndRevealWorkspace).not.toHaveBeenCalled() }) - it('reports a failed worktree activation distinguishably from a stale page', () => { - mocks.activateAndRevealWorktree.mockReturnValue(false) + it('reports blocked activation without claiming the workspace disappeared', () => { + mocks.activateAndRevealWorkspace.mockReturnValue(false) - expect(activateBrowserPagePaletteResult(target)).toEqual({ - status: 'failed', - reason: 'missing-worktree' - }) + expect(activateBrowserPagePaletteResult(target)).toEqual({ status: 'blocked' }) expect(useAppStore.getState().activeBrowserTabId).toBeNull() }) }) @@ -334,7 +331,7 @@ describe('activateBrowserPagePaletteResult', () => { describe('activateBrowserPagePaletteResult group focus', () => { beforeEach(() => { vi.clearAllMocks() - mocks.activateAndRevealWorktree.mockReturnValue(true) + mocks.activateAndRevealWorkspace.mockReturnValue(true) seedStore() }) diff --git a/src/renderer/src/lib/browser-page-palette-activation.ts b/src/renderer/src/lib/browser-page-palette-activation.ts index 72f76722cd7..2e18a077cfe 100644 --- a/src/renderer/src/lib/browser-page-palette-activation.ts +++ b/src/renderer/src/lib/browser-page-palette-activation.ts @@ -5,7 +5,7 @@ import { } from '@/lib/browser-workspace-tab-activation' import type { ExecutionHostId } from '../../../shared/execution-host' import { isBlankBrowserUrl } from './browser-palette-search' -import { activateAndRevealWorktree } from './worktree-activation' +import { activateAndRevealWorkspace } from './worktree-activation' export type BrowserPagePaletteActivationFailure = | 'missing-page' @@ -16,6 +16,7 @@ export type BrowserPageFocusTarget = 'address-bar' | 'webview' export type BrowserPagePaletteActivationResult = | { status: 'activated'; pageId: string; focusTarget: BrowserPageFocusTarget } + | { status: 'blocked' } | { status: 'failed'; reason: BrowserPagePaletteActivationFailure } export type BrowserPagePaletteActivationTarget = { @@ -51,7 +52,7 @@ export function activateBrowserPagePaletteResult({ return { status: 'failed', reason: 'missing-page' } } - // Why: activateAndRevealWorktree mutates store state, so a later page lookup + // Why: activateAndRevealWorkspace mutates store state, so a later page lookup // is unreliable — resolve the focus target from the URL captured here. const focusTarget: BrowserPageFocusTarget = isBlankBrowserUrl(page.url) ? 'address-bar' @@ -63,12 +64,12 @@ export function activateBrowserPagePaletteResult({ ) { return { status: 'failed', reason: 'missing-tab' } } - const activated = activateAndRevealWorktree( + const activated = activateAndRevealWorkspace( worktree.id, targetHostId ? { executionHostId: targetHostId } : {} ) if (!activated) { - return { status: 'failed', reason: 'missing-worktree' } + return { status: 'blocked' } } // Why the failure and not a bare activation: without a unified tab the browser state would go diff --git a/src/renderer/src/lib/github-work-item-workspace-attachment.ts b/src/renderer/src/lib/github-work-item-workspace-attachment.ts index 457c67e5d37..b30c0815b3a 100644 --- a/src/renderer/src/lib/github-work-item-workspace-attachment.ts +++ b/src/renderer/src/lib/github-work-item-workspace-attachment.ts @@ -6,6 +6,7 @@ import { githubRepoIdentityKey } from '../../../shared/github/repository-identit import type { GitHubWorkItem } from '../../../shared/github/work-item-types' import { getWorkspaceAttachments } from '../../../shared/workspace-attachments' import type { Worktree } from '../../../shared/worktree/types' +import { parseWorkspaceKey } from '../../../shared/workspace-scope' type GitHubWorkItemType = GitHubWorkItem['type'] @@ -71,6 +72,10 @@ export function worktreeMatchesGitHubNumber( if (item.provider !== 'github' || item.number !== number) { return false } + // A folder has no checkout repository; its attachments define the source. + if (parseWorkspaceKey(worktree.id)?.type === 'folder') { + return true + } if (item.repoId && item.repoId !== worktree.repoId) { return false } diff --git a/src/renderer/src/lib/simulator-tab-palette-activation.test.ts b/src/renderer/src/lib/simulator-tab-palette-activation.test.ts index dc398524c0e..f652f3ba77a 100644 --- a/src/renderer/src/lib/simulator-tab-palette-activation.test.ts +++ b/src/renderer/src/lib/simulator-tab-palette-activation.test.ts @@ -6,10 +6,10 @@ import type { Worktree } from '../../../shared/worktree/types' import { useAppStore } from '@/store' import type { AppState } from '@/store/types' -const mocks = vi.hoisted(() => ({ activateAndRevealWorktree: vi.fn() })) +const mocks = vi.hoisted(() => ({ activateAndRevealWorkspace: vi.fn() })) vi.mock('./worktree-activation', () => ({ - activateAndRevealWorktree: mocks.activateAndRevealWorktree + activateAndRevealWorkspace: mocks.activateAndRevealWorkspace })) import { activateSimulatorTabPaletteResult } from './simulator-tab-palette-activation' @@ -85,7 +85,7 @@ const target = { tabId: 'sim-tab-1', worktreeId: 'wt-1' } describe('activateSimulatorTabPaletteResult', () => { beforeEach(() => { vi.clearAllMocks() - mocks.activateAndRevealWorktree.mockReturnValue(true) + mocks.activateAndRevealWorkspace.mockReturnValue(true) seedStore() }) @@ -96,7 +96,7 @@ describe('activateSimulatorTabPaletteResult', () => { }) const state = useAppStore.getState() - expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', {}) + expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith('wt-1', {}) expect(state.activeTabType).toBe('simulator') expect(state.activeGroupIdByWorktree['wt-1']).toBe('group-1') expect(state.groupsByWorktree['wt-1'][0].activeTabId).toBe('sim-tab-1') @@ -106,7 +106,7 @@ describe('activateSimulatorTabPaletteResult', () => { seedStore({ worktreesByRepo: { 'repo-1': [makeWorktree({ hostId: 'ssh:host-1' })] } }) expect(activateSimulatorTabPaletteResult(target).status).toBe('activated') - expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', { + expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith('wt-1', { executionHostId: 'ssh:host-1' }) }) @@ -127,7 +127,7 @@ describe('activateSimulatorTabPaletteResult', () => { activateSimulatorTabPaletteResult({ ...target, executionHostId: 'runtime:paired-host' }) .status ).toBe('activated') - expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', { + expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith('wt-1', { executionHostId: 'runtime:paired-host' }) }) @@ -154,7 +154,7 @@ describe('activateSimulatorTabPaletteResult', () => { { status: 'failed', reason: 'missing-tab' } ) expect(useAppStore.getState()).toBe(before) - expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() + expect(mocks.activateAndRevealWorkspace).not.toHaveBeenCalled() }) it('rejects a hostless tab when its worktree id exists on multiple hosts', () => { @@ -168,7 +168,7 @@ describe('activateSimulatorTabPaletteResult', () => { expect(activateSimulatorTabPaletteResult({ ...target, executionHostId: 'ssh:remote' })).toEqual( { status: 'failed', reason: 'missing-tab' } ) - expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() + expect(mocks.activateAndRevealWorkspace).not.toHaveBeenCalled() }) it('reports an unknown worktree without activating', () => { @@ -178,7 +178,7 @@ describe('activateSimulatorTabPaletteResult', () => { status: 'failed', reason: 'missing-worktree' }) - expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() + expect(mocks.activateAndRevealWorkspace).not.toHaveBeenCalled() }) it('focuses the owning group when the tab lives in another split column', () => { @@ -215,7 +215,7 @@ describe('activateSimulatorTabPaletteResult', () => { status: 'failed', reason: 'missing-tab' }) - expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() + expect(mocks.activateAndRevealWorkspace).not.toHaveBeenCalled() }) it('rejects a tab id that no longer belongs to a simulator tab', () => { @@ -229,13 +229,10 @@ describe('activateSimulatorTabPaletteResult', () => { }) }) - it('reports a failed worktree activation distinguishably from a stale tab', () => { - mocks.activateAndRevealWorktree.mockReturnValue(false) + it('reports blocked activation without claiming the workspace disappeared', () => { + mocks.activateAndRevealWorkspace.mockReturnValue(false) - expect(activateSimulatorTabPaletteResult(target)).toEqual({ - status: 'failed', - reason: 'missing-worktree' - }) + expect(activateSimulatorTabPaletteResult(target)).toEqual({ status: 'blocked' }) expect(useAppStore.getState().activeTabType).not.toBe('simulator') }) }) diff --git a/src/renderer/src/lib/simulator-tab-palette-activation.ts b/src/renderer/src/lib/simulator-tab-palette-activation.ts index be13a841b6c..84e1294c4fc 100644 --- a/src/renderer/src/lib/simulator-tab-palette-activation.ts +++ b/src/renderer/src/lib/simulator-tab-palette-activation.ts @@ -1,6 +1,6 @@ import { useAppStore } from '@/store' import type { ExecutionHostId } from '../../../shared/execution-host' -import { activateAndRevealWorktree } from './worktree-activation' +import { activateAndRevealWorkspace } from './worktree-activation' import { findAmbiguousWorktreeIds, getPaletteOwnershipWorktreeIds, @@ -11,6 +11,7 @@ export type SimulatorTabPaletteActivationFailure = 'missing-tab' | 'missing-work export type SimulatorTabPaletteActivationResult = | { status: 'activated'; tabId: string } + | { status: 'blocked' } | { status: 'failed'; reason: SimulatorTabPaletteActivationFailure } export type SimulatorTabPaletteActivationTarget = { @@ -48,12 +49,12 @@ export function activateSimulatorTabPaletteResult({ } const targetHostId = executionHostId ?? worktree.hostId - const activated = activateAndRevealWorktree( + const activated = activateAndRevealWorkspace( worktree.id, targetHostId ? { executionHostId: targetHostId } : {} ) if (!activated) { - return { status: 'failed', reason: 'missing-worktree' } + return { status: 'blocked' } } const state = useAppStore.getState() diff --git a/src/renderer/src/lib/workspace-activation-path-gate.test.ts b/src/renderer/src/lib/workspace-activation-path-gate.test.ts index 7ede5eb2a1d..afd8364eaa7 100644 --- a/src/renderer/src/lib/workspace-activation-path-gate.test.ts +++ b/src/renderer/src/lib/workspace-activation-path-gate.test.ts @@ -3,6 +3,9 @@ import type { FolderWorkspace } from '../../../shared/folder-workspace-types' import type { ProjectGroup } from '../../../shared/project-group-types' import type { FolderWorkspacePathStatusReason } from '../../../shared/folder-workspace-path-status' import { folderWorkspaceKey } from '../../../shared/workspace-scope' +import { getDefaultSettings } from '../../../shared/constants' +import { getFolderWorkspaceStatusRequestSnapshot } from '@/store/folder-workspaces/folder-path-status' +import { makeGroup, makeUnifiedTab } from '@/components/worktree-jump-palette-test-fixtures' // Why: drives the real activation module against a real store, so a regression in // the workspace dispatch or in the path-status guard itself both surface here. @@ -22,6 +25,9 @@ const store = createTestStore() vi.mock('@/store', () => ({ useAppStore: store })) const { activateAndRevealWorkspace } = await import('./worktree-activation') +const { activateWorkspaceTabPaletteResult } = await import('./workspace-tab-palette-activation') +const { activateBrowserPagePaletteResult } = await import('./browser-page-palette-activation') +const { activateSimulatorTabPaletteResult } = await import('./simulator-tab-palette-activation') const projectGroup: ProjectGroup = { id: 'group-1', @@ -61,6 +67,7 @@ async function seedFolderWorkspace( activeWorktreeId: null, activeWorkspaceKey: null, activeView: 'terminal', + sidebarBody: 'workspaces', folderWorkspacePathStatuses: {} }) mocks.getPathStatus.mockResolvedValue({ path: folderWorkspace.folderPath, ...status }) @@ -76,6 +83,7 @@ async function seedFolderWorkspace( describe('Cmd/Ctrl+1-9 folder-workspace path gate (#10716)', () => { beforeEach(() => { + store.setState(store.getInitialState(), true) mocks.toastError.mockClear() vi.stubGlobal('window', { api: { folderWorkspaces: { getPathStatus: mocks.getPathStatus } } @@ -109,4 +117,166 @@ describe('Cmd/Ctrl+1-9 folder-workspace path gate (#10716)', () => { expect(store.getState().activeWorkspaceKey).toBe(folderWorkspaceKey(folderWorkspace.id)) expect(mocks.toastError).not.toHaveBeenCalled() }) + + it.each(['local', 'runtime:builder'] as const)( + 'keeps the selected %s host on a folder sidebar reveal', + async (executionHostId) => { + await seedFolderWorkspace({ exists: true }) + store.setState({ + folderWorkspaces: [ + { + ...folderWorkspace, + executionHostId: executionHostId === 'local' ? 'runtime:builder' : 'local' + }, + { ...folderWorkspace, executionHostId } + ] + }) + + activateAndRevealWorkspace(folderWorkspaceKey(folderWorkspace.id), { + executionHostId, + providesInitialSurface: true + }) + + expect(store.getState().pendingRevealWorktree).toMatchObject({ + worktreeId: folderWorkspaceKey(folderWorkspace.id), + executionHostId + }) + } + ) + + it.each([ + ['local', null], + ['ssh:builder', 'builder'] + ] as const)( + 'reveals the %s catalog owner when an unrelated server is focused', + async (executionHostId, connectionId) => { + await seedFolderWorkspace({ exists: true }) + store.setState({ + projectGroups: [{ ...projectGroup, connectionId }], + settings: { + ...getDefaultSettings('/tmp'), + activeRuntimeEnvironmentId: 'focused-server' + } + }) + + activateAndRevealWorkspace(folderWorkspaceKey(folderWorkspace.id), { + providesInitialSurface: true + }) + + expect(store.getState().pendingRevealWorktree).toMatchObject({ + worktreeId: folderWorkspaceKey(folderWorkspace.id), + executionHostId + }) + } + ) + + it.each(['local', 'runtime:builder'] as const)( + 'checks the selected %s folder when the same id is active on another host', + async (executionHostId) => { + await seedFolderWorkspace({ exists: true }) + const workspaceKey = folderWorkspaceKey(folderWorkspace.id) + store.setState({ + folderWorkspaces: [ + { ...folderWorkspace, executionHostId: 'local' }, + { ...folderWorkspace, executionHostId: 'runtime:builder', folderPath: '/srv/project' } + ], + activeWorktreeId: workspaceKey, + activeWorkspaceExecutionHostId: executionHostId === 'local' ? 'runtime:builder' : 'local' + }) + const request = { scope: 'folder-workspace', folderWorkspaceId: folderWorkspace.id } as const + const runtimeEnvironmentId = executionHostId === 'local' ? null : 'builder' + const requestSnapshot = getFolderWorkspaceStatusRequestSnapshot(store.getState(), request, { + runtimeEnvironmentId + }) + if (requestSnapshot === null) { + throw new Error('The folder fixture must have a path snapshot') + } + const cacheKey = store + .getState() + .getFolderWorkspacePathStatusCacheKey(request, { runtimeEnvironmentId }) + store.setState({ + folderWorkspacePathStatuses: { + ...store.getState().folderWorkspacePathStatuses, + [cacheKey]: { + checkedAt: Date.now(), + requestSnapshot, + status: { path: '/selected/missing', exists: false, reason: 'missing' } + } + } + }) + const before = store.getState() + + expect(activateAndRevealWorkspace(workspaceKey, { executionHostId })).toBe(false) + expect(store.getState()).toBe(before) + expect(mocks.toastError).toHaveBeenCalledTimes(1) + } + ) + + it.each(['terminal', 'browser', 'simulator'] as const)( + 'preserves the path error for a blocked folder %s jump', + async (contentType) => { + await seedFolderWorkspace({ exists: false, reason: 'missing' }) + const worktreeId = folderWorkspaceKey(folderWorkspace.id) + const tab = { ...makeUnifiedTab('tab-1', worktreeId, 'entity-1', 'Research'), contentType } + store.setState({ + unifiedTabsByWorktree: { [worktreeId]: [tab] }, + groupsByWorktree: { [worktreeId]: [makeGroup(worktreeId, [tab.id])] }, + browserTabsByWorktree: { + [worktreeId]: [ + { + id: 'entity-1', + worktreeId, + url: 'https://example.com', + title: 'Research', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 0 + } + ] + }, + browserPagesByWorkspace: { + 'entity-1': [ + { + id: 'page-1', + worktreeId, + workspaceId: 'entity-1', + url: 'https://example.com', + title: 'Research', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 0 + } + ] + } + }) + const before = store.getState() + const result = + contentType === 'terminal' + ? activateWorkspaceTabPaletteResult({ + worktreeId, + contentType, + tabId: tab.id, + entityId: tab.entityId, + groupId: tab.groupId + }) + : contentType === 'browser' + ? activateBrowserPagePaletteResult({ + worktreeId, + pageId: 'page-1', + workspaceId: tab.entityId + }) + : activateSimulatorTabPaletteResult({ worktreeId, tabId: tab.id }) + + expect(result).toEqual({ status: 'blocked' }) + expect(store.getState()).toBe(before) + expect(mocks.toastError).toHaveBeenCalledTimes(1) + expect(mocks.toastError).toHaveBeenCalledWith('Folder not found', expect.anything()) + } + ) }) diff --git a/src/renderer/src/lib/workspace-tab-palette-activation.store.test.ts b/src/renderer/src/lib/workspace-tab-palette-activation.store.test.ts index 60b10097f86..0396a0f74cb 100644 --- a/src/renderer/src/lib/workspace-tab-palette-activation.store.test.ts +++ b/src/renderer/src/lib/workspace-tab-palette-activation.store.test.ts @@ -5,7 +5,7 @@ import { useAppStore } from '@/store' import type { Tab } from '../../../shared/tab-types' import type { Worktree } from '../../../shared/worktree/types' -vi.mock('./worktree-activation', () => ({ activateAndRevealWorktree: () => true })) +vi.mock('./worktree-activation', () => ({ activateAndRevealWorkspace: () => true })) import { activateWorkspaceTabPaletteResult } from './workspace-tab-palette-activation' diff --git a/src/renderer/src/lib/workspace-tab-palette-activation.test.ts b/src/renderer/src/lib/workspace-tab-palette-activation.test.ts index 921377a2223..b80e03c2b6b 100644 --- a/src/renderer/src/lib/workspace-tab-palette-activation.test.ts +++ b/src/renderer/src/lib/workspace-tab-palette-activation.test.ts @@ -60,7 +60,7 @@ const mocks = vi.hoisted(() => { } return { store, - activateAndRevealWorktree: vi.fn(), + activateAndRevealWorkspace: vi.fn(), activateWebRuntimeSessionTab: vi.fn(), focusTerminalTabSurface: vi.fn(), getRuntimeEnvironmentIdForWorktree: vi.fn(), @@ -90,7 +90,7 @@ vi.mock('@/runtime/web-runtime-session-environment', () => ({ })) vi.mock('./worktree-activation', () => ({ - activateAndRevealWorktree: mocks.activateAndRevealWorktree + activateAndRevealWorkspace: mocks.activateAndRevealWorkspace })) import { activateWorkspaceTabPaletteResult } from './workspace-tab-palette-activation' @@ -171,7 +171,7 @@ describe('activateWorkspaceTabPaletteResult', () => { .flat() .find((worktree) => worktree.id === worktreeId) ) - mocks.activateAndRevealWorktree.mockReturnValue(true) + mocks.activateAndRevealWorkspace.mockReturnValue(true) mocks.getRuntimeEnvironmentIdForWorktree.mockReturnValue('runtime-1') mocks.isWebRuntimeSessionActive.mockReturnValue(false) }) @@ -179,7 +179,7 @@ describe('activateWorkspaceTabPaletteResult', () => { it('activates terminal tabs and focuses the terminal surface', () => { expect(activateWorkspaceTabPaletteResult(makeResult())).toEqual({ status: 'activated' }) - expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-1') + expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith('wt-1') expect(mocks.store.focusGroup).toHaveBeenCalledWith('wt-1', 'group-1') expect(mocks.store.activateTab).toHaveBeenCalledWith('unified-terminal-1', { worktreeId: 'wt-1' @@ -189,6 +189,15 @@ describe('activateWorkspaceTabPaletteResult', () => { expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('terminal-1') }) + it('reports blocked activation without changing the tab or focus', () => { + mocks.activateAndRevealWorkspace.mockReturnValue(false) + + expect(activateWorkspaceTabPaletteResult(makeResult())).toEqual({ status: 'blocked' }) + expect(mocks.store.activateTab).not.toHaveBeenCalled() + expect(mocks.store.focusGroup).not.toHaveBeenCalled() + expect(mocks.focusTerminalTabSurface).not.toHaveBeenCalled() + }) + it('scopes activation to the host carried by the search result', () => { const executionHostId = 'runtime:host-1' as const mocks.store.getKnownWorktreeById.mockReturnValue({ @@ -204,7 +213,7 @@ describe('activateWorkspaceTabPaletteResult', () => { }) expect(mocks.store.getKnownWorktreeById).toHaveBeenCalledWith('wt-1', executionHostId) - expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', { executionHostId }) + expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith('wt-1', { executionHostId }) }) it('rejects colliding child ids before mutating either host', () => { @@ -228,7 +237,7 @@ describe('activateWorkspaceTabPaletteResult', () => { status: 'failed', reason: 'missing-tab' }) - expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() + expect(mocks.activateAndRevealWorkspace).not.toHaveBeenCalled() expect(mocks.store.activateTab).not.toHaveBeenCalled() }) @@ -237,7 +246,7 @@ describe('activateWorkspaceTabPaletteResult', () => { mocks.store.getKnownWorktreeById.mockReturnValue({ id: 'wt-1', repoId: 'repo-1' }) expect(activateWorkspaceTabPaletteResult(makeResult())).toEqual({ status: 'activated' }) - expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-1') + expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith('wt-1') }) it('uses the web-runtime terminal activation path when active', () => { @@ -356,7 +365,7 @@ describe('activateWorkspaceTabPaletteResult', () => { status: 'failed', reason: 'missing-group' }) - expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() + expect(mocks.activateAndRevealWorkspace).not.toHaveBeenCalled() expect(mocks.store.focusGroup).not.toHaveBeenCalled() resetStore() @@ -377,7 +386,7 @@ describe('activateWorkspaceTabPaletteResult', () => { status: 'failed', reason: 'missing-file' }) - expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() + expect(mocks.activateAndRevealWorkspace).not.toHaveBeenCalled() }) it('treats missing editor backing files and worktrees as stale', () => { diff --git a/src/renderer/src/lib/workspace-tab-palette-activation.ts b/src/renderer/src/lib/workspace-tab-palette-activation.ts index 7f91e7acb85..45e56089e81 100644 --- a/src/renderer/src/lib/workspace-tab-palette-activation.ts +++ b/src/renderer/src/lib/workspace-tab-palette-activation.ts @@ -5,7 +5,7 @@ import { isWebRuntimeSessionActive } from '@/runtime/web-runtime-session-environ import { useAppStore } from '@/store' import type { AppState } from '@/store/types' import type { ExecutionHostId } from '../../../shared/execution-host' -import { activateAndRevealWorktree } from './worktree-activation' +import { activateAndRevealWorkspace } from './worktree-activation' import { findAmbiguousWorktreeIds, getPaletteOwnershipWorktreeIds, @@ -23,6 +23,7 @@ export type WorkspaceTabPaletteActivationFailure = export type WorkspaceTabPaletteActivationResult = | { status: 'activated' } + | { status: 'blocked' } | { status: 'failed'; reason: WorkspaceTabPaletteActivationFailure } // Why: callers outside Cmd+J hold only the identifiers, not a full search result. @@ -106,10 +107,10 @@ export function activateWorkspaceTabPaletteResult( const executionHostId = result.executionHostId ?? initialState.getKnownWorktreeById(result.worktreeId)?.hostId const activated = executionHostId - ? activateAndRevealWorktree(result.worktreeId, { executionHostId }) - : activateAndRevealWorktree(result.worktreeId) + ? activateAndRevealWorkspace(result.worktreeId, { executionHostId }) + : activateAndRevealWorkspace(result.worktreeId) if (!activated) { - return { status: 'failed', reason: 'missing-worktree' } + return { status: 'blocked' } } const state = useAppStore.getState() diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index b1fc4b38054..b1c3c6f0b4b 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -1,4 +1,3 @@ -import type { FolderWorkspace } from '../../../shared/folder-workspace-types' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' import type { PendingSidebarWorktreeReveal } from '@/store/slices/ui' @@ -17,9 +16,16 @@ import { } from './folder-workspace-path-status' import { toast } from 'sonner' import type { ExecutionHostId } from '../../../shared/execution-host' -import { findFolderWorkspaceOwner } from './folder-workspace-runtime-owner' +import { + findFolderWorkspaceOwner, + getRuntimeEnvironmentIdForFolderWorkspace +} from './folder-workspace-runtime-owner' +import { getFolderWorkspaceHostId } from '@/store/folder-workspaces/folder-workspace-catalog' import type { WorktreeStartupPayload } from '@/lib/worktree-startup-payload' -import { ensureWorktreeHasInitialTerminal } from '@/lib/worktree-initial-terminal-seeding' +import { + ensureFolderWorkspaceInitialTerminal, + ensureWorktreeHasInitialTerminal +} from '@/lib/worktree-initial-terminal-seeding' import { ensureWebRuntimeWorktreeTerminalAfterWake } from '@/lib/web-runtime-worktree-terminal-after-wake' import { applyWorktreeNavViewEntry } from '@/lib/worktree-nav-view-history-replay' import { @@ -44,32 +50,6 @@ export type ActivateAndRevealResult = { primaryTabId: string | null } -function ensureFolderWorkspaceInitialTerminal( - folderWorkspace: FolderWorkspace, - startup?: WorktreeStartupPayload, - providesInitialSurface?: boolean, - seedUserDefaultSurface?: boolean -): string | null { - if (providesInitialSurface === true && startup === undefined) { - return null - } - const state = useAppStore.getState() - const workspaceKey = folderWorkspaceKey(folderWorkspace.id) - const primaryTabId = ensureWorktreeHasInitialTerminal( - state, - workspaceKey, - startup, - undefined, - undefined, - undefined, - { - reseedEmptiedWorkspace: providesInitialSurface !== true, - ...(seedUserDefaultSurface ? { seedUserDefaultSurface: true } : {}) - } - ) - return primaryTabId -} - function canInspectAgentActivationInventory(): boolean { return ( typeof window !== 'undefined' && @@ -104,7 +84,7 @@ export function activateAndRevealFolderWorkspace( const runtimeEnvironmentId = opts && 'runtimeEnvironmentId' in opts ? (opts.runtimeEnvironmentId ?? null) - : getRuntimeEnvironmentIdForWorktree(state, folderWorkspaceKey(folderWorkspaceId)) + : getRuntimeEnvironmentIdForFolderWorkspace(state, folderWorkspaceId, opts?.executionHostId) const pathStatus = state.getFreshFolderWorkspacePathStatus( { scope: 'folder-workspace', @@ -169,10 +149,11 @@ export function activateAndRevealFolderWorkspace( state.setSidebarBody('workspaces') } if (opts?.revealInSidebar !== false) { - state.revealWorktreeInSidebar( - workspaceKey, - opts?.sidebarRevealBehavior ? { behavior: opts.sidebarRevealBehavior } : undefined - ) + state.revealWorktreeInSidebar(workspaceKey, { + executionHostId: + opts?.executionHostId ?? getFolderWorkspaceHostId(folderWorkspace, state.projectGroups), + ...(opts?.sidebarRevealBehavior ? { behavior: opts.sidebarRevealBehavior } : {}) + }) } if (opts?.providesInitialSurface !== true) { diff --git a/src/renderer/src/lib/worktree-initial-terminal-seeding.ts b/src/renderer/src/lib/worktree-initial-terminal-seeding.ts index 026fbb58f5c..cfd57ac7c98 100644 --- a/src/renderer/src/lib/worktree-initial-terminal-seeding.ts +++ b/src/renderer/src/lib/worktree-initial-terminal-seeding.ts @@ -3,6 +3,8 @@ import type { WorktreeSetupLaunch } from '../../../shared/worktree/launch-types' import type { ExecutionHostId } from '../../../shared/execution-host' +import type { FolderWorkspace } from '../../../shared/folder-workspace-types' +import { folderWorkspaceKey } from '../../../shared/workspace-scope' import { shouldAutoCreateInitialTerminal } from '@/components/terminal/initial-terminal' import { isTerminalWorkspaceEmptiedOnPurpose } from '../../../shared/closed-terminal-tab-tombstones' import { createSequencedSetupAgentCommands } from '../../../shared/setup-agent-sequencing' @@ -67,6 +69,32 @@ export function reseedGatedEmptyWorkspace( ) } +export function ensureFolderWorkspaceInitialTerminal( + folderWorkspace: FolderWorkspace, + startup?: WorktreeStartupPayload, + providesInitialSurface?: boolean, + seedUserDefaultSurface?: boolean +): string | null { + if (providesInitialSurface === true && startup === undefined) { + return null + } + const state = useAppStore.getState() + const workspaceKey = folderWorkspaceKey(folderWorkspace.id) + const primaryTabId = ensureWorktreeHasInitialTerminal( + state, + workspaceKey, + startup, + undefined, + undefined, + undefined, + { + reseedEmptiedWorkspace: providesInitialSurface !== true, + ...(seedUserDefaultSurface ? { seedUserDefaultSurface: true } : {}) + } + ) + return primaryTabId +} + export function ensureWorktreeHasInitialTerminal( store: WorktreeActivationStore, worktreeId: string, diff --git a/src/renderer/src/lib/worktree-palette-attachment-urls.test.ts b/src/renderer/src/lib/worktree-palette-attachment-urls.test.ts index a9b8424e706..e4dfa6fff03 100644 --- a/src/renderer/src/lib/worktree-palette-attachment-urls.test.ts +++ b/src/renderer/src/lib/worktree-palette-attachment-urls.test.ts @@ -1,6 +1,8 @@ import { worktreeMatchesGitHubNumber } from './github-work-item-workspace-attachment' import { describe, expect, it } from 'vitest' import type { Worktree } from '../../../shared/worktree/types' +import { folderWorkspaceToWorktree } from '../../../shared/folder-workspace-worktree' +import { makeFolderWorkspace } from '@/components/worktree-jump-palette-test-fixtures' import { referenceWorkspace, referenceRepo @@ -57,6 +59,30 @@ describe('multiple workspace task attachments', () => { }) describe('bare GitHub number activation', () => { + it.each([ + { repoId: 'source-repo' }, + { url: 'https://github.com/acme/product/issues/42' }, + { repoId: 'source-repo', url: 'https://github.example.com/acme/product/pull/42' } + ])('matches an explicitly attached folder task with source metadata: %j', (source) => { + const worktree = folderWorkspaceToWorktree( + makeFolderWorkspace({ + linkedItems: [{ provider: 'github', type: 'issue', number: 42, ...source }] + }) + ) + + expect(worktreeMatchesGitHubNumber(worktree, 42, undefined)).toBe(true) + expect(worktreeMatchesGitHubNumber(worktree, 43, undefined)).toBe(false) + }) + + it('keeps other providers out of folder GitHub-number matching', () => { + const worktree = folderWorkspaceToWorktree( + makeFolderWorkspace({ + linkedItems: [{ provider: 'gitlab', type: 'issue', number: 42 }] + }) + ) + + expect(worktreeMatchesGitHubNumber(worktree, 42, undefined)).toBe(false) + }) it.each([ { repoId: 'foreign' }, { url: 'https://github.com/foreign/project/pull/42' }, diff --git a/src/renderer/src/store/folder-workspaces/folder-path-status-actions.ts b/src/renderer/src/store/folder-workspaces/folder-path-status-actions.ts index 2c8be71fd64..ad7666f3178 100644 --- a/src/renderer/src/store/folder-workspaces/folder-path-status-actions.ts +++ b/src/renderer/src/store/folder-workspaces/folder-path-status-actions.ts @@ -5,7 +5,6 @@ import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' import { runtimeTargetForOwnerEnvironment } from '../../runtime/runtime-client-target' import type { RepoSlice } from '../repos/repo-state' import { - getFolderWorkspacePathStatusRequestSnapshotForRead, getFolderWorkspaceStatusRequestSnapshot, getFreshFolderWorkspacePathStatusFromCache } from './folder-path-status' @@ -31,13 +30,13 @@ export function createFolderPathStatusActions( const state = get() const cacheKey = get().getFolderWorkspacePathStatusCacheKey(request, options) const cached = state.folderWorkspacePathStatuses[cacheKey] - const requestSnapshot = getFolderWorkspacePathStatusRequestSnapshotForRead(state, request) + const requestSnapshot = getFolderWorkspaceStatusRequestSnapshot(state, request, options) return getFreshFolderWorkspacePathStatusFromCache({ entry: cached, requestSnapshot }) }, fetchFolderWorkspacePathStatus: async (request, options) => { const cacheKey = get().getFolderWorkspacePathStatusCacheKey(request, options) - const requestSnapshot = getFolderWorkspaceStatusRequestSnapshot(get(), request) + const requestSnapshot = getFolderWorkspaceStatusRequestSnapshot(get(), request, options) const cached = get().folderWorkspacePathStatuses[cacheKey] const freshCachedStatus = getFreshFolderWorkspacePathStatusFromCache({ entry: cached, @@ -62,7 +61,7 @@ export function createFolderPathStatusActions( set((state) => ({ folderWorkspacePathStatuses: requestSnapshot !== null && - getFolderWorkspaceStatusRequestSnapshot(state, request) === requestSnapshot + getFolderWorkspaceStatusRequestSnapshot(state, request, options) === requestSnapshot ? { ...state.folderWorkspacePathStatuses, [cacheKey]: { status, checkedAt: Date.now(), requestSnapshot } diff --git a/src/renderer/src/store/folder-workspaces/folder-path-status-ownership.test.ts b/src/renderer/src/store/folder-workspaces/folder-path-status-ownership.test.ts new file mode 100644 index 00000000000..2e40c1a9a7b --- /dev/null +++ b/src/renderer/src/store/folder-workspaces/folder-path-status-ownership.test.ts @@ -0,0 +1,148 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { FolderWorkspacePathStatus } from '../../../../shared/folder-workspace-path-status' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import type * as RuntimeRpcClient from '@/runtime/runtime-rpc-client' +import { makeFolderWorkspace } from '@/components/worktree-jump-palette-test-fixtures' +import { createTestStore } from '../slices/store-test-helpers' + +const mocks = vi.hoisted(() => ({ getPathStatus: vi.fn(), callRuntimeRpc: vi.fn() })) + +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => { + const original = await importOriginal() + return { ...original, callRuntimeRpc: mocks.callRuntimeRpc } +}) + +const owners = [ + { executionHostId: 'local', runtimeEnvironmentId: null, path: '/local/folder' }, + { executionHostId: 'runtime:builder', runtimeEnvironmentId: 'builder', path: '/remote/folder' } +] as const + +const folders = owners.map((owner) => + makeFolderWorkspace({ executionHostId: owner.executionHostId, folderPath: owner.path }) +) +const request = { scope: 'folder-workspace', folderWorkspaceId: folders[0].id } as const + +beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('window', { + api: { folderWorkspaces: { getPathStatus: mocks.getPathStatus } } + }) +}) + +describe('folder path status ownership', () => { + it.each(owners)( + 'keeps the $executionHostId cache valid after catalog reordering', + async (owner) => { + const store = createTestStore() + store.setState({ folderWorkspaces: folders }) + for (const candidate of owners) { + const status: FolderWorkspacePathStatus = { + path: candidate.path, + exists: candidate.executionHostId === 'local', + ...(candidate.executionHostId === 'local' ? {} : { reason: 'missing' as const }) + } + mocks.getPathStatus.mockResolvedValue(status) + mocks.callRuntimeRpc.mockResolvedValue({ status }) + await store.getState().fetchFolderWorkspacePathStatus(request, candidate) + } + + store.setState({ folderWorkspaces: folders.toReversed() }) + + expect(store.getState().getFreshFolderWorkspacePathStatus(request, owner)).toMatchObject({ + path: owner.path, + exists: owner.executionHostId === 'local' + }) + expect(mocks.getPathStatus).toHaveBeenCalledOnce() + expect(mocks.callRuntimeRpc).toHaveBeenCalledExactlyOnceWith( + { kind: 'environment', environmentId: 'builder' }, + 'folderWorkspace.getPathStatus', + request, + { timeoutMs: 15_000 } + ) + } + ) + + it.each(owners)( + 'rejects a delayed $executionHostId error after its folder path changes', + async (owner) => { + const store = createTestStore() + // The selected folder is second, so a bare-id lookup would fingerprint the other host. + store.setState({ + folderWorkspaces: [ + ...folders.filter((folder) => folder.executionHostId !== owner.executionHostId), + ...folders.filter((folder) => folder.executionHostId === owner.executionHostId) + ] + }) + const pending = Promise.withResolvers() + mocks.getPathStatus.mockReturnValue(pending.promise) + mocks.callRuntimeRpc.mockImplementation(async () => ({ status: await pending.promise })) + const oldProbe = store.getState().fetchFolderWorkspacePathStatus(request, owner) + const repairedPath = `${owner.path}-repaired` + store.setState((state) => ({ + folderWorkspaces: state.folderWorkspaces.map((folder) => + folder.executionHostId === owner.executionHostId + ? { ...folder, folderPath: repairedPath } + : folder + ), + folderWorkspacePathStatuses: {} + })) + const repairedStatus = { path: repairedPath, exists: true } + mocks.getPathStatus.mockResolvedValue(repairedStatus) + mocks.callRuntimeRpc.mockResolvedValue({ status: repairedStatus }) + await store.getState().fetchFolderWorkspacePathStatus(request, { ...owner, force: true }) + + pending.resolve({ path: owner.path, exists: false, reason: 'missing' }) + await oldProbe + + expect(store.getState().getFreshFolderWorkspacePathStatus(request, owner)).toEqual( + repairedStatus + ) + } + ) + + it('does not borrow a same-id folder when the requested host has no owner', async () => { + const store = createTestStore() + store.setState({ folderWorkspaces: folders }) + mocks.callRuntimeRpc.mockResolvedValue({ + status: { path: '/unrelated/folder', exists: false, reason: 'missing' } + }) + const owner = { runtimeEnvironmentId: 'missing-host' } + + await store.getState().fetchFolderWorkspacePathStatus(request, owner) + + expect(store.getState().getFreshFolderWorkspacePathStatus(request, owner)).toBeNull() + expect(store.getState().folderWorkspacePathStatuses).toEqual({}) + }) + + it.each(owners)( + 'checks the $executionHostId project group when its id exists on another host', + async (owner) => { + const groups: ProjectGroup[] = owners.map((candidate) => ({ + id: 'group-1', + name: 'Research', + parentPath: candidate.path, + parentGroupId: null, + executionHostId: candidate.executionHostId, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 0, + updatedAt: 0 + })) + const store = createTestStore() + store.setState({ projectGroups: groups }) + const groupRequest = { scope: 'project-group', projectGroupId: 'group-1' } as const + const status = { path: owner.path, exists: true } + mocks.getPathStatus.mockResolvedValue(status) + mocks.callRuntimeRpc.mockResolvedValue({ status }) + await store.getState().fetchFolderWorkspacePathStatus(groupRequest, owner) + + store.setState({ projectGroups: groups.toReversed() }) + + expect(store.getState().getFreshFolderWorkspacePathStatus(groupRequest, owner)).toEqual( + status + ) + } + ) +}) diff --git a/src/renderer/src/store/folder-workspaces/folder-path-status.ts b/src/renderer/src/store/folder-workspaces/folder-path-status.ts index 94592e5328b..7b3ed8b8928 100644 --- a/src/renderer/src/store/folder-workspaces/folder-path-status.ts +++ b/src/renderer/src/store/folder-workspaces/folder-path-status.ts @@ -6,89 +6,108 @@ import type { } from '../../../../shared/folder-workspace-path-status' import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups' import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path' -import type { FolderWorkspacePathStatusCacheEntry } from '../repos/repo-state' +import { getRepoExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' +import type { + FolderWorkspacePathStatusCacheEntry, + FolderWorkspacePathStatusRouteOptions +} from '../repos/repo-state' +import { + getRuntimeTargetHostId, + runtimeTargetForOwnerEnvironment +} from '../../runtime/runtime-client-target' +import { catalogOwnsHost, getProjectGroupHostId } from '../slices/project-group-owner-routing' +import { getFolderWorkspaceHostId } from './folder-workspace-catalog' -export function getFolderWorkspaceStatusRequestSnapshot( - state: Pick, - request: FolderWorkspacePathStatusRequest -): string | null { +type FolderPathStatusSnapshotState = Pick< + AppState, + 'projectGroups' | 'folderWorkspaces' | 'repos' | 'sshConnectionStates' +> + +type FolderPathStatusScope = { + path: string + projectGroupId: string | null + connectionId: string | null + executionHostId: ExecutionHostId +} + +function getFolderPathStatusScope( + state: FolderPathStatusSnapshotState, + request: FolderWorkspacePathStatusRequest, + ownerHostId: ExecutionHostId +): FolderPathStatusScope | null { if (request.scope === 'path') { - const candidateRepos = state.repos.filter((repo) => - isPathInsideOrEqual(request.path, repo.path) - ) - const relevantConnectionIds = new Set() - if (request.connectionId) { - relevantConnectionIds.add(request.connectionId) + return { + path: request.path, + projectGroupId: null, + connectionId: request.connectionId ?? null, + executionHostId: ownerHostId } - 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 [request.path, '', request.connectionId ?? '', sshFingerprint, repoFingerprint].join( - '\0' - ) } - - 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) { + if (request.scope === 'project-group') { + const groups = state.projectGroups.filter( + (group) => + group.id === request.projectGroupId && + catalogOwnsHost(ownerHostId, getProjectGroupHostId(group)) + ) + const group = groups.length === 1 ? groups[0] : null + return group?.parentPath + ? { + path: group.parentPath, + projectGroupId: group.id, + connectionId: group.connectionId ?? null, + executionHostId: getProjectGroupHostId(group) + } + : null + } + const folders = state.folderWorkspaces.filter( + (workspace) => + workspace.id === request.folderWorkspaceId && + catalogOwnsHost(ownerHostId, getFolderWorkspaceHostId(workspace, state.projectGroups)) + ) + const folder = folders.length === 1 ? folders[0] : null + if (!folder?.folderPath || !folder.projectGroupId) { return null } - const groupIds = getProjectGroupSubtreeIds(state.projectGroups, projectGroupId) + const executionHostId = getFolderWorkspaceHostId(folder, state.projectGroups) + const group = state.projectGroups.find( + (candidate) => + candidate.id === folder.projectGroupId && getProjectGroupHostId(candidate) === executionHostId + ) + return { + path: folder.folderPath, + projectGroupId: folder.projectGroupId, + connectionId: folder.connectionId ?? group?.connectionId ?? null, + executionHostId + } +} + +export function getFolderWorkspaceStatusRequestSnapshot( + state: FolderPathStatusSnapshotState, + request: FolderWorkspacePathStatusRequest, + options: FolderWorkspacePathStatusRouteOptions +): string | null { + const ownerHostId = getRuntimeTargetHostId( + runtimeTargetForOwnerEnvironment(options.runtimeEnvironmentId) + ) + const scope = getFolderPathStatusScope(state, request, ownerHostId) + if (!scope) { + return null + } + const projectGroups = state.projectGroups.filter((group) => + catalogOwnsHost(ownerHostId, getProjectGroupHostId(group)) + ) + const groupIds = scope.projectGroupId + ? getProjectGroupSubtreeIds(projectGroups, scope.projectGroupId) + : new Set() const candidateRepos = state.repos.filter( (repo) => - (typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) || - isPathInsideOrEqual(folderPath, repo.path) + catalogOwnsHost(ownerHostId, getRepoExecutionHostId(repo)) && + ((typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) || + isPathInsideOrEqual(scope.path, repo.path)) ) const relevantConnectionIds = new Set() - if (scopeConnectionId) { - relevantConnectionIds.add(scopeConnectionId) + if (scope.connectionId) { + relevantConnectionIds.add(scope.connectionId) } for (const repo of candidateRepos) { if (repo.connectionId) { @@ -104,14 +123,16 @@ export function getFolderWorkspaceStatusRequestSnapshot( .join('|') const repoFingerprint = candidateRepos .map( - (repo) => `${repo.id}:${repo.path}:${repo.projectGroupId ?? ''}:${repo.connectionId ?? ''}` + (repo) => + `${repo.id}:${repo.path}:${repo.projectGroupId ?? ''}:${repo.connectionId ?? ''}:${getRepoExecutionHostId(repo)}` ) .sort() .join('|') return [ - folderPath, - projectGroupId, - scopeConnectionId ?? '', + scope.path, + scope.projectGroupId ?? '', + scope.connectionId ?? '', + scope.executionHostId, sshFingerprint, repoFingerprint ].join('\0') @@ -127,10 +148,3 @@ export function getFreshFolderWorkspacePathStatusFromCache(args: { } return Date.now() - entry.checkedAt < FOLDER_WORKSPACE_PATH_STATUS_TTL_MS ? entry.status : null } - -export function getFolderWorkspacePathStatusRequestSnapshotForRead( - state: AppState, - request: FolderWorkspacePathStatusRequest -): string | null { - return getFolderWorkspaceStatusRequestSnapshot(state, request) -} 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 e4565e8e91c..f21623ab4f2 100644 --- a/src/renderer/src/store/slices/repos-project-groups.test.ts +++ b/src/renderer/src/store/slices/repos-project-groups.test.ts @@ -201,7 +201,11 @@ describe('project group store routing', () => { result: { status: { path: '/workspace/platform', exists: true } }, _meta: { runtimeId: 'runtime-remote' } }) - const folderGroup = { ...projectGroup, parentPath: '/workspace/platform' } + const folderGroup: ProjectGroup = { + ...projectGroup, + parentPath: '/workspace/platform', + executionHostId: 'runtime:env-1' + } const store = createTestStore() store.setState({ settings: { activeRuntimeEnvironmentId: 'wrong-env' } as never, diff --git a/src/renderer/src/store/slices/workspace-activation-preparation.test.ts b/src/renderer/src/store/slices/workspace-activation-preparation.test.ts new file mode 100644 index 00000000000..f75d032b29b --- /dev/null +++ b/src/renderer/src/store/slices/workspace-activation-preparation.test.ts @@ -0,0 +1,130 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { + markWorktreeSleepIntent, + onWorktreeSleepIntentCleared, + forgetWorktreeSleepIntent +} from '@/lib/worktree-sleep-intent' +import { createTestStore, makeTab, makeWorktree } from './store-test-helpers' +import { makeFolderWorkspace } from './worktrees-slice-test-fixtures' + +const FOLDER_ID = 'folder-activation' +const FOLDER_KEY = folderWorkspaceKey(FOLDER_ID) +const WORKTREE_ID = 'worktree-activation' + +afterEach(() => { + forgetWorktreeSleepIntent(FOLDER_KEY) + forgetWorktreeSleepIntent(WORKTREE_ID) +}) + +function seedWorkspace(kind: 'folder' | 'git', executionHostId: ExecutionHostId) { + const store = createTestStore() + const workspaceId = kind === 'folder' ? FOLDER_KEY : WORKTREE_ID + store.setState({ + folderWorkspaces: [makeFolderWorkspace({ id: FOLDER_ID, executionHostId })], + worktreesByRepo: { + repo: [makeWorktree({ id: WORKTREE_ID, repoId: 'repo', hostId: executionHostId })] + }, + tabsByWorktree: { + [workspaceId]: [ + makeTab({ id: 'terminal', worktreeId: workspaceId, ptyId: 'saved-session', generation: 3 }) + ] + }, + activeTabIdByWorktree: { [workspaceId]: 'terminal' }, + everActivatedWorktreeIds: new Set([workspaceId]), + rightSidebarExplorerView: 'search', + rightSidebarExplorerViewByWorktree: { [workspaceId]: 'files' }, + refreshGitHubForWorktreeIfStale: vi.fn() + }) + const activate = (): void => { + if (kind === 'folder') { + store.getState().setActiveFolderWorkspace(FOLDER_ID, executionHostId) + } else { + store.getState().setActiveWorktree(WORKTREE_ID, executionHostId) + } + } + return { store, workspaceId, activate } +} + +it.each([ + ['folder', 'local'], + ['git', 'local'], + ['folder', 'ssh:remote'], + ['git', 'ssh:remote'] +] as const)('wakes a slept %s on %s and restores its explorer mode atomically', (kind, hostId) => { + const { store, workspaceId, activate } = seedWorkspace(kind, hostId) + markWorktreeSleepIntent(workspaceId) + const wake = vi.fn(() => { + expect(store.getState().activeWorktreeId).toBe(workspaceId) + expect(store.getState().tabsByWorktree[workspaceId][0].generation).toBe(4) + expect(store.getState().rightSidebarExplorerView).toBe('files') + }) + onWorktreeSleepIntentCleared(workspaceId, wake) + + activate() + + expect(wake).toHaveBeenCalledOnce() + expect(store.getState().activeWorkspaceExecutionHostId).toBe(hostId) + expect(store.getState().tabsByWorktree[workspaceId][0].pendingActivationSpawn).toBeDefined() + expect(store.getState().activeTabId).toBe('terminal') + expect(store.getState().refreshGitHubForWorktreeIfStale).toHaveBeenCalledTimes( + kind === 'git' ? 1 : 0 + ) +}) + +it.each(['folder', 'git'] as const)('preserves a live %s terminal on repeat activation', (kind) => { + const { store, workspaceId, activate } = seedWorkspace(kind, 'local') + store.setState({ ptyIdsByTabId: { terminal: ['live-pty'] } }) + activate() + const activated = store.getState() + + activate() + + expect(store.getState()).toBe(activated) + expect(store.getState().tabsByWorktree[workspaceId][0].generation).toBe(3) +}) + +it.each(['folder', 'git'] as const)( + 'wakes an already-selected %s after its PTY is released', + (kind) => { + const { store, workspaceId, activate } = seedWorkspace(kind, 'local') + store.setState({ ptyIdsByTabId: { terminal: ['live-pty'] } }) + activate() + store.setState({ ptyIdsByTabId: {} }) + + activate() + + expect(store.getState().tabsByWorktree[workspaceId][0].generation).toBe(4) + } +) + +it('clears unread only for the selected folder owner and persists to that host', () => { + const { store, activate } = seedWorkspace('folder', 'ssh:remote') + const updateFolderWorkspace = vi.fn().mockResolvedValue(null) + store.setState({ + folderWorkspaces: [ + makeFolderWorkspace({ id: FOLDER_ID, executionHostId: 'local', isUnread: true }), + makeFolderWorkspace({ id: FOLDER_ID, executionHostId: 'ssh:remote', isUnread: true }) + ], + updateFolderWorkspace + }) + + activate() + + expect(store.getState().folderWorkspaces.map((folder) => folder.isUnread)).toEqual([true, false]) + expect(updateFolderWorkspace).toHaveBeenCalledWith( + FOLDER_ID, + { isUnread: false }, + { executionHostId: 'ssh:remote' } + ) +}) + +it('leaves selection unchanged when the requested folder owner is absent', () => { + const { store } = seedWorkspace('folder', 'local') + const before = store.getState() + + store.getState().setActiveFolderWorkspace(FOLDER_ID, 'ssh:missing') + + expect(store.getState()).toBe(before) +}) diff --git a/src/renderer/src/store/slices/worktrees/session/set-active-folder-workspace.ts b/src/renderer/src/store/slices/worktrees/session/set-active-folder-workspace.ts index b89da1ace44..7324c349e0a 100644 --- a/src/renderer/src/store/slices/worktrees/session/set-active-folder-workspace.ts +++ b/src/renderer/src/store/slices/worktrees/session/set-active-folder-workspace.ts @@ -1,76 +1,19 @@ import type { WorktreeSlice } from '../../worktree-helpers' import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' import { folderWorkspaceKey } from '../../../../../../shared/workspace-scope' -import { markInputQuietSchedulerInput } from '@/lib/input-quiet-scheduler' -import { moveFocusToRendererBeforeFocusedWebviewHidden } from '../../browser-webview-cleanup' -import { - findKnownWorktreeById, - folderWorkspaceMatchesHost -} from '../listing/detected-worktree-meta' -import { shouldDeferActivationTerminalPrep } from './activation-terminal-prep' -import { deriveActiveSurfaceForWorktree } from '../../tabs/tabs-surface' -import { clearWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' +import { findKnownWorktreeById } from '../listing/detected-worktree-meta' +import { createSetActiveWorktree } from './set-active-worktree' export function createSetActiveFolderWorkspace( set: WorktreeSliceSet, get: WorktreeSliceGet ): WorktreeSlice['setActiveFolderWorkspace'] { + const activateWorkspace = createSetActiveWorktree(set, get) return (folderWorkspaceId, executionHostId) => { const workspaceKey = folderWorkspaceKey(folderWorkspaceId) - const workspace = findKnownWorktreeById(get(), workspaceKey, executionHostId) - if (!workspace) { + if (!findKnownWorktreeById(get(), workspaceKey, executionHostId)) { return } - if (shouldDeferActivationTerminalPrep()) { - markInputQuietSchedulerInput() - } - if (get().activeWorktreeId !== workspaceKey) { - moveFocusToRendererBeforeFocusedWebviewHidden() - } - const reconciledActiveTabId = - get().reconcileWorktreeTabModel(workspaceKey).activeRenderableTabId - set((s) => { - const { activeFileId, activeBrowserTabId, activeTabType, activeTabId } = - deriveActiveSurfaceForWorktree(s, workspaceKey, undefined, { - legacySelection: 'remembered-type', - preferredTabId: reconciledActiveTabId ?? undefined - }) - const nextEverActivated = s.everActivatedWorktreeIds.has(workspaceKey) - ? s.everActivatedWorktreeIds - : new Set([...s.everActivatedWorktreeIds, workspaceKey]) - return { - activeRepoId: null, - activeWorktreeId: workspaceKey, - activeWorkspaceKey: workspaceKey, - activeWorkspaceExecutionHostId: executionHostId ?? null, - 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 && - (!executionHostId || folderWorkspaceMatchesHost(entry, executionHostId)) - ? { ...entry, isUnread: false } - : entry - ) - : s.folderWorkspaces - } - }) - // Why: cleared after the set() so a waiting pane connects against the activated state. - clearWorktreeSleepIntent(workspaceKey) - if (workspace.isUnread) { - void get().updateFolderWorkspace( - folderWorkspaceId, - { isUnread: false }, - executionHostId ? { executionHostId } : undefined - ) - } + activateWorkspace(workspaceKey, executionHostId) } } diff --git a/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts b/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts index e4536b71507..c282b51f425 100644 --- a/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts +++ b/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts @@ -1,6 +1,5 @@ import type { WorktreeSlice } from '../../worktree-helpers' import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' -import type { AppState } from '../../../types' import type { WorktreeMeta } from '../../../../../../shared/worktree/meta-types' import { applyWorktreeUpdates } from '../../worktree-helpers' import { projectWorktreeTabModelReconciliation } from '../../tabs' @@ -20,7 +19,8 @@ import { } from '../../../../../../shared/workspace-scope' import { applyDetectedWorktreeUpdates, - findKnownWorktreeById + findKnownWorktreeById, + folderWorkspaceMatchesHost } from '../listing/detected-worktree-meta' import { persistPassiveWorktreeMetaForOwner } from '../listing/worktree-owner-settings' import { resolveActivatedWorktreeSurface } from './active-worktree-surface' @@ -57,9 +57,7 @@ export function createSetActiveWorktree( let shouldPrepareTerminalTabs = false let shouldTagTerminalTabs = false set((current) => { - const transitioned = stateTransition - ? ({ ...current, ...stateTransition.patch } as AppState) - : current + const transitioned = stateTransition ? { ...current, ...stateTransition.patch } : current const reconciliation = worktreeId ? projectWorktreeTabModelReconciliation(transitioned, worktreeId) : null @@ -68,7 +66,7 @@ export function createSetActiveWorktree( ) const s = reconciliation && reconciliationChanged - ? ({ ...transitioned, ...reconciliation.patch } as AppState) + ? { ...transitioned, ...reconciliation.patch } : transitioned const reconciledActiveTabId = reconciliation?.activeRenderableTabId ?? null if (!worktreeId) { @@ -129,7 +127,8 @@ export function createSetActiveWorktree( const nextFolderWorkspaces = shouldClearUnread && workspaceScope?.type === 'folder' ? s.folderWorkspaces.map((workspace) => - workspace.id === workspaceScope.folderWorkspaceId + workspace.id === workspaceScope.folderWorkspaceId && + (!executionHostId || folderWorkspaceMatchesHost(workspace, executionHostId)) ? { ...workspace, isUnread: false } : workspace ) @@ -180,6 +179,7 @@ export function createSetActiveWorktree( nextDetectedWorktrees !== s.detectedWorktreesByRepo || nextFolderWorkspaces !== s.folderWorkspaces || nextActiveRepoId !== s.activeRepoId || + allDead || reconciliationChanged || stateTransition !== undefined if (!hasStateChange) { @@ -275,7 +275,7 @@ export function createSetActiveWorktree( } // Why: activation is explicit enough to revalidate PR state now; the coordinator still coalesces and rate-guards. - if (worktreeId) { + if (worktreeId && workspaceScope?.type !== 'folder') { get().refreshGitHubForWorktreeIfStale(worktreeId) } @@ -285,7 +285,11 @@ export function createSetActiveWorktree( if (shouldClearUnread) { if (workspaceScope?.type === 'folder') { - void get().updateFolderWorkspace(workspaceScope.folderWorkspaceId, { isUnread: false }) + void get().updateFolderWorkspace( + workspaceScope.folderWorkspaceId, + { isUnread: false }, + executionHostId ? { executionHostId } : undefined + ) return true } persistPassiveWorktreeMetaForOwner( diff --git a/tests/e2e/folder-workspace-palette-wake.spec.ts b/tests/e2e/folder-workspace-palette-wake.spec.ts new file mode 100644 index 00000000000..289ca4572fa --- /dev/null +++ b/tests/e2e/folder-workspace-palette-wake.spec.ts @@ -0,0 +1,104 @@ +import { mkdtempSync, realpathSync, rmSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { expect, test } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' +import { + execInTerminal, + expectTerminalAccessibilityText, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' + +test.use({ seedTestRepo: false }) + +for (const selection of ['workspace', 'tab'] as const) { + test(`Cmd+J ${selection} selection wakes a sleeping folder and restores Files mode`, async ({ + orcaPage, + electronApp, + registerPostElectronShutdownCleanup + }, testInfo) => { + const folderPath = realpathSync(mkdtempSync(path.join(os.tmpdir(), 'orca-folder-wake-'))) + registerPostElectronShutdownCleanup(async () => + rmSync(folderPath, { recursive: true, force: true }) + ) + await waitForSessionReady(orcaPage) + const folders = await orcaPage.evaluate(async (folderPath) => { + const store = window.__store! + await store.getState().updateSettingsOrThrow({ defaultTuiAgent: 'blank' }) + const group = await window.api.projectGroups.create({ + name: 'Folder wake regression', + parentPath: folderPath, + createdFrom: 'folder-scan' + }) + await store.getState().fetchProjectGroups({ runtimeEnvironmentId: null, throwOnError: true }) + const target = await store + .getState() + .createFolderWorkspace( + { projectGroupId: group.id, name: 'Sleeping research', folderPath }, + { runtimeEnvironmentId: null } + ) + const other = await store + .getState() + .createFolderWorkspace( + { projectGroupId: group.id, name: 'Other research', folderPath }, + { runtimeEnvironmentId: null } + ) + if (!target || !other) { + throw new Error('Could not create folder workspaces') + } + return { target: target.id, other: other.id } + }, folderPath) + const openPalette = async (): Promise => { + // Native menu accelerators need desktop focus; drive the same palette through CDP. + await orcaPage.evaluate(() => window.__store!.getState().openModal('worktree-palette')) + } + const palette = orcaPage.getByRole('dialog', { name: 'Jump to...' }) + await openPalette() + await palette.getByRole('combobox').fill('Sleeping research') + await palette.getByRole('combobox').press('Enter') + await expect(palette).toHaveCount(0) + await waitForActiveTerminalManager(orcaPage) + await waitForActivePanePtyId(orcaPage) + const tabId = await orcaPage.evaluate(() => { + const state = window.__store!.getState() + const tabId = state.activeTabId + if (!tabId) { + throw new Error('Folder terminal is missing') + } + state.setTabCustomTitle(tabId, 'Wake marker terminal') + state.showRightSidebarFiles() + return tabId + }) + await expect(orcaPage.getByRole('textbox', { name: 'Find files', exact: true })).toBeVisible() + await openPalette() + await palette.getByRole('combobox').fill('Other research') + await palette.getByRole('combobox').press('Enter') + await expect(palette).toHaveCount(0) + await orcaPage.evaluate(async (targetId) => { + const state = window.__store!.getState() + state.showRightSidebarSearch() + await state.shutdownWorktreeTerminals(`folder:${targetId}`, { keepIdentifiers: true }) + }, folders.target) + await expect(orcaPage.getByRole('textbox', { name: 'Find files', exact: true })).toBeHidden() + + await openPalette() + const query = selection === 'workspace' ? 'Sleeping research' : 'Wake marker terminal' + await palette.getByRole('combobox').fill(query) + const row = palette.locator('[cmdk-item][data-selected="true"]') + await expect(row).toContainText(query) + await palette.getByRole('combobox').press('Enter') + await expect(palette).toHaveCount(0) + await expect(orcaPage.getByRole('textbox', { name: 'Find files', exact: true })).toBeVisible() + await waitForActiveTerminalManager(orcaPage) + const ptyId = await waitForActivePanePtyId(orcaPage) + await execInTerminal(orcaPage, ptyId, 'echo FOLDER_WAKE_OK') + await expectTerminalAccessibilityText(orcaPage, tabId, 'FOLDER_WAKE_OK') + await orcaPage.screenshot({ path: testInfo.outputPath(`folder-${selection}-wake.png`) }) + expect( + await electronApp.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().every((window) => !window.isVisible()) + ) + ).toBe(true) + }) +} diff --git a/tests/e2e/worktree-jump-palette-filter.spec.ts b/tests/e2e/worktree-jump-palette-filter.spec.ts index 5b9a2cf9e29..99574564cff 100644 --- a/tests/e2e/worktree-jump-palette-filter.spec.ts +++ b/tests/e2e/worktree-jump-palette-filter.spec.ts @@ -9,7 +9,7 @@ const LOCAL_PROJECT = 'E2E Palette Local Project' const REMOTE_PROJECT = 'E2E Palette Remote Project' const REMOTE_WORKSPACE = 'E2E Palette Remote Workspace' const REMOTE_HOST = 'E2E Palette Builder' -const SEARCH_PLACEHOLDER = 'Search chats, terminals, worktrees, settings, and actions...' +const SEARCH_PLACEHOLDER = 'Search chats, terminals, workspaces, settings, and actions...' type PaletteFilterFixture = { localRepoId: string diff --git a/tests/e2e/worktree-jump-palette-folder-workspaces.spec.ts b/tests/e2e/worktree-jump-palette-folder-workspaces.spec.ts new file mode 100644 index 00000000000..d5d83935ad5 --- /dev/null +++ b/tests/e2e/worktree-jump-palette-folder-workspaces.spec.ts @@ -0,0 +1,73 @@ +import { mkdtempSync, realpathSync, rmSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { encodePaletteIdentity } from '../../src/renderer/src/lib/palette-match/palette-ranking' +import { expect, test } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' + +test.use({ seedTestRepo: false }) + +for (const theme of ['light', 'dark'] as const) { + test(`Cmd+J searches and opens a folder workspace without repositories (${theme})`, async ({ + orcaPage, + electronApp, + registerPostElectronShutdownCleanup + }, testInfo) => { + const folderPath = realpathSync(mkdtempSync(path.join(os.tmpdir(), 'orca-palette-folder-'))) + registerPostElectronShutdownCleanup(async () => + rmSync(folderPath, { recursive: true, force: true }) + ) + await waitForSessionReady(orcaPage) + const workspaceId = await orcaPage.evaluate( + async ({ folderPath, theme }) => { + const store = window.__store! + await store.getState().updateSettingsOrThrow({ theme, defaultTuiAgent: 'blank' }) + const group = await window.api.projectGroups.create({ + name: 'Product research', + parentPath: folderPath, + createdFrom: 'folder-scan' + }) + await store + .getState() + .fetchProjectGroups({ runtimeEnvironmentId: null, throwOnError: true }) + const folder = await store + .getState() + .createFolderWorkspace( + { projectGroupId: group.id, name: 'Customer interviews', folderPath }, + { runtimeEnvironmentId: null } + ) + if (!folder) { + throw new Error('Could not create folder workspace') + } + await store.getState().updateFolderWorkspace(folder.id, { comment: 'Research synthesis' }) + store.setState({ showSleepingWorkspaces: false }) + return `folder:${folder.id}` + }, + { folderPath, theme } + ) + await orcaPage.evaluate(() => window.__store!.getState().openModal('worktree-palette')) + const palette = orcaPage.getByRole('dialog', { name: 'Jump to...' }) + await expect(palette).toBeVisible() + const rowId = encodePaletteIdentity(['worktree', `local|${workspaceId}`]) + const row = palette.locator(`[cmdk-item][data-value=${JSON.stringify(rowId)}]`) + await expect(row).toContainText('Customer interviews') + const input = palette.locator('[cmdk-input]') + await input.fill('Research synthesis') + await expect(row).toContainText('Research synthesis') + const screenshot = testInfo.outputPath(`folder-palette-${theme}.png`) + await palette.screenshot({ path: screenshot, animations: 'disabled' }) + await testInfo.attach(`folder-palette-${theme}`, { path: screenshot, contentType: 'image/png' }) + await input.press('Enter') + await expect(palette).toHaveCount(0) + const sidebarRow = orcaPage.locator( + `[data-worktree-sidebar] [role="option"][data-worktree-id=${JSON.stringify(workspaceId)}]` + ) + await expect(sidebarRow).toHaveAttribute('aria-current', 'page') + await expect(sidebarRow).toContainText('Customer interviews') + expect( + await electronApp.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().every((window) => !window.isVisible()) + ) + ).toBe(true) + }) +}