From 714dfa317d65b9ce402ef9cc246445274b252bc6 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 12 Nov 2026 17:30:33 -0800 Subject: [PATCH] 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. --- src/renderer/src/assets/main.css | 4 + .../listing/branch-section.fixture.tsx | 18 ++++- .../listing/branch-section.test.tsx | 59 +++++++++++++- .../source-control/listing/branch-section.tsx | 11 ++- .../listing/merged-branch-chip.tsx | 2 +- .../merged-branch-section-disclosure.ts | 74 +++++++++++------ .../source-control/listing/section-header.tsx | 16 ++-- .../use-merged-branch-section-disclosure.ts | 43 ++++++---- .../source-control/panel/panel-content.tsx | 5 +- .../source-control/panel/use-panel-state.ts | 1 + .../panel/use-panel-view-state.ts | 81 ++++++++++--------- src/renderer/src/components/ui/badge.tsx | 2 +- 12 files changed, 219 insertions(+), 97 deletions(-) diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 4a0ccb646b0..1b08fa29f37 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -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; diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/branch-section.fixture.tsx b/src/renderer/src/components/right-sidebar/source-control/listing/branch-section.fixture.tsx index f4eeb47c644..3cd9ff565c2 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/branch-section.fixture.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/listing/branch-section.fixture.tsx @@ -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: () => , interact: expandBranchSection }) + +export const longLocaleMinimumWidth = defineFixture({ + width: RIGHT_SIDEBAR_MIN_WIDTH, + store: { settings: germanSettings }, + render: () => , + interact: expandBranchSection +}) diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/branch-section.test.tsx b/src/renderer/src/components/right-sidebar/source-control/listing/branch-section.test.tsx index 80a77380706..fb2b928e930 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/branch-section.test.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/listing/branch-section.test.tsx @@ -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 () => { diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/branch-section.tsx b/src/renderer/src/components/right-sidebar/source-control/listing/branch-section.tsx index 82149818e6e..3c3642cadc1 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/branch-section.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/listing/branch-section.tsx @@ -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 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(null) const { mergedReview } = useMergedBranchSectionDisclosure({ - worktreeId: currentWorktreeId, + owner: worktreeOwner, isMainWorktree, review, statusHeadOid, uncommittedCount, branchDiffEditorActive: isBranchDiffEditorActive, - setBranchSectionCollapsed, + setBranchSectionAutoCollapsed, sectionRef }) diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/merged-branch-chip.tsx b/src/renderer/src/components/right-sidebar/source-control/listing/merged-branch-chip.tsx index 1c326d3eb82..d8626fdd6c2 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/merged-branch-chip.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/listing/merged-branch-chip.tsx @@ -21,7 +21,7 @@ export function MergedBranchChip({ // Title is for mouse hover; screen readers get the sr-only text in the toggle's name.