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) + }) +}