fix(source-control): scope merged branch disclosure to host and checkout

Key the merged-section record by host-qualified identity plus checkout
instance, so a same-ID workspace on another host or a recreated checkout
neither inherits the other's collapse ownership nor shows its merged
proof. The automatic collapse now lives only in that record and is
unioned into the displayed sections, instead of being written into the
raw-ID collapsed cache that same-ID twins share. Pruning raises its
threshold after each scan so record writes stay amortized O(1).

If a branch diff is the active editor when the workspace turns safe to
delete, skip the automatic collapse for the session instead of deferring
it. The chip text uses a merged-state foreground that keeps 4.5:1 on its
tint at rest and on hover, and it goes icon-only by the header row's width
so long action labels no longer hide the text at normal sidebar widths.
This commit is contained in:
Jinjing
2026-11-13 13:06:53 -08:00
parent e353a312cf
commit 714dfa317d
12 changed files with 219 additions and 97 deletions
+4
View File
@@ -154,6 +154,7 @@
--color-status-warning-border: var(--status-warning-border);
--color-callout-note: var(--callout-note);
--color-callout-important: var(--callout-important);
--color-merged-state-foreground: var(--merged-state-foreground);
--color-workspace-status-done: var(--workspace-status-done);
--color-workspace-status-review: var(--workspace-status-review);
--color-workspace-status-progress: var(--workspace-status-progress);
@@ -467,6 +468,8 @@
/* GitHub's own hues for the two callout kinds no status token covers. */
--callout-note: #0969da;
--callout-important: #8250df;
/* Merged text on a callout-important tint: GitHub's deeper purple keeps 4.5:1 at rest and on hover. */
--merged-state-foreground: #6639ba;
--git-decoration-added: #587c0c;
--git-decoration-modified: #895503;
--git-decoration-deleted: #ad0707;
@@ -502,6 +505,7 @@
--status-warning-border: color-mix(in srgb, var(--status-warning) 25%, transparent);
--callout-note: #4493f8;
--callout-important: #ab7df8;
--merged-state-foreground: #d2a8ff;
--git-decoration-added: #81b88b;
--git-decoration-modified: #e2c08d;
--git-decoration-deleted: #c74e39;
@@ -37,8 +37,10 @@ function BranchSectionFrame({
uncommittedCount?: number
isMainWorktree?: boolean
}) {
const owner = { id: worktreeId }
const view = useSourceControlPanelViewState({
activeWorktreeId: worktreeId,
activeWorktreeOwner: owner,
settings: null,
updateSettings: async () => {}
})
@@ -59,7 +61,8 @@ function BranchSectionFrame({
totalBranchEntryCount={entries.length}
collapsedSections={view.collapsedSections}
toggleSection={view.toggleSection}
setBranchSectionCollapsed={view.setBranchSectionCollapsed}
setBranchSectionAutoCollapsed={view.setBranchSectionAutoCollapsed}
worktreeOwner={owner}
review={review}
statusHeadOid={statusHeadOid}
uncommittedCount={uncommittedCount}
@@ -128,9 +131,18 @@ export const minimumSidebarWidth = defineFixture({
interact: expandBranchSection
})
const germanSettings = { ...getDefaultSettings('/home/test'), uiLanguage: 'de' as const }
export const longLocale = defineFixture({
width: 300,
store: { settings: { ...getDefaultSettings('/home/test'), uiLanguage: 'de' } },
width: 308,
store: { settings: germanSettings },
render: () => <BranchSectionFrame worktreeId="fixture-long-locale" />,
interact: expandBranchSection
})
export const longLocaleMinimumWidth = defineFixture({
width: RIGHT_SIDEBAR_MIN_WIDTH,
store: { settings: germanSettings },
render: () => <BranchSectionFrame worktreeId="fixture-long-locale-min" />,
interact: expandBranchSection
})
@@ -3,6 +3,7 @@ import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, expect, it } from 'vitest'
import { useAppStore } from '@/store'
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
import type { HostedReviewInfo } from '../../../../../../shared/source-control/hosted-review/hosted-review-contracts'
import type { GitBranchChangeEntry } from '../../../../../../shared/git/diff/git-diff-compare-types'
import type { SourceControlTreeNode } from '../../../../../../shared/source-control/listing/source-control-tree'
@@ -43,6 +44,8 @@ const mergedReview: HostedReviewInfo = {
type SectionInputs = {
worktreeId: string
hostId?: ExecutionHostId
instanceId?: string
review?: HostedReviewInfo | null
statusHeadOid?: string | null
uncommittedCount?: number
@@ -53,6 +56,8 @@ type SectionInputs = {
function Panel({
worktreeId,
hostId,
instanceId,
review = mergedReview,
statusHeadOid = PR_HEAD,
uncommittedCount = 0,
@@ -60,8 +65,10 @@ function Panel({
isBranchDiffEditorActive = false,
tree = false
}: SectionInputs) {
const owner = { id: worktreeId, hostId, instanceId }
const view = useSourceControlPanelViewState({
activeWorktreeId: worktreeId,
activeWorktreeOwner: owner,
settings: null,
updateSettings: async () => {}
})
@@ -81,7 +88,8 @@ function Panel({
totalBranchEntryCount={entries.length}
collapsedSections={view.collapsedSections}
toggleSection={view.toggleSection}
setBranchSectionCollapsed={view.setBranchSectionCollapsed}
setBranchSectionAutoCollapsed={view.setBranchSectionAutoCollapsed}
worktreeOwner={owner}
review={review}
statusHeadOid={statusHeadOid}
uncommittedCount={uncommittedCount}
@@ -253,13 +261,60 @@ it('never collapses a user-toggled section', async () => {
expect(isExpanded()).toBe(true)
})
it('waits until no branch diff is the active editor before collapsing', async () => {
it('skips the collapse for the session when a branch diff is active as the workspace turns safe', async () => {
const worktreeId = nextWorktreeId()
await render({ worktreeId, isBranchDiffEditorActive: true })
expect(isExpanded()).toBe(true)
await render({ worktreeId })
expect(isExpanded()).toBe(true)
expect(hasMergedChip()).toBe(true)
})
it('reopens an automatic collapse when the review is no longer merged', async () => {
const worktreeId = nextWorktreeId()
await render({ worktreeId })
expect(isExpanded()).toBe(false)
await render({ worktreeId, review: { ...mergedReview, state: 'open' } })
expect(isExpanded()).toBe(true)
expect(hasMergedChip()).toBe(false)
})
it('names a merge request by its host label', async () => {
await render({ worktreeId: nextWorktreeId(), review: { ...mergedReview, provider: 'gitlab' } })
expect(toggle().textContent).toContain('MR #412 is merged')
})
it('keeps disclosure and merged proof apart for same-ID workspaces on other hosts', async () => {
const worktreeId = nextWorktreeId()
await render({ worktreeId, hostId: 'ssh:host-a' })
await clickToggle()
expect(isExpanded()).toBe(true)
await render({ worktreeId, hostId: 'ssh:host-b', review: null })
expect(hasMergedChip()).toBe(false)
await render({ worktreeId, hostId: 'ssh:host-b' })
expect(isExpanded()).toBe(false)
await render({ worktreeId, hostId: 'ssh:host-a', review: null })
expect(isExpanded()).toBe(true)
expect(hasMergedChip()).toBe(true)
})
it('starts a recreated workspace fresh and leaves the old checkout restorable', async () => {
const worktreeId = nextWorktreeId()
await render({ worktreeId, instanceId: 'first' })
expect(isExpanded()).toBe(false)
await render({ worktreeId, instanceId: 'second', review: null })
expect(isExpanded()).toBe(true)
expect(hasMergedChip()).toBe(false)
await render({ worktreeId, instanceId: 'first', review: null })
expect(isExpanded()).toBe(false)
expect(hasMergedChip()).toBe(true)
})
it('moves focus from a row to the header when collapsing', async () => {
@@ -16,6 +16,7 @@ import { SourceControlBranchTreeDirectoryRow } from './tree-directory-rows'
import { VirtualizedList } from '../../../virtualized-list'
import { MergedBranchChip } from './merged-branch-chip'
import { useMergedBranchSectionDisclosure } from './use-merged-branch-section-disclosure'
import type { MergedBranchSectionOwner } from './merged-branch-section-disclosure'
export function SourceControlBranchSection({
activeOpenRowKeys,
@@ -24,7 +25,8 @@ export function SourceControlBranchSection({
totalBranchEntryCount,
collapsedSections,
toggleSection,
setBranchSectionCollapsed,
setBranchSectionAutoCollapsed,
worktreeOwner,
review,
statusHeadOid,
uncommittedCount,
@@ -49,7 +51,8 @@ export function SourceControlBranchSection({
totalBranchEntryCount: number
collapsedSections: Set<string>
toggleSection: (section: string) => void
setBranchSectionCollapsed: (collapsed: boolean) => void
setBranchSectionAutoCollapsed: (collapsed: boolean) => void
worktreeOwner: MergedBranchSectionOwner
/** Null for folder workspaces and branches without a review. */
review: HostedReviewInfo | null
/** HEAD from the latest successful status read; null while unknown. */
@@ -93,13 +96,13 @@ export function SourceControlBranchSection({
const sectionRef = useRef<HTMLDivElement | null>(null)
const { mergedReview } = useMergedBranchSectionDisclosure({
worktreeId: currentWorktreeId,
owner: worktreeOwner,
isMainWorktree,
review,
statusHeadOid,
uncommittedCount,
branchDiffEditorActive: isBranchDiffEditorActive,
setBranchSectionCollapsed,
setBranchSectionAutoCollapsed,
sectionRef
})
@@ -21,7 +21,7 @@ export function MergedBranchChip({
// Title is for mouse hover; screen readers get the sr-only text in the toggle's name.
<Badge variant="mergedState" title={reviewMerged}>
<GitMerge aria-hidden="true" />
<span className="@max-[9rem]:sr-only">
<span className="@max-[15rem]/section-header:sr-only">
{t('auto.components.right.sidebar.SourceControl.branchSectionMerged', 'Merged')}
</span>
<span className="sr-only">{` · ${reviewMerged}`}</span>
@@ -1,5 +1,7 @@
import { useAppStore } from '@/store'
import { getWorktreeLookupIndex } from '@/store/github/refresh/worktree-refresh'
import { selectWorktreesByRepo } from '@/store/workspaces/catalog/workspace-record-fields'
import { getWorktreeHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity'
import type { Worktree } from '../../../../../../shared/worktree/types'
import type { HostedReviewInfo } from '../../../../../../shared/source-control/hosted-review/hosted-review-contracts'
import {
mergedReviewIncludesHead,
@@ -93,15 +95,19 @@ export function stepMergedBranchSection(
} else if (
disclosure.kind === 'untouched' &&
!observation.isMainWorktree &&
!observation.branchDiffEditorActive &&
resolveMergedReviewWorkspaceReadiness({
review: observation.review,
headOid: head,
uncommittedCount: observation.uncommittedCount
})?.kind === 'safe-to-delete'
) {
disclosure = { kind: 'auto-collapsed', headOid: head }
action = 'collapse'
// A diff the user is reading from this section wins for the rest of the session.
if (observation.branchDiffEditorActive) {
disclosure = { kind: 'auto-released' }
} else {
disclosure = { kind: 'auto-collapsed', headOid: head }
action = 'collapse'
}
}
if (merged === record.merged && disclosure === record.disclosure) {
return { record, action: null }
@@ -109,41 +115,63 @@ export function stepMergedBranchSection(
return { record: { merged, disclosure }, action }
}
/** One host's copy of a workspace in one checkout lifetime; a recreated workspace starts fresh. */
export type MergedBranchSectionOwner = Pick<Worktree, 'id' | 'hostId' | 'instanceId'>
export function mergedBranchSectionOwnerKey(owner: MergedBranchSectionOwner): string {
return JSON.stringify([getWorktreeHostIdentity(owner), owner.instanceId ?? null])
}
// Session-only: a restart starts fresh. Records are dropped only for workspaces that no longer exist,
// so a count cap can never forget a live workspace's choice and collapse it again.
const RECORD_PRUNE_THRESHOLD = 512
const recordsByWorktree = new Map<string, MergedBranchSectionRecord>()
const MIN_PRUNE_THRESHOLD = 512
let pruneThreshold = MIN_PRUNE_THRESHOLD
const recordsByOwner = new Map<string, MergedBranchSectionRecord>()
export function readMergedBranchSectionRecord(worktreeId: string): MergedBranchSectionRecord {
return recordsByWorktree.get(worktreeId) ?? UNTOUCHED_RECORD
function pruneRecordsOfRemovedWorkspaces(keep: string): void {
const live = new Set<string>()
for (const worktrees of Object.values(selectWorktreesByRepo(useAppStore.getState()) ?? {})) {
for (const worktree of worktrees) {
live.add(mergedBranchSectionOwnerKey(worktree))
}
}
for (const key of recordsByOwner.keys()) {
if (key !== keep && !live.has(key)) {
recordsByOwner.delete(key)
}
}
// Doubling the bar keeps scans amortized O(1) per write when most records belong to live workspaces.
pruneThreshold = Math.max(MIN_PRUNE_THRESHOLD, recordsByOwner.size * 2)
}
export function readMergedBranchSectionRecord(
owner: MergedBranchSectionOwner
): MergedBranchSectionRecord {
return recordsByOwner.get(mergedBranchSectionOwnerKey(owner)) ?? UNTOUCHED_RECORD
}
export function writeMergedBranchSectionRecord(
worktreeId: string,
owner: MergedBranchSectionOwner,
record: MergedBranchSectionRecord
): void {
const key = mergedBranchSectionOwnerKey(owner)
if (record.merged === null && record.disclosure.kind === 'untouched') {
recordsByWorktree.delete(worktreeId)
recordsByOwner.delete(key)
return
}
recordsByWorktree.set(worktreeId, record)
if (recordsByWorktree.size > RECORD_PRUNE_THRESHOLD) {
const liveWorktreeIds = getWorktreeLookupIndex(useAppStore.getState()).byId
for (const id of recordsByWorktree.keys()) {
if (id !== worktreeId && !liveWorktreeIds.has(id)) {
recordsByWorktree.delete(id)
}
}
recordsByOwner.set(key, record)
if (recordsByOwner.size > pruneThreshold) {
pruneRecordsOfRemovedWorkspaces(key)
}
}
export function markBranchSectionToggledByUser(worktreeId: string): void {
writeMergedBranchSectionRecord(worktreeId, {
...readMergedBranchSectionRecord(worktreeId),
export function markBranchSectionToggledByUser(owner: MergedBranchSectionOwner): void {
writeMergedBranchSectionRecord(owner, {
...readMergedBranchSectionRecord(owner),
disclosure: { kind: 'user' }
})
}
export function isBranchSectionAutoCollapsed(worktreeId: string): boolean {
return readMergedBranchSectionRecord(worktreeId).disclosure.kind === 'auto-collapsed'
export function isBranchSectionAutoCollapsed(owner: MergedBranchSectionOwner): boolean {
return readMergedBranchSectionRecord(owner).disclosure.kind === 'auto-collapsed'
}
@@ -31,7 +31,14 @@ export function SectionHeader({
const compact = useSourceControlScope() !== null
// Share the rounded container so hover styling spans the whole row.
return (
<div className={cn('pl-1 pr-3 pt-3', compact ? 'pb-0' : 'pb-1')}>
<div
className={cn(
'pl-1 pr-3 pt-3',
compact ? 'pb-0' : 'pb-1',
// Status chips size against the whole row, so long action labels don't shrink them early.
status && '@container/section-header'
)}
>
<div className="group/section flex items-center gap-x-1 rounded-md pr-1 hover:bg-accent hover:text-accent-foreground">
<Button
type="button"
@@ -47,12 +54,7 @@ export function SectionHeader({
<ChevronDown
className={cn('size-3.5 shrink-0 transition-transform', isCollapsed && '-rotate-90')}
/>
<span
className={cn(
'min-w-0 flex-1 text-xs font-semibold uppercase tracking-wider text-foreground/70 group-hover/section:text-accent-foreground',
status && '@container'
)}
>
<span className="min-w-0 flex-1 text-xs font-semibold uppercase tracking-wider text-foreground/70 group-hover/section:text-accent-foreground">
<span className="flex min-w-0 items-center gap-1">
<span className="min-w-0 truncate" title={label}>
{label}
@@ -1,10 +1,12 @@
import { useEffect, useState, type RefObject } from 'react'
import {
mergedBranchSectionOwnerKey,
readMergedBranchSectionRecord,
resolveMergedBranchProof,
stepMergedBranchSection,
writeMergedBranchSectionRecord,
type MergedBranchProof,
type MergedBranchSectionOwner,
type MergedBranchSectionReview
} from './merged-branch-section-disclosure'
@@ -22,32 +24,36 @@ function moveFocusOutOfBranchRows(section: HTMLElement | null): void {
/** Marks the branch compare section merged and owns its one automatic collapse per session. */
export function useMergedBranchSectionDisclosure({
worktreeId,
owner,
isMainWorktree,
review,
statusHeadOid,
uncommittedCount,
branchDiffEditorActive,
setBranchSectionCollapsed,
setBranchSectionAutoCollapsed,
sectionRef
}: {
worktreeId: string
owner: MergedBranchSectionOwner
isMainWorktree: boolean
review: MergedBranchSectionReview | null
statusHeadOid: string | null
uncommittedCount: number
branchDiffEditorActive: boolean
setBranchSectionCollapsed: (collapsed: boolean) => void
setBranchSectionAutoCollapsed: (collapsed: boolean) => void
sectionRef: RefObject<HTMLElement | null>
}): { mergedReview: MergedBranchProof | null } {
const ownerKey = mergedBranchSectionOwnerKey(owner)
const ownerId = owner.id
const ownerHostId = owner.hostId
const ownerInstanceId = owner.instanceId
const [memory, setMemory] = useState(() => ({
worktreeId,
merged: readMergedBranchSectionRecord(worktreeId).merged
ownerKey,
merged: readMergedBranchSectionRecord(owner).merged
}))
if (memory.worktreeId !== worktreeId) {
setMemory({ worktreeId, merged: readMergedBranchSectionRecord(worktreeId).merged })
if (memory.ownerKey !== ownerKey) {
setMemory({ ownerKey, merged: readMergedBranchSectionRecord(owner).merged })
}
const lastMerged = memory.worktreeId === worktreeId ? memory.merged : null
const lastMerged = memory.ownerKey === ownerKey ? memory.merged : null
const reviewProvider = review?.provider
const reviewNumber = review?.number
@@ -55,7 +61,8 @@ export function useMergedBranchSectionDisclosure({
const reviewHeadSha = review?.headSha
const reviewContainedHeadOid = review?.confirmedContainedHeadOid
useEffect(() => {
const record = readMergedBranchSectionRecord(worktreeId)
const effectOwner = { id: ownerId, hostId: ownerHostId, instanceId: ownerInstanceId }
const record = readMergedBranchSectionRecord(effectOwner)
const step = stepMergedBranchSection(record, {
review:
reviewProvider && reviewNumber !== undefined && reviewState
@@ -75,27 +82,29 @@ export function useMergedBranchSectionDisclosure({
if (step.record === record) {
return
}
writeMergedBranchSectionRecord(worktreeId, step.record)
setMemory({ worktreeId, merged: step.record.merged })
writeMergedBranchSectionRecord(effectOwner, step.record)
setMemory({ ownerKey: mergedBranchSectionOwnerKey(effectOwner), merged: step.record.merged })
if (step.action === 'collapse') {
moveFocusOutOfBranchRows(sectionRef.current)
setBranchSectionCollapsed(true)
setBranchSectionAutoCollapsed(true)
} else if (step.action === 'expand') {
setBranchSectionCollapsed(false)
setBranchSectionAutoCollapsed(false)
}
}, [
branchDiffEditorActive,
isMainWorktree,
ownerHostId,
ownerId,
ownerInstanceId,
reviewContainedHeadOid,
reviewHeadSha,
reviewNumber,
reviewProvider,
reviewState,
sectionRef,
setBranchSectionCollapsed,
setBranchSectionAutoCollapsed,
statusHeadOid,
uncommittedCount,
worktreeId
uncommittedCount
])
return { mergedReview: resolveMergedBranchProof(lastMerged, review, statusHeadOid) }
@@ -74,7 +74,7 @@ export function SourceControlPanelContent(props: SourceControlPanelReadyProps) {
revealInExplorer,
selectedKeySet,
setBaseRefDialogOpen,
setBranchSectionCollapsed,
setBranchSectionAutoCollapsed,
sourceControlAiActionsVisible,
sourceControlViewMode,
statusReadError,
@@ -233,7 +233,8 @@ export function SourceControlPanelContent(props: SourceControlPanelReadyProps) {
totalBranchEntryCount={branchEntries.length}
collapsedSections={collapsedSections}
toggleSection={toggleSection}
setBranchSectionCollapsed={setBranchSectionCollapsed}
setBranchSectionAutoCollapsed={setBranchSectionAutoCollapsed}
worktreeOwner={activeWorktree}
review={model.isFolder ? null : model.hostedReview}
statusHeadOid={statusHeadOid}
uncommittedCount={model.entries.length}
@@ -31,6 +31,7 @@ export function useSourceControlPanelState() {
})
const viewState = useSourceControlPanelViewState({
activeWorktreeId,
activeWorktreeOwner: activeWorktree ?? null,
settings,
updateSettings: storeActions.updateSettings
})
@@ -8,7 +8,9 @@ import type { SourceControlStoreActions } from '../listing/use-store-actions'
import type { SourceControlWorktreeContext } from '../listing/use-worktree-context'
import {
isBranchSectionAutoCollapsed,
markBranchSectionToggledByUser
markBranchSectionToggledByUser,
mergedBranchSectionOwnerKey,
type MergedBranchSectionOwner
} from '../listing/merged-branch-section-disclosure'
const DEFAULT_COLLAPSED_SECTIONS = ['history'] as const
@@ -21,24 +23,21 @@ function createDefaultCollapsedSections(): Set<string> {
const collapsedSectionsByWorktree = new BoundedMap<string, Set<string>>({ maxEntries: 512 })
function readCollapsedSections(worktreeId: string | null): Set<string> {
const cached = worktreeId ? collapsedSectionsByWorktree.get(worktreeId) : undefined
if (cached) {
return cached
}
const sections = createDefaultCollapsedSections()
// The disclosure record outlives this cache, so an evicted automatic collapse stays collapsed.
if (worktreeId && isBranchSectionAutoCollapsed(worktreeId)) {
sections.add('branch')
}
return sections
return (
(worktreeId ? collapsedSectionsByWorktree.get(worktreeId) : undefined) ??
createDefaultCollapsedSections()
)
}
export function useSourceControlPanelViewState({
activeWorktreeId,
activeWorktreeOwner,
settings,
updateSettings
}: {
activeWorktreeId: string | null
/** Host and checkout identity; automatic disclosure must not leak to a same-ID twin. */
activeWorktreeOwner: MergedBranchSectionOwner | null
settings: SourceControlWorktreeContext['settings']
updateSettings: SourceControlStoreActions['updateSettings']
}) {
@@ -47,7 +46,7 @@ export function useSourceControlPanelViewState({
const [fileListScrollElement, setFileListScrollElement] = useState<HTMLDivElement | null>(null)
const isMac = useMemo(() => navigator.userAgent.includes('Mac'), [])
const [filterExpanded, setFilterExpanded] = useState(false)
const [collapsedSections, setCollapsedSections] = useState<Set<string>>(() =>
const [storedCollapsedSections, setCollapsedSections] = useState<Set<string>>(() =>
readCollapsedSections(activeWorktreeId)
)
const persistedSourceControlViewMode = normalizeSourceControlViewMode(
@@ -58,7 +57,28 @@ export function useSourceControlPanelViewState({
const [collapsedTreeDirs, setCollapsedTreeDirs] = useState<Set<string>>(new Set())
const [baseRefDialogOpen, setBaseRefDialogOpen] = useState(false)
const [filterQuery, setFilterQuery] = useState('')
const isGitHistoryExpanded = !collapsedSections.has('history')
const isGitHistoryExpanded = !storedCollapsedSections.has('history')
// The automatic collapse lives in the host-qualified record, not the raw-ID cache shared by twins.
const ownerKey = activeWorktreeOwner ? mergedBranchSectionOwnerKey(activeWorktreeOwner) : null
const [branchAutoCollapse, setBranchAutoCollapse] = useState(() => ({
ownerKey,
collapsed: activeWorktreeOwner ? isBranchSectionAutoCollapsed(activeWorktreeOwner) : false
}))
if (branchAutoCollapse.ownerKey !== ownerKey) {
setBranchAutoCollapse({
ownerKey,
collapsed: activeWorktreeOwner ? isBranchSectionAutoCollapsed(activeWorktreeOwner) : false
})
}
const isBranchAutoCollapsed =
branchAutoCollapse.ownerKey === ownerKey && branchAutoCollapse.collapsed
const collapsedSections = useMemo(() => {
if (!isBranchAutoCollapsed || storedCollapsedSections.has('branch')) {
return storedCollapsedSections
}
return new Set(storedCollapsedSections).add('branch')
}, [isBranchAutoCollapsed, storedCollapsedSections])
const handleToggleSourceControlViewMode = useCallback(() => {
const current = useAppStore.getState().settings
@@ -87,8 +107,8 @@ export function useSourceControlPanelViewState({
const toggleSection = useCallback(
(section: string) => {
const next = new Set(collapsedSections)
if (next.has(section)) {
const next = new Set(storedCollapsedSections)
if (collapsedSections.has(section)) {
next.delete(section)
} else {
next.add(section)
@@ -96,32 +116,19 @@ export function useSourceControlPanelViewState({
setCollapsedSections(next)
if (activeWorktreeId) {
collapsedSectionsByWorktree.set(activeWorktreeId, next)
if (section === 'branch') {
markBranchSectionToggledByUser(activeWorktreeId)
}
}
if (section === 'branch' && activeWorktreeOwner) {
markBranchSectionToggledByUser(activeWorktreeOwner)
setBranchAutoCollapse({ ownerKey, collapsed: false })
}
},
[activeWorktreeId, collapsedSections]
[activeWorktreeId, activeWorktreeOwner, collapsedSections, ownerKey, storedCollapsedSections]
)
// Automatic disclosure leaves the user's toggle ownership untouched.
const setBranchSectionCollapsed = useCallback(
(collapsed: boolean) => {
if (collapsedSections.has('branch') === collapsed) {
return
}
const next = new Set(collapsedSections)
if (collapsed) {
next.add('branch')
} else {
next.delete('branch')
}
setCollapsedSections(next)
if (activeWorktreeId) {
collapsedSectionsByWorktree.set(activeWorktreeId, next)
}
},
[activeWorktreeId, collapsedSections]
const setBranchSectionAutoCollapsed = useCallback(
(collapsed: boolean) => setBranchAutoCollapse({ ownerKey, collapsed }),
[ownerKey]
)
const toggleTreeDir = useCallback((key: string) => {
@@ -147,7 +154,7 @@ export function useSourceControlPanelViewState({
isGitHistoryExpanded,
isMac,
setBaseRefDialogOpen,
setBranchSectionCollapsed,
setBranchSectionAutoCollapsed,
setFileListScrollElement,
setFilterExpanded,
setFilterQuery,
+1 -1
View File
@@ -28,7 +28,7 @@ const badgeVariants = cva(
'h-4 border-current/30 px-1.5 py-0 align-[1px] text-3xs font-semibold tracking-wider uppercase',
/** A merged-review state inside an uppercase section header, kept in normal case. */
mergedState:
'h-4 gap-0.5 bg-callout-important/15 px-1 py-0 text-[10px] tracking-normal normal-case text-callout-important',
'h-4 gap-0.5 bg-callout-important/10 px-1 py-0 text-[10px] tracking-normal normal-case text-merged-state-foreground [&>svg]:text-callout-important',
/** A skill or file sitting inline in the chat message box, ringed while selected. */
promptPill:
'max-w-full border-border bg-secondary px-1.5 py-0 align-baseline text-muted-foreground data-[selected=true]:ring-1 data-[selected=true]:ring-ring'