Add setting for pinned worktree group display

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-06-23 17:21:34 -07:00
co-authored by Orca
parent 36f8056446
commit 92bede6af8
23 changed files with 1587 additions and 240 deletions
@@ -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({
}
/>
</SearchableSetting>
<SearchableSetting
title={showPinnedWorktreesInGroupsEntry.title}
description={showPinnedWorktreesInGroupsEntry.description}
keywords={showPinnedWorktreesInGroupsEntry.keywords}
>
<SettingsSwitchRow
label={translate(
'auto.components.settings.AppearancePane.showPinnedWorktreesInGroups.title',
'Show pinned worktrees in other groups'
)}
description={translate(
'auto.components.settings.AppearancePane.showPinnedWorktreesInGroups.description',
'Pinned worktrees also appear in All, Project, Status, and PR groups.'
)}
checked={settings.showPinnedWorktreesInGroups === true}
onChange={() =>
updateSettings({
showPinnedWorktreesInGroups: !(settings.showPinnedWorktreesInGroups === true)
})
}
/>
</SearchableSetting>
</div>
</section>
) : null,
@@ -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()
])
@@ -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)
})
})
@@ -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<string, unknown>
@@ -21,6 +22,7 @@ type WorktreeListComponent = React.ComponentType<{
}>
let WorktreeList: WorktreeListComponent
let getPinnedWorktreeRevealCollapsedGroupKeys: typeof WorktreeListModule.getPinnedWorktreeRevealCollapsedGroupKeys
function makeFolderWorkspacePathStatusMockState(): Record<string, unknown> {
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 () => {
@@ -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<HTMLElement>('[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<string>
@@ -483,6 +490,7 @@ type VirtualizedWorktreeViewportProps = {
worktree: Worktree
) => readonly Worktree[]
repoMap: Map<string, Repo>
defaultHostId: ExecutionHostId
worktreeMap: Map<string, Worktree>
worktreeLineageById: Record<string, WorktreeLineage>
repoOrder: Map<string, number>
@@ -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 (
<Tooltip>
<TooltipTrigger asChild>
<span
className="inline-flex h-4 shrink-0 items-center gap-1 rounded-full border border-worktree-sidebar-border bg-worktree-sidebar-accent px-1.5 text-[9px] font-medium leading-none text-muted-foreground/90"
aria-label={label}
>
<StatusIndicator status="working" aria-hidden="true" className="size-2.5" />
{count}
</span>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{label}
</TooltipContent>
</Tooltip>
)
}
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<string>()
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<string>()
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>
}): 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<string, string>
groupIndexByRowKey: Map<string, number>
} {
const groupKeyByRowKey = new Map<string, string>()
const groupIndexByRowKey = new Map<string, number>()
const groupIndexes = new Map<string, number>()
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<string>()
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<Row, { type: 'item' }> => r.type === 'item')
const seenWorktreeIds = new Set<string>()
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
<div className="min-w-0 truncate text-[13px] font-semibold leading-none">
{row.label}
</div>
{isHeaderCollapsed && row.runningCount && row.runningCount > 0 ? (
<SectionRunningBadge count={row.runningCount} />
) : null}
<RepoForkIndicator upstream={row.repo?.upstream} />
<FolderPathStatusIndicator status={projectGroupPathStatus} />
</div>
@@ -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}
@@ -46,6 +46,19 @@ function header(key: string, label = key): Extract<Row, { type: 'header' }> {
}
}
function pinnedHeader(
counts: ReadonlyMap<'local' | 'ssh:ssh-1', number>,
idsByHost?: ReadonlyMap<'local' | 'ssh:ssh-1', readonly string[]>
): Extract<Row, { type: 'header' }> {
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<Row, { type: 'header' }> {
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', () => {
@@ -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<string>()
let pendingHeaderCount: number | null = null
let pendingHeader: Extract<Row, { type: 'header' }> | 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<Row, { type: 'header' }>[],
hostId: ExecutionHostId
): Extract<Row, { type: 'header' }>[] {
const localized: Extract<Row, { type: 'header' }>[] = []
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<Row, { type: 'header' }>[]): 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<Row, { type: 'header' }>[] = []
let pendingRowsWereUsed = false
const pendingRowsKeyByHostId = new Map<ExecutionHostId, string>()
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) {
@@ -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 () => {
@@ -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<HTMLElement>('[data-worktree-card-active="primary"]')
) ??
options.find((element) => element.dataset.worktreeSectionKey !== PINNED_GROUP_KEY) ??
options.find((element) => !isPinnedSidebarWorktreeOption(element)) ??
options[0] ??
null
)
@@ -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'),
@@ -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) {
@@ -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',
@@ -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<ExecutionHostId, number>
hostWorktreeIds?: ReadonlyMap<ExecutionHostId, readonly string[]>
worktreeIds?: readonly string[]
runningCount?: number
hostRunningCounts?: ReadonlyMap<ExecutionHostId, number>
}
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<string, Repo>,
defaultHostId: ExecutionHostId,
collapsedGroups: Set<string>,
visibleUnpinnedRepoIds: ReadonlySet<string>,
renderedNaturalAnchorRepoIds: ReadonlySet<string>,
importedWorktreesByRepo: ReadonlyMap<string, ImportedWorktreesCardCandidate>,
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<ExecutionHostId, number>()
const hostWorktreeIds = new Map<ExecutionHostId, string[]>()
const pinnedRepoOrder: string[] = []
const seenPinnedRepoIds = new Set<string>()
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<string, number>()
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<string, Repo>,
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<string, Repo>,
defaultHostId: ExecutionHostId
): Map<ExecutionHostId, number> | undefined {
if (worktrees.length === 0) {
return undefined
}
const counts = new Map<ExecutionHostId, number>()
const seenWorktreeIds = new Set<string>()
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<string, Repo>,
defaultHostId: ExecutionHostId
): Map<ExecutionHostId, string[]> | undefined {
if (worktrees.length === 0) {
return undefined
}
const idsByHost = new Map<ExecutionHostId, string[]>()
const seenWorktreeIds = new Set<string>()
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<string, Repo>
prCache: Record<string, unknown> | null
collapsedGroups: ReadonlySet<string>
workspaceStatuses: readonly WorkspaceStatusDefinition[]
settings?: AppState['settings']
projectGrouping?: ProjectGroupingModel
}): Set<string> {
const renderedRepoIds = new Set<string>()
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<string, string>
hostLabelById?: ReadonlyMap<string, string>,
defaultHostId: ExecutionHostId = LOCAL_EXECUTION_HOST_ID,
pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy = getPinnedWorktreeDisplayPolicy(settings),
sectionActivitySummaries?: ReadonlyMap<string, WorktreeSectionActivitySummary>
): 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<GroupHeaderRow, 'runningCount' | 'hostRunningCounts'> => {
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<string, WorktreeGroupEntry>()
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) ?? []) {
@@ -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<Row, { type: 'item' }> => ({
lineageChildCount: 0
})
const makePinnedWorktreeRow = (id: string): Extract<Row, { type: 'item' }> => ({
...makeWorktreeRow(id),
rowKey: `pinned:${id}`,
sectionKey: 'pinned',
worktree: { ...makeWorktree(id), isPinned: true }
})
const makeImportedCardRow = (): Extract<Row, { type: 'imported-worktrees-card' }> => ({
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(
@@ -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<typeof buildWorktreeSectionActivitySummaries>[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]
])
)
})
})
@@ -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<string, readonly Pick<TerminalTab, 'id' | 'title'>[]>
browserTabsByWorktree: Record<string, readonly BrowserActivityTab[]>
@@ -36,6 +45,7 @@ export type WorktreeSectionActivityState = Pick<
export type WorktreeSectionActivitySummary = {
runningCount: number
hostRunningCounts?: ReadonlyMap<ExecutionHostId, number>
}
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<string, WorktreeSectionActivitySummary> {
const summaries = new Map<string, WorktreeSectionActivitySummary>()
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<string, Repo>,
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
+9 -1
View File
@@ -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."
}
}
},
+10 -2
View File
@@ -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": {
+10 -2
View File
@@ -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": {
+9 -1
View File
@@ -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 그룹에도 표시됩니다."
}
}
},
+10 -2
View File
@@ -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": {
+1
View File
@@ -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.
+3
View File
@@ -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