mirror of
https://github.com/stablyai/orca.git
synced 2026-10-27 00:02:48 +00:00
Add setting for pinned worktree group display
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 그룹에도 표시됩니다."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user