diff --git a/src/renderer/src/components/settings/AppearancePane.tsx b/src/renderer/src/components/settings/AppearancePane.tsx index 8a392e30515..d326b541a71 100644 --- a/src/renderer/src/components/settings/AppearancePane.tsx +++ b/src/renderer/src/components/settings/AppearancePane.tsx @@ -52,7 +52,10 @@ import { import { translate } from '@/i18n/i18n' import type { UiLanguage } from '../../../../shared/ui-language' import { LeftSidebarAppearanceSetting } from './LeftSidebarAppearanceSetting' -import { getWorkspaceCardLayoutEntry } from './appearance-sidebar-search' +import { + getShowPinnedWorktreesInGroupsEntry, + getWorkspaceCardLayoutEntry +} from './appearance-sidebar-search' export { getAppearancePaneSearchEntries } type AppearancePaneProps = { @@ -117,6 +120,7 @@ export function AppearancePane({ const systemTrayEntries = getSystemTrayEntries({ showSystemTray: isDesktopWindows }) const leftSidebarAppearanceEntry = getLeftSidebarAppearanceEntry() const workspaceCardLayoutEntry = getWorkspaceCardLayoutEntry() + const showPinnedWorktreesInGroupsEntry = getShowPinnedWorktreesInGroupsEntry() const visibleSections = [ matchesSettingsSearch(searchQuery, getThemeEntries()) || (SHOW_UI_LANGUAGE_SETTING && matchesSettingsSearch(searchQuery, getLanguageEntries())) || @@ -569,6 +573,29 @@ export function AppearancePane({ } /> + + + + updateSettings({ + showPinnedWorktreesInGroups: !(settings.showPinnedWorktreesInGroups === true) + }) + } + /> + ) : null, diff --git a/src/renderer/src/components/settings/appearance-sidebar-search.ts b/src/renderer/src/components/settings/appearance-sidebar-search.ts index d723e3c4a0e..9b31ffb015d 100644 --- a/src/renderer/src/components/settings/appearance-sidebar-search.ts +++ b/src/renderer/src/components/settings/appearance-sidebar-search.ts @@ -79,6 +79,54 @@ export const getWorkspaceCardLayoutEntry = createLocalizedCatalog( }) ) +export const getShowPinnedWorktreesInGroupsEntry = createLocalizedCatalog( + (): SettingsSearchEntry => ({ + title: translate( + 'auto.components.settings.appearance.search.showPinnedWorktreesInGroups.title', + 'Show pinned worktrees in other groups' + ), + description: translate( + 'auto.components.settings.appearance.search.showPinnedWorktreesInGroups.description', + 'Pinned worktrees also appear in All, Project, Status, and PR groups.' + ), + keywords: [ + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.showPinnedWorktreesInGroups.pinned', + 'pinned' + ), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.showPinnedWorktreesInGroups.worktree', + 'worktree' + ), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.showPinnedWorktreesInGroups.workspace', + 'workspace' + ), + ...translateSearchKeyword('auto.components.settings.appearance.search.5bff6a2ef0', 'sidebar'), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.showPinnedWorktreesInGroups.all', + 'all' + ), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.showPinnedWorktreesInGroups.project', + 'project' + ), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.showPinnedWorktreesInGroups.status', + 'status' + ), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.showPinnedWorktreesInGroups.pr', + 'pr' + ), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.showPinnedWorktreesInGroups.duplicate', + 'duplicate' + ) + ] + }) +) + export const getSidebarEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [ { title: translate('auto.components.settings.appearance.search.155a1e7438', 'Show Tasks Button'), @@ -144,5 +192,6 @@ export const getSidebarEntries = createLocalizedCatalog((): SettingsSearchEntry[ ] }, getWorkspaceCardLayoutEntry(), - getLeftSidebarAppearanceEntry() + getLeftSidebarAppearanceEntry(), + getShowPinnedWorktreesInGroupsEntry() ]) diff --git a/src/renderer/src/components/settings/terminal-search.test.ts b/src/renderer/src/components/settings/terminal-search.test.ts index 56ca5e3c283..29c37101138 100644 --- a/src/renderer/src/components/settings/terminal-search.test.ts +++ b/src/renderer/src/components/settings/terminal-search.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from 'vitest' import { getTerminalPaneSearchEntries } from './terminal-search' import { getAppearancePaneSearchEntries, getSidebarEntries } from './appearance-search' -import { getWorkspaceCardLayoutEntry } from './appearance-sidebar-search' +import { + getShowPinnedWorktreesInGroupsEntry, + getWorkspaceCardLayoutEntry +} from './appearance-sidebar-search' import { matchesSettingsSearch } from './settings-search' describe('getTerminalPaneSearchEntries', () => { @@ -172,4 +175,12 @@ describe('getTerminalPaneSearchEntries', () => { it('matches the Appearance catalog for compact workspace card searches', () => { expect(matchesSettingsSearch('compact', getAppearancePaneSearchEntries())).toBe(true) }) + + it('includes pinned worktree duplicate display in sidebar and Appearance search', () => { + const entry = getShowPinnedWorktreesInGroupsEntry() + + expect(getSidebarEntries()).toContainEqual(entry) + expect(getAppearancePaneSearchEntries()).toContainEqual(entry) + expect(matchesSettingsSearch('duplicate', entry)).toBe(true) + }) }) diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts index 6ce0830d27d..dd4ea4de31e 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts +++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts @@ -10,6 +10,7 @@ import type { WorktreeLineage } from '../../../../shared/types' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import type * as WorktreeListModule from './WorktreeList' const mockStore = vi.hoisted(() => ({ state: {} as Record @@ -21,6 +22,7 @@ type WorktreeListComponent = React.ComponentType<{ }> let WorktreeList: WorktreeListComponent +let getPinnedWorktreeRevealCollapsedGroupKeys: typeof WorktreeListModule.getPinnedWorktreeRevealCollapsedGroupKeys function makeFolderWorkspacePathStatusMockState(): Record { return { @@ -426,12 +428,12 @@ function setFolderWorkspaceFixtureState( } } -function setPinnedDuplicateFixtureState(): void { +function setPinnedFixtureState(): void { const repo = makeRepo() const pinned = makeWorktree({ id: 'pinned', instanceId: 'pinned-instance', - displayName: 'pinned duplicate', + displayName: 'pinned workspace', branch: 'pinned-branch', sortOrder: 20 }) @@ -842,8 +844,10 @@ function escapeRegExp(value: string): string { describe('WorktreeList lineage child card renderer', () => { beforeAll(async () => { - WorktreeList = (await import('./WorktreeList')).default as WorktreeListComponent - }, 20_000) + const module = await import('./WorktreeList') + WorktreeList = module.default as WorktreeListComponent + getPinnedWorktreeRevealCollapsedGroupKeys = module.getPinnedWorktreeRevealCollapsedGroupKeys + }, 60_000) it('renders project group headers when repos import before worktree rows load', async () => { setProjectGroupWithoutWorktreeRowsState() @@ -877,6 +881,24 @@ describe('WorktreeList lineage child card renderer', () => { expect(markup).not.toContain('data-repo-header-collapse-affordance=""') }) + it('uncollapses pinned reveal through the pinned section after host expansion', () => { + const worktree = makeWorktree({ + id: 'pinned-ssh', + instanceId: 'pinned-ssh-instance', + displayName: 'Pinned SSH workspace', + branch: 'pinned-ssh', + sortOrder: 1 + }) + worktree.isPinned = true + + expect( + getPinnedWorktreeRevealCollapsedGroupKeys({ + worktree, + collapsedGroups: new Set(['host:ssh:builder-1', 'pinned', 'done']) + }) + ).toEqual(['pinned']) + }) + it('renders a collapse chevron on grouped repo headers with worktrees', async () => { setLineageFixtureState('repo') const markup = await renderWorktreeListMarkup() @@ -1075,12 +1097,23 @@ describe('WorktreeList lineage child card renderer', () => { ).toBe(30) }) - it('points aria-activedescendant at the natural row for active pinned duplicates', async () => { - setPinnedDuplicateFixtureState() + it('points aria-activedescendant at the pinned row for active pinned workspaces', async () => { + setPinnedFixtureState() + const markup = await renderWorktreeListMarkup() + + expect(markup).toContain('aria-activedescendant="worktree-list-option-pinned%3Apinned"') + expect(markup).toContain('id="worktree-list-option-pinned%3Apinned"') + expect(markup).not.toContain('id="worktree-list-option-all%3Apinned"') + }) + + it('points aria-activedescendant at the natural duplicate when enabled', async () => { + setPinnedFixtureState() + mockStore.state.settings = { showPinnedWorktreesInGroups: true } const markup = await renderWorktreeListMarkup() expect(markup).toContain('aria-activedescendant="worktree-list-option-all%3Apinned"') expect(markup).toContain('id="worktree-list-option-pinned%3Apinned"') + expect(markup).toContain('id="worktree-list-option-all%3Apinned"') }) it('opens inline rename only for the row-scoped lineage child request', async () => { diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 3764ee8adff..dc0e7ef886f 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -33,6 +33,7 @@ import { useWorktreeMap } from '@/store/selectors' import WorktreeCard, { type ActiveSurfaceVariant } from './WorktreeCard' +import StatusIndicator from './StatusIndicator' import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree' import { PendingWorktreeRow } from './PendingWorktreeRow' import { SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT } from './WorktreeCardAgents' @@ -79,7 +80,9 @@ import { PINNED_GROUP_KEY, buildRows, getGroupKeysForWorktree, - getLineageGroupKey + getLineageGroupKey, + getPinnedWorktreeDisplayPolicy, + type PinnedWorktreeDisplayPolicy } from './worktree-list-groups' import { estimateRenderRowSize, @@ -111,8 +114,11 @@ import { import { getEmptyProjectPlaceholderRepoIds } from './empty-project-placeholder-repos' import { getVisibleWorktreeBrowserActivityTabs, - getVisibleWorktreeTerminalActivityTabs + getVisibleWorktreeTerminalActivityTabs, + getWorktreeSectionTerminalActivityTabs } from './visible-worktree-activity-inputs' +import { selectTerminalLayoutRootsForWorktrees } from './worktree-card-status-inputs' +import { buildWorktreeSectionActivitySummaries } from './worktree-section-activity' import { VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT, useVirtualizedScrollAnchor, @@ -413,9 +419,9 @@ function markSidebarWorktreeActiveImmediately(worktreeId: string, primaryRowKey? ? option.dataset.worktreeRowKey === primaryRowKey ? 'primary' : 'secondary' - : option.dataset.worktreeSectionKey === PINNED_GROUP_KEY - ? 'secondary' - : 'primary' + : option === nextOption + ? 'primary' + : 'secondary' const surface = option.matches('[data-worktree-card-surface]') ? option : option.querySelector('[data-worktree-card-surface]') @@ -452,6 +458,7 @@ type VirtualizedWorktreeViewportProps = { activeWorktreeId: string | null currentWorktreeId: string | null groupBy: WorktreeGroupBy + pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy projectOrderBy: ProjectOrderBy toggleGroup: (key: string) => void collapsedGroups: Set @@ -483,6 +490,7 @@ type VirtualizedWorktreeViewportProps = { worktree: Worktree ) => readonly Worktree[] repoMap: Map + defaultHostId: ExecutionHostId worktreeMap: Map worktreeLineageById: Record repoOrder: Map @@ -563,6 +571,27 @@ function SectionMetricsBadge({ count }: { count: number }): React.JSX.Element { ) } +function SectionRunningBadge({ count }: { count: number }): React.JSX.Element { + const label = formatSectionActivityLabel(count, 'running workspace') + + return ( + + + + + + + {label} + + + ) +} + function HostHeaderHealthIcon({ health }: { @@ -878,6 +907,50 @@ export function renderRowContainsWorktree(row: RenderRow, worktreeId: string | n return row.type === 'item' && row.worktree.id === worktreeId } +function isPinnedWorktreeRow(row: WorktreeItemRow): boolean { + return row.sectionKey === PINNED_GROUP_KEY +} + +function getRenderRowWorktreeItem(row: RenderRow, worktreeId: string): WorktreeItemRow | null { + if (row.type === 'lineage-group') { + return row.rows.find((item) => item.worktree.id === worktreeId) ?? null + } + return row.type === 'item' && row.worktree.id === worktreeId ? row : null +} + +function getPreferredWorktreeRows( + rows: readonly WorktreeItemRow[], + pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy +): WorktreeItemRow[] { + if (pinnedDisplayPolicy === 'single-location') { + const seen = new Set() + return rows.filter((row) => { + if (seen.has(row.worktree.id)) { + return false + } + seen.add(row.worktree.id) + return true + }) + } + const preferredRows: WorktreeItemRow[] = [] + const seen = new Set() + for (const row of rows) { + if (isPinnedWorktreeRow(row) || seen.has(row.worktree.id)) { + continue + } + preferredRows.push(row) + seen.add(row.worktree.id) + } + for (const row of rows) { + if (seen.has(row.worktree.id)) { + continue + } + preferredRows.push(row) + seen.add(row.worktree.id) + } + return preferredRows +} + function getRenderRowOptionId( row: RenderRow | undefined, worktreeId?: string | null @@ -898,20 +971,10 @@ function getRenderRowOptionId( return undefined } -function renderRowContainsNaturalWorktree(row: RenderRow, worktreeId: string): boolean { - if (row.type === 'lineage-group') { - return row.rows.some( - (item) => item.worktree.id === worktreeId && item.sectionKey !== PINNED_GROUP_KEY - ) - } - return ( - row.type === 'item' && row.worktree.id === worktreeId && row.sectionKey !== PINNED_GROUP_KEY - ) -} - function getActiveDescendantOptionId(args: { activeWorktreeId: string | null primaryActiveRowKey?: string + pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy renderRows: readonly RenderRow[] virtualItems: readonly { index: number }[] }): string | undefined { @@ -927,19 +990,67 @@ function getActiveDescendantOptionId(args: { } } } - for (const item of args.virtualItems) { - const row = args.renderRows[item.index] - if (row && renderRowContainsNaturalWorktree(row, args.activeWorktreeId)) { - return getRenderRowOptionId(row, args.activeWorktreeId) - } - } + let fallbackOptionId: string | undefined for (const item of args.virtualItems) { const row = args.renderRows[item.index] if (row && renderRowContainsWorktree(row, args.activeWorktreeId)) { - return getRenderRowOptionId(row, args.activeWorktreeId) + const optionId = getRenderRowOptionId(row, args.activeWorktreeId) + if (!optionId) { + continue + } + const itemRow = getRenderRowWorktreeItem(row, args.activeWorktreeId) + if ( + args.pinnedDisplayPolicy === 'duplicate-in-groups' && + itemRow && + !isPinnedWorktreeRow(itemRow) + ) { + return optionId + } + fallbackOptionId ??= optionId } } - return undefined + return fallbackOptionId +} + +function findPreferredRenderRowIndexForWorktree( + renderRows: readonly RenderRow[], + worktreeId: string, + pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy +): number { + let fallbackIndex = -1 + for (let index = 0; index < renderRows.length; index++) { + const row = renderRows[index] + if (!renderRowContainsWorktree(row, worktreeId)) { + continue + } + if (fallbackIndex === -1) { + fallbackIndex = index + } + const itemRow = getRenderRowWorktreeItem(row, worktreeId) + if (pinnedDisplayPolicy === 'duplicate-in-groups' && itemRow && !isPinnedWorktreeRow(itemRow)) { + return index + } + } + return fallbackIndex +} + +export function getPinnedWorktreeRevealCollapsedGroupKeys({ + worktree, + collapsedGroups +}: { + worktree: Worktree + collapsedGroups: ReadonlySet +}): string[] { + if (!worktree.isPinned) { + return [] + } + const keys: string[] = [] + // Why: the reveal effect opens the owning host first; returning it here would + // toggle the same host closed again while React state is still stale. + if (collapsedGroups.has(PINNED_GROUP_KEY)) { + keys.push(PINNED_GROUP_KEY) + } + return keys } function uniqueWorktreeIds(ids: readonly string[]): string[] { @@ -1006,6 +1117,11 @@ export function getRenderRowKey(row: RenderRow): string { export function getWorktreeDragGroups(rows: HostSectionRow[]): WorktreeDragGroup[] { const groups: WorktreeDragGroup[] = [] let current: { key: string; ids: string[] } | null = null + const naturalWorktreeIds = new Set( + rows.flatMap((row) => + row.type === 'item' && row.sectionKey !== PINNED_GROUP_KEY ? [row.worktree.id] : [] + ) + ) for (const row of rows) { if (row.type === 'header') { @@ -1021,7 +1137,7 @@ export function getWorktreeDragGroups(rows: HostSectionRow[]): WorktreeDragGroup ) { continue } - if (row.sectionKey === PINNED_GROUP_KEY) { + if (row.sectionKey === PINNED_GROUP_KEY && naturalWorktreeIds.has(row.worktree.id)) { continue } if (!current) { @@ -1041,19 +1157,27 @@ export function canKeepImportedWorktreesHidden( return row.placement === 'repo-group' && actionState?.forceVisible !== true } -function getWorktreeDragIndexes(rows: readonly HostSectionRow[]): { +export function getWorktreeDragIndexes(rows: readonly HostSectionRow[]): { groupKeyByRowKey: Map groupIndexByRowKey: Map } { const groupKeyByRowKey = new Map() const groupIndexByRowKey = new Map() const groupIndexes = new Map() + const naturalWorktreeIds = new Set( + rows.flatMap((row) => + row.type === 'item' && row.sectionKey !== PINNED_GROUP_KEY ? [row.worktree.id] : [] + ) + ) for (const row of rows) { if (row.type === 'header') { groupIndexes.set(row.key, 0) continue } - if (row.type !== 'item' || row.sectionKey === PINNED_GROUP_KEY) { + if (row.type !== 'item') { + continue + } + if (row.sectionKey === PINNED_GROUP_KEY && naturalWorktreeIds.has(row.worktree.id)) { continue } const index = groupIndexes.get(row.sectionKey) ?? 0 @@ -1078,6 +1202,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp activeWorktreeId, currentWorktreeId, groupBy, + pinnedDisplayPolicy, projectOrderBy, toggleGroup, collapsedGroups, @@ -1106,6 +1231,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp onImmediateWorktreeActivate, onContextMenuSelect, repoMap, + defaultHostId, worktreeMap, worktreeLineageById, repoOrder, @@ -1275,13 +1401,25 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp useEffect(() => () => onHostDragActiveChange(false), [onHostDragActiveChange]) const worktreeDragGroups = useMemo(() => getWorktreeDragGroups(rows), [rows]) const worktreeDragUnitGroups = useMemo(() => getWorktreeDragUnitGroups(rows), [rows]) + const naturalDragWorktreeIds = useMemo( + () => + new Set( + rows.flatMap((row) => + row.type === 'item' && row.sectionKey !== PINNED_GROUP_KEY ? [row.worktree.id] : [] + ) + ), + [rows] + ) const worktreeLineageDragRows = useMemo( () => rows .filter((row): row is WorktreeItemRow => row.type === 'item') - .filter((row) => row.sectionKey !== PINNED_GROUP_KEY) + .filter( + (row) => + row.sectionKey !== PINNED_GROUP_KEY || !naturalDragWorktreeIds.has(row.worktree.id) + ) .map((row) => ({ worktreeId: row.worktree.id, depth: row.depth })), - [rows] + [naturalDragWorktreeIds, rows] ) const getReorderDraggedIds = useCallback( (draggedIds: readonly string[]) => @@ -1447,28 +1585,21 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp return rowStillVisible ? current : null }) }, [activeWorktreeId, rows]) - const activeWorktreeHasNaturalRow = useMemo( - () => - activeWorktreeId !== null && - rows.some( - (row) => - row.type === 'item' && - row.worktree.id === activeWorktreeId && - row.sectionKey !== PINNED_GROUP_KEY - ), - [activeWorktreeId, rows] - ) const getActiveSurfaceVariant = useCallback( (row: WorktreeItemRow): ActiveSurfaceVariant => { if (primaryActiveWorktreeRow?.worktreeId === row.worktree.id) { return primaryActiveWorktreeRow.rowKey === row.rowKey ? 'primary' : 'secondary' } - if (activeWorktreeHasNaturalRow && row.sectionKey === PINNED_GROUP_KEY) { + if ( + pinnedDisplayPolicy === 'duplicate-in-groups' && + activeWorktreeId === row.worktree.id && + isPinnedWorktreeRow(row) + ) { return 'secondary' } return 'primary' }, - [activeWorktreeHasNaturalRow, primaryActiveWorktreeRow] + [activeWorktreeId, pinnedDisplayPolicy, primaryActiveWorktreeRow] ) const handleImmediateWorktreeRowActivate = useCallback( (worktreeId: string, rowKey: string | undefined): void => { @@ -1768,7 +1899,17 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp } } else { const targetWorktree = worktrees.find((w) => w.id === pendingRevealWorktree.worktreeId) - if (targetWorktree && !targetWorktree.isPinned) { + const targetRepo = targetWorktree ? repoMap.get(targetWorktree.repoId) : undefined + if (targetWorktree) { + const hostId = + targetRepo && (targetRepo.connectionId || targetRepo.executionHostId) + ? getRepoExecutionHostId(targetRepo) + : defaultHostId + const hostGroupKey = `host:${hostId}` + if (collapsedGroups.has(hostGroupKey)) { + toggleGroup(hostGroupKey) + } + const seen = new Set() let current: Worktree | undefined = targetWorktree while (current && !seen.has(current.id)) { @@ -1789,26 +1930,23 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp } current = parent } - } - if (targetWorktree?.isPinned) { - // Why: pinned worktrees live in the dedicated "Pinned" section regardless - // of their PR-status / project group. Only uncollapse the Pinned header - // itself — expanding the underlying status group would be surprising since - // the user intentionally collapsed it. - if (collapsedGroups.has(PINNED_GROUP_KEY)) { - toggleGroup(PINNED_GROUP_KEY) - } - } else if (targetWorktree) { - const groupKeys = getGroupKeysForWorktree( - groupBy, - targetWorktree, - repoMap, - prCache, - workspaceStatuses, - settings, - projectGroups - ) + const groupKeys = + targetWorktree.isPinned && pinnedDisplayPolicy === 'single-location' + ? getPinnedWorktreeRevealCollapsedGroupKeys({ + worktree: targetWorktree, + collapsedGroups + }) + : getGroupKeysForWorktree( + groupBy, + targetWorktree, + repoMap, + prCache, + workspaceStatuses, + settings, + projectGroups, + projectGrouping + ) for (const groupKey of groupKeys) { if (collapsedGroups.has(groupKey)) { toggleGroup(groupKey) @@ -1828,8 +1966,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp worktrees, folderWorkspaces ) - const targetIndex = renderRows.findIndex((row) => - renderRowContainsWorktree(row, pendingRevealWorktree.worktreeId) + const targetIndex = findPreferredRenderRowIndexForWorktree( + renderRows, + pendingRevealWorktree.worktreeId, + pinnedDisplayPolicy ) const outcome = resolvePendingSidebarReveal({ targetIndex, targetWorktreeStillExists }) if (outcome === 'scroll-and-clear') { @@ -1925,8 +2065,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp clearPendingRevealWorktreeId, toggleGroup, collapsedGroups, + defaultHostId, workspaceStatuses, settings, + pinnedDisplayPolicy, + projectGrouping, projectGroups, pendingRevealRetryTick, flashRevealedWorktree, @@ -2034,16 +2177,13 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp new Set(), new Map(), [], - projectGrouping + projectGrouping, + [], + undefined, + defaultHostId, + pinnedDisplayPolicy ).filter((r): r is Extract => r.type === 'item') - const seenWorktreeIds = new Set() - const worktreeRows = allWorktreeRows.filter((row) => { - if (seenWorktreeIds.has(row.worktree.id)) { - return false - } - seenWorktreeIds.add(row.worktree.id) - return true - }) + const worktreeRows = getPreferredWorktreeRows(allWorktreeRows, pinnedDisplayPolicy) if (worktreeRows.length === 0) { return } @@ -2070,7 +2210,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp // it must flow through the same activation helper that records history. activateAndRevealWorktree(nextWorktreeId) - const rowIndex = renderRows.findIndex((row) => renderRowContainsWorktree(row, nextWorktreeId)) + const rowIndex = findPreferredRenderRowIndexForWorktree( + renderRows, + nextWorktreeId, + pinnedDisplayPolicy + ) if (rowIndex !== -1) { virtualizer.scrollToIndex(rowIndex, { align: 'auto' }) } @@ -2083,6 +2227,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp projectOrderBy, worktrees, repoMap, + defaultHostId, prCache, repoOrder, workspaceStatuses, @@ -2090,7 +2235,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp worktreeMap, settings, projectGroups, - projectGrouping + projectGrouping, + pinnedDisplayPolicy ] ) @@ -3291,6 +3437,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp primaryActiveWorktreeRow?.worktreeId === activeWorktreeId ? primaryActiveWorktreeRow.rowKey : undefined, + pinnedDisplayPolicy, renderRows, virtualItems }) @@ -3819,6 +3966,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
{row.label}
+ {isHeaderCollapsed && row.runningCount && row.runningCount > 0 ? ( + + ) : null} @@ -4338,8 +4488,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp onCardDragStart={handleWorktreeCardDragStart} onCardDragEnd={clearWorktreeDrag} hideRepoBadge={groupBy === 'repo'} - // Why: pinned worktrees also render in their natural group; - // only the overlay row is the mixed-repo section needing icons. + // Why: pinned worktrees mix repos in one section; only that + // section needs the leading repo identity chip. hostContextLabel={itemRow.hostContextLabel} inPinnedSection={isPinnedOverlayRow} renameRowKey={itemRow.rowKey} @@ -4743,6 +4893,21 @@ const WorktreeList = React.memo(function WorktreeList({ : null ) const settings = useAppStore((s) => s.settings) + const pinnedDisplayPolicy = getPinnedWorktreeDisplayPolicy(settings) + const sectionActivitySourceState = useAppStore( + useShallow((s) => ({ + tabsByWorktree: getWorktreeSectionTerminalActivityTabs(s.tabsByWorktree), + browserTabsByWorktree: getVisibleWorktreeBrowserActivityTabs(s.browserTabsByWorktree), + ptyIdsByTabId: s.ptyIdsByTabId, + runtimePaneTitlesByTabId: s.runtimePaneTitlesByTabId, + terminalLayoutsByTabId: s.terminalLayoutsByTabId, + agentStatusEpoch: s.agentStatusEpoch, + agentStatusByPaneKey: s.agentStatusByPaneKey, + migrationUnsupportedByPtyId: s.migrationUnsupportedByPtyId, + retainedAgentsByPaneKey: s.retainedAgentsByPaneKey, + runtimeAgentOrchestrationByPaneKey: s.runtimeAgentOrchestrationByPaneKey + })) + ) const sshTargetLabels = useAppStore((s) => s.sshTargetLabels) const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) @@ -5232,6 +5397,49 @@ const WorktreeList = React.memo(function WorktreeList({ () => new Map(hostOptions.map((host) => [host.id, host.label])), [hostOptions] ) + const sectionActivityWorktreeIds = useMemo( + () => worktrees.map((worktree) => worktree.id), + [worktrees] + ) + const sectionActivityState = useMemo( + () => ({ + ...sectionActivitySourceState, + terminalLayoutRootsByTabId: selectTerminalLayoutRootsForWorktrees( + sectionActivitySourceState, + sectionActivityWorktreeIds + ) + }), + [sectionActivitySourceState, sectionActivityWorktreeIds] + ) + const sectionActivitySummaries = useMemo( + () => + buildWorktreeSectionActivitySummaries({ + groupBy, + worktrees, + repoMap, + prCache, + workspaceStatuses, + settings, + projectGroups: visibleProjectGroupsForRows, + projectGrouping, + pinnedDisplayPolicy, + defaultHostId, + state: sectionActivityState + }), + [ + defaultHostId, + groupBy, + pinnedDisplayPolicy, + prCache, + projectGrouping, + repoMap, + sectionActivityState, + settings, + visibleProjectGroupsForRows, + workspaceStatuses, + worktrees + ] + ) // Build flat row list for rendering const rows: Row[] = useMemo( @@ -5255,7 +5463,10 @@ const WorktreeList = React.memo(function WorktreeList({ pendingCreations, projectGrouping, visibleFolderWorkspacesForRows, - hostLabelById + hostLabelById, + defaultHostId, + pinnedDisplayPolicy, + sectionActivitySummaries ), [ groupBy, @@ -5263,6 +5474,7 @@ const WorktreeList = React.memo(function WorktreeList({ repoMap, prCache, effectiveCollapsedGroups, + defaultHostId, repoOrder, workspaceStatuses, projectOrderBy, @@ -5275,7 +5487,9 @@ const WorktreeList = React.memo(function WorktreeList({ placeholderRepoIds, importedWorktreesByRepo, pendingCreations, - hostLabelById + hostLabelById, + pinnedDisplayPolicy, + sectionActivitySummaries ] ) const orderedHostOptions = useMemo( @@ -5338,19 +5552,23 @@ const WorktreeList = React.memo(function WorktreeList({ // 'pr-status') can reorder cards into grouped sections. Using the flat // order would cause Cmd+1–9 shortcuts to not match the visual card // positions when grouping is active. - const renderedWorktrees = useMemo( - () => - sectionRows.flatMap((row) => { - if (row.type === 'item') { - return [row.worktree] - } - if (row.type === 'folder-workspace') { - return [folderWorkspaceToWorktree(row.folderWorkspace)] - } - return [] - }), - [sectionRows] - ) + const renderedWorktrees = useMemo(() => { + const itemRows: WorktreeItemRow[] = [] + const folderRows: Worktree[] = [] + for (const row of sectionRows) { + if (row.type === 'item') { + itemRows.push(row) + continue + } + if (row.type === 'folder-workspace') { + folderRows.push(folderWorkspaceToWorktree(row.folderWorkspace)) + } + } + return [ + ...getPreferredWorktreeRows(itemRows, pinnedDisplayPolicy).map((row) => row.worktree), + ...folderRows + ] + }, [pinnedDisplayPolicy, sectionRows]) const renderedWorktreeIds = useMemo( () => uniqueWorktreeIds(renderedWorktrees.map((worktree) => worktree.id)), [renderedWorktrees] @@ -6054,6 +6272,7 @@ const WorktreeList = React.memo(function WorktreeList({ activeWorktreeId={selectedSidebarWorktreeId} currentWorktreeId={currentSidebarWorktreeId} groupBy={groupBy} + pinnedDisplayPolicy={pinnedDisplayPolicy} projectOrderBy={projectOrderBy} toggleGroup={toggleGroup} collapsedGroups={effectiveCollapsedGroups} @@ -6082,6 +6301,7 @@ const WorktreeList = React.memo(function WorktreeList({ onImmediateWorktreeActivate={handleImmediateWorktreeActivate} onContextMenuSelect={selectForContextMenu} repoMap={repoMap} + defaultHostId={defaultHostId} worktreeMap={worktreeMap} worktreeLineageById={worktreeLineageById} repoOrder={repoOrder} diff --git a/src/renderer/src/components/sidebar/host-section-rows.test.ts b/src/renderer/src/components/sidebar/host-section-rows.test.ts index e03ddbb63cc..136b835eb33 100644 --- a/src/renderer/src/components/sidebar/host-section-rows.test.ts +++ b/src/renderer/src/components/sidebar/host-section-rows.test.ts @@ -46,6 +46,19 @@ function header(key: string, label = key): Extract { } } +function pinnedHeader( + counts: ReadonlyMap<'local' | 'ssh:ssh-1', number>, + idsByHost?: ReadonlyMap<'local' | 'ssh:ssh-1', readonly string[]> +): Extract { + return { + ...header(PINNED_GROUP_KEY, 'Pinned'), + count: Array.from(counts.values()).reduce((sum, count) => sum + count, 0), + hostWorktreeCounts: counts, + hostWorktreeIds: idsByHost, + worktreeIds: idsByHost ? Array.from(idsByHost.values()).flat() : undefined + } +} + function repoHeader(project: Repo): Extract { return { ...header(`repo:${project.id}`, project.displayName), @@ -294,17 +307,20 @@ describe('addHostSectionRows', () => { ]) }) - it('copies global pinned and all headers into each mixed-host section', () => { + it('copies global pinned and all headers into each mixed-host section without duplicating pins', () => { const local = repo('local') const ssh = repo('ssh', 'ssh-1') const rows = [ - header(PINNED_GROUP_KEY, 'Pinned'), + pinnedHeader( + new Map([ + ['local', 1], + ['ssh:ssh-1', 1] + ]) + ), pinnedItem('local-pinned', local, PINNED_GROUP_KEY), pinnedItem('ssh-pinned', ssh, PINNED_GROUP_KEY), header('all', 'All'), - pinnedItem('local-pinned', local, 'all'), item('local-normal', local), - pinnedItem('ssh-pinned', ssh, 'all'), item('ssh-normal', ssh) ] @@ -329,19 +345,143 @@ describe('addHostSectionRows', () => { 'pinned', 'pinned:local-pinned', 'all', - 'all:local-pinned', 'repo:local:local-normal', 'host:ssh:ssh-1', 'pinned', 'pinned:ssh-pinned', 'all', - 'all:ssh-pinned', 'repo:ssh:ssh-normal' ]) expect(sectioned.filter((row) => row.type === 'host-header')).toMatchObject([ { label: 'Local Mac', count: 2 }, { label: 'Builder', count: 2 } ]) + expect( + sectioned.filter((row) => row.type === 'header' && row.key === PINNED_GROUP_KEY) + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ count: 1, hostId: 'local' }), + expect.objectContaining({ count: 1, hostId: 'ssh:ssh-1' }) + ]) + ) + }) + + it('keeps collapsed pinned rows attributable to their owning hosts', () => { + const rows = [ + pinnedHeader( + new Map([ + ['local', 1], + ['ssh:ssh-1', 2] + ]) + ) + ] + + const sectioned = addHostSectionRows({ + rows, + hostOptions: [ + { + id: 'local', + kind: 'local', + label: 'Local Mac', + detail: 'This computer', + health: 'local' + }, + { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' } + ], + workspaceHostScope: 'all', + defaultHostId: 'local', + collapsedHostKeys: new Set(['host:ssh:ssh-1']) + }) + + expect(sectioned.map(rowKey)).toEqual(['host:local', 'pinned', 'host:ssh:ssh-1']) + expect(sectioned.filter((row) => row.type === 'host-header')).toMatchObject([ + { hostId: 'local', collapsed: false, count: 1 }, + { hostId: 'ssh:ssh-1', collapsed: true, count: 2 } + ]) + expect(sectioned.filter((row) => row.type === 'header')).toMatchObject([ + { key: PINNED_GROUP_KEY, count: 1, hostId: 'local' } + ]) + }) + + it('does not double-count a collapsed pinned header and its natural duplicate row', () => { + const local = repo('local') + const ssh = repo('ssh', 'ssh-1') + const rows = [ + pinnedHeader(new Map([['local', 1]]), new Map([['local', ['wt-1']]])), + { ...header('all', 'All'), count: 1, worktreeIds: ['wt-1'] }, + item('wt-1', local), + repoHeader(ssh), + item('ssh-wt', ssh) + ] + + const sectioned = addHostSectionRows({ + rows, + hostOptions: [ + { + id: 'local', + kind: 'local', + label: 'Local Mac', + detail: 'This computer', + health: 'local' + }, + { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' } + ], + workspaceHostScope: 'all', + defaultHostId: 'local' + }) + + expect( + sectioned.find((row) => row.type === 'host-header' && row.hostId === 'local') + ).toMatchObject({ count: 1 }) + }) + + it('localizes collapsed natural headers even when the hidden rows are owned by one host', () => { + const ssh = repo('ssh', 'ssh-1') + const localHostId = 'local' as const + const rows = [ + { + ...header('all', 'All'), + count: 1, + hostWorktreeCounts: new Map([[localHostId, 1]]), + hostWorktreeIds: new Map([[localHostId, ['local-wt']]]), + worktreeIds: ['local-wt'], + runningCount: 1, + hostRunningCounts: new Map([[localHostId, 1]]) + }, + repoHeader(ssh), + item('ssh-wt', ssh) + ] + + const sectioned = addHostSectionRows({ + rows, + hostOptions: [ + { + id: 'local', + kind: 'local', + label: 'Local Mac', + detail: 'This computer', + health: 'local' + }, + { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' } + ], + workspaceHostScope: 'all', + defaultHostId: 'local' + }) + + expect(sectioned.map(rowKey)).toEqual([ + 'host:local', + 'all', + 'host:ssh:ssh-1', + 'repo:ssh', + 'ssh-wt' + ]) + expect(sectioned.filter((row) => row.type === 'host-header')).toMatchObject([ + { hostId: 'local', count: 1 }, + { hostId: 'ssh:ssh-1', count: 1 } + ]) + expect(sectioned.filter((row) => row.type === 'header' && row.key === 'all')).toMatchObject([ + { hostId: 'local', count: 1, runningCount: 1, worktreeIds: ['local-wt'] } + ]) }) it('groups explicitly runtime-owned repos under their owner host, not the focused host', () => { diff --git a/src/renderer/src/components/sidebar/host-section-rows.ts b/src/renderer/src/components/sidebar/host-section-rows.ts index bfa18c3aaa9..ff58da06023 100644 --- a/src/renderer/src/components/sidebar/host-section-rows.ts +++ b/src/renderer/src/components/sidebar/host-section-rows.ts @@ -11,7 +11,6 @@ import type { ExecutionHostHealth } from '../../../../shared/execution-host-regi import type { RuntimeCompatVerdict } from '../../../../shared/protocol-compat' import type { SshConnectionStatus } from '../../../../shared/ssh-types' import type { FolderWorkspace, ProjectGroup, Repo } from '../../../../shared/types' -import { PINNED_GROUP_KEY } from './worktree-list-groups' import type { Row } from './worktree-list-groups' export type HostHeaderRow = { @@ -98,19 +97,28 @@ function countWorktreeRows(rows: readonly Row[]): number { // while a visibly populated project sits right under it. let count = 0 const seenWorktreeIds = new Set() - let pendingHeaderCount: number | null = null + let pendingHeader: Extract | null = null let pendingHeaderHadItems = false const flushHeader = (): void => { - if (pendingHeaderCount !== null && !pendingHeaderHadItems) { - count += pendingHeaderCount + if (pendingHeader && !pendingHeaderHadItems) { + if (pendingHeader.worktreeIds) { + for (const worktreeId of pendingHeader.worktreeIds) { + if (!seenWorktreeIds.has(worktreeId)) { + count += 1 + seenWorktreeIds.add(worktreeId) + } + } + } else { + count += pendingHeader.count + } } - pendingHeaderCount = null + pendingHeader = null pendingHeaderHadItems = false } for (const row of rows) { if (row.type === 'header') { flushHeader() - pendingHeaderCount = row.key === PINNED_GROUP_KEY ? null : row.count + pendingHeader = row continue } if (row.type === 'item') { @@ -118,13 +126,47 @@ function countWorktreeRows(rows: readonly Row[]): number { count += 1 seenWorktreeIds.add(row.worktree.id) } - pendingHeaderHadItems = pendingHeaderCount !== null + pendingHeaderHadItems = pendingHeader !== null } } flushHeader() return count } +function localizePendingRowsForHost( + rows: readonly Extract[], + hostId: ExecutionHostId +): Extract[] { + const localized: Extract[] = [] + for (const row of rows) { + if (!row.hostWorktreeCounts) { + localized.push(row) + continue + } + const count = row.hostWorktreeCounts.get(hostId) + if (count !== undefined && count > 0) { + const runningCount = row.hostRunningCounts?.get(hostId) + localized.push({ + ...row, + count, + hostId, + worktreeIds: row.hostWorktreeIds?.get(hostId) ?? row.worktreeIds, + runningCount: runningCount && runningCount > 0 ? runningCount : undefined + }) + } + } + return localized +} + +function getPendingRowsKey(rows: readonly Extract[]): string { + return rows + .map( + (pendingRow) => + `${pendingRow.key}:${pendingRow.count}:${pendingRow.worktreeIds?.join(',') ?? ''}` + ) + .join('\0') +} + export function addHostSectionRows(args: { rows: readonly Row[] hostOptions: readonly HostSectionOption[] @@ -160,18 +202,50 @@ export function addHostSectionRows(args: { let pendingRows: Extract[] = [] let pendingRowsWereUsed = false const pendingRowsKeyByHostId = new Map() + const flushUnusedPendingRows = (): void => { + if (pendingRows.length === 0 || pendingRowsWereUsed) { + return + } + const hostScopedRow = pendingRows.some((row) => row.hostWorktreeCounts) + if (!hostScopedRow) { + globalRows.push(...pendingRows) + return + } + for (const row of pendingRows) { + for (const [hostId, count] of row.hostWorktreeCounts ?? []) { + if (count <= 0) { + continue + } + const hostRows = rowsByHostId.get(hostId) ?? [] + const hostIds = row.hostWorktreeIds?.get(hostId) + const runningCount = row.hostRunningCounts?.get(hostId) + hostRows.push({ + ...row, + count, + hostId, + worktreeIds: hostIds ?? row.worktreeIds, + runningCount: runningCount && runningCount > 0 ? runningCount : undefined + }) + rowsByHostId.set(hostId, hostRows) + } + } + } for (const row of args.rows) { const rowHostId = getRowHostId(row, args.defaultHostId) if (rowHostId) { const hostRows = rowsByHostId.get(rowHostId) ?? [] if (pendingRows.length > 0) { - const pendingRowsKey = pendingRows.map((pendingRow) => pendingRow.key).join('\0') - if (pendingRowsKeyByHostId.get(rowHostId) !== pendingRowsKey) { - hostRows.push(...pendingRows) + const localizedPendingRows = localizePendingRowsForHost(pendingRows, rowHostId) + const pendingRowsKey = getPendingRowsKey(localizedPendingRows) + if ( + localizedPendingRows.length > 0 && + pendingRowsKeyByHostId.get(rowHostId) !== pendingRowsKey + ) { + hostRows.push(...localizedPendingRows) pendingRowsKeyByHostId.set(rowHostId, pendingRowsKey) } - pendingRowsWereUsed = true + pendingRowsWereUsed = pendingRowsWereUsed || localizedPendingRows.length > 0 } hostRows.push(row) rowsByHostId.set(rowHostId, hostRows) @@ -180,6 +254,7 @@ export function addHostSectionRows(args: { // Why: status/"All" headers describe the rows that follow. Buffer them // for every host-owned run so host remains above the existing grouping. if (row.type === 'header') { + flushUnusedPendingRows() pendingRows = [row] pendingRowsWereUsed = false } else { @@ -187,9 +262,7 @@ export function addHostSectionRows(args: { } } - if (pendingRows.length > 0 && !pendingRowsWereUsed) { - globalRows.push(...pendingRows) - } + flushUnusedPendingRows() const hostOrder: ExecutionHostId[] = [] for (const host of args.hostOptions) { diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts index c349dd6eba0..0279a9d63ed 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts @@ -140,51 +140,32 @@ describe('runSleepWorktree', () => { expect(requestAnimationFrame).toHaveBeenCalledTimes(1) }) - it('anchors sleep restoration to the primary duplicate row', async () => { - let frameCount = 0 - const requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { - frameCount += 1 - if (frameCount === 1) { - callback(0) - } - return frameCount - }) + it('anchors sleep restoration to the natural duplicate row when no primary row is marked', async () => { + const requestAnimationFrame = vi.fn(() => 1) const scroller = { dispatchEvent: vi.fn(), scrollHeight: 100, scrollTop: 0 } + const pinnedGetBoundingClientRect = vi.fn(() => ({ top: 10 })) + const naturalGetBoundingClientRect = vi.fn(() => ({ top: 42 })) const pinnedRow = { - closest: (selector: string) => - selector === '[data-worktree-virtual-row]' ? pinnedRow : null, - getBoundingClientRect: () => ({ top: 10 }) + getBoundingClientRect: pinnedGetBoundingClientRect } - let naturalTop = 40 const naturalRow = { - closest: (selector: string) => - selector === '[data-worktree-virtual-row]' ? naturalRow : null, - getBoundingClientRect: () => ({ top: naturalTop }) + getBoundingClientRect: naturalGetBoundingClientRect } const pinnedOption = { - dataset: { - worktreeId: 'wt-1', - worktreeRowKey: 'pinned:wt-1', - worktreeSectionKey: 'pinned' - }, + dataset: { worktreeId: 'wt-1', worktreeRowKey: 'pinned:wt-1' }, closest: (selector: string) => selector === '[data-worktree-virtual-row]' ? pinnedRow : null, querySelector: () => null } const naturalOption = { - dataset: { - worktreeId: 'wt-1', - worktreeRowKey: 'all:wt-1', - worktreeSectionKey: 'all' - }, + dataset: { worktreeId: 'wt-1', worktreeRowKey: 'all:wt-1' }, closest: (selector: string) => selector === '[data-worktree-virtual-row]' ? naturalRow : null, - querySelector: (selector: string) => - selector === '[data-worktree-card-active="primary"]' ? {} : null + querySelector: () => null } vi.stubGlobal('document', { querySelector: (selector: string) => @@ -195,13 +176,10 @@ describe('runSleepWorktree', () => { vi.stubGlobal('window', { requestAnimationFrame }) mocks.state.activeWorktreeId = 'wt-1' - mocks.state.setActiveWorktree.mockImplementation(() => { - naturalTop = 45 - }) - await runSleepWorktree('wt-1') - expect(scroller.scrollTop).toBe(5) + expect(naturalGetBoundingClientRect).toHaveBeenCalled() + expect(pinnedGetBoundingClientRect).not.toHaveBeenCalled() }) it('leaves activeWorktreeId alone when sleeping a background worktree', async () => { diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts index c0a34d6ad90..053fece73a2 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts @@ -4,7 +4,6 @@ import { clearWorktreeSleepIntent, markWorktreeSleepIntent } from '@/lib/worktre import { cancelPendingSidebarWorktreeActivation } from '@/lib/sidebar-worktree-activation' import { VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT } from '@/hooks/useVirtualizedScrollAnchor' import { translate } from '@/i18n/i18n' -import { PINNED_GROUP_KEY } from './worktree-list-groups' /** * Shared "sleep worktree" flow (close all panels to free memory / CPU) @@ -29,13 +28,17 @@ function getSidebarWorktreeOptions(worktreeId: string): HTMLElement[] { ) } +function isPinnedSidebarWorktreeOption(element: HTMLElement): boolean { + return element.dataset.worktreeRowKey?.startsWith('pinned:') === true +} + function findPrimarySidebarWorktreeOption(worktreeId: string): HTMLElement | null { const options = getSidebarWorktreeOptions(worktreeId) return ( options.find((element) => element.querySelector('[data-worktree-card-active="primary"]') ) ?? - options.find((element) => element.dataset.worktreeSectionKey !== PINNED_GROUP_KEY) ?? + options.find((element) => !isPinnedSidebarWorktreeOption(element)) ?? options[0] ?? null ) 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 f77ea72b2ee..9d6f230d5cd 100644 --- a/src/renderer/src/components/sidebar/worktree-drag-units.test.ts +++ b/src/renderer/src/components/sidebar/worktree-drag-units.test.ts @@ -70,7 +70,26 @@ describe('getWorktreeDragUnitGroups', () => { ]) }) - it('ignores pinned overlay rows', () => { + it('includes pinned rows when they are the only rendered copy', () => { + const groups = getWorktreeDragUnitGroups([ + header('pinned'), + item('pinned-copy', 0, 'pinned'), + item('other-pinned', 0, 'pinned') + ]) + + expect(groups).toEqual([ + { + key: 'pinned', + worktreeIds: ['pinned-copy', 'other-pinned'], + units: [ + { worktreeId: 'pinned-copy', worktreeIds: ['pinned-copy'] }, + { worktreeId: 'other-pinned', worktreeIds: ['other-pinned'] } + ] + } + ]) + }) + + it('uses natural drag units when pinned rows have duplicate natural copies', () => { const groups = getWorktreeDragUnitGroups([ header('pinned'), item('pinned-copy', 0, 'pinned'), diff --git a/src/renderer/src/components/sidebar/worktree-drag-units.ts b/src/renderer/src/components/sidebar/worktree-drag-units.ts index 4707423aae3..be0166e6142 100644 --- a/src/renderer/src/components/sidebar/worktree-drag-units.ts +++ b/src/renderer/src/components/sidebar/worktree-drag-units.ts @@ -18,6 +18,11 @@ export function getWorktreeDragUnitGroups( ): WorktreeDragUnitGroup[] { const groups: WorktreeDragUnitGroup[] = [] let current: { key: string; units: WorktreeDragUnitGroup['units'] } | null = null + const naturalWorktreeIds = new Set( + rows.flatMap((row) => + row.type === 'item' && row.sectionKey !== PINNED_GROUP_KEY ? [row.worktree.id] : [] + ) + ) for (const row of rows) { if (row.type === 'header') { @@ -37,7 +42,7 @@ export function getWorktreeDragUnitGroups( ) { continue } - if (row.sectionKey === PINNED_GROUP_KEY) { + if (row.sectionKey === PINNED_GROUP_KEY && naturalWorktreeIds.has(row.worktree.id)) { continue } if (!current) { 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 cb667f6f1d5..610716fc3c8 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts @@ -11,6 +11,7 @@ import { getLineageGroupKey, getLineageRenderInfo, getPRGroupKey, + PINNED_GROUP_KEY, type PendingCreationRef } from './worktree-list-groups' import { @@ -215,7 +216,7 @@ describe('buildRows with pinned worktrees', () => { const rows = buildRows('none', [unpinned1, pinned, unpinned2], repoMap, null, new Set()) expect(rows[0]).toMatchObject({ type: 'header', key: 'pinned', label: 'Pinned' }) expect(rows[1]).toMatchObject({ type: 'item', worktree: { id: 'wt-pinned' } }) - expect(rows[2]).toMatchObject({ type: 'header', key: 'all', label: 'All', count: 3 }) + expect(rows[2]).toMatchObject({ type: 'header', key: 'all', label: 'All', count: 2 }) expect(rows[2]).toMatchObject({ type: 'header', icon: ALL_GROUP_META.icon }) }) @@ -229,30 +230,55 @@ describe('buildRows with pinned worktrees', () => { ]) }) - it('keeps pinned worktrees above the All group', () => { + it('moves pinned worktrees out of the All group', () => { const rows = buildRows('none', [unpinned1, pinned, unpinned2], repoMap, null, new Set()) expect(rows).toMatchObject([ { type: 'header', key: 'pinned' }, { type: 'item', worktree: { id: 'wt-pinned' } }, - { type: 'header', key: 'all', count: 3 }, + { type: 'header', key: 'all', count: 2 }, { type: 'item', worktree: { id: 'wt-1' } }, - { type: 'item', worktree: { id: 'wt-pinned' } }, { type: 'item', worktree: { id: 'wt-2' } } ]) }) + it('duplicates pinned worktrees into All when the policy allows it', () => { + const rows = buildRows( + 'none', + [unpinned1, pinned, unpinned2], + repoMap, + null, + new Set(), + undefined, + undefined, + undefined, + {}, + undefined, + false, + { showPinnedWorktreesInGroups: true } as never + ) + + expect(rows).toMatchObject([ + { type: 'header', key: 'pinned' }, + { type: 'item', sectionKey: PINNED_GROUP_KEY, worktree: { id: 'wt-pinned' } }, + { type: 'header', key: 'all', count: 3 }, + { type: 'item', sectionKey: 'all', worktree: { id: 'wt-1' } }, + { type: 'item', sectionKey: 'all', worktree: { id: 'wt-pinned' } }, + { type: 'item', sectionKey: 'all', worktree: { id: 'wt-2' } } + ]) + }) + it('collapses the All group in groupBy none', () => { const rows = buildRows('none', [unpinned1, pinned, unpinned2], repoMap, null, new Set(['all'])) expect(rows).toMatchObject([ { type: 'header', key: 'pinned' }, { type: 'item', worktree: { id: 'wt-pinned' } }, - { type: 'header', key: 'all', count: 3 } + { type: 'header', key: 'all', count: 2 } ]) }) - it('emits status headers for all matching worktrees in groupBy workspace-status', () => { + it('emits status headers for unpinned matching worktrees in groupBy workspace-status', () => { const rows = buildRows( 'workspace-status', [unpinned1, pinned, unpinned2], @@ -264,21 +290,45 @@ describe('buildRows with pinned worktrees', () => { type: 'header', key: 'workspace-status:in-progress', label: 'In progress', - count: 3 + count: 2 }) expect(rows[3]).toMatchObject({ type: 'item', worktree: { id: 'wt-1' } }) - expect(rows[4]).toMatchObject({ type: 'item', worktree: { id: 'wt-pinned' } }) - expect(rows[5]).toMatchObject({ type: 'item', worktree: { id: 'wt-2' } }) + expect(rows[4]).toMatchObject({ type: 'item', worktree: { id: 'wt-2' } }) }) - it('keeps pinned items in regular groups in pr-status mode', () => { + it('duplicates pinned worktrees into status groups when the policy allows it', () => { + const rows = buildRows( + 'workspace-status', + [unpinned1, pinned], + repoMap, + null, + new Set(), + undefined, + undefined, + undefined, + {}, + undefined, + false, + { showPinnedWorktreesInGroups: true } as never + ) + + expect(rows).toMatchObject([ + { type: 'header', key: 'pinned', count: 1 }, + { type: 'item', sectionKey: PINNED_GROUP_KEY, worktree: { id: 'wt-pinned' } }, + { type: 'header', key: 'workspace-status:in-progress', count: 2 }, + { type: 'item', sectionKey: 'workspace-status:in-progress', worktree: { id: 'wt-1' } }, + { type: 'item', sectionKey: 'workspace-status:in-progress', worktree: { id: 'wt-pinned' } } + ]) + }) + + it('moves pinned items out of regular groups in pr-status mode', () => { const rows = buildRows('pr-status', [unpinned1, pinned], repoMap, null, new Set()) const pinnedHeader = rows.find((r) => r.type === 'header' && r.key === 'pinned') expect(pinnedHeader).toBeDefined() const prGroup = rows.filter((r) => r.type === 'header' && r.key.startsWith('pr:')) for (const header of prGroup) { if (header.type === 'header') { - expect(header.count).toBe(2) + expect(header.count).toBe(1) } } }) @@ -304,20 +354,14 @@ describe('buildRows with pinned worktrees', () => { ) expect(rows[0]).toMatchObject({ type: 'header', key: 'pinned' }) expect(rows[1]).toMatchObject({ type: 'header', key: 'workspace-status:in-progress' }) - expect(rows[2]).toMatchObject({ type: 'item', worktree: { id: 'wt-pinned' } }) - expect(rows[3]).toMatchObject({ type: 'item', worktree: { id: 'wt-1' } }) + expect(rows[2]).toMatchObject({ type: 'item', worktree: { id: 'wt-1' } }) }) - it('keeps status sections complete when all worktrees are pinned', () => { + it('omits status sections when all matching worktrees are pinned', () => { const allPinned = { ...unpinned1, isPinned: true } const rows = buildRows('workspace-status', [pinned, allPinned], repoMap, null, new Set()) - expect(rows.filter((r) => r.type === 'header')).toHaveLength(2) + expect(rows.filter((r) => r.type === 'header')).toHaveLength(1) expect(rows[0]).toMatchObject({ type: 'header', key: 'pinned', count: 2 }) - expect(rows[3]).toMatchObject({ - type: 'header', - key: 'workspace-status:in-progress', - count: 2 - }) }) it('preserves repo display casing in group labels', () => { @@ -798,7 +842,124 @@ describe('buildRows with pinned worktrees', () => { expect(rows.some((row) => row.type === 'imported-worktrees-card')).toBe(false) }) - it('emits imported worktree cards in repo groups when visible rows are pinned', () => { + it('places non-repo imported fallbacks after each repo last pinned row when expanded', () => { + 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( + 'none', + [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.map((row) => + row.type === 'item' + ? row.worktree.id + : row.type === 'imported-worktrees-card' + ? `${row.placement}:${row.repo.id}` + : row.key + ) + ).toEqual([ + 'pinned', + 'repo-1-pinned-a', + 'repo-2-pinned', + 'pinned-fallback:repo-2', + 'repo-1-pinned-b', + 'pinned-fallback:repo-1' + ]) + }) + + it('places collapsed non-repo imported fallbacks after Pinned in pinned repo order', () => { + 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( + 'workspace-status', + [pinnedOneA, pinnedTwo, pinnedOneB], + new Map([ + [repo.id, repo], + [repoTwo.id, repoTwo] + ]), + null, + new Set([PINNED_GROUP_KEY]), + 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_GROUP_KEY, count: 3 }, + { type: 'imported-worktrees-card', placement: 'pinned-fallback', repo: { id: repo.id } }, + { type: 'imported-worktrees-card', placement: 'pinned-fallback', repo: { id: repoTwo.id } } + ]) + }) + + it('emits imported worktree cards in repo groups when visible worktrees are pinned', () => { const repoTwo: Repo = { ...repo, id: 'repo-2', displayName: 'auth-service' } const pinnedOneA = { ...worktree, id: 'repo-1-pinned-a', isPinned: true } const pinnedTwo = { @@ -853,24 +1014,112 @@ describe('buildRows with pinned worktrees', () => { placement: 'repo-group' } ]) + expect( + rows.filter((row) => row.type === 'item' && row.sectionKey !== PINNED_GROUP_KEY) + ).toEqual([]) expect(rows).toEqual( expect.arrayContaining([ expect.objectContaining({ type: 'item', + sectionKey: PINNED_GROUP_KEY, worktree: expect.objectContaining({ id: 'repo-1-pinned-a' }) }), expect.objectContaining({ type: 'item', + sectionKey: PINNED_GROUP_KEY, worktree: expect.objectContaining({ id: 'repo-1-pinned-b' }) }), expect.objectContaining({ type: 'item', + sectionKey: PINNED_GROUP_KEY, worktree: expect.objectContaining({ id: 'repo-2-pinned' }) }) ]) ) }) + it('duplicates pinned worktrees into repo groups when the policy allows it', () => { + const pinnedWorktree = { ...worktree, id: 'wt-pinned', isPinned: true } + const rows = buildRows( + 'repo', + [pinnedWorktree], + repoMap, + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map([[pinnedWorktree.id, pinnedWorktree]]), + false, + { showPinnedWorktreesInGroups: true } as never, + [], + new Set(), + new Map([[repo.id, { repo, hiddenWorktrees: [makeDetectedWorktree()] }]]) + ) + + expect(rows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'item', + sectionKey: PINNED_GROUP_KEY, + worktree: expect.objectContaining({ id: 'wt-pinned' }) + }), + expect.objectContaining({ + type: 'item', + sectionKey: `repo:${repo.id}`, + worktree: expect.objectContaining({ id: 'wt-pinned' }) + }) + ]) + ) + }) + + it('suppresses duplicate-mode imported fallback only when a natural anchor renders', () => { + const pinnedWorktree = { ...worktree, id: 'wt-pinned', isPinned: true } + const imported = new Map([[repo.id, { repo, hiddenWorktrees: [makeDetectedWorktree()] }]]) + const expanded = buildRows( + 'none', + [pinnedWorktree], + repoMap, + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map([[pinnedWorktree.id, pinnedWorktree]]), + false, + { showPinnedWorktreesInGroups: true } as never, + [], + new Set(), + imported + ) + const collapsedAll = buildRows( + 'none', + [pinnedWorktree], + repoMap, + null, + new Set(['all']), + undefined, + undefined, + undefined, + {}, + new Map([[pinnedWorktree.id, pinnedWorktree]]), + false, + { showPinnedWorktreesInGroups: true } as never, + [], + new Set(), + imported + ) + + expect(expanded.some((row) => row.type === 'imported-worktrees-card')).toBe(false) + expect(collapsedAll).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: 'imported-worktrees-card', 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( @@ -922,9 +1171,11 @@ describe('buildRows with pinned worktrees', () => { expect(rows).toMatchObject([ { type: 'header', key: 'pinned' }, { type: 'header', key: 'repo:repo-1' }, - { type: 'imported-worktrees-card', placement: 'repo-group' }, - { type: 'item', worktree: { id: 'wt-pinned' } } + { type: 'imported-worktrees-card', placement: 'repo-group' } ]) + expect( + rows.filter((row) => row.type === 'item' && row.sectionKey !== PINNED_GROUP_KEY) + ).toEqual([]) }) it('groups folder-mode workspaces under their folder name', () => { @@ -1292,6 +1543,63 @@ describe('project groups', () => { }) }) + it('attaches activity summaries to Project Group headers', () => { + const group: ProjectGroup = { + id: 'group-1', + name: 'Platform', + parentPath: '/platform', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + const groupedRepo: Repo = { ...repo, projectGroupId: group.id } + const hostRunningCounts = new Map([['local', 1] as const]) + + const rows = buildRows( + 'repo', + [], + new Map([[groupedRepo.id, groupedRepo]]), + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map(), + false, + undefined, + [group], + new Set([groupedRepo.id]), + new Map(), + [], + undefined, + [], + undefined, + 'local', + 'single-location', + new Map([ + [ + 'project-group:group-1', + { + runningCount: 1, + hostRunningCounts + } + ] + ]) + ) + + expect(rows[0]).toMatchObject({ + type: 'header', + key: 'project-group:group-1', + runningCount: 1, + hostRunningCounts + }) + }) + it('does not resurrect filtered repos as empty Project Group headers', () => { const group: ProjectGroup = { id: 'group-1', diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts index 190ef5bcebc..4f8c863ec8a 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts @@ -34,11 +34,24 @@ import type { AppState } from '../../store/types' import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from '../../store/slices/github-cache-key' import { getRepoDisplayLabelsByPath } from '@/lib/repo-display-labels' import { translate } from '@/i18n/i18n' -import { getExecutionHostLabel, getRepoExecutionHostId } from '../../../../shared/execution-host' +import type { WorktreeSectionActivitySummary } from './worktree-section-activity' +import { + getExecutionHostLabel, + LOCAL_EXECUTION_HOST_ID, + getRepoExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' export { branchName } export type WorktreeGroupBy = 'none' | 'workspace-status' | 'repo' | 'pr-status' +export type PinnedWorktreeDisplayPolicy = 'single-location' | 'duplicate-in-groups' + +export function getPinnedWorktreeDisplayPolicy( + settings?: { showPinnedWorktreesInGroups?: boolean } | null +): PinnedWorktreeDisplayPolicy { + return settings?.showPinnedWorktreesInGroups === true ? 'duplicate-in-groups' : 'single-location' +} export type GroupHeaderRow = { type: 'header' @@ -50,6 +63,12 @@ export type GroupHeaderRow = { repo?: Repo projectGroup?: ProjectGroup | { id: null; name: 'Ungrouped'; tabOrder: number } projectGroupDepth?: number + hostId?: ExecutionHostId + hostWorktreeCounts?: ReadonlyMap + hostWorktreeIds?: ReadonlyMap + worktreeIds?: readonly string[] + runningCount?: number + hostRunningCounts?: ReadonlyMap } export type WorktreeRow = { @@ -370,23 +389,43 @@ export function getPRGroupKey( /** * Emit a "Pinned" header + its items into `result`. * - * Why: pinned is a shortcut overlay, not the worktree's canonical grouping. - * Normal sections still include pinned worktrees so labels like "All" and - * "In progress" remain literal. + * Why: the dedicated Pinned section is always present for pinned worktrees; + * the display policy decides whether their natural group rows also render. */ function emitPinnedGroup( worktrees: Worktree[], repoMap: Map, + defaultHostId: ExecutionHostId, collapsedGroups: Set, - visibleUnpinnedRepoIds: ReadonlySet, + renderedNaturalAnchorRepoIds: ReadonlySet, importedWorktreesByRepo: ReadonlyMap, allowImportedFallback: boolean, - result: Row[] + result: Row[], + activitySummary?: WorktreeSectionActivitySummary ): void { const pinned = worktrees.filter((w) => w.isPinned) if (pinned.length === 0) { return } + const hostWorktreeCounts = new Map() + const hostWorktreeIds = new Map() + const pinnedRepoOrder: string[] = [] + const seenPinnedRepoIds = new Set() + for (const worktree of pinned) { + const repo = repoMap.get(worktree.repoId) + const hostId = + repo && (repo.connectionId || repo.executionHostId) + ? getRepoExecutionHostId(repo) + : defaultHostId + hostWorktreeCounts.set(hostId, (hostWorktreeCounts.get(hostId) ?? 0) + 1) + const hostIds = hostWorktreeIds.get(hostId) ?? [] + hostIds.push(worktree.id) + hostWorktreeIds.set(hostId, hostIds) + if (!seenPinnedRepoIds.has(worktree.repoId)) { + pinnedRepoOrder.push(worktree.repoId) + seenPinnedRepoIds.add(worktree.repoId) + } + } result.push({ type: 'header', @@ -394,9 +433,21 @@ function emitPinnedGroup( label: PINNED_GROUP_META.label, count: pinned.length, tone: PINNED_GROUP_META.tone, - icon: PINNED_GROUP_META.icon + icon: PINNED_GROUP_META.icon, + hostWorktreeCounts, + hostWorktreeIds, + worktreeIds: pinned.map((worktree) => worktree.id), + runningCount: activitySummary?.runningCount, + hostRunningCounts: activitySummary?.hostRunningCounts }) - if (!collapsedGroups.has(PINNED_GROUP_KEY)) { + if (collapsedGroups.has(PINNED_GROUP_KEY)) { + for (const repoId of pinnedRepoOrder) { + const candidate = importedWorktreesByRepo.get(repoId) + if (allowImportedFallback && candidate && !renderedNaturalAnchorRepoIds.has(repoId)) { + result.push(buildImportedWorktreesCardRow(candidate, 'pinned-fallback')) + } + } + } else { const lastPinnedIndexByRepoId = new Map() pinned.forEach((worktree, index) => lastPinnedIndexByRepoId.set(worktree.repoId, index)) for (const [index, worktree] of pinned.entries()) { @@ -416,7 +467,7 @@ function emitPinnedGroup( if ( allowImportedFallback && candidate && - !visibleUnpinnedRepoIds.has(worktree.repoId) && + !renderedNaturalAnchorRepoIds.has(worktree.repoId) && lastPinnedIndexByRepoId.get(worktree.repoId) === index ) { result.push(buildImportedWorktreesCardRow(candidate, 'pinned-fallback')) @@ -610,6 +661,112 @@ function getMixedHostContextLabels( return uniqueLabels.size > 1 ? labelsByRepoId : undefined } +function getWorktreeHostId( + worktree: Worktree, + repoMap: Map, + defaultHostId: ExecutionHostId +): ExecutionHostId { + const repo = repoMap.get(worktree.repoId) + return repo && (repo.connectionId || repo.executionHostId) + ? getRepoExecutionHostId(repo) + : defaultHostId +} + +function getHostWorktreeCounts( + worktrees: readonly Worktree[], + repoMap: Map, + defaultHostId: ExecutionHostId +): Map | undefined { + if (worktrees.length === 0) { + return undefined + } + const counts = new Map() + const seenWorktreeIds = new Set() + for (const worktree of worktrees) { + if (seenWorktreeIds.has(worktree.id)) { + continue + } + seenWorktreeIds.add(worktree.id) + const hostId = getWorktreeHostId(worktree, repoMap, defaultHostId) + counts.set(hostId, (counts.get(hostId) ?? 0) + 1) + } + return counts +} + +function getHostWorktreeIds( + worktrees: readonly Worktree[], + repoMap: Map, + defaultHostId: ExecutionHostId +): Map | undefined { + if (worktrees.length === 0) { + return undefined + } + const idsByHost = new Map() + const seenWorktreeIds = new Set() + for (const worktree of worktrees) { + if (seenWorktreeIds.has(worktree.id)) { + continue + } + seenWorktreeIds.add(worktree.id) + const hostId = getWorktreeHostId(worktree, repoMap, defaultHostId) + const ids = idsByHost.get(hostId) ?? [] + ids.push(worktree.id) + idsByHost.set(hostId, ids) + } + return idsByHost +} + +function getRenderedNaturalAnchorRepoIds({ + groupBy, + worktrees, + repoMap, + prCache, + collapsedGroups, + workspaceStatuses, + settings, + projectGrouping +}: { + groupBy: WorktreeGroupBy + worktrees: readonly Worktree[] + repoMap: Map + prCache: Record | null + collapsedGroups: ReadonlySet + workspaceStatuses: readonly WorkspaceStatusDefinition[] + settings?: AppState['settings'] + projectGrouping?: ProjectGroupingModel +}): Set { + const renderedRepoIds = new Set() + if (groupBy === 'none') { + if (!collapsedGroups.has(ALL_GROUP_KEY)) { + for (const worktree of worktrees) { + renderedRepoIds.add(worktree.repoId) + } + } + return renderedRepoIds + } + if (groupBy === 'repo') { + for (const worktree of worktrees) { + renderedRepoIds.add(worktree.repoId) + } + return renderedRepoIds + } + for (const worktree of worktrees) { + const groupKey = getGroupKeyForWorktree( + groupBy, + worktree, + repoMap, + prCache, + workspaceStatuses, + settings, + projectGrouping + ) + if (groupKey && !collapsedGroups.has(groupKey)) { + renderedRepoIds.add(worktree.repoId) + } + } + return renderedRepoIds +} + function orderMainWorktreeFirst(worktrees: Worktree[]): Worktree[] { const mainWorktrees = worktrees.filter((worktree) => worktree.isMainWorktree) if (mainWorktrees.length === 0) { @@ -771,7 +928,10 @@ export function buildRows( pendingCreations: readonly PendingCreationRef[] = [], projectGrouping?: ProjectGroupingModel, folderWorkspaces: readonly FolderWorkspace[] = [], - hostLabelById?: ReadonlyMap + hostLabelById?: ReadonlyMap, + defaultHostId: ExecutionHostId = LOCAL_EXECUTION_HOST_ID, + pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy = getPinnedWorktreeDisplayPolicy(settings), + sectionActivitySummaries?: ReadonlyMap ): Row[] { const result: Row[] = [] const projectIndex = buildProjectGroupingIndex(projectGrouping) @@ -792,34 +952,55 @@ export function buildRows( } } - 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 naturalWorktrees = + pinnedDisplayPolicy === 'duplicate-in-groups' + ? worktrees + : worktrees.filter((worktree) => !worktree.isPinned) + const renderedNaturalAnchorRepoIds = getRenderedNaturalAnchorRepoIds({ + groupBy, + worktrees: naturalWorktrees, + repoMap, + prCache, + collapsedGroups, + workspaceStatuses, + settings, + projectGrouping + }) + const getSectionActivity = ( + key: string + ): Pick => { + const summary = sectionActivitySummaries?.get(key) + return summary && summary.runningCount > 0 + ? { runningCount: summary.runningCount, hostRunningCounts: summary.hostRunningCounts } + : {} + } emitPinnedGroup( worktrees, repoMap, + defaultHostId, collapsedGroups, - visibleUnpinnedRepoIds, + renderedNaturalAnchorRepoIds, importedWorktreesByRepo, groupBy !== 'repo', - result + result, + sectionActivitySummaries?.get(PINNED_GROUP_KEY) ) - if (groupBy === 'none') { - if (worktrees.length > 0) { + if (naturalWorktrees.length > 0) { result.push({ type: 'header', key: ALL_GROUP_KEY, label: ALL_GROUP_META.label, - count: worktrees.length, + count: naturalWorktrees.length, tone: ALL_GROUP_META.tone, - icon: ALL_GROUP_META.icon + icon: ALL_GROUP_META.icon, + hostWorktreeCounts: getHostWorktreeCounts(naturalWorktrees, repoMap, defaultHostId), + hostWorktreeIds: getHostWorktreeIds(naturalWorktrees, repoMap, defaultHostId), + worktreeIds: naturalWorktrees.map((worktree) => worktree.id), + ...getSectionActivity(ALL_GROUP_KEY) }) if (!collapsedGroups.has(ALL_GROUP_KEY)) { - appendWorktreeRows(result, worktrees, repoMap, lineageById, worktreeMap, { + appendWorktreeRows(result, naturalWorktrees, repoMap, lineageById, worktreeMap, { nestLineage, collapsedGroups, groupDepth: 0, @@ -831,7 +1012,7 @@ export function buildRows( } const grouped = new Map() - for (const w of worktrees) { + for (const w of naturalWorktrees) { let key: string let label: string let repo: Repo | undefined @@ -882,7 +1063,7 @@ export function buildRows( for (const [repoId, candidate] of importedWorktreesByRepo) { const grouping = getProjectGroupingForRepo(repoId, repoMap, projectIndex) const key = grouping.key - if (!grouped.has(key) && !visiblePinnedRepoIds.has(repoId)) { + if (!grouped.has(key)) { grouped.set(key, { label: grouping.label, items: [], @@ -984,7 +1165,11 @@ export function buildRows( label: definition?.label ?? workspaceStatus, count: group.items.length, tone: meta.tone, - icon: meta.icon + icon: meta.icon, + hostWorktreeCounts: getHostWorktreeCounts(group.items, repoMap, defaultHostId), + hostWorktreeIds: getHostWorktreeIds(group.items, repoMap, defaultHostId), + worktreeIds: group.items.map((worktree) => worktree.id), + ...getSectionActivity(key) } })() : (() => { @@ -996,7 +1181,11 @@ export function buildRows( label: meta.label, count: group.items.length, tone: meta.tone, - icon: meta.icon + icon: meta.icon, + hostWorktreeCounts: getHostWorktreeCounts(group.items, repoMap, defaultHostId), + hostWorktreeIds: getHostWorktreeIds(group.items, repoMap, defaultHostId), + worktreeIds: group.items.map((worktree) => worktree.id), + ...getSectionActivity(key) } })() @@ -1128,7 +1317,8 @@ export function buildRows( tone: PROJECT_GROUP_META.tone, icon: PROJECT_GROUP_META.icon, projectGroup, - projectGroupDepth: depth + projectGroupDepth: depth, + ...getSectionActivity(key) }) if (!collapsedGroups.has(key)) { for (const folderWorkspace of folderWorkspacesByProjectGroupId.get(projectGroup.id) ?? []) { 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 2bb4e44a9dd..67f3760c91c 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 @@ -3,6 +3,7 @@ import { canKeepImportedWorktreesHidden, getRenderRowKey, getWorktreeDragGroups, + getWorktreeDragIndexes, renderRowContainsWorktree } from './WorktreeList' import type { Repo, Worktree } from '../../../../shared/types' @@ -59,6 +60,13 @@ const makeWorktreeRow = (id: string): Extract => ({ lineageChildCount: 0 }) +const makePinnedWorktreeRow = (id: string): Extract => ({ + ...makeWorktreeRow(id), + rowKey: `pinned:${id}`, + sectionKey: 'pinned', + worktree: { ...makeWorktree(id), isPinned: true } +}) + const makeImportedCardRow = (): Extract => ({ type: 'imported-worktrees-card', key: 'imported-worktrees-card:repo-group:repo-1', @@ -86,6 +94,29 @@ describe('imported worktree virtual rows', () => { ).toEqual([{ key: 'repo:repo-1', worktreeIds: ['main', 'feature'] }]) }) + it('indexes pinned rows for sidebar drag when they are the only rendered copy', () => { + const rows = [makeHeaderRow('pinned'), makePinnedWorktreeRow('main')] + const { groupIndexByRowKey, groupKeyByRowKey } = getWorktreeDragIndexes(rows) + + expect(getWorktreeDragGroups(rows)).toEqual([{ key: 'pinned', worktreeIds: ['main'] }]) + expect(groupKeyByRowKey.get('pinned:main')).toBe('pinned') + expect(groupIndexByRowKey.get('pinned:main')).toBe(0) + }) + + it('uses natural drag metadata when pinned rows have duplicate natural copies', () => { + const rows = [ + makeHeaderRow('pinned'), + makePinnedWorktreeRow('main'), + makeHeaderRow('all'), + makeWorktreeRow('main') + ] + const { groupKeyByRowKey } = getWorktreeDragIndexes(rows) + + expect(getWorktreeDragGroups(rows)).toEqual([{ key: 'all', worktreeIds: ['main'] }]) + expect(groupKeyByRowKey.has('pinned:main')).toBe(false) + expect(groupKeyByRowKey.get('all:main')).toBe('all') + }) + it('only allows keep-hidden actions for repo-group cards that are not forced visible', () => { expect(canKeepImportedWorktreesHidden(makeImportedCardRow(), undefined)).toBe(true) expect( diff --git a/src/renderer/src/components/sidebar/worktree-section-activity.test.ts b/src/renderer/src/components/sidebar/worktree-section-activity.test.ts index 5de876fedb3..b503ecf25d2 100644 --- a/src/renderer/src/components/sidebar/worktree-section-activity.test.ts +++ b/src/renderer/src/components/sidebar/worktree-section-activity.test.ts @@ -1,10 +1,19 @@ import { describe, expect, it } from 'vitest' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import { makePaneKey } from '../../../../shared/stable-pane-id' -import type { ProjectGroup, Repo, TerminalTab, Worktree } from '../../../../shared/types' +import type { + Project, + ProjectGroup, + ProjectHostSetup, + Repo, + TerminalTab, + Worktree +} from '../../../../shared/types' import { getProjectGroupHeaderKey, PINNED_GROUP_KEY, + type PinnedWorktreeDisplayPolicy, + type ProjectGroupingModel, type WorktreeGroupBy } from './worktree-list-groups' import { @@ -91,6 +100,7 @@ function makeState( agentStatusByPaneKey: {}, migrationUnsupportedByPtyId: {}, retainedAgentsByPaneKey: {}, + runtimeAgentOrchestrationByPaneKey: {}, ...overrides } } @@ -100,12 +110,18 @@ function buildSummaries({ worktrees, repos, projectGroups = [], + projectGrouping, + pinnedDisplayPolicy, + defaultHostId, state }: { groupBy?: WorktreeGroupBy worktrees: readonly Worktree[] repos: readonly Repo[] projectGroups?: readonly ProjectGroup[] + projectGrouping?: ProjectGroupingModel + pinnedDisplayPolicy?: PinnedWorktreeDisplayPolicy + defaultHostId?: Parameters[0]['defaultHostId'] state: WorktreeSectionActivityState }) { return buildWorktreeSectionActivitySummaries({ @@ -115,6 +131,9 @@ function buildSummaries({ prCache: null, workspaceStatuses: [], projectGroups, + projectGrouping, + pinnedDisplayPolicy, + defaultHostId, state }) } @@ -148,17 +167,20 @@ describe('buildWorktreeSectionActivitySummaries', () => { }) expect(summaries.get(getProjectGroupHeaderKey(parent.id))).toEqual({ - runningCount: 1 + runningCount: 1, + hostRunningCounts: new Map([['local', 1]]) }) expect(summaries.get(getProjectGroupHeaderKey(child.id))).toEqual({ - runningCount: 1 + runningCount: 1, + hostRunningCounts: new Map([['local', 1]]) }) expect(summaries.get(`repo:${repo.id}`)).toEqual({ - runningCount: 1 + runningCount: 1, + hostRunningCounts: new Map([['local', 1]]) }) }) - it('counts pinned workspace activity on pinned and natural headers', () => { + it('counts pinned workspace activity only on the pinned header', () => { const repo = makeRepo({ projectGroupId: 'group-1' }) const worktree = makeWorktree({ repoId: repo.id, isPinned: true }) const now = Date.now() @@ -187,14 +209,170 @@ describe('buildWorktreeSectionActivitySummaries', () => { state }) - expect(summaries.get(PINNED_GROUP_KEY)).toEqual({ - runningCount: 1 + const pinnedSummary = summaries.get(PINNED_GROUP_KEY) + expect(pinnedSummary?.runningCount).toBe(1) + expect(pinnedSummary?.hostRunningCounts).toEqual(new Map([['local', 1]])) + expect(summaries.get(`repo:${repo.id}`)).toBeUndefined() + expect(summaries.get(getProjectGroupHeaderKey('group-1'))).toBeUndefined() + }) + + it('counts pinned activity in natural groups when duplicate display is enabled', () => { + const repo = makeRepo({ projectGroupId: 'group-1' }) + const worktree = makeWorktree({ repoId: repo.id, isPinned: true }) + const now = Date.now() + const entry: AgentStatusEntry = { + state: 'working', + prompt: '', + updatedAt: now, + stateStartedAt: now, + paneKey: makePaneKey('tab-1', LEAF_ID), + stateHistory: [] + } + const state = makeState({ + tabsByWorktree: { + [worktree.id]: [makeTerminalTab({ id: 'tab-1', worktreeId: worktree.id })] + }, + agentStatusEpoch: 1, + agentStatusByPaneKey: { + [entry.paneKey]: entry + } }) - expect(summaries.get(`repo:${repo.id}`)).toEqual({ - runningCount: 1 + + const summaries = buildSummaries({ + worktrees: [worktree], + repos: [repo], + projectGroups: [makeProjectGroup({ id: 'group-1' })], + pinnedDisplayPolicy: 'duplicate-in-groups', + state }) - expect(summaries.get(getProjectGroupHeaderKey('group-1'))).toEqual({ - runningCount: 1 + + expect(summaries.get(PINNED_GROUP_KEY)?.runningCount).toBe(1) + expect(summaries.get(`repo:${repo.id}`)?.runningCount).toBe(1) + expect(summaries.get(`repo:${repo.id}`)?.hostRunningCounts).toEqual(new Map([['local', 1]])) + expect(summaries.get(getProjectGroupHeaderKey('group-1'))?.runningCount).toBe(1) + expect(summaries.get(getProjectGroupHeaderKey('group-1'))?.hostRunningCounts).toEqual( + new Map([['local', 1]]) + ) + }) + + it('counts duplicate-mode pinned activity on project setup headers', () => { + const repo = makeRepo() + const project: Project = { + id: 'github:stablyai/orca', + displayName: 'Orca', + badgeColor: '#737373', + sourceRepoIds: [repo.id], + createdAt: 1, + updatedAt: 1 + } + const setup: ProjectHostSetup = { + id: repo.id, + projectId: project.id, + hostId: 'local', + repoId: repo.id, + path: repo.path, + displayName: repo.displayName, + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + } + const worktree = makeWorktree({ repoId: repo.id, isPinned: true }) + const state = makeState({ + tabsByWorktree: { + [worktree.id]: [ + makeTerminalTab({ id: 'tab-1', worktreeId: worktree.id, title: 'Codex working' }) + ] + }, + ptyIdsByTabId: { + 'tab-1': ['pty-1'] + } }) + + const summaries = buildSummaries({ + worktrees: [worktree], + repos: [repo], + projectGrouping: { projects: [project], projectHostSetups: [setup] }, + pinnedDisplayPolicy: 'duplicate-in-groups', + state + }) + + expect(summaries.get(PINNED_GROUP_KEY)?.runningCount).toBe(1) + expect(summaries.get(`project:${project.id}`)).toEqual({ + runningCount: 1, + hostRunningCounts: new Map([['local', 1]]) + }) + expect(summaries.get(`repo:${repo.id}`)).toBeUndefined() + }) + + it('keeps running activity attributable to owning hosts per section', () => { + const localRepo = makeRepo({ id: 'local-repo' }) + const sshRepo = makeRepo({ + id: 'ssh-repo', + connectionId: 'ssh-1' + }) + const localWorktree = makeWorktree({ + id: 'local-wt', + repoId: localRepo.id, + isPinned: true + }) + const sshWorktree = makeWorktree({ + id: 'ssh-wt', + repoId: sshRepo.id, + isPinned: true + }) + const state = makeState({ + tabsByWorktree: { + [localWorktree.id]: [ + makeTerminalTab({ + id: 'local-tab', + worktreeId: localWorktree.id, + title: 'Codex working' + }) + ], + [sshWorktree.id]: [ + makeTerminalTab({ + id: 'ssh-tab', + worktreeId: sshWorktree.id, + title: 'Codex working' + }) + ] + }, + ptyIdsByTabId: { + 'local-tab': ['local-pty'], + 'ssh-tab': ['ssh-pty'] + } + }) + + const summaries = buildSummaries({ + worktrees: [localWorktree, sshWorktree], + repos: [localRepo, sshRepo], + defaultHostId: 'local', + state + }) + + const pinnedSummary = summaries.get(PINNED_GROUP_KEY) + expect(pinnedSummary?.runningCount).toBe(2) + expect(pinnedSummary?.hostRunningCounts).toEqual( + new Map([ + ['local', 1], + ['ssh:ssh-1', 1] + ]) + ) + const allSummary = buildSummaries({ + groupBy: 'none', + worktrees: [localWorktree, sshWorktree], + repos: [localRepo, sshRepo], + pinnedDisplayPolicy: 'duplicate-in-groups', + defaultHostId: 'local', + state + }).get('all') + expect(allSummary?.runningCount).toBe(2) + expect(allSummary?.hostRunningCounts).toEqual( + new Map([ + ['local', 1], + ['ssh:ssh-1', 1] + ]) + ) }) }) diff --git a/src/renderer/src/components/sidebar/worktree-section-activity.ts b/src/renderer/src/components/sidebar/worktree-section-activity.ts index fb81da4f0cf..e36ebc984fa 100644 --- a/src/renderer/src/components/sidebar/worktree-section-activity.ts +++ b/src/renderer/src/components/sidebar/worktree-section-activity.ts @@ -8,9 +8,17 @@ import type { Worktree, WorkspaceStatusDefinition } from '../../../../shared/types' +import { + getRepoExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + type ExecutionHostId +} from '../../../../shared/execution-host' import { getGroupKeysForWorktree, + getPinnedWorktreeDisplayPolicy, + type ProjectGroupingModel, type WorktreeGroupBy, + type PinnedWorktreeDisplayPolicy, PINNED_GROUP_KEY } from './worktree-list-groups' import { @@ -28,6 +36,7 @@ export type WorktreeSectionActivityState = Pick< | 'agentStatusByPaneKey' | 'migrationUnsupportedByPtyId' | 'retainedAgentsByPaneKey' + | 'runtimeAgentOrchestrationByPaneKey' > & { tabsByWorktree: Record[]> browserTabsByWorktree: Record @@ -36,6 +45,7 @@ export type WorktreeSectionActivityState = Pick< export type WorktreeSectionActivitySummary = { runningCount: number + hostRunningCounts?: ReadonlyMap } export const EMPTY_WORKTREE_SECTION_ACTIVITY: WorktreeSectionActivitySummary = { @@ -50,6 +60,9 @@ export function buildWorktreeSectionActivitySummaries({ workspaceStatuses, settings, projectGroups, + projectGrouping, + pinnedDisplayPolicy = getPinnedWorktreeDisplayPolicy(settings), + defaultHostId = LOCAL_EXECUTION_HOST_ID, state }: { groupBy: WorktreeGroupBy @@ -59,23 +72,30 @@ export function buildWorktreeSectionActivitySummaries({ workspaceStatuses: readonly WorkspaceStatusDefinition[] settings?: AppState['settings'] projectGroups: readonly ProjectGroup[] + projectGrouping?: ProjectGroupingModel + pinnedDisplayPolicy?: PinnedWorktreeDisplayPolicy + defaultHostId?: ExecutionHostId state: WorktreeSectionActivityState }): Map { const summaries = new Map() for (const worktree of worktrees) { - const groupKeys = [ - ...(worktree.isPinned ? [PINNED_GROUP_KEY] : []), - ...getGroupKeysForWorktree( - groupBy, - worktree, - repoMap, - prCache, - workspaceStatuses, - settings, - projectGroups - ) - ] + const naturalGroupKeys = getGroupKeysForWorktree( + groupBy, + worktree, + repoMap, + prCache, + workspaceStatuses, + settings, + projectGroups, + projectGrouping + ) + const groupKeys = + worktree.isPinned && pinnedDisplayPolicy === 'single-location' + ? [PINNED_GROUP_KEY] + : worktree.isPinned + ? [PINNED_GROUP_KEY, ...naturalGroupKeys] + : naturalGroupKeys if (groupKeys.length === 0) { continue } @@ -85,6 +105,10 @@ export function buildWorktreeSectionActivitySummaries({ const summary = summaries.get(groupKey) ?? { ...EMPTY_WORKTREE_SECTION_ACTIVITY } if (status === 'working') { summary.runningCount++ + const hostRunningCounts = new Map(summary.hostRunningCounts ?? []) + const hostId = getWorktreeHostId(worktree, repoMap, defaultHostId) + hostRunningCounts.set(hostId, (hostRunningCounts.get(hostId) ?? 0) + 1) + summary.hostRunningCounts = hostRunningCounts } summaries.set(groupKey, summary) } @@ -93,6 +117,20 @@ export function buildWorktreeSectionActivitySummaries({ return summaries } +function getWorktreeHostId( + worktree: Worktree, + repoMap: ReadonlyMap, + defaultHostId: ExecutionHostId +): ExecutionHostId { + const repo = repoMap.get(worktree.repoId) + // Why: pinned headers can be cloned inside host sections, so hidden pinned + // activity must stay attributed to the owning host instead of the global key. + if (repo?.connectionId || repo?.executionHostId) { + return getRepoExecutionHostId(repo) + } + return defaultHostId +} + function getSectionWorktreeStatus( state: WorktreeSectionActivityState, worktreeId: string diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 0c6fdfd6a65..0718a524cb2 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -4523,7 +4523,11 @@ "tintOpacity": "Tint Strength", "tintOpacityDescription": "Controls how strongly the tint is mixed into the sidebar." }, - "workspaceCardLayoutGuidance": "Use the workspace sidebar options menu > Card layout > Compact." + "workspaceCardLayoutGuidance": "Use the workspace sidebar options menu > Card layout > Compact.", + "showPinnedWorktreesInGroups": { + "title": "Show pinned worktrees in other groups", + "description": "Pinned worktrees also appear in All, Project, Status, and PR groups." + } }, "AutoRenameBranchFromWorkSetting": { "1626524572": "Nautilus", @@ -6682,6 +6686,10 @@ "cardLayout": "card layout", "workspaceOptions": "workspace options", "detailed": "detailed" + }, + "showPinnedWorktreesInGroups": { + "title": "Show pinned worktrees in other groups", + "description": "Pinned worktrees also appear in All, Project, Status, and PR groups." } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 590f5bc91a8..9e3ec2e781d 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -4523,7 +4523,11 @@ "workspaceCardLayoutGuidance": "Usa el menú de opciones de la barra lateral de espacios de trabajo > Diseño de tarjeta > Compacto.", "872af9556e": "Bandeja del sistema", "2edf606c46": "Minimizar a la bandeja al cerrar", - "b707773a0d": "Cuando está activado, cerrar la ventana mantiene Orca en ejecución en la bandeja del sistema en lugar de salir." + "b707773a0d": "Cuando está activado, cerrar la ventana mantiene Orca en ejecución en la bandeja del sistema en lugar de salir.", + "showPinnedWorktreesInGroups": { + "title": "Mostrar worktrees fijados en otros grupos", + "description": "Los worktrees fijados también aparecen en los grupos Todos, Proyecto, Estado y PR." + } }, "AutoRenameBranchFromWorkSetting": { "1626524572": "Nautilus", @@ -6645,7 +6649,11 @@ "detailed": "detallado" }, "9a115966d3": "Minimizar a la bandeja al cerrar", - "4d5b9427b5": "Cuando está activado, cerrar la ventana mantiene Orca en ejecución en la bandeja del sistema en lugar de salir." + "4d5b9427b5": "Cuando está activado, cerrar la ventana mantiene Orca en ejecución en la bandeja del sistema en lugar de salir.", + "showPinnedWorktreesInGroups": { + "title": "Mostrar worktrees fijados en otros grupos", + "description": "Los worktrees fijados también aparecen en los grupos Todos, Proyecto, Estado y PR." + } } }, "auto": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 75555ae1548..76d0e30da47 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -4508,7 +4508,11 @@ "workspaceCardLayoutGuidance": "ワークスペースサイドバーのオプションメニュー > カードレイアウト > コンパクト を使用します。", "872af9556e": "システムトレイ", "2edf606c46": "閉じるときにトレイへ最小化", - "b707773a0d": "有効にすると、ウィンドウを閉じてもOrcaは終了せず、システムトレイで実行を続けます。" + "b707773a0d": "有効にすると、ウィンドウを閉じてもOrcaは終了せず、システムトレイで実行を続けます。", + "showPinnedWorktreesInGroups": { + "title": "ピン留めしたワークツリーを他のグループにも表示", + "description": "ピン留めしたワークツリーも、すべて、プロジェクト、ステータス、PR グループに表示されます。" + } }, "AutoRenameBranchFromWorkSetting": { "1626524572": "Nautilus", @@ -6667,7 +6671,11 @@ "detailed": "詳細" }, "9a115966d3": "閉じるときにトレイへ最小化", - "4d5b9427b5": "有効にすると、ウィンドウを閉じてもOrcaは終了せず、システムトレイで実行を続けます。" + "4d5b9427b5": "有効にすると、ウィンドウを閉じてもOrcaは終了せず、システムトレイで実行を続けます。", + "showPinnedWorktreesInGroups": { + "title": "ピン留めしたワークツリーを他のグループにも表示", + "description": "ピン留めしたワークツリーも、すべて、プロジェクト、ステータス、PR グループに表示されます。" + } } }, "auto": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 8b6dc42fd9a..222de9cf3f1 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -4508,7 +4508,11 @@ "tintOpacity": "색조 강도", "tintOpacityDescription": "사이드바에 색조를 얼마나 강하게 섞을지 조절합니다." }, - "workspaceCardLayoutGuidance": "워크스페이스 사이드바 옵션 메뉴 > 카드 레이아웃 > 컴팩트를 사용하세요." + "workspaceCardLayoutGuidance": "워크스페이스 사이드바 옵션 메뉴 > 카드 레이아웃 > 컴팩트를 사용하세요.", + "showPinnedWorktreesInGroups": { + "title": "고정된 워크트리를 다른 그룹에도 표시", + "description": "고정된 워크트리는 전체, 프로젝트, 상태 및 PR 그룹에도 표시됩니다." + } }, "AutoRenameBranchFromWorkSetting": { "1626524572": "Nautilus", @@ -6630,6 +6634,10 @@ "cardLayout": "카드 레이아웃", "workspaceOptions": "워크스페이스 옵션", "detailed": "상세" + }, + "showPinnedWorktreesInGroups": { + "title": "고정된 워크트리를 다른 그룹에도 표시", + "description": "고정된 워크트리는 전체, 프로젝트, 상태 및 PR 그룹에도 표시됩니다." } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 83e32e2934e..25b1cf515b7 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -4508,7 +4508,11 @@ "workspaceCardLayoutGuidance": "使用工作区侧边栏选项菜单 > 卡片布局 > 紧凑。", "872af9556e": "系统托盘", "2edf606c46": "关闭时最小化到托盘", - "b707773a0d": "启用后,关闭窗口会让 Orca 继续在系统托盘中运行,而不是退出。" + "b707773a0d": "启用后,关闭窗口会让 Orca 继续在系统托盘中运行,而不是退出。", + "showPinnedWorktreesInGroups": { + "title": "在其他分组中显示已固定的工作树", + "description": "已固定的工作树也会显示在全部、项目、状态和 PR 分组中。" + } }, "AutoRenameBranchFromWorkSetting": { "1626524572": "Nautilus", @@ -6630,7 +6634,11 @@ "detailed": "详细" }, "9a115966d3": "关闭时最小化到托盘", - "4d5b9427b5": "启用后,关闭窗口会让 Orca 继续在系统托盘中运行,而不是退出。" + "4d5b9427b5": "启用后,关闭窗口会让 Orca 继续在系统托盘中运行,而不是退出。", + "showPinnedWorktreesInGroups": { + "title": "在其他分组中显示已固定的工作树", + "description": "已固定的工作树也会显示在全部、项目、状态和 PR 分组中。" + } } }, "auto": { diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 6a57d5fb3dd..1eddc47f7d0 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -271,6 +271,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { showTasksButton: true, showAutomationsButton: true, showMobileButton: true, + showPinnedWorktreesInGroups: false, ctrlTabOrderMode: 'mru', // Why: switching worktrees and opening command surfaces from a focused // terminal is a core Orca workflow; users who prefer TUI ownership opt in. diff --git a/src/shared/types.ts b/src/shared/types.ts index 1ddd0af588e..33ace946b43 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2518,6 +2518,9 @@ export type GlobalSettings = { /** Why: Orca Mobile remains reachable from the toolbox; this only controls * whether the top-level sidebar shortcut is shown. */ showMobileButton?: boolean + /** Why: pinned workspaces default to one sidebar location; users can opt + * back into seeing them in their natural groups too. */ + showPinnedWorktreesInGroups?: boolean /** Controls how Ctrl+Tab chooses the next visible tab. Optional for * profiles saved before this setting existed; readers default to MRU. */ ctrlTabOrderMode?: CtrlTabOrderMode