diff --git a/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityCard.test.tsx b/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityCard.test.tsx deleted file mode 100644 index ef981c4fce0..00000000000 --- a/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityCard.test.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { renderToStaticMarkup } from 'react-dom/server' -import type { ComponentProps } from 'react' -import { describe, expect, it, vi } from 'vitest' - -import ImportedWorktreesVisibilityCard from './ImportedWorktreesVisibilityCard' -import { TooltipProvider } from '@/components/ui/tooltip' - -const hiddenWorktrees = [ - { - id: 'hidden-1', - displayName: 'payments-refactor', - path: '/worktrees/demo-project/payments-refactor', - branch: 'refs/heads/payments-refactor' - }, - { - id: 'hidden-2', - displayName: 'auth-cache-debug', - path: '/worktrees/demo-project/auth-cache-debug', - branch: 'refs/heads/auth-cache-debug' - }, - { - id: 'hidden-3', - displayName: 'legacy-oauth-fix', - path: '/worktrees/legacy/legacy-oauth-fix', - branch: 'refs/heads/legacy-oauth-fix' - }, - { - id: 'hidden-4', - displayName: 'ssh-worktree', - path: '/srv/repos/orca/ssh-worktree', - branch: 'refs/heads/ssh-worktree' - } -] - -function renderCard( - overrides: Partial> = {} -): string { - return renderToStaticMarkup( - - - - ) -} - -describe('ImportedWorktreesVisibilityCard', () => { - it('renders the required repo-group copy, three-item preview, actions, and repo menu hint', () => { - const markup = renderCard() - - expect(markup).toContain('Imported 4 existing worktrees') - expect(markup).toContain( - 'Orca found 4 worktrees and imported them automatically into this repo.' - ) - expect(markup).toContain('payments-refactor') - expect(markup).toContain('auth-cache-debug') - expect(markup).toContain('legacy-oauth-fix') - expect(markup).toContain('/worktrees/demo-project') - expect(markup).toContain('/worktrees/legacy') - expect((markup.match(/>hidden { - const markup = renderCard({ placement: 'pinned-fallback' }) - - expect(markup).toContain('Imported 4 existing worktrees in orca') - expect(markup).toContain('imported them automatically into orca.') - expect(markup).toContain('Showing them restores the imported worktrees to the repo list.') - expect(markup).not.toContain('repo options') - }) - - it('preserves Windows parent path separators in the preview', () => { - const markup = renderCard({ - hiddenWorktrees: [ - { - id: 'windows-hidden', - displayName: 'FeatureX', - path: 'C:\\Repos\\Orca\\FeatureX' - } - ] - }) - - expect(markup).toContain('C:\\Repos\\Orca') - expect(markup).not.toContain('C:/Repos/Orca') - }) - - it('does not expose Keep hidden in the pinned-only fallback state', () => { - const markup = renderCard({ placement: 'pinned-fallback', onKeepHidden: undefined }) - - expect(markup).not.toContain('Keep hidden') - expect(markup).toContain('Use Show to restore this repo') - expect(markup).toContain('Show') - }) - - it('disables actions while pending and renders inline errors', () => { - const markup = renderCard({ pending: true, error: 'Could not show imported worktrees.' }) - - expect(markup).toContain('aria-busy="true"') - expect(markup).toContain('disabled=""') - expect(markup).toContain('role="alert"') - expect(markup).toContain('Could not show imported worktrees.') - }) -}) diff --git a/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityCard.tsx b/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityCard.tsx deleted file mode 100644 index 10eda47eec1..00000000000 --- a/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityCard.tsx +++ /dev/null @@ -1,223 +0,0 @@ -import React, { useState } from 'react' -import { Ellipsis } 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' - -export type ImportedWorktreesVisibilityPlacement = 'repo-group' | 'pinned-fallback' - -export type ImportedWorktreeVisibilityPreview = { - id?: string - displayName: string - path?: string - branch?: string -} - -type ImportedWorktreesVisibilityCardProps = { - repoDisplayName: string - hiddenWorktrees: readonly ImportedWorktreeVisibilityPreview[] - placement: ImportedWorktreesVisibilityPlacement - pending: boolean - error: string | null - onShow: () => void - onKeepHidden?: () => void - className?: string -} - -const PREVIEW_LIMIT = 3 -const UNKNOWN_LOCATION_LABEL = 'Unknown location' - -type ImportedWorktreePathGroup = { - path: string - worktrees: ImportedWorktreeVisibilityPreview[] -} - -function pluralizeWorktree(count: number): string { - return count === 1 ? 'worktree' : 'worktrees' -} - -function getWorktreeKey( - worktree: ImportedWorktreeVisibilityPreview, - index: number, - prefix: string -): string { - return worktree.id ?? worktree.path ?? `${prefix}-${worktree.displayName}-${index}` -} - -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 -} - -function groupWorktreesByParentPath( - worktrees: readonly ImportedWorktreeVisibilityPreview[] -): ImportedWorktreePathGroup[] { - const groups: ImportedWorktreePathGroup[] = [] - const groupByPath = new Map() - for (const worktree of worktrees) { - const path = getParentPath(worktree.path) - const existing = groupByPath.get(path) - if (existing) { - existing.worktrees.push(worktree) - continue - } - const group = { path, worktrees: [worktree] } - groupByPath.set(path, group) - groups.push(group) - } - return groups -} - -export default function ImportedWorktreesVisibilityCard({ - repoDisplayName, - hiddenWorktrees, - placement, - pending, - error, - onShow, - onKeepHidden, - className -}: ImportedWorktreesVisibilityCardProps): React.JSX.Element | null { - const [isExpanded, setIsExpanded] = useState(false) - const hiddenCount = hiddenWorktrees.length - const worktreeNoun = pluralizeWorktree(hiddenCount) - const visibleWorktrees = isExpanded ? hiddenWorktrees : hiddenWorktrees.slice(0, PREVIEW_LIMIT) - const visibleWorktreeGroups = groupWorktreesByParentPath(visibleWorktrees) - const remainingCount = Math.max(0, hiddenWorktrees.length - visibleWorktrees.length) - - if (hiddenCount === 0) { - return null - } - - const title = - placement === 'pinned-fallback' - ? `Imported ${hiddenCount} existing ${worktreeNoun} in ${repoDisplayName}` - : `Imported ${hiddenCount} existing ${worktreeNoun}` - const subtitle = - placement === 'pinned-fallback' - ? `Orca found ${hiddenCount} ${worktreeNoun} and imported them automatically into ${repoDisplayName}.` - : `Orca found ${hiddenCount} ${worktreeNoun} and imported them automatically into this repo.` - - return ( -
-
-
-

{title}

-

{subtitle}

-
-
- -
- {visibleWorktreeGroups.map((group) => ( -
- - - - {group.path} - - - - {group.path} - - - {group.worktrees.map((worktree, index) => ( -
- - {worktree.displayName} - - hidden -
- ))} -
- ))} -
- - {remainingCount > 0 ? ( - - ) : null} - - {placement === 'repo-group' ? ( -

- They are currently hidden, but you can show or hide them anytime by clicking{' '} - - {' '} - on this repo. -

- ) : ( -

- They are currently hidden in this view. Showing them restores the imported worktrees to - the repo list. -

- )} - - {error ? ( -

- {error} -

- ) : null} - -
- {onKeepHidden ? ( - - ) : ( - - Use Show to restore this repo's imported worktrees. - - )} - -
-
- ) -} - -export type { ImportedWorktreesVisibilityCardProps } diff --git a/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.test.tsx b/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.test.tsx new file mode 100644 index 00000000000..8ce863aba03 --- /dev/null +++ b/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.test.tsx @@ -0,0 +1,103 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import type { ComponentProps } from 'react' +import { describe, expect, it, vi } from 'vitest' + +import ImportedWorktreesVisibilityLine, { + groupWorktreesByParentPath +} from './ImportedWorktreesVisibilityLine' +import { TooltipProvider } from '@/components/ui/tooltip' + +const hiddenWorktrees = [ + { + id: 'hidden-1', + displayName: 'payments-refactor', + path: '/worktrees/demo-project/payments-refactor', + branch: 'refs/heads/payments-refactor' + }, + { + id: 'hidden-2', + displayName: 'auth-cache-debug', + path: '/worktrees/demo-project/auth-cache-debug', + branch: 'refs/heads/auth-cache-debug' + }, + { + id: 'hidden-3', + displayName: 'legacy-oauth-fix', + path: '/worktrees/legacy/legacy-oauth-fix', + branch: 'refs/heads/legacy-oauth-fix' + }, + { + id: 'hidden-4', + displayName: 'ssh-worktree', + path: '/srv/repos/orca/ssh-worktree', + branch: 'refs/heads/ssh-worktree' + } +] + +function renderLine( + overrides: Partial> = {} +): string { + return renderToStaticMarkup( + + + + ) +} + +describe('ImportedWorktreesVisibilityLine', () => { + it('renders the compact repo-group line with inline show 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( + 'Keep 4 discovered worktrees hidden for orca; recover from the repo 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('payments-refactor') + expect(markup).not.toContain('/worktrees/demo-project') + }) + + it('scopes pinned fallback copy to the repo name without a dismiss action', () => { + 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') + }) + + it('preserves Windows parent path separators in preview groups', () => { + const groups = groupWorktreesByParentPath([ + { + id: 'windows-hidden', + displayName: 'FeatureX', + path: 'C:\\Repos\\Orca\\FeatureX' + } + ]) + + expect(groups).toMatchObject([{ path: 'C:\\Repos\\Orca' }]) + expect(groups[0]?.path).not.toBe('C:/Repos/Orca') + }) + + it('disables actions while pending and renders inline errors', () => { + const markup = renderLine({ pending: true, error: 'Could not show imported worktrees.' }) + + expect(markup).toContain('aria-busy="true"') + expect(markup).toContain('disabled=""') + expect(markup).toContain('role="alert"') + expect(markup).toContain('Could not show imported worktrees.') + }) +}) diff --git a/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.tsx b/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.tsx new file mode 100644 index 00000000000..ab0701dbd35 --- /dev/null +++ b/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.tsx @@ -0,0 +1,216 @@ +import React, { useState } from 'react' +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' + +export type ImportedWorktreesVisibilityPlacement = 'repo-group' | 'pinned-fallback' + +export type ImportedWorktreeVisibilityPreview = { + id?: string + displayName: string + path?: string + branch?: string +} + +type ImportedWorktreesVisibilityLineProps = { + repoDisplayName: string + hiddenWorktrees: readonly ImportedWorktreeVisibilityPreview[] + placement: ImportedWorktreesVisibilityPlacement + pending: boolean + error: string | null + 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' + +type ImportedWorktreePathGroup = { + path: string + worktrees: ImportedWorktreeVisibilityPreview[] +} + +function pluralizeWorktree(count: number): string { + return count === 1 ? 'worktree' : 'worktrees' +} + +function getWorktreeKey( + worktree: ImportedWorktreeVisibilityPreview, + index: number, + prefix: string +): string { + return worktree.id ?? worktree.path ?? `${prefix}-${worktree.displayName}-${index}` +} + +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 +} + +export function groupWorktreesByParentPath( + worktrees: readonly ImportedWorktreeVisibilityPreview[] +): ImportedWorktreePathGroup[] { + const groups: ImportedWorktreePathGroup[] = [] + const groupByPath = new Map() + for (const worktree of worktrees) { + const path = getParentPath(worktree.path) + const existing = groupByPath.get(path) + if (existing) { + existing.worktrees.push(worktree) + continue + } + const group = { path, worktrees: [worktree] } + groupByPath.set(path, group) + groups.push(group) + } + return groups +} + +export default function ImportedWorktreesVisibilityLine({ + repoDisplayName, + hiddenWorktrees, + placement, + pending, + error, + onShow, + onKeepHidden, + className +}: ImportedWorktreesVisibilityLineProps): React.JSX.Element | null { + const [isExpanded, setIsExpanded] = useState(false) + 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` + + if (hiddenCount === 0) { + return null + } + + const lineText = + placement === 'pinned-fallback' + ? `Hiding ${hiddenCount} discovered ${worktreeNoun} in ${repoDisplayName}` + : `Hiding ${hiddenCount} discovered ${worktreeNoun}` + + return ( +
+
+ +
+ + {isExpanded ? ( +
+ {visibleWorktreeGroups.map((group) => ( +
+ + + + {group.path} + + + + {group.path} + + + {group.worktrees.map((worktree, index) => ( +
+
+ ))} +
+ ))} + {remainingCount > 0 ? ( +
+ + {remainingCount} more +
+ ) : null} +
+ ) : null} + + {error ? ( +

+ {error} +

+ ) : null} +
+ ) +} + +export type { ImportedWorktreesVisibilityLineProps } diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 06f91b188d8..f8610b6db68 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -170,7 +170,7 @@ import { } from '../../../../shared/worktree-ownership' import { RepoIconGlyph } from '@/components/repo/repo-icon' import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel' -import ImportedWorktreesVisibilityCard from './ImportedWorktreesVisibilityCard' +import ImportedWorktreesVisibilityLine from './ImportedWorktreesVisibilityLine' import { keepImportedWorktreesHiddenCard, showImportedWorktreesCard, @@ -293,7 +293,7 @@ function getWorktreeVisibilityMenuLabel(repo: Repo): string { repo, isLegacyRepoForExternalWorktreeVisibility(repo) ) - return visibility === 'show' ? 'Hide non-Orca worktrees' : 'Import Worktrees' + return visibility === 'show' ? 'Hide non-Orca worktrees' : 'Show hidden worktrees' } const LINEAGE_INDENT = 18 @@ -3016,7 +3016,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp className="absolute left-0 right-0 top-0" style={{ transform: getVirtualRowTransform(vItem.start) }} > - { expect(activeSize).toBe(36) }) - it('estimates imported worktree card rows with a stable larger height', () => { + it('estimates imported worktree line rows with a stable compact height', () => { const rows = [makeHeaderRow('repo:repo-1'), makeImportedCardRow()] - expect(estimateRenderRowSize(rows, 1, 0, null)).toBe(224) + expect(estimateRenderRowSize(rows, 1, 0, null)).toBe(36) }) it('keeps the previous header active until the secondary header row reaches the top', () => { diff --git a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts index 61ab9db8bee..ef5fa4a3fde 100644 --- a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts @@ -5,7 +5,7 @@ import { PINNED_GROUP_KEY } from './worktree-list-groups' export const GROUP_HEADER_ROW_HEIGHT = 28 const SECONDARY_GROUP_HEADER_TOP_MARGIN = 8 -const IMPORTED_WORKTREES_CARD_ROW_HEIGHT = 224 +const IMPORTED_WORKTREES_LINE_ROW_HEIGHT = 36 type WorktreeItemRow = Extract export type RenderRow = Row | { type: 'lineage-group'; key: string; rows: WorktreeItemRow[] } @@ -44,7 +44,7 @@ export function estimateRenderRowSize( return 100 + Math.max(0, row.rows.length - 1) * 96 } if (row?.type === 'imported-worktrees-card') { - return IMPORTED_WORKTREES_CARD_ROW_HEIGHT + return IMPORTED_WORKTREES_LINE_ROW_HEIGHT } return 116 }