mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
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:
@@ -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;
|
||||
|
||||
+15
-3
@@ -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
|
||||
})
|
||||
|
||||
+57
-2
@@ -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
|
||||
})
|
||||
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+51
-23
@@ -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}
|
||||
|
||||
+26
-17
@@ -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
|
||||
})
|
||||
|
||||
+44
-37
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user