diff --git a/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.test.tsx b/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.test.tsx index 8ce863aba..527c7496a 100644 --- a/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.test.tsx +++ b/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.test.tsx @@ -54,19 +54,21 @@ function renderLine( } describe('ImportedWorktreesVisibilityLine', () => { - it('renders the compact repo-group line with inline show and dismiss actions', () => { + it('renders the compact repo-group line with expand and dismiss actions', () => { const markup = renderLine() expect(markup).toContain('Hiding 4 discovered worktrees') - expect(markup).toContain('Show all') - expect(markup).toContain('Show all 4 discovered worktrees for orca') + expect(markup).toContain('Expand hidden worktrees for orca') expect(markup).toContain( - 'Keep 4 discovered worktrees hidden for orca; recover from the repo menu' + 'Keep 4 discovered worktrees hidden for orca; recover from the project menu' ) expect(markup).toContain('aria-expanded="false"') expect(markup).not.toContain('Imported 4 existing worktrees') expect(markup).not.toContain('Orca found 4 worktrees') expect(markup).not.toContain('repo options') + expect(markup).not.toContain('Reveal') + expect(markup).not.toContain('Always show') + expect(markup).not.toContain('Hidden worktrees by location') expect(markup).not.toContain('payments-refactor') expect(markup).not.toContain('/worktrees/demo-project') }) @@ -75,11 +77,11 @@ describe('ImportedWorktreesVisibilityLine', () => { const markup = renderLine({ placement: 'pinned-fallback', onKeepHidden: undefined }) expect(markup).toContain('Hiding 4 discovered worktrees in orca') - expect(markup).toContain('Show all') - expect(markup).not.toContain('Keep hidden - recover from the repo menu') + expect(markup).not.toContain('Review') + expect(markup).not.toContain('Keep hidden - recover from the project menu') }) - it('preserves Windows parent path separators in preview groups', () => { + it('normalizes Windows parent path separators in preview groups', () => { const groups = groupWorktreesByParentPath([ { id: 'windows-hidden', @@ -88,8 +90,42 @@ describe('ImportedWorktreesVisibilityLine', () => { } ]) - expect(groups).toMatchObject([{ path: 'C:\\Repos\\Orca' }]) - expect(groups[0]?.path).not.toBe('C:/Repos/Orca') + expect(groups).toMatchObject([{ path: 'C:/Repos/Orca' }]) + expect(groups[0]?.path).not.toBe('C:\\Repos\\Orca') + }) + + it('keeps Windows drive roots as parent path labels', () => { + const groups = groupWorktreesByParentPath([ + { + id: 'windows-root-hidden', + displayName: 'FeatureX', + path: 'C:/' + } + ]) + + expect(groups).toMatchObject([{ path: 'C:/' }]) + }) + + it('keeps UNC share roots as parent path labels', () => { + const groups = groupWorktreesByParentPath([ + { + id: 'unc-root-hidden', + displayName: 'ShareRoot', + path: '\\\\server\\share' + }, + { + id: 'unc-repo-hidden', + displayName: 'Repo', + path: '\\\\server\\share\\repo' + } + ]) + + expect(groups).toMatchObject([ + { + path: '//server/share', + worktrees: [{ id: 'unc-root-hidden' }, { id: 'unc-repo-hidden' }] + } + ]) }) it('disables actions while pending and renders inline errors', () => { diff --git a/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.tsx b/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.tsx index ab0701dbd..069669e26 100644 --- a/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.tsx +++ b/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.tsx @@ -3,8 +3,9 @@ import { ChevronRight, EyeOff, X } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { dirname } from '@/lib/path' import { cn } from '@/lib/utils' +import { getExternalWorktreeParentPath } from '../../../../shared/external-worktree-visibility' +import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' export type ImportedWorktreesVisibilityPlacement = 'repo-group' | 'pinned-fallback' @@ -21,14 +22,14 @@ type ImportedWorktreesVisibilityLineProps = { placement: ImportedWorktreesVisibilityPlacement pending: boolean error: string | null - onShow: () => void + onShow?: () => void onKeepHidden?: () => void className?: string } const PREVIEW_LIMIT = 3 -const UNKNOWN_LOCATION_LABEL = 'Unknown location' -const KEEP_HIDDEN_LABEL = 'Keep hidden - recover from the repo menu' +const KEEP_HIDDEN_LABEL = 'Keep hidden - recover from the project menu' +const GROUP_LIMIT = 5 type ImportedWorktreePathGroup = { path: string @@ -48,14 +49,7 @@ function getWorktreeKey( } function getParentPath(path: string | undefined): string { - if (!path) { - return UNKNOWN_LOCATION_LABEL - } - const parentPath = dirname(path) - if (!parentPath || parentPath === '.') { - return UNKNOWN_LOCATION_LABEL - } - return parentPath + return getExternalWorktreeParentPath(path) } export function groupWorktreesByParentPath( @@ -88,12 +82,13 @@ export default function ImportedWorktreesVisibilityLine({ className }: ImportedWorktreesVisibilityLineProps): React.JSX.Element | null { const [isExpanded, setIsExpanded] = useState(false) + const [expandedGroupPathKeys, setExpandedGroupPathKeys] = useState>(new Set()) const hiddenCount = hiddenWorktrees.length const worktreeNoun = pluralizeWorktree(hiddenCount) - const visibleWorktrees = hiddenWorktrees.slice(0, PREVIEW_LIMIT) - const visibleWorktreeGroups = groupWorktreesByParentPath(visibleWorktrees) - const remainingCount = Math.max(0, hiddenWorktrees.length - visibleWorktrees.length) - const keepHiddenAriaLabel = `Keep ${hiddenCount} discovered ${worktreeNoun} hidden for ${repoDisplayName}; recover from the repo menu` + const worktreeGroups = groupWorktreesByParentPath(hiddenWorktrees) + const visibleWorktreeGroups = worktreeGroups.slice(0, GROUP_LIMIT) + const remainingGroupCount = Math.max(0, worktreeGroups.length - visibleWorktreeGroups.length) + const keepHiddenAriaLabel = `Keep ${hiddenCount} discovered ${worktreeNoun} hidden for ${repoDisplayName}; recover from the project menu` if (hiddenCount === 0) { return null @@ -104,10 +99,23 @@ export default function ImportedWorktreesVisibilityLine({ ? `Hiding ${hiddenCount} discovered ${worktreeNoun} in ${repoDisplayName}` : `Hiding ${hiddenCount} discovered ${worktreeNoun}` + const toggleGroupExpanded = (path: string): void => { + const key = normalizeRuntimePathForComparison(path) + setExpandedGroupPathKeys((previous) => { + const next = new Set(previous) + if (next.has(key)) { + next.delete(key) + } else { + next.add(key) + } + return next + }) + } + return (
{isExpanded ? ( -
+
{visibleWorktreeGroups.map((group) => ( -
- - - +
+
+ + + + {group.path} + + + {group.path} - - - - {group.path} - - - {group.worktrees.map((worktree, index) => ( -
-
- ))} + + + + {group.worktrees.length} + +
+
    + {group.worktrees + .slice( + 0, + expandedGroupPathKeys.has(normalizeRuntimePathForComparison(group.path)) + ? group.worktrees.length + : PREVIEW_LIMIT + ) + .map((worktree, index) => ( +
  • + + {worktree.displayName} + +
  • + ))} + {group.worktrees.length > PREVIEW_LIMIT ? ( +
  • + +
  • + ) : null} +
))} - {remainingCount > 0 ? ( + {remainingGroupCount > 0 ? (
- + {remainingCount} more + + {remainingGroupCount} more locations
) : null} +
+

+ Change this later from the project menu. +

+
+ {onKeepHidden ? ( + + ) : null} + {onShow ? ( + + ) : null} +
+
) : null} diff --git a/src/renderer/src/components/sidebar/imported-worktrees-card-actions.test.ts b/src/renderer/src/components/sidebar/imported-worktrees-card-actions.test.ts index 7151edfee..7ffdbac0b 100644 --- a/src/renderer/src/components/sidebar/imported-worktrees-card-actions.test.ts +++ b/src/renderer/src/components/sidebar/imported-worktrees-card-actions.test.ts @@ -22,25 +22,25 @@ describe('imported worktrees card actions', () => { fetchWorktrees.mockResolvedValue(true) }) - it('shows imported worktrees only after visibility update and refresh succeed', async () => { + it('shows discovered worktrees after visibility update and refresh succeed', async () => { await showImportedWorktreesCard({ projectId, updateRepo, fetchWorktrees, setCardState }) expect(updateRepo).toHaveBeenCalledWith(projectId, { externalWorktreeVisibility: 'show' }) expect(fetchWorktrees).toHaveBeenCalledWith(projectId, { requireAuthoritative: true }) expect(setCardState).toHaveBeenNthCalledWith(1, projectId, { pending: true, - error: null, - forceVisible: true + error: null }) expect(setCardState).toHaveBeenLastCalledWith(projectId, null) }) - it('leaves the card visible when showing fails before refresh', async () => { - updateRepo.mockResolvedValueOnce(false) + it('rolls visibility back when refresh fails after showing', async () => { + fetchWorktrees.mockResolvedValueOnce(false) await showImportedWorktreesCard({ projectId, updateRepo, fetchWorktrees, setCardState }) - expect(fetchWorktrees).not.toHaveBeenCalled() + expect(updateRepo).toHaveBeenNthCalledWith(1, projectId, { externalWorktreeVisibility: 'show' }) + expect(updateRepo).toHaveBeenNthCalledWith(2, projectId, { externalWorktreeVisibility: 'hide' }) expect(setCardState).toHaveBeenLastCalledWith(projectId, { pending: false, error: IMPORTED_WORKTREES_SHOW_ERROR @@ -71,19 +71,6 @@ describe('imported worktrees card actions', () => { }) }) - it('rolls visibility back and leaves an error when refresh fails after showing', async () => { - fetchWorktrees.mockResolvedValueOnce(false) - - await showImportedWorktreesCard({ projectId, updateRepo, fetchWorktrees, setCardState }) - - expect(updateRepo).toHaveBeenNthCalledWith(1, projectId, { externalWorktreeVisibility: 'show' }) - expect(updateRepo).toHaveBeenNthCalledWith(2, projectId, { externalWorktreeVisibility: 'hide' }) - expect(setCardState).toHaveBeenLastCalledWith(projectId, { - pending: false, - error: IMPORTED_WORKTREES_SHOW_ERROR - }) - }) - it('keeps the card force-visible when rollback fails after a refresh failure', async () => { fetchWorktrees.mockResolvedValueOnce(false) updateRepo.mockResolvedValueOnce(true).mockResolvedValueOnce(false) diff --git a/src/renderer/src/components/sidebar/imported-worktrees-card-actions.ts b/src/renderer/src/components/sidebar/imported-worktrees-card-actions.ts index cb5d962e7..5ebdf3ef5 100644 --- a/src/renderer/src/components/sidebar/imported-worktrees-card-actions.ts +++ b/src/renderer/src/components/sidebar/imported-worktrees-card-actions.ts @@ -22,18 +22,19 @@ type ImportedWorktreeCardActionDeps = { ) => Promise } -export const IMPORTED_WORKTREES_SHOW_ERROR = 'Could not show imported worktrees. Try again.' +export const IMPORTED_WORKTREES_SHOW_ERROR = 'Could not show discovered worktrees. Try again.' export const IMPORTED_WORKTREES_KEEP_HIDDEN_ERROR = - 'Could not keep imported worktrees hidden. Try again.' + 'Could not keep discovered worktrees hidden. Try again.' export async function showImportedWorktreesCard( args: ImportedWorktreeCardActionDeps ): Promise { const forceVisible = args.forceVisible === true + // Preserve rollback-failure retry state so the visible error/action surface does not disappear. args.setCardState(args.projectId, { pending: true, error: null, - forceVisible: true + ...(forceVisible ? { forceVisible: true } : {}) }) const updated = await args.updateRepo(args.projectId, { externalWorktreeVisibility: 'show' }) if (!updated) { diff --git a/src/renderer/src/components/sidebar/worktree-list-imported-rows.test.ts b/src/renderer/src/components/sidebar/worktree-list-imported-rows.test.ts index 9960ea363..15714ec7d 100644 --- a/src/renderer/src/components/sidebar/worktree-list-imported-rows.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-imported-rows.test.ts @@ -84,12 +84,12 @@ describe('imported worktree virtual rows', () => { ).toEqual([{ key: 'repo:repo-1', worktreeIds: ['main', 'feature'] }]) }) - it('suppresses keep-hidden actions for force-visible rollback failure cards', () => { + it('only allows keep-hidden actions for repo-group cards that are not forced visible', () => { expect(canKeepImportedWorktreesHidden(makeImportedCardRow(), undefined)).toBe(true) expect( canKeepImportedWorktreesHidden(makeImportedCardRow(), { pending: false, - error: 'Could not show imported worktrees.', + error: 'Could not show discovered worktrees.', forceVisible: true }) ).toBe(false) diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 0c5ce205a..a6055f07d 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -17,6 +17,10 @@ const STALE_PANE_KEY = makePaneKey('tab-future', STALE_LEAF_ID) const ORPHAN_PANE_KEY = makePaneKey('tab-orphan', ORPHAN_LEAF_ID) const TAB_1_PANE_KEY = makePaneKey('tab-1', TAB_1_LEAF_ID) +function expectWorktreeRouting(worktreeId: string): unknown { + return expect.objectContaining({ worktreeId }) +} + function makeTarget(args: { hasXtermClass?: boolean; editorClosest?: boolean }): { classList: { contains: (token: string) => boolean } closest: (selector: string) => Element | null @@ -2990,7 +2994,8 @@ describe('useIpcEvents agent status snapshot integration', () => { FUTURE_PANE_KEY, expect.objectContaining({ state: 'working', prompt: 'p', agentType: 'claude' }), 'Future Tab', - { updatedAt: 1_700_000_000_000, stateStartedAt: 1_699_999_999_000 } + { updatedAt: 1_700_000_000_000, stateStartedAt: 1_699_999_999_000 }, + expectWorktreeRouting('wt-1') ) }) @@ -3057,7 +3062,8 @@ describe('useIpcEvents agent status snapshot integration', () => { lastAssistantMessage: 'inactive completion' }), 'Inactive Tab', - { updatedAt: 1_700_000_000_200, stateStartedAt: 1_699_999_999_100 } + { updatedAt: 1_700_000_000_200, stateStartedAt: 1_699_999_999_100 }, + expectWorktreeRouting('wt-1') ) }) @@ -3138,7 +3144,8 @@ describe('useIpcEvents agent status snapshot integration', () => { lastAssistantMessage: 'cursor completion' }), 'Cursor ready', - { updatedAt: 1_700_000_000_200, stateStartedAt: 1_699_999_999_100 } + { updatedAt: 1_700_000_000_200, stateStartedAt: 1_699_999_999_100 }, + expectWorktreeRouting('wt-1') ) }) @@ -3221,7 +3228,8 @@ describe('useIpcEvents agent status snapshot integration', () => { lastAssistantMessage: 'codex completion' }), 'Codex ready', - { updatedAt: 1_700_000_000_200, stateStartedAt: 1_699_999_999_100 } + { updatedAt: 1_700_000_000_200, stateStartedAt: 1_699_999_999_100 }, + expectWorktreeRouting('wt-1') ) expect(updateTabTitle).toHaveBeenCalledTimes(1) expect(updateTabTitle).toHaveBeenCalledWith('tab-future', 'Codex ready') @@ -3384,7 +3392,8 @@ describe('useIpcEvents agent status snapshot integration', () => { agentType: 'openclaude' }), 'Terminal 2', - { updatedAt: 1_700_000_000_200, stateStartedAt: 1_699_999_999_100 } + { updatedAt: 1_700_000_000_200, stateStartedAt: 1_699_999_999_100 }, + expectWorktreeRouting('wt-1') ) }) @@ -3458,7 +3467,8 @@ describe('useIpcEvents agent status snapshot integration', () => { lastAssistantMessage: 'inactive completion' }), 'Inactive Tab', - { updatedAt: 1_700_000_000_200, stateStartedAt: 1_699_999_999_100 } + { updatedAt: 1_700_000_000_200, stateStartedAt: 1_699_999_999_100 }, + expectWorktreeRouting('wt-1') ) }) @@ -3560,7 +3570,8 @@ describe('useIpcEvents agent status snapshot integration', () => { FUTURE_PANE_KEY, expect.objectContaining({ state: 'working', prompt: 'queued prompt', agentType: 'codex' }), 'Future Tab', - { updatedAt: 1_700_000_000_100, stateStartedAt: 1_699_999_999_100 } + { updatedAt: 1_700_000_000_100, stateStartedAt: 1_699_999_999_100 }, + expectWorktreeRouting('wt-1') ) expect(setAgentStatus).toHaveBeenNthCalledWith( 2, @@ -3572,7 +3583,8 @@ describe('useIpcEvents agent status snapshot integration', () => { lastAssistantMessage: 'queued completion' }), 'Future Tab', - { updatedAt: 1_700_000_000_200, stateStartedAt: 1_699_999_999_100 } + { updatedAt: 1_700_000_000_200, stateStartedAt: 1_699_999_999_100 }, + expectWorktreeRouting('wt-1') ) }) @@ -3636,7 +3648,8 @@ describe('useIpcEvents agent status snapshot integration', () => { FUTURE_PANE_KEY, expect.objectContaining({ state: 'working', prompt: 'remote p', agentType: 'codex' }), 'SSH Tab', - { updatedAt: 1_700_000_000_000, stateStartedAt: 1_699_999_999_000 } + { updatedAt: 1_700_000_000_000, stateStartedAt: 1_699_999_999_000 }, + expectWorktreeRouting('wt-1') ) }) @@ -4063,7 +4076,8 @@ describe('useIpcEvents agent status snapshot integration', () => { TAB_1_PANE_KEY, expect.objectContaining({ state: 'working' }), 'Terminal 1', - { updatedAt: 1_700_000_000_100, stateStartedAt: 1_699_999_999_100 } + { updatedAt: 1_700_000_000_100, stateStartedAt: 1_699_999_999_100 }, + expectWorktreeRouting('wt-1') ) }) diff --git a/src/shared/external-worktree-visibility.ts b/src/shared/external-worktree-visibility.ts new file mode 100644 index 000000000..d1a5d4be5 --- /dev/null +++ b/src/shared/external-worktree-visibility.ts @@ -0,0 +1,44 @@ +import { normalizeRuntimePathSeparators } from './cross-platform-path' + +export const UNKNOWN_EXTERNAL_WORKTREE_PARENT_PATH = 'Unknown location' + +function trimRuntimePathTrailingSlash(value: string): string { + if (value === '/' || /^[A-Za-z]:\/$/.test(value)) { + return value + } + return value.replace(/\/+$/, '') +} + +export function getExternalWorktreeParentPath(worktreePath: string | undefined): string { + if (!worktreePath) { + return UNKNOWN_EXTERNAL_WORKTREE_PARENT_PATH + } + const normalized = trimRuntimePathTrailingSlash(normalizeRuntimePathSeparators(worktreePath)) + if (!normalized) { + return UNKNOWN_EXTERNAL_WORKTREE_PARENT_PATH + } + if (normalized.startsWith('//')) { + const parts = normalized.slice(2).split('/').filter(Boolean) + if (parts.length < 2) { + return UNKNOWN_EXTERNAL_WORKTREE_PARENT_PATH + } + if (parts.length === 2) { + return `//${parts[0]}/${parts[1]}` + } + return `//${parts.slice(0, -1).join('/')}` + } + const lastSeparatorIndex = normalized.lastIndexOf('/') + if (lastSeparatorIndex < 0) { + return UNKNOWN_EXTERNAL_WORKTREE_PARENT_PATH + } + if (lastSeparatorIndex === 0) { + return '/' + } + if (/^[A-Za-z]:\/$/.test(normalized)) { + return normalized + } + if (/^[A-Za-z]:\/[^/]+$/.test(normalized)) { + return `${normalized.slice(0, 2)}/` + } + return normalized.slice(0, lastSeparatorIndex) +}