mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
Show folder workspaces in the Cmd+J jump palette (#28175)
* Show folder workspaces in the Cmd+J jump palette Cmd+J only listed git worktrees, so folder workspaces could not be found or opened from it. The palette now includes folder workspaces alongside git worktrees and renames the section from "Worktrees" to "Workspaces" to match. - Folder rows are searchable by name, comment, and attached GitHub item, and follow the sidebar's host scope. - A blocked activation (for example, a folder path that no longer exists) keeps the palette open and shows only the path error, instead of closing or showing a second generic error. - Folder path-status caching and sidebar reveal are keyed by execution host, so folders with the same id on different hosts no longer share cache entries or reveal the wrong group. * Consolidate runtime target imports in folder-path-status `getRuntimeTargetHostId` now comes from the same module as `runtimeTargetForOwnerEnvironment`, so both are imported from `runtime/runtime-client-target` instead of splitting them across two modules. * Wake sleeping folder workspaces from Cmd+J Picking a sleeping folder workspace from the jump palette did not restart its terminal or restore its Files explorer view. Folder and git worktree activation now share one code path in workspace-activation.ts, so both wake the same way. - Wake a sleeping folder the same way as a worktree: bump the tab generation and tag the pending spawn when no PTY is live, including when the workspace is already selected. - Restore the remembered right-sidebar explorer view for folders. - Skip the GitHub PR refresh for folder workspaces, which have no PR. - Clear unread only on the folder entry whose execution host matches the activation. * Reveal folder workspaces on their catalog owner host Cmd+J activation revealed folder workspaces against the focused runtime server's host, so a folder owned by a local or SSH project could be revealed under an unrelated server. The reveal host now comes from the folder workspace's catalog owner, and an explicit caller host still wins. Add a regression test covering local and SSH owners while another runtime environment is focused. * Keep shared workspace activation in the existing layout writer
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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<typeof ActivateWorkspace>(() => ({ primaryTabId: null }))
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/worktree-activation', () => ({ activateAndRevealWorktree }))
|
||||
vi.mock('@/lib/worktree-activation', () => ({ activateAndRevealWorkspace }))
|
||||
|
||||
vi.mock('react-i18next', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof ReactI18Next>()
|
||||
@@ -49,14 +59,12 @@ vi.mock('@/components/repo/RepoBadgeLabel', () => ({
|
||||
RepoBadgeMark: () => <span data-repo-badge-mark="true" />
|
||||
}))
|
||||
|
||||
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<HTMLButtonElement>(
|
||||
`[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<HTMLButtonElement>(
|
||||
`[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<HTMLButtonElement>(
|
||||
'[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<HTMLButtonElement>(
|
||||
`[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'
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
|
||||
@@ -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<Repo, 'connectionId' | 'executionHostId'> | 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 }
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<string>()
|
||||
let groupId: string | null = folderWorkspace.projectGroupId
|
||||
|
||||
+2
-1
@@ -63,7 +63,8 @@ export function expandGroupsForWorktreeReveal(
|
||||
groupBy: args.groupBy,
|
||||
workspaceStatuses: args.workspaceStatuses,
|
||||
defaultHostId: args.defaultHostId,
|
||||
hostScopedGroups
|
||||
hostScopedGroups,
|
||||
executionHostId
|
||||
}
|
||||
)
|
||||
if (folderGroupKeys.length > 0) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string>()
|
||||
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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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<WorktreeJumpPaletteFilter, 'repoMap'> &
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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<string, string>()
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -314,7 +314,7 @@ function perfTabsPaletteProps(count: number): Partial<AppState> {
|
||||
|
||||
/** 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<HTMLElement>(
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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> = {}): 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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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 (
|
||||
<CommandItem
|
||||
|
||||
@@ -2609,8 +2609,8 @@
|
||||
"34c8fbb46e": "SSH remote",
|
||||
"63c2be1914": "SSH disconnected",
|
||||
"95be6587d3": "Create worktree \"{{value0}}\"",
|
||||
"worktreesHeader": "Worktrees",
|
||||
"recentWorktreesHeader": "Recent Worktrees",
|
||||
"worktreesHeader": "Workspaces",
|
||||
"recentWorktreesHeader": "Recent Workspaces",
|
||||
"settingsBadge": "Settings",
|
||||
"actionBadge": "Action",
|
||||
"paletteHostBadge": "Host: {{value0}}",
|
||||
@@ -2620,8 +2620,8 @@
|
||||
"repoGroupBadge": "Repo group",
|
||||
"pluginCommandFailed": "Could not run the plugin command.",
|
||||
"recentChatsTerminalsHeader": "Recent Chats & Terminals",
|
||||
"27f10cca63": "Search chats, terminals, worktrees, settings, and actions...",
|
||||
"2770f02910": "Search chats, terminals, worktrees, settings, and actions",
|
||||
"27f10cca63": "Search chats, terminals, workspaces, settings, and actions...",
|
||||
"2770f02910": "Search chats, terminals, workspaces, settings, and actions",
|
||||
"lastActiveTime": "Last active {{value0}} ago"
|
||||
},
|
||||
"github": {
|
||||
|
||||
@@ -2377,8 +2377,8 @@
|
||||
"34c8fbb46e": "SSH remoto",
|
||||
"63c2be1914": "SSH desconectado",
|
||||
"95be6587d3": "Crear worktree \"{{value0}}\"",
|
||||
"worktreesHeader": "Worktrees",
|
||||
"recentWorktreesHeader": "Worktrees recientes",
|
||||
"worktreesHeader": "Espacios de trabajo",
|
||||
"recentWorktreesHeader": "Espacios de trabajo recientes",
|
||||
"settingsBadge": "Ajustes",
|
||||
"actionBadge": "Acción",
|
||||
"paletteHostBadge": "Host: {{value0}}",
|
||||
@@ -2387,8 +2387,8 @@
|
||||
"projectBadge": "Proyecto",
|
||||
"repoGroupBadge": "Grupo de repositorios",
|
||||
"pluginCommandFailed": "No se pudo ejecutar el comando del plugin.",
|
||||
"27f10cca63": "Busca chats, terminales, worktrees, ajustes y acciones...",
|
||||
"2770f02910": "Busca chats, terminales, worktrees, ajustes y acciones",
|
||||
"27f10cca63": "Busca chats, terminales, espacios de trabajo, ajustes y acciones...",
|
||||
"2770f02910": "Busca chats, terminales, espacios de trabajo, ajustes y acciones",
|
||||
"recentChatsTerminalsHeader": "Chats y terminales recientes",
|
||||
"lastActiveTime": "Última actividad hace {{value0}}"
|
||||
},
|
||||
|
||||
@@ -2482,8 +2482,8 @@
|
||||
"34c8fbb46e": "Remote SSH",
|
||||
"63c2be1914": "SSH déconnecté",
|
||||
"95be6587d3": "Créer le worktree « {{value0}} »",
|
||||
"worktreesHeader": "Worktrees",
|
||||
"recentWorktreesHeader": "Worktrees récents",
|
||||
"worktreesHeader": "Espaces de travail",
|
||||
"recentWorktreesHeader": "Espaces de travail récents",
|
||||
"settingsBadge": "Paramètres",
|
||||
"actionBadge": "Action",
|
||||
"paletteHostBadge": "Hôte : {{value0}}",
|
||||
@@ -2493,8 +2493,8 @@
|
||||
"repoGroupBadge": "Groupe de dépôts",
|
||||
"pluginCommandFailed": "Impossible d'exécuter la commande du plugin.",
|
||||
"recentChatsTerminalsHeader": "Chats et terminaux récents",
|
||||
"27f10cca63": "Rechercher chats, terminaux, worktrees, paramètres et actions...",
|
||||
"2770f02910": "Rechercher chats, terminaux, worktrees, paramètres et actions",
|
||||
"27f10cca63": "Rechercher chats, terminaux, espaces de travail, paramètres et actions...",
|
||||
"2770f02910": "Rechercher chats, terminaux, espaces de travail, paramètres et actions",
|
||||
"lastActiveTime": "Dernière activité il y a {{value0}}"
|
||||
},
|
||||
"github": {
|
||||
|
||||
@@ -2377,8 +2377,8 @@
|
||||
"34c8fbb46e": "SSH リモート",
|
||||
"63c2be1914": "SSH が切断されました",
|
||||
"95be6587d3": "ワークツリー「{{value0}}」を作成する",
|
||||
"worktreesHeader": "ワークツリー",
|
||||
"recentWorktreesHeader": "最近のワークツリー",
|
||||
"worktreesHeader": "ワークスペース",
|
||||
"recentWorktreesHeader": "最近のワークスペース",
|
||||
"settingsBadge": "設定",
|
||||
"actionBadge": "操作",
|
||||
"paletteHostBadge": "ホスト: {{value0}}",
|
||||
@@ -2387,8 +2387,8 @@
|
||||
"projectBadge": "プロジェクト",
|
||||
"repoGroupBadge": "リポジトリグループ",
|
||||
"pluginCommandFailed": "プラグインのコマンドを実行できませんでした。",
|
||||
"27f10cca63": "チャット、ターミナル、ワークツリー、設定、操作を検索します…",
|
||||
"2770f02910": "チャット、ターミナル、ワークツリー、設定、操作を検索します",
|
||||
"27f10cca63": "チャット、ターミナル、ワークスペース、設定、操作を検索します…",
|
||||
"2770f02910": "チャット、ターミナル、ワークスペース、設定、操作を検索します",
|
||||
"recentChatsTerminalsHeader": "最近のチャットとターミナル",
|
||||
"lastActiveTime": "最後にアクティブになったのは{{value0}}前です"
|
||||
},
|
||||
|
||||
@@ -2380,8 +2380,8 @@
|
||||
"34c8fbb46e": "SSH 원격",
|
||||
"63c2be1914": "SSH 연결이 끊어졌습니다.",
|
||||
"95be6587d3": "워크트리 \"{{value0}}\" 만들기",
|
||||
"worktreesHeader": "워크트리",
|
||||
"recentWorktreesHeader": "최근 워크트리",
|
||||
"worktreesHeader": "작업 공간",
|
||||
"recentWorktreesHeader": "최근 작업 공간",
|
||||
"settingsBadge": "설정",
|
||||
"actionBadge": "작업",
|
||||
"paletteHostBadge": "호스트: {{value0}}",
|
||||
@@ -2390,8 +2390,8 @@
|
||||
"projectBadge": "프로젝트",
|
||||
"repoGroupBadge": "리포지토리 그룹",
|
||||
"pluginCommandFailed": "플러그인 명령을 실행할 수 없습니다.",
|
||||
"27f10cca63": "채팅, 터미널, 워크트리, 설정 및 작업 검색...",
|
||||
"2770f02910": "채팅, 터미널, 워크트리, 설정 및 작업 검색",
|
||||
"27f10cca63": "채팅, 터미널, 작업 공간, 설정 및 작업 검색...",
|
||||
"2770f02910": "채팅, 터미널, 작업 공간, 설정 및 작업 검색",
|
||||
"recentChatsTerminalsHeader": "최근 채팅 및 터미널",
|
||||
"lastActiveTime": "마지막 활동: {{value0}} 전"
|
||||
},
|
||||
|
||||
@@ -2377,8 +2377,8 @@
|
||||
"34c8fbb46e": "SSH远程",
|
||||
"63c2be1914": "SSH 已断开连接",
|
||||
"95be6587d3": "创建工作树“{{value0}}”",
|
||||
"worktreesHeader": "工作树",
|
||||
"recentWorktreesHeader": "最近的工作树",
|
||||
"worktreesHeader": "工作空间",
|
||||
"recentWorktreesHeader": "最近的工作空间",
|
||||
"settingsBadge": "设置",
|
||||
"actionBadge": "操作",
|
||||
"paletteHostBadge": "主机:{{value0}}",
|
||||
@@ -2387,8 +2387,8 @@
|
||||
"projectBadge": "项目",
|
||||
"repoGroupBadge": "仓库组",
|
||||
"pluginCommandFailed": "无法运行插件命令。",
|
||||
"27f10cca63": "搜索聊天、终端、工作树、设置和操作...",
|
||||
"2770f02910": "搜索聊天、终端、工作树、设置和操作",
|
||||
"27f10cca63": "搜索聊天、终端、工作空间、设置和操作...",
|
||||
"2770f02910": "搜索聊天、终端、工作空间、设置和操作",
|
||||
"recentChatsTerminalsHeader": "最近的聊天和终端",
|
||||
"lastActiveTime": "最后活跃时间 {{value0}} 前"
|
||||
},
|
||||
|
||||
@@ -10,10 +10,10 @@ import { folderWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
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 { 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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<typeof RuntimeRpcClient>()
|
||||
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<FolderWorkspacePathStatus>()
|
||||
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
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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<AppState, 'projectGroups' | 'folderWorkspaces' | 'repos' | 'sshConnectionStates'>,
|
||||
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<string>()
|
||||
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<string>()
|
||||
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<string>()
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<void> => {
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user