diff --git a/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx b/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx index 685bd04d3..4804e51ae 100644 --- a/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx +++ b/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx @@ -17,7 +17,7 @@ type DialogStep = 'add' | 'clone' | 'remote' | 'create' | 'nested' | 'setup' type RepoKind = 'git' | 'folder' export function useCreateRepo( - fetchWorktrees: (repoId: string) => Promise, + fetchWorktrees: (repoId: string) => Promise, setStep: (step: DialogStep) => void, setAddedRepo: (repo: Repo | null) => void, closeModal: () => void, diff --git a/src/renderer/src/components/sidebar/AddRepoSteps.tsx b/src/renderer/src/components/sidebar/AddRepoSteps.tsx index bb5071859..c2071220a 100644 --- a/src/renderer/src/components/sidebar/AddRepoSteps.tsx +++ b/src/renderer/src/components/sidebar/AddRepoSteps.tsx @@ -19,7 +19,7 @@ import { createNestedRepoTelemetryAttemptId } from '../../../../shared/nested-re // ── Remote project hook ───────────────────────────────────────────── export function useRemoteRepo( - fetchWorktrees: (repoId: string) => Promise, + fetchWorktrees: (repoId: string) => Promise, setStep: (step: 'add' | 'clone' | 'remote' | 'create' | 'nested' | 'setup') => void, setAddedRepo: (repo: Repo | null) => void, closeModal: () => void, diff --git a/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityCard.test.tsx b/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityCard.test.tsx new file mode 100644 index 000000000..ef981c4fc --- /dev/null +++ b/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityCard.test.tsx @@ -0,0 +1,117 @@ +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 new file mode 100644 index 000000000..10eda47ee --- /dev/null +++ b/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityCard.tsx @@ -0,0 +1,223 @@ +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/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 53cf1de6f..e962327e3 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -171,6 +171,13 @@ import { } from '../../../../shared/worktree-ownership' import { RepoIconGlyph } from '@/components/repo/repo-icon' import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel' +import ImportedWorktreesVisibilityCard from './ImportedWorktreesVisibilityCard' +import { + keepImportedWorktreesHiddenCard, + showImportedWorktreesCard, + type ImportedWorktreeCardActionState +} from './imported-worktrees-card-actions' +import { buildImportedWorktreesCardCandidates } from './imported-worktrees-card-candidates' import { buildWorktreeSectionActivitySummaries, EMPTY_WORKTREE_SECTION_ACTIVITY, @@ -308,6 +315,9 @@ type VirtualizedWorktreeViewportProps = { handleCreateForRepo: (projectId: string) => void handleOpenRepoSettings: (projectId: string, sectionId?: string) => void handleOpenWorktreeVisibility: (projectId: string) => void + handleShowImportedWorktrees: (projectId: string) => void + handleKeepImportedWorktreesHidden: (projectId: string) => void + importedWorktreeCardActionState: ReadonlyMap handleRemoveProject: (repo: Repo) => void handleCreateGroupFromRepo: (repo: Repo) => void handleMoveProjectToGroup: (repo: Repo, groupId: string) => void @@ -522,7 +532,7 @@ function isWorktreeItemRow(row: Row): row is WorktreeItemRow { return row.type === 'item' } -function renderRowContainsWorktree(row: RenderRow, worktreeId: string | null): boolean { +export function renderRowContainsWorktree(row: RenderRow, worktreeId: string | null): boolean { if (worktreeId === null) { return false } @@ -567,17 +577,20 @@ function buildRenderableRows(rows: Row[]): RenderRow[] { return renderRows } -function getRenderRowKey(row: RenderRow): string { +export function getRenderRowKey(row: RenderRow): string { if (row.type === 'header') { return `hdr:${row.key}` } if (row.type === 'lineage-group') { return `lineage-group:${row.key}` } + if (row.type === 'imported-worktrees-card') { + return `imported:${row.key}` + } return `wt:${row.worktree.id}` } -function getWorktreeDragGroups(rows: Row[]): WorktreeDragGroup[] { +export function getWorktreeDragGroups(rows: Row[]): WorktreeDragGroup[] { const groups: WorktreeDragGroup[] = [] let current: { key: string; ids: string[] } | null = null @@ -587,6 +600,9 @@ function getWorktreeDragGroups(rows: Row[]): WorktreeDragGroup[] { groups.push({ key: current.key, worktreeIds: current.ids }) continue } + if (row.type === 'imported-worktrees-card') { + continue + } if (!current) { current = { key: ALL_GROUP_KEY, ids: [] } groups.push({ key: current.key, worktreeIds: current.ids }) @@ -597,6 +613,13 @@ function getWorktreeDragGroups(rows: Row[]): WorktreeDragGroup[] { return groups.filter((group) => group.worktreeIds.length > 0) } +export function canKeepImportedWorktreesHidden( + row: Extract, + actionState: ImportedWorktreeCardActionState | undefined +): boolean { + return row.placement === 'repo-group' && actionState?.forceVisible !== true +} + function getWorktreeDragIndexes(groups: readonly WorktreeDragGroup[]): { groupKeyByWorktreeId: Map groupIndexByWorktreeId: Map @@ -632,6 +655,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp handleCreateForRepo, handleOpenRepoSettings, handleOpenWorktreeVisibility, + handleShowImportedWorktrees, + handleKeepImportedWorktreesHidden, + importedWorktreeCardActionState, handleRemoveProject, handleCreateGroupFromRepo, handleMoveProjectToGroup, @@ -2981,6 +3007,37 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ) } + if (row.type === 'imported-worktrees-card') { + const actionState = importedWorktreeCardActionState.get(row.repo.id) + return ( +
+ handleShowImportedWorktrees(row.repo.id)} + onKeepHidden={ + canKeepImportedWorktreesHidden(row, actionState) + ? () => handleKeepImportedWorktreesHidden(row.repo.id) + : undefined + } + /> +
+ ) + } + const itemWorkspaceStatus = groupBy === 'workspace-status' ? getWorkspaceStatus(row.worktree, workspaceStatuses) @@ -3049,6 +3106,7 @@ const WorktreeList = React.memo(function WorktreeList({ const worktreeMap = useWorktreeMap() const worktreeLineageById = useAppStore((s) => s.worktreeLineageById) const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) + const detectedWorktreesByRepo = useAppStore((s) => s.detectedWorktreesByRepo) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) const groupBy = useAppStore((s) => s.groupBy) const workspaceStatuses = useAppStore((s) => s.workspaceStatuses) @@ -3062,6 +3120,8 @@ const WorktreeList = React.memo(function WorktreeList({ const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta) const updateWorktreesMeta = useAppStore((s) => s.updateWorktreesMeta) + const updateRepo = useAppStore((s) => s.updateRepo) + const fetchWorktrees = useAppStore((s) => s.fetchWorktrees) const activeView = useAppStore((s) => s.activeView) const activeModal = useAppStore((s) => s.activeModal) const pendingRevealWorktree = useAppStore((s) => s.pendingRevealWorktree) @@ -3376,6 +3436,22 @@ const WorktreeList = React.memo(function WorktreeList({ repos.forEach((r, i) => map.set(r.id, i)) return map }, [repos]) + const [importedWorktreeCardActionState, setImportedWorktreeCardActionState] = useState< + Map + >(new Map()) + const importedWorktreesByRepo = useMemo(() => { + const forceVisibleRepoIds = new Set( + [...importedWorktreeCardActionState.entries()] + .filter(([, state]) => state.forceVisible) + .map(([repoId]) => repoId) + ) + return buildImportedWorktreesCardCandidates({ + repos, + detectedWorktreesByRepo, + filterRepoIds, + forceVisibleRepoIds + }) + }, [detectedWorktreesByRepo, filterRepoIds, importedWorktreeCardActionState, repos]) const placeholderRepoIds = useMemo(() => { if (groupBy !== 'repo' || projectGroups.length === 0) { return new Set() @@ -3460,7 +3536,8 @@ const WorktreeList = React.memo(function WorktreeList({ true, settings, projectGroups, - placeholderRepoIds + placeholderRepoIds, + importedWorktreesByRepo ), [ groupBy, @@ -3475,7 +3552,8 @@ const WorktreeList = React.memo(function WorktreeList({ worktreeMap, settings, projectGroups, - placeholderRepoIds + placeholderRepoIds, + importedWorktreesByRepo ] ) // Why: header/mode changes can shift entire groups, so remount the @@ -3620,6 +3698,45 @@ const WorktreeList = React.memo(function WorktreeList({ [openModal] ) + const setImportedWorktreeCardState = useCallback( + (projectId: string, state: ImportedWorktreeCardActionState | null) => { + setImportedWorktreeCardActionState((previous) => { + const next = new Map(previous) + if (state) { + next.set(projectId, state) + } else { + next.delete(projectId) + } + return next + }) + }, + [] + ) + + const handleShowImportedWorktrees = useCallback( + async (projectId: string) => { + await showImportedWorktreesCard({ + projectId, + forceVisible: importedWorktreeCardActionState.get(projectId)?.forceVisible === true, + updateRepo, + fetchWorktrees, + setCardState: setImportedWorktreeCardState + }) + }, + [fetchWorktrees, importedWorktreeCardActionState, setImportedWorktreeCardState, updateRepo] + ) + + const handleKeepImportedWorktreesHidden = useCallback( + async (projectId: string) => { + await keepImportedWorktreesHiddenCard({ + projectId, + updateRepo, + setCardState: setImportedWorktreeCardState + }) + }, + [setImportedWorktreeCardState, updateRepo] + ) + const handleRemoveProject = useCallback( (repo: Repo) => { openModal('confirm-remove-folder', { @@ -3880,7 +3997,11 @@ const WorktreeList = React.memo(function WorktreeList({ } }, [handleRevealCurrentWorkspaceRequest]) - const filtersHideAllRows = hasFilters && worktrees.length === 0 && placeholderRepoIds.size === 0 + const filtersHideAllRows = + hasFilters && + worktrees.length === 0 && + placeholderRepoIds.size === 0 && + importedWorktreesByRepo.size === 0 // Why: Project Group headers can render before workspace rows load, but when // active filters hide everything the Clear Filters empty state must win. if (rows.length === 0 || filtersHideAllRows) { @@ -3953,6 +4074,9 @@ const WorktreeList = React.memo(function WorktreeList({ handleCreateForRepo={handleCreateForRepo} handleOpenRepoSettings={handleOpenRepoSettings} handleOpenWorktreeVisibility={handleOpenWorktreeVisibility} + handleShowImportedWorktrees={handleShowImportedWorktrees} + handleKeepImportedWorktreesHidden={handleKeepImportedWorktreesHidden} + importedWorktreeCardActionState={importedWorktreeCardActionState} handleRemoveProject={handleRemoveProject} handleCreateGroupFromRepo={handleCreateGroupFromRepo} handleMoveProjectToGroup={handleMoveProjectToGroup} 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 new file mode 100644 index 000000000..7151edfee --- /dev/null +++ b/src/renderer/src/components/sidebar/imported-worktrees-card-actions.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { + IMPORTED_WORKTREES_KEEP_HIDDEN_ERROR, + IMPORTED_WORKTREES_SHOW_ERROR, + keepImportedWorktreesHiddenCard, + showImportedWorktreesCard, + type ImportedWorktreeCardActionState +} from './imported-worktrees-card-actions' + +const projectId = 'repo-1' + +describe('imported worktrees card actions', () => { + const updateRepo = vi.fn() + const fetchWorktrees = vi.fn() + const setCardState = + vi.fn<(projectId: string, state: ImportedWorktreeCardActionState | null) => void>() + + beforeEach(() => { + vi.clearAllMocks() + updateRepo.mockResolvedValue(true) + fetchWorktrees.mockResolvedValue(true) + }) + + it('shows imported worktrees only 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 + }) + expect(setCardState).toHaveBeenLastCalledWith(projectId, null) + }) + + it('leaves the card visible when showing fails before refresh', async () => { + updateRepo.mockResolvedValueOnce(false) + + await showImportedWorktreesCard({ projectId, updateRepo, fetchWorktrees, setCardState }) + + expect(fetchWorktrees).not.toHaveBeenCalled() + expect(setCardState).toHaveBeenLastCalledWith(projectId, { + pending: false, + error: IMPORTED_WORKTREES_SHOW_ERROR + }) + }) + + it('preserves force-visible state during a retry after rollback failure', async () => { + updateRepo.mockResolvedValueOnce(false) + + await showImportedWorktreesCard({ + projectId, + forceVisible: true, + updateRepo, + fetchWorktrees, + setCardState + }) + + expect(fetchWorktrees).not.toHaveBeenCalled() + expect(setCardState).toHaveBeenNthCalledWith(1, projectId, { + pending: true, + error: null, + forceVisible: true + }) + expect(setCardState).toHaveBeenLastCalledWith(projectId, { + pending: false, + error: IMPORTED_WORKTREES_SHOW_ERROR, + forceVisible: true + }) + }) + + 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) + + 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, + forceVisible: true + }) + }) + + it('dismisses the card when keep-hidden update succeeds', async () => { + await keepImportedWorktreesHiddenCard({ projectId, updateRepo, setCardState }) + + expect(updateRepo).toHaveBeenCalledWith(projectId, { + externalWorktreeVisibilityPromptDismissedAt: expect.any(Number) + }) + expect(setCardState).toHaveBeenLastCalledWith(projectId, null) + }) + + it('leaves the card visible when keep-hidden update fails', async () => { + updateRepo.mockResolvedValueOnce(false) + + await keepImportedWorktreesHiddenCard({ projectId, updateRepo, setCardState }) + + expect(setCardState).toHaveBeenLastCalledWith(projectId, { + pending: false, + error: IMPORTED_WORKTREES_KEEP_HIDDEN_ERROR + }) + }) +}) diff --git a/src/renderer/src/components/sidebar/imported-worktrees-card-actions.ts b/src/renderer/src/components/sidebar/imported-worktrees-card-actions.ts new file mode 100644 index 000000000..cb5d962e7 --- /dev/null +++ b/src/renderer/src/components/sidebar/imported-worktrees-card-actions.ts @@ -0,0 +1,75 @@ +import type { Repo } from '../../../../shared/types' + +export type ImportedWorktreeCardActionState = { + pending: boolean + error: string | null + forceVisible?: boolean +} + +type ImportedWorktreeCardActionDeps = { + projectId: string + forceVisible?: boolean + setCardState: (projectId: string, state: ImportedWorktreeCardActionState | null) => void + updateRepo: ( + projectId: string, + updates: Partial< + Pick + > + ) => Promise + fetchWorktrees: ( + projectId: string, + options?: { requireAuthoritative?: boolean } + ) => Promise +} + +export const IMPORTED_WORKTREES_SHOW_ERROR = 'Could not show imported worktrees. Try again.' +export const IMPORTED_WORKTREES_KEEP_HIDDEN_ERROR = + 'Could not keep imported worktrees hidden. Try again.' + +export async function showImportedWorktreesCard( + args: ImportedWorktreeCardActionDeps +): Promise { + const forceVisible = args.forceVisible === true + args.setCardState(args.projectId, { + pending: true, + error: null, + forceVisible: true + }) + const updated = await args.updateRepo(args.projectId, { externalWorktreeVisibility: 'show' }) + if (!updated) { + args.setCardState(args.projectId, { + pending: false, + error: IMPORTED_WORKTREES_SHOW_ERROR, + ...(forceVisible ? { forceVisible: true } : {}) + }) + return + } + const refreshed = await args.fetchWorktrees(args.projectId, { requireAuthoritative: true }) + if (!refreshed) { + const rolledBack = await args.updateRepo(args.projectId, { externalWorktreeVisibility: 'hide' }) + args.setCardState(args.projectId, { + pending: false, + error: IMPORTED_WORKTREES_SHOW_ERROR, + ...(rolledBack ? {} : { forceVisible: true }) + }) + return + } + args.setCardState(args.projectId, null) +} + +export async function keepImportedWorktreesHiddenCard( + args: Omit +): Promise { + args.setCardState(args.projectId, { pending: true, error: null }) + const updated = await args.updateRepo(args.projectId, { + externalWorktreeVisibilityPromptDismissedAt: Date.now() + }) + if (!updated) { + args.setCardState(args.projectId, { + pending: false, + error: IMPORTED_WORKTREES_KEEP_HIDDEN_ERROR + }) + return + } + args.setCardState(args.projectId, null) +} diff --git a/src/renderer/src/components/sidebar/imported-worktrees-card-candidates.test.ts b/src/renderer/src/components/sidebar/imported-worktrees-card-candidates.test.ts new file mode 100644 index 000000000..985100fd1 --- /dev/null +++ b/src/renderer/src/components/sidebar/imported-worktrees-card-candidates.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from 'vitest' + +import { + buildImportedWorktreesCardCandidates, + getHiddenImportedWorktrees +} from './imported-worktrees-card-candidates' +import type { + DetectedWorktree, + DetectedWorktreeListResult, + Repo, + Worktree +} from '../../../../shared/types' + +const repo: Repo = { + id: 'repo-1', + path: '/repo', + displayName: 'orca', + badgeColor: '#000000', + addedAt: Date.UTC(2026, 4, 24), + externalWorktreeVisibility: 'hide' +} + +const visibleWorktree: Worktree = { + id: 'repo-1::/repo', + repoId: repo.id, + path: '/repo', + displayName: 'main', + branch: 'refs/heads/main', + head: 'abc123', + isBare: false, + isMainWorktree: true, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0 +} + +function detectedWorktree(overrides: Partial = {}): DetectedWorktree { + return { + ...visibleWorktree, + id: 'repo-1::/repo-worktree', + path: '/repo-worktree', + displayName: 'repo-worktree', + isMainWorktree: false, + ownership: 'external', + selectedCheckout: false, + visible: false, + ...overrides + } +} + +function detectedResult( + worktrees: DetectedWorktree[], + overrides: Partial = {} +): DetectedWorktreeListResult { + return { + repoId: repo.id, + authoritative: true, + source: 'git', + worktrees, + ...overrides + } +} + +describe('getHiddenImportedWorktrees', () => { + it('returns only authoritative hidden external worktrees', () => { + const hidden = detectedWorktree({ id: 'hidden' }) + const result = getHiddenImportedWorktrees( + detectedResult([ + hidden, + detectedWorktree({ id: 'visible', visible: true }), + detectedWorktree({ id: 'selected', selectedCheckout: true }), + detectedWorktree({ id: 'orca-managed', ownership: 'orca-managed' }) + ]) + ) + + expect(result).toEqual([hidden]) + }) + + it('suppresses non-authoritative results', () => { + expect( + getHiddenImportedWorktrees(detectedResult([detectedWorktree()], { authoritative: false })) + ).toEqual([]) + }) +}) + +describe('buildImportedWorktreesCardCandidates', () => { + it('builds a candidate for hidden imported worktrees in a visible repo', () => { + const candidates = buildImportedWorktreesCardCandidates({ + repos: [repo], + visibleWorktrees: [visibleWorktree], + detectedWorktreesByRepo: { [repo.id]: detectedResult([detectedWorktree()]) } + }) + + expect(candidates.get(repo.id)).toMatchObject({ + repo: { id: repo.id }, + hiddenWorktrees: [{ id: 'repo-1::/repo-worktree' }] + }) + }) + + it('suppresses candidates after show, dismissal, folder repos, or repo filters exclude the repo', () => { + const detectedWorktreesByRepo = { [repo.id]: detectedResult([detectedWorktree()]) } + + expect( + buildImportedWorktreesCardCandidates({ + repos: [{ ...repo, externalWorktreeVisibility: 'show' }], + visibleWorktrees: [visibleWorktree], + detectedWorktreesByRepo + }).size + ).toBe(0) + expect( + buildImportedWorktreesCardCandidates({ + repos: [{ ...repo, externalWorktreeVisibilityPromptDismissedAt: 1 }], + visibleWorktrees: [visibleWorktree], + detectedWorktreesByRepo + }).size + ).toBe(0) + expect( + buildImportedWorktreesCardCandidates({ + repos: [{ ...repo, kind: 'folder' }], + visibleWorktrees: [visibleWorktree], + detectedWorktreesByRepo + }).size + ).toBe(0) + expect( + buildImportedWorktreesCardCandidates({ + repos: [repo], + detectedWorktreesByRepo, + filterRepoIds: ['other-repo'] + }).size + ).toBe(0) + }) + + it('keeps candidates visible after a rollback failure forces a shown repo to render the card', () => { + const candidates = buildImportedWorktreesCardCandidates({ + repos: [{ ...repo, externalWorktreeVisibility: 'show' }], + visibleWorktrees: [visibleWorktree], + detectedWorktreesByRepo: { [repo.id]: detectedResult([detectedWorktree()]) }, + forceVisibleRepoIds: new Set([repo.id]) + }) + + expect(candidates.get(repo.id)).toMatchObject({ + repo: { id: repo.id }, + hiddenWorktrees: [{ id: 'repo-1::/repo-worktree' }] + }) + }) + + it('builds candidates even when workspace-row filters hide every visible worktree', () => { + const candidates = buildImportedWorktreesCardCandidates({ + repos: [repo], + detectedWorktreesByRepo: { [repo.id]: detectedResult([detectedWorktree()]) } + }) + + expect(candidates.has(repo.id)).toBe(true) + }) +}) diff --git a/src/renderer/src/components/sidebar/imported-worktrees-card-candidates.ts b/src/renderer/src/components/sidebar/imported-worktrees-card-candidates.ts new file mode 100644 index 000000000..5ad0c8ae8 --- /dev/null +++ b/src/renderer/src/components/sidebar/imported-worktrees-card-candidates.ts @@ -0,0 +1,64 @@ +import type { + DetectedWorktree, + DetectedWorktreeListResult, + Repo, + Worktree +} from '../../../../shared/types' +import { isGitRepoKind } from '../../../../shared/repo-kind' +import { + effectiveExternalWorktreeVisibility, + isLegacyRepoForExternalWorktreeVisibility +} from '../../../../shared/worktree-ownership' +import type { ImportedWorktreesCardCandidate } from './worktree-list-groups' + +export function getHiddenImportedWorktrees( + detected: DetectedWorktreeListResult | undefined +): DetectedWorktree[] { + if (detected?.authoritative !== true) { + return [] + } + return detected.worktrees.filter( + (worktree) => + !worktree.visible && !worktree.selectedCheckout && worktree.ownership !== 'orca-managed' + ) +} + +export function buildImportedWorktreesCardCandidates(args: { + repos: readonly Repo[] + visibleWorktrees?: readonly Worktree[] + detectedWorktreesByRepo: Readonly> + filterRepoIds?: readonly string[] + forceVisibleRepoIds?: ReadonlySet +}): Map { + const visibleRepoIds = args.visibleWorktrees + ? new Set(args.visibleWorktrees.map((worktree) => worktree.repoId)) + : null + const filterRepoIds = args.filterRepoIds?.length ? new Set(args.filterRepoIds) : null + const candidates = new Map() + for (const repo of args.repos) { + if (filterRepoIds && !filterRepoIds.has(repo.id)) { + continue + } + if (visibleRepoIds && !visibleRepoIds.has(repo.id)) { + continue + } + if (!isGitRepoKind(repo)) { + continue + } + if (typeof repo.externalWorktreeVisibilityPromptDismissedAt === 'number') { + continue + } + const visibility = effectiveExternalWorktreeVisibility( + repo, + isLegacyRepoForExternalWorktreeVisibility(repo) + ) + if (visibility !== 'hide' && !args.forceVisibleRepoIds?.has(repo.id)) { + continue + } + const hiddenWorktrees = getHiddenImportedWorktrees(args.detectedWorktreesByRepo[repo.id]) + if (hiddenWorktrees.length > 0) { + candidates.set(repo.id, { repo, hiddenWorktrees }) + } + } + return candidates +} diff --git a/src/renderer/src/components/sidebar/worktree-drag-units.test.ts b/src/renderer/src/components/sidebar/worktree-drag-units.test.ts index 238bc3d76..8091c8b3e 100644 --- a/src/renderer/src/components/sidebar/worktree-drag-units.test.ts +++ b/src/renderer/src/components/sidebar/worktree-drag-units.test.ts @@ -12,6 +12,10 @@ function item(id: string, depth = 0): { type: 'item'; worktree: { id: string }; return { type: 'item', worktree: { id }, depth } } +function importedCard(): { type: 'imported-worktrees-card' } { + return { type: 'imported-worktrees-card' } +} + describe('getWorktreeDragUnitGroups', () => { it('treats expanded lineage descendants as part of the parent drag unit', () => { const groups = getWorktreeDragUnitGroups([ @@ -33,6 +37,34 @@ describe('getWorktreeDragUnitGroups', () => { } ]) }) + + it('ignores imported worktree card rows without splitting drag groups', () => { + const groups = getWorktreeDragUnitGroups([ + header('repo:one'), + item('main'), + importedCard(), + item('feature'), + header('repo:two'), + importedCard(), + item('other') + ]) + + expect(groups).toEqual([ + { + key: 'repo:one', + worktreeIds: ['main', 'feature'], + units: [ + { worktreeId: 'main', worktreeIds: ['main'] }, + { worktreeId: 'feature', worktreeIds: ['feature'] } + ] + }, + { + key: 'repo:two', + worktreeIds: ['other'], + units: [{ worktreeId: 'other', worktreeIds: ['other'] }] + } + ]) + }) }) describe('getFullDropIndexForWorktreeDragUnit', () => { diff --git a/src/renderer/src/components/sidebar/worktree-drag-units.ts b/src/renderer/src/components/sidebar/worktree-drag-units.ts index d2091527e..775feb0e8 100644 --- a/src/renderer/src/components/sidebar/worktree-drag-units.ts +++ b/src/renderer/src/components/sidebar/worktree-drag-units.ts @@ -8,6 +8,7 @@ export type WorktreeDragUnitGroup = WorktreeDragGroup & { type WorktreeDragUnitRow = | { type: 'header'; key: string } | { type: 'item'; worktree: { id: string }; depth: number } + | { type: 'imported-worktrees-card' } export function getWorktreeDragUnitGroups( rows: readonly WorktreeDragUnitRow[] @@ -25,6 +26,9 @@ export function getWorktreeDragUnitGroups( }) continue } + if (row.type === 'imported-worktrees-card') { + continue + } if (!current) { current = { key: ALL_GROUP_KEY, units: [] } groups.push({ diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts index 903509a1b..6efe8a1a2 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts @@ -12,7 +12,13 @@ import { getPRGroupKey, getProjectGroupOrdering } from './worktree-list-groups' -import type { Repo, ProjectGroup, Worktree, WorktreeLineage } from '../../../../shared/types' +import type { + DetectedWorktree, + Repo, + ProjectGroup, + Worktree, + WorktreeLineage +} from '../../../../shared/types' const repo: Repo = { id: 'repo-1', @@ -44,6 +50,19 @@ const worktree: Worktree = { const repoMap = new Map([[repo.id, repo]]) +function makeDetectedWorktree(overrides: Partial = {}): DetectedWorktree { + return { + ...worktree, + id: overrides.id ?? `${repo.id}::/tmp/${overrides.displayName ?? 'hidden'}`, + path: overrides.path ?? `/tmp/${overrides.displayName ?? 'hidden'}`, + displayName: overrides.displayName ?? 'hidden', + visible: false, + selectedCheckout: false, + ownership: 'external', + ...overrides + } +} + describe('getPRGroupKey', () => { it('puts merged PRs in the done group', () => { const prCache = { @@ -234,6 +253,230 @@ describe('buildRows with pinned worktrees', () => { expect(rows[0]).toMatchObject({ type: 'header', label: 'c15t' }) }) + it('emits an imported worktrees card at the top of repo-group rows', () => { + const hidden = [ + makeDetectedWorktree({ id: 'hidden-1', displayName: 'payments-refactor' }), + makeDetectedWorktree({ id: 'hidden-2', displayName: 'auth-cache-debug' }), + makeDetectedWorktree({ id: 'hidden-3', displayName: 'legacy-oauth-fix' }) + ] + const rows = buildRows( + 'repo', + [worktree], + repoMap, + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map([[worktree.id, worktree]]), + false, + undefined, + [], + new Set(), + new Map([[repo.id, { repo, hiddenWorktrees: hidden }]]) + ) + + expect(rows).toMatchObject([ + { type: 'header', key: 'repo:repo-1' }, + { + type: 'imported-worktrees-card', + key: 'imported-worktrees-card:repo-group:repo-1', + placement: 'repo-group', + repo: { id: 'repo-1' }, + hiddenWorktrees: [{ id: 'hidden-1' }, { id: 'hidden-2' }, { id: 'hidden-3' }] + }, + { type: 'item', worktree: { id: 'wt-1' } } + ]) + }) + + it('suppresses the repo-group imported worktrees card when the repo group is collapsed', () => { + const rows = buildRows( + 'repo', + [worktree], + repoMap, + null, + new Set(['repo:repo-1']), + undefined, + undefined, + undefined, + {}, + new Map([[worktree.id, worktree]]), + false, + undefined, + [], + new Set(), + new Map([[repo.id, { repo, hiddenWorktrees: [makeDetectedWorktree()] }]]) + ) + + expect(rows).toMatchObject([{ type: 'header', key: 'repo:repo-1' }]) + }) + + it('emits a repo header and imported worktrees card when no visible worktree rows remain', () => { + const rows = buildRows( + 'repo', + [], + repoMap, + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map(), + false, + undefined, + [], + new Set(), + new Map([[repo.id, { repo, hiddenWorktrees: [makeDetectedWorktree()] }]]) + ) + + expect(rows).toMatchObject([ + { type: 'header', key: 'repo:repo-1', count: 0 }, + { + type: 'imported-worktrees-card', + key: 'imported-worktrees-card:repo-group:repo-1', + placement: 'repo-group' + } + ]) + }) + + it('does not emit unpinned imported worktree cards outside repo grouping', () => { + const rows = buildRows( + 'workspace-status', + [worktree], + repoMap, + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map([[worktree.id, worktree]]), + false, + undefined, + [], + new Set(), + new Map([[repo.id, { repo, hiddenWorktrees: [makeDetectedWorktree()] }]]) + ) + + expect(rows.some((row) => row.type === 'imported-worktrees-card')).toBe(false) + }) + + it('emits pinned-only imported worktree fallback cards after the repo final pinned row', () => { + const repoTwo: Repo = { ...repo, id: 'repo-2', displayName: 'auth-service' } + const pinnedOneA = { ...worktree, id: 'repo-1-pinned-a', isPinned: true } + const pinnedTwo = { + ...worktree, + id: 'repo-2-pinned', + repoId: repoTwo.id, + isPinned: true, + displayName: 'auth-main' + } + const pinnedOneB = { ...worktree, id: 'repo-1-pinned-b', isPinned: true } + const rows = buildRows( + 'repo', + [pinnedOneA, pinnedTwo, pinnedOneB], + new Map([ + [repo.id, repo], + [repoTwo.id, repoTwo] + ]), + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map([ + [pinnedOneA.id, pinnedOneA], + [pinnedTwo.id, pinnedTwo], + [pinnedOneB.id, pinnedOneB] + ]), + false, + undefined, + [], + new Set(), + new Map([ + [repo.id, { repo, hiddenWorktrees: [makeDetectedWorktree({ id: 'hidden-one' })] }], + [ + repoTwo.id, + { + repo: repoTwo, + hiddenWorktrees: [makeDetectedWorktree({ id: 'hidden-two', repoId: repoTwo.id })] + } + ] + ]) + ) + + expect(rows).toMatchObject([ + { type: 'header', key: 'pinned', count: 3 }, + { type: 'item', worktree: { id: 'repo-1-pinned-a' } }, + { type: 'item', worktree: { id: 'repo-2-pinned' } }, + { + type: 'imported-worktrees-card', + key: 'imported-worktrees-card:pinned-fallback:repo-2', + placement: 'pinned-fallback' + }, + { type: 'item', worktree: { id: 'repo-1-pinned-b' } }, + { + type: 'imported-worktrees-card', + key: 'imported-worktrees-card:pinned-fallback:repo-1', + placement: 'pinned-fallback' + } + ]) + }) + + it('suppresses pinned imported worktree fallback when the repo has visible unpinned rows', () => { + const pinnedWorktree = { ...worktree, id: 'wt-pinned', isPinned: true } + const rows = buildRows( + 'repo', + [pinnedWorktree, worktree], + repoMap, + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map([ + [pinnedWorktree.id, pinnedWorktree], + [worktree.id, worktree] + ]), + false, + undefined, + [], + new Set(), + new Map([[repo.id, { repo, hiddenWorktrees: [makeDetectedWorktree()] }]]) + ) + + expect(rows.filter((row) => row.type === 'imported-worktrees-card')).toMatchObject([ + { placement: 'repo-group' } + ]) + }) + + it('suppresses pinned imported worktree fallback when Pinned is collapsed', () => { + const pinnedWorktree = { ...worktree, id: 'wt-pinned', isPinned: true } + const rows = buildRows( + 'repo', + [pinnedWorktree], + repoMap, + null, + new Set(['pinned']), + undefined, + undefined, + undefined, + {}, + new Map([[pinnedWorktree.id, pinnedWorktree]]), + false, + undefined, + [], + new Set(), + new Map([[repo.id, { repo, hiddenWorktrees: [makeDetectedWorktree()] }]]) + ) + + expect(rows).toMatchObject([{ type: 'header', key: 'pinned' }]) + }) + it('groups folder-mode workspaces under their folder name', () => { const folderRepo: Repo = { ...repo, diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts index 738335eb8..5375e3b75 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts @@ -2,6 +2,7 @@ import { CircleX, FolderTree, List, Pin } from 'lucide-react' import type React from 'react' import type { + DetectedWorktree, Repo, ProjectGroup, Worktree, @@ -63,7 +64,21 @@ export type WorktreeRow = { lineageGroupKey?: string lineageCollapsed?: boolean } -export type Row = GroupHeaderRow | WorktreeRow + +export type ImportedWorktreesCardCandidate = { + repo: Repo + hiddenWorktrees: DetectedWorktree[] +} + +export type ImportedWorktreesCardRow = { + type: 'imported-worktrees-card' + key: string + repo: Repo + hiddenWorktrees: DetectedWorktree[] + placement: 'repo-group' | 'pinned-fallback' +} + +export type Row = GroupHeaderRow | WorktreeRow | ImportedWorktreesCardRow export type PRGroupKey = 'done' | 'in-review' | 'in-progress' | 'closed' @@ -209,9 +224,9 @@ export function getPRGroupKey( function emitPinnedGroup( worktrees: Worktree[], repoMap: Map, - lineageById: Record, - worktreeMap: Map, collapsedGroups: Set, + visibleUnpinnedRepoIds: ReadonlySet, + importedWorktreesByRepo: ReadonlyMap, result: Row[] ): Set { const pinned = worktrees.filter((w) => w.isPinned) @@ -228,14 +243,36 @@ function emitPinnedGroup( icon: PINNED_GROUP_META.icon }) if (!collapsedGroups.has(PINNED_GROUP_KEY)) { - appendWorktreeRows(result, pinned, repoMap, lineageById, worktreeMap, { - nestLineage: false, - collapsedGroups - }) + const lastPinnedIndexByRepoId = new Map() + pinned.forEach((worktree, index) => lastPinnedIndexByRepoId.set(worktree.repoId, index)) + for (const [index, worktree] of pinned.entries()) { + result.push(buildWorktreeRow(worktree, repoMap, 0, [], false, 0, false)) + const candidate = importedWorktreesByRepo.get(worktree.repoId) + if ( + candidate && + !visibleUnpinnedRepoIds.has(worktree.repoId) && + lastPinnedIndexByRepoId.get(worktree.repoId) === index + ) { + result.push(buildImportedWorktreesCardRow(candidate, 'pinned-fallback')) + } + } } return new Set(pinned.map((w) => w.id)) } +function buildImportedWorktreesCardRow( + candidate: ImportedWorktreesCardCandidate, + placement: ImportedWorktreesCardRow['placement'] +): ImportedWorktreesCardRow { + return { + type: 'imported-worktrees-card', + key: `imported-worktrees-card:${placement}:${candidate.repo.id}`, + repo: candidate.repo, + hiddenWorktrees: candidate.hiddenWorktrees, + placement + } +} + function buildWorktreeRow( worktree: Worktree, repoMap: Map, @@ -364,16 +401,23 @@ export function buildRows( nestLineage = false, settings?: AppState['settings'], projectGroups: readonly ProjectGroup[] = [], - placeholderRepoIds: ReadonlySet = new Set() + placeholderRepoIds: ReadonlySet = new Set(), + importedWorktreesByRepo: ReadonlyMap = new Map() ): Row[] { const result: Row[] = [] + const visibleUnpinnedRepoIds = new Set( + worktrees.filter((worktree) => !worktree.isPinned).map((worktree) => worktree.repoId) + ) + const visiblePinnedRepoIds = new Set( + worktrees.filter((worktree) => worktree.isPinned).map((worktree) => worktree.repoId) + ) const pinnedIds = emitPinnedGroup( worktrees, repoMap, - lineageById, - worktreeMap, collapsedGroups, + visibleUnpinnedRepoIds, + importedWorktreesByRepo, result ) const unpinned = pinnedIds.size > 0 ? worktrees.filter((w) => !pinnedIds.has(w.id)) : worktrees @@ -433,6 +477,18 @@ export function buildRows( } } } + if (groupBy === 'repo') { + for (const [repoId, candidate] of importedWorktreesByRepo) { + const key = `repo:${repoId}` + if (!grouped.has(key) && !visiblePinnedRepoIds.has(repoId)) { + grouped.set(key, { + label: candidate.repo.displayName, + items: [], + repo: candidate.repo + }) + } + } + } const orderedGroups: [string, { label: string; items: Worktree[]; repo?: Repo }][] = [] if (groupBy === 'pr-status') { @@ -531,6 +587,12 @@ export function buildRows( result.push(header) if (!isCollapsed) { + if (groupBy === 'repo' && repo) { + const candidate = importedWorktreesByRepo.get(repo.id) + if (candidate) { + result.push(buildImportedWorktreesCardRow(candidate, 'repo-group')) + } + } appendWorktreeRows(result, group.items, repoMap, lineageById, worktreeMap, { nestLineage, collapsedGroups diff --git a/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts b/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts index 04e6fe5e3..2175b567a 100644 --- a/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it, vi } from 'vitest' import { + canKeepImportedWorktreesHidden, countRecordKeysByReference, + getRenderRowKey, getScrollTopToRevealBounds, + getWorktreeDragGroups, + renderRowContainsWorktree, resolvePendingSidebarReveal, WORKTREE_SIDEBAR_REVEAL_TOP_INSET, shouldAdjustWorktreeSidebarMeasuredRowScroll @@ -11,15 +15,64 @@ import { GROUP_HEADER_ROW_HEIGHT, getActiveStickyHeaderIndexForScroll } from './worktree-list-virtual-rows' +import type { Repo, Worktree } from '../../../../shared/types' +import type { Row } from './worktree-list-groups' -const makeHeaderRow = (key: string) => - ({ - type: 'header', - key, - label: key, - count: 0, - tone: 'text-foreground' - }) as const +const repo: Repo = { + id: 'repo-1', + path: '/repo', + displayName: 'orca', + badgeColor: '#000', + addedAt: 1 +} + +const makeHeaderRow = (key: string): Extract => ({ + type: 'header', + key, + label: key, + count: 0, + tone: 'text-foreground' +}) + +const makeWorktree = (id: string): Worktree => ({ + id, + repoId: repo.id, + path: `/repo/${id}`, + head: 'abc123', + branch: `refs/heads/${id}`, + isBare: false, + isMainWorktree: false, + displayName: id, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0 +}) + +const makeWorktreeRow = (id: string): Extract => ({ + type: 'item', + worktree: makeWorktree(id), + repo, + depth: 0, + lineageTrail: [], + isLastLineageChild: false, + lineageChildCount: 0 +}) + +const makeImportedCardRow = (): Extract => ({ + type: 'imported-worktrees-card', + key: 'imported-worktrees-card:repo-group:repo-1', + repo, + hiddenWorktrees: [], + placement: 'repo-group' +}) const makeScrollContainer = (scrollTop: number, clientHeight: number): HTMLElement => ({ scrollTop, clientHeight }) as HTMLElement @@ -177,6 +230,12 @@ describe('estimateRenderRowSize', () => { expect(activeSize).toBe(36) }) + it('estimates imported worktree card rows with a stable larger height', () => { + const rows = [makeHeaderRow('repo:repo-1'), makeImportedCardRow()] + + expect(estimateRenderRowSize(rows, 1, 0, null)).toBe(224) + }) + it('keeps the previous header active until the secondary header row reaches the top', () => { expect( getActiveStickyHeaderIndexForScroll({ @@ -202,3 +261,40 @@ describe('estimateRenderRowSize', () => { ).toBe(1) }) }) + +describe('imported worktree virtual rows', () => { + it('uses stable imported row keys and does not match worktree ids', () => { + const card = makeImportedCardRow() + + expect(getRenderRowKey(card)).toBe('imported:imported-worktrees-card:repo-group:repo-1') + expect(renderRowContainsWorktree(card, 'wt-1')).toBe(false) + }) + + it('keeps imported card rows out of worktree drag groups', () => { + expect( + getWorktreeDragGroups([ + makeHeaderRow('repo:repo-1'), + makeWorktreeRow('main'), + makeImportedCardRow(), + makeWorktreeRow('feature') + ]) + ).toEqual([{ key: 'repo:repo-1', worktreeIds: ['main', 'feature'] }]) + }) + + it('suppresses keep-hidden actions for force-visible rollback failure cards', () => { + expect(canKeepImportedWorktreesHidden(makeImportedCardRow(), undefined)).toBe(true) + expect( + canKeepImportedWorktreesHidden(makeImportedCardRow(), { + pending: false, + error: 'Could not show imported worktrees.', + forceVisible: true + }) + ).toBe(false) + expect( + canKeepImportedWorktreesHidden( + { ...makeImportedCardRow(), placement: 'pinned-fallback' }, + undefined + ) + ).toBe(false) + }) +}) 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 8c6192bd3..b4b1770e3 100644 --- a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts @@ -4,6 +4,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 type WorktreeItemRow = Extract export type RenderRow = Row | { type: 'lineage-group'; key: string; rows: WorktreeItemRow[] } @@ -41,6 +42,9 @@ export function estimateRenderRowSize( if (row?.type === 'lineage-group') { return 100 + Math.max(0, row.rows.length - 1) * 96 } + if (row?.type === 'imported-worktrees-card') { + return IMPORTED_WORKTREES_CARD_ROW_HEIGHT + } return 116 } diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index 6ac44d1bb..e87640025 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -70,7 +70,7 @@ export type WorktreeSlice = { */ hasHydratedWorktreePurge: boolean fetchDetectedWorktrees: (repoId: string) => Promise - fetchWorktrees: (repoId: string) => Promise + fetchWorktrees: (repoId: string, options?: { requireAuthoritative?: boolean }) => Promise fetchAllWorktrees: () => Promise fetchWorktreeLineage: () => Promise updateWorktreeLineage: ( diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index f1390f115..48d9099f5 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -267,12 +267,13 @@ describe('fetchWorktrees', () => { } as Partial) const unsubscribe = store.subscribe(subscriber) - await store.getState().fetchWorktrees('repo1') + const result = await store.getState().fetchWorktrees('repo1') unsubscribe() expect(store.getState().worktreesByRepo.repo1).toEqual([existing]) expect(store.getState().sortEpoch).toBe(7) expect(subscriber).not.toHaveBeenCalled() + expect(result).toBe(true) }) it('updates the repo entry and bumps sortEpoch when git reports a branch change', async () => { @@ -338,10 +339,59 @@ describe('fetchWorktrees', () => { ) store.setState({ worktreesByRepo: { repo1: [existing] }, sortEpoch: 7 } as Partial) - await store.getState().fetchWorktrees('repo1') + const result = await store.getState().fetchWorktrees('repo1') expect(store.getState().worktreesByRepo.repo1).toEqual([existing]) expect(store.getState().sortEpoch).toBe(7) + expect(result).toBe(false) + }) + + it('reports unchanged non-authoritative refreshes as not fully refreshed', async () => { + const store = createTestStore() + const existing = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', path: '/path/wt1' }) + + mockApi.worktrees.listDetected.mockResolvedValueOnce( + makeDetectedResult('repo1', [existing], { + authoritative: false, + source: 'metadata-fallback' + }) + ) + store.setState({ worktreesByRepo: { repo1: [existing] }, sortEpoch: 7 } as Partial) + + const result = await store.getState().fetchWorktrees('repo1') + + expect(store.getState().worktreesByRepo.repo1).toEqual([existing]) + expect(store.getState().sortEpoch).toBe(7) + expect(result).toBe(false) + }) + + it('does not publish non-authoritative rows when an authoritative refresh is required', async () => { + const store = createTestStore() + const existing = makeWorktree({ + id: 'repo1::/path/existing', + repoId: 'repo1', + path: '/path/existing' + }) + const fallback = makeWorktree({ + id: 'repo1::/path/fallback', + repoId: 'repo1', + path: '/path/fallback' + }) + + mockApi.worktrees.listDetected.mockResolvedValueOnce( + makeDetectedResult('repo1', [fallback], { + authoritative: false, + source: 'metadata-fallback' + }) + ) + store.setState({ worktreesByRepo: { repo1: [existing] }, sortEpoch: 7 } as Partial) + + const result = await store.getState().fetchWorktrees('repo1', { requireAuthoritative: true }) + + expect(store.getState().worktreesByRepo.repo1).toEqual([existing]) + expect(store.getState().detectedWorktreesByRepo.repo1).toBeUndefined() + expect(store.getState().sortEpoch).toBe(7) + expect(result).toBe(false) }) it('purges remembered right sidebar tabs for worktrees removed by a committed refresh', async () => { @@ -494,7 +544,7 @@ describe('fetchWorktrees', () => { } } as unknown as Partial) - await store.getState().fetchWorktrees('repo1') + const result = await store.getState().fetchWorktrees('repo1') expect(store.getState().rightSidebarTabByWorktree).toEqual({ [missingFromFallback.id]: 'search', @@ -505,6 +555,7 @@ describe('fetchWorktrees', () => { ]) expect(store.getState().worktreesByRepo.repo1).toEqual([fallback]) expect(store.getState().sortEpoch).toBe(8) + expect(result).toBe(false) }) it('does not purge remembered right sidebar tabs on a transient empty refresh', async () => { @@ -523,11 +574,12 @@ describe('fetchWorktrees', () => { rightSidebarTabByWorktree: { [existing.id]: 'search' } } as Partial) - await store.getState().fetchWorktrees('repo1') + const result = await store.getState().fetchWorktrees('repo1') expect(store.getState().worktreesByRepo.repo1).toEqual([existing]) expect(store.getState().rightSidebarTabByWorktree).toEqual({ [existing.id]: 'search' }) expect(store.getState().sortEpoch).toBe(7) + expect(result).toBe(false) }) it('accepts an empty refresh when the repo had no cached worktrees', async () => { diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index f3940444b..6f4f48d75 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -731,10 +731,13 @@ export const createWorktreeSlice: StateCreator } }, - fetchWorktrees: async (repoId) => { + fetchWorktrees: async (repoId, options) => { try { const settings = get().settings const detected = await listDetectedWorktreesForRepo(settings, repoId) + if (options?.requireAuthoritative && !detected.authoritative) { + return false + } const worktrees = toVisibleWorktrees(detected) const current = get().worktreesByRepo[repoId] if (areWorktreesEqual(current, worktrees)) { @@ -752,7 +755,7 @@ export const createWorktreeSlice: StateCreator } }) await refreshRemoteWorktreeLineageBestEffort(settings, set) - return + return detected.authoritative } // Why: `git worktree list` can fail transiently (e.g. concurrent git @@ -766,7 +769,7 @@ export const createWorktreeSlice: StateCreator set((s) => ({ detectedWorktreesByRepo: { ...s.detectedWorktreesByRepo, [repoId]: detected } })) - return + return false } set((s) => { @@ -786,8 +789,10 @@ export const createWorktreeSlice: StateCreator } }) await refreshRemoteWorktreeLineageBestEffort(settings, set) + return detected.authoritative } catch (err) { console.error(`Failed to fetch worktrees for repo ${repoId}:`, err) + return false } },