diff --git a/.gitignore b/.gitignore index a469ca2b20a..6722fc5ae54 100644 --- a/.gitignore +++ b/.gitignore @@ -103,7 +103,6 @@ docs/** !docs/agent-skill-sharing-implementation-checklist.md !docs/mobile-terminal-shortcut-bar.md !docs/reference/ -!docs/reference/cmd-j-ranking.md !docs/reference/git-compatibility.md !docs/reference/headless-linux-server.md !docs/reference/ime-regression-checklist.md diff --git a/src/renderer/src/components/worktree-jump-palette-primitives.test.tsx b/src/renderer/src/components/worktree-jump-palette-primitives.test.tsx new file mode 100644 index 00000000000..1d39ab09749 --- /dev/null +++ b/src/renderer/src/components/worktree-jump-palette-primitives.test.tsx @@ -0,0 +1,45 @@ +// @vitest-environment happy-dom + +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, it } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import { PaletteOpenTabPrimaryLine } from './worktree-jump-palette-primitives' + +afterEach(() => cleanup()) + +function renderPrimaryLine( + secondaryMatches: readonly { text: string; ranges: readonly never[] }[] +): void { + render( + + + + ) +} + +it('exposes the extra secondary matches without adding a palette tab stop', () => { + renderPrimaryLine([ + { text: 'src/app.ts', ranges: [] }, + { text: 'src/deep/nested.ts', ranges: [] }, + { text: 'docs/readme.md', ranges: [] } + ]) + + expect(screen.getByText('src/deep/nested.ts, docs/readme.md')).toBeTruthy() + const badge = screen.getByText('+2') + expect(badge.getAttribute('aria-hidden')).toBe('true') + expect(badge.tabIndex).toBe(-1) +}) + +it('renders no badge when every secondary match is already shown', () => { + renderPrimaryLine([{ text: 'src/app.ts', ranges: [] }]) + + expect(screen.queryByText(/^\+\d+$/)).toBeNull() +}) diff --git a/src/renderer/src/components/worktree-jump-palette-primitives.tsx b/src/renderer/src/components/worktree-jump-palette-primitives.tsx index ffdcff0740a..85e8ab04f29 100644 --- a/src/renderer/src/components/worktree-jump-palette-primitives.tsx +++ b/src/renderer/src/components/worktree-jump-palette-primitives.tsx @@ -124,27 +124,35 @@ export function PaletteOpenTabPrimaryLine({ ) : null} {additionalSecondaryMatches.length ? ( - - - match.text).join(', ')} - className="shrink-0 rounded-md border border-border/60 bg-background/45 px-1.5 py-px text-[9px] font-medium text-muted-foreground/88" - > - +{additionalSecondaryMatches.length} - - - - {additionalSecondaryMatches.map((match) => ( -
- -
- ))} -
-
+ <> + {/* Tab selects the palette filter, so the badge stays out of the tab order and + reads its matches through the row's own accessible name instead. */} + + {additionalSecondaryMatches.map((match) => match.text).join(', ')} + + + + + +{additionalSecondaryMatches.length} + + + + {additionalSecondaryMatches.map((match) => ( +
+ +
+ ))} +
+
+ ) : null} {showWorktree ? ( <> diff --git a/src/renderer/src/lib/browser-workspace-tab-activation.test.ts b/src/renderer/src/lib/browser-workspace-tab-activation.test.ts new file mode 100644 index 00000000000..f8e351e1170 --- /dev/null +++ b/src/renderer/src/lib/browser-workspace-tab-activation.test.ts @@ -0,0 +1,82 @@ +// @vitest-environment happy-dom + +import { afterEach, expect, it } from 'vitest' +import { useAppStore } from '@/store' +import type { Tab } from '../../../shared/tab-types' +import type { Worktree } from '../../../shared/worktree/types' +import { getActivatableBrowserWorkspaceTab } from './browser-workspace-tab-activation' + +const initialState = useAppStore.getInitialState() +afterEach(() => useAppStore.setState(initialState, true)) + +function makeWorktree(overrides: Partial & Pick): Worktree { + return { + repoId: 'repo', + path: '/workspace', + head: '', + branch: 'main', + isBare: false, + isMainWorktree: true, + displayName: 'Workspace', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + ...overrides + } +} + +const browserTab: Tab = { + id: 'unified-browser', + entityId: 'workspace', + groupId: 'group', + worktreeId: 'wt', + contentType: 'browser', + label: 'Browser', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 +} + +function seedState(worktreesByRepo: Record, tab: Tab): void { + useAppStore.setState( + { ...initialState, worktreesByRepo, unifiedTabsByWorktree: { wt: [tab] } }, + true + ) +} + +it('refuses a hostless browser tab for a remote worktree whose ID also exists locally', () => { + seedState( + { + local: [makeWorktree({ id: 'wt' })], + remote: [makeWorktree({ id: 'wt', repoId: 'repo-remote', hostId: 'ssh:remote' })] + }, + browserTab + ) + + expect( + getActivatableBrowserWorkspaceTab({ + worktreeId: 'wt', + workspaceId: 'workspace', + executionHostId: 'ssh:remote' + }) + ).toBeNull() +}) + +it('accepts a hostless browser tab when the worktree ID is unambiguous', () => { + seedState({ remote: [makeWorktree({ id: 'wt', hostId: 'ssh:remote' })] }, browserTab) + + expect( + getActivatableBrowserWorkspaceTab({ + worktreeId: 'wt', + workspaceId: 'workspace', + executionHostId: 'ssh:remote' + }) + ).toEqual(browserTab) +}) diff --git a/src/renderer/src/lib/browser-workspace-tab-activation.ts b/src/renderer/src/lib/browser-workspace-tab-activation.ts index 3e82d7c0985..3f5146877b4 100644 --- a/src/renderer/src/lib/browser-workspace-tab-activation.ts +++ b/src/renderer/src/lib/browser-workspace-tab-activation.ts @@ -1,7 +1,8 @@ import { useAppStore } from '@/store' import type { Tab } from '../../../shared/tab-types' import type { ExecutionHostId } from '../../../shared/execution-host' -import { isUnifiedTabOwnedByWorktree } from './unified-tab-host-ownership' +import { getIndexedAllWorktrees } from '@/store/worktree-repo-index' +import { findAmbiguousWorktreeIds, isUnifiedTabOwnedByWorktree } from './unified-tab-host-ownership' type BrowserWorkspaceTabTarget = { worktreeId: string @@ -18,6 +19,10 @@ export function getActivatableBrowserWorkspaceTab(params: BrowserWorkspaceTabTar if (params.executionHostId && !worktree) { return null } + // A hostless tab cannot be attributed when the same worktree ID exists on several hosts. + const ambiguousWorktreeIds = findAmbiguousWorktreeIds( + getIndexedAllWorktrees(state.worktreesByRepo) + ) // setActiveBrowserTab resolves its backing tab globally by workspace ID. const tabs = Object.values(state.unifiedTabsByWorktree).flat() const browserTabs = tabs.filter( @@ -28,7 +33,7 @@ export function getActivatableBrowserWorkspaceTab(params: BrowserWorkspaceTabTar browserTabs.some( (tab) => tab.worktreeId !== params.worktreeId || - (worktree && !isUnifiedTabOwnedByWorktree(tab, worktree, new Set())) + (worktree && !isUnifiedTabOwnedByWorktree(tab, worktree, ambiguousWorktreeIds)) ) || !unifiedTab || tabs.filter((candidate) => candidate.id === unifiedTab.id).length !== 1 diff --git a/src/renderer/src/lib/workspace-tab-palette-activation.store.test.ts b/src/renderer/src/lib/workspace-tab-palette-activation.store.test.ts index 70672e256b6..60b10097f86 100644 --- a/src/renderer/src/lib/workspace-tab-palette-activation.store.test.ts +++ b/src/renderer/src/lib/workspace-tab-palette-activation.store.test.ts @@ -81,3 +81,132 @@ it('keeps the selected diff active when an editor for the same file shares its g expect(useAppStore.getState().groupsByWorktree.wt[0].activeTabId).toBe('diff') expect(useAppStore.getState().activeFileId).toBe('file') }) + +function makeWorktree(overrides: Partial & Pick): Worktree { + return { + repoId: 'repo', + path: '/workspace', + head: '', + branch: 'main', + isBare: false, + isMainWorktree: true, + displayName: 'Workspace', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + ...overrides + } +} + +const COLLIDING_WORKTREES = { + local: [makeWorktree({ id: 'wt' })], + remote: [makeWorktree({ id: 'wt', repoId: 'repo-remote', hostId: 'ssh:remote' })] +} + +it('refuses a hostless tab for a remote target whose worktree ID also exists locally', () => { + const terminal: Tab = { + id: 'unified-terminal', + entityId: 'terminal', + groupId: 'group', + worktreeId: 'wt', + contentType: 'terminal', + label: 'Terminal', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + useAppStore.setState( + { + ...initialState, + worktreesByRepo: COLLIDING_WORKTREES, + groupsByWorktree: { + wt: [ + { + id: 'group', + worktreeId: 'wt', + activeTabId: 'unified-terminal', + tabOrder: ['unified-terminal'] + } + ] + }, + activeGroupIdByWorktree: { wt: 'group' }, + unifiedTabsByWorktree: { wt: [terminal] } + }, + true + ) + + expect( + activateWorkspaceTabPaletteResult({ + worktreeId: 'wt', + groupId: 'group', + tabId: 'unified-terminal', + entityId: 'terminal', + contentType: 'terminal', + executionHostId: 'ssh:remote' + }) + ).toEqual({ status: 'failed', reason: 'missing-tab' }) +}) + +it('refuses a hostless backing file for a remote target whose worktree ID also exists locally', () => { + const editor: Tab = { + id: 'unified-editor', + entityId: 'file', + groupId: 'group', + worktreeId: 'wt', + contentType: 'editor', + executionHostId: 'ssh:remote', + label: 'app.ts', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + useAppStore.setState( + { + ...initialState, + worktreesByRepo: COLLIDING_WORKTREES, + groupsByWorktree: { + wt: [ + { + id: 'group', + worktreeId: 'wt', + activeTabId: 'unified-editor', + tabOrder: ['unified-editor'] + } + ] + }, + activeGroupIdByWorktree: { wt: 'group' }, + unifiedTabsByWorktree: { wt: [editor] }, + openFiles: [ + { + id: 'file', + worktreeId: 'wt', + filePath: '/workspace/app.ts', + relativePath: 'app.ts', + language: 'typescript', + isDirty: false, + mode: 'edit' + } + ] + }, + true + ) + + expect( + activateWorkspaceTabPaletteResult({ + worktreeId: 'wt', + groupId: 'group', + tabId: 'unified-editor', + entityId: 'file', + contentType: 'editor', + executionHostId: 'ssh:remote' + }) + ).toEqual({ status: 'failed', reason: 'missing-file' }) +}) diff --git a/src/renderer/src/lib/workspace-tab-palette-activation.ts b/src/renderer/src/lib/workspace-tab-palette-activation.ts index 4e52d8077f9..810e5f13d4c 100644 --- a/src/renderer/src/lib/workspace-tab-palette-activation.ts +++ b/src/renderer/src/lib/workspace-tab-palette-activation.ts @@ -8,7 +8,9 @@ import { useAppStore } from '@/store' import type { AppState } from '@/store/types' import type { ExecutionHostId } from '../../../shared/execution-host' import { activateAndRevealWorktree } from './worktree-activation' +import { getIndexedAllWorktrees } from '@/store/worktree-repo-index' import { + findAmbiguousWorktreeIds, isOpenFileOwnedByWorktree, isUnifiedTabOwnedByWorktree } from './unified-tab-host-ownership' @@ -41,6 +43,7 @@ type WorkspaceTabPaletteActivationState = Pick< | 'setActiveTab' | 'setActiveTabType' | 'unifiedTabsByWorktree' + | 'worktreesByRepo' > function validateTarget( @@ -51,6 +54,10 @@ function validateTarget( if (!worktree) { return 'missing-worktree' } + // A hostless record cannot be attributed when the same worktree ID exists on several hosts. + const ambiguousWorktreeIds = findAmbiguousWorktreeIds( + getIndexedAllWorktrees(state.worktreesByRepo) + ) const group = (state.groupsByWorktree[result.worktreeId] ?? []).find( (candidate) => candidate.id === result.groupId ) @@ -66,7 +73,7 @@ function validateTarget( candidate.groupId === result.groupId && candidate.worktreeId === result.worktreeId && candidate.contentType === result.contentType && - isUnifiedTabOwnedByWorktree(candidate, worktree, new Set()) + isUnifiedTabOwnedByWorktree(candidate, worktree, ambiguousWorktreeIds) ) if (tabs.length !== 1 || !tab) { return 'missing-tab' @@ -77,9 +84,12 @@ function validateTarget( return 'missing-file' } const file = files[0] - const hasExplicitHost = + const hasExplicitHost = Boolean( file.operationProvenance || file.externalSshTargetId || file.runtimeEnvironmentId - if (hasExplicitHost && !isOpenFileOwnedByWorktree(file, worktree)) { + ) + // A hostless file falls back to local ownership, which only decides the match when IDs collide. + const requiresOwnershipCheck = hasExplicitHost || ambiguousWorktreeIds.has(worktree.id) + if (requiresOwnershipCheck && !isOpenFileOwnedByWorktree(file, worktree)) { return 'missing-file' } }