From fac7e0440cbebaab8b962641dfb0b7dc7e42faea Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:07:09 -0700 Subject: [PATCH] Prevent sidebar crashes from missing worktree titles (#6127) Co-authored-by: Orca --- .../src/components/sidebar/WorktreeCard.tsx | 8 ++- .../worktree-card-title-display.test.ts | 72 ++++++++++++++++++- .../sidebar/worktree-card-title-display.ts | 39 ++++++++-- 3 files changed, 109 insertions(+), 10 deletions(-) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index d41cf15e5..d8f306c05 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -45,7 +45,10 @@ import { import { WorktreeCardPortsDetails, WorktreeCardPortsTrigger } from './WorktreeCardPorts' import { writeWorkspaceDragData } from './workspace-status' import { getWorktreeCardPrDisplay } from './worktree-card-pr-display' -import { getWorktreeCardTitleDisplay } from './worktree-card-title-display' +import { + coerceWorktreeCardVisibleTitle, + getWorktreeCardTitleDisplay +} from './worktree-card-title-display' import { useWorktreeCardDetailsHoverControl } from './worktree-card-details-hover-state' import { isEventTargetInsideCurrentTarget } from './worktree-card-dom-events' import { getWorkspacePortsByWorktreeId } from '@/lib/workspace-port-groups' @@ -505,7 +508,8 @@ const WorktreeCard = React.memo(function WorktreeCard({ issueTitle: issueDisplay?.title, reviewTitle: prDisplay?.title }) - const visibleCardTitle = newCardStyle ? cardTitleDisplay : worktree.displayName + const legacyCardTitleDisplay = coerceWorktreeCardVisibleTitle(worktree.displayName) + const visibleCardTitle = newCardStyle ? cardTitleDisplay : legacyCardTitleDisplay const isDeleting = deleteState?.isDeleting ?? false const deleteModifierPressed = useWorkspaceDeleteModifierPressed() diff --git a/src/renderer/src/components/sidebar/worktree-card-title-display.test.ts b/src/renderer/src/components/sidebar/worktree-card-title-display.test.ts index 5e56d7df4..bb5cf7625 100644 --- a/src/renderer/src/components/sidebar/worktree-card-title-display.test.ts +++ b/src/renderer/src/components/sidebar/worktree-card-title-display.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { getWorktreeCardTitleDisplay } from './worktree-card-title-display' +import { + coerceWorktreeCardVisibleTitle, + getWorktreeCardTitleDisplay +} from './worktree-card-title-display' describe('worktree card title display', () => { it('keeps custom workspace titles', () => { @@ -10,6 +13,14 @@ describe('worktree card title display', () => { reviewTitle: 'Fix stale PR' }) ).toBe('Custom workspace') + + expect( + getWorktreeCardTitleDisplay({ + storedDisplayName: ' Custom workspace ', + branchName: 'feature/custom', + reviewTitle: 'Fix stale PR' + }) + ).toBe(' Custom workspace ') }) it('uses linked work titles instead of repeating the branch as the card title', () => { @@ -49,4 +60,63 @@ describe('worktree card title display', () => { }) ).toBe('test454545') }) + + it('uses linked work titles when the stored title is nullish and the branch is usable', () => { + expect( + getWorktreeCardTitleDisplay({ + storedDisplayName: undefined, + branchName: 'feature/local-branch', + reviewTitle: 'Fix stale GH PR' + }) + ).toBe('Fix stale GH PR') + + expect( + getWorktreeCardTitleDisplay({ + storedDisplayName: null, + branchName: 'feature/local-branch', + issueTitle: 'Fix stale issue' + }) + ).toBe('Fix stale issue') + }) + + it('treats blank stored titles as absent', () => { + expect( + getWorktreeCardTitleDisplay({ + storedDisplayName: ' ', + branchName: 'feature/local-branch' + }) + ).toBe('') + + expect( + getWorktreeCardTitleDisplay({ + storedDisplayName: '', + branchName: 'feature/local-branch', + linearIssueTitle: 'Fix stale Linear issue' + }) + ).toBe('Fix stale Linear issue') + }) + + it('skips linked-title replacement when the branch name is nullish or blank', () => { + expect( + getWorktreeCardTitleDisplay({ + storedDisplayName: 'Custom workspace', + branchName: undefined, + reviewTitle: 'Fix stale GH PR' + }) + ).toBe('Custom workspace') + + expect( + getWorktreeCardTitleDisplay({ + storedDisplayName: null, + branchName: ' ', + reviewTitle: 'Fix stale GH PR' + }) + ).toBe('') + }) + + it('coerces legacy visible titles before downstream title operations', () => { + expect(coerceWorktreeCardVisibleTitle(undefined).trim()).toBe('') + expect(coerceWorktreeCardVisibleTitle(null).trim()).toBe('') + expect(coerceWorktreeCardVisibleTitle(' Custom workspace ')).toBe(' Custom workspace ') + }) }) diff --git a/src/renderer/src/components/sidebar/worktree-card-title-display.ts b/src/renderer/src/components/sidebar/worktree-card-title-display.ts index 5da84c1f5..be0aa9200 100644 --- a/src/renderer/src/components/sidebar/worktree-card-title-display.ts +++ b/src/renderer/src/components/sidebar/worktree-card-title-display.ts @@ -1,11 +1,16 @@ type WorktreeCardTitleDisplayInput = { - storedDisplayName: string - branchName: string + storedDisplayName: string | null | undefined + branchName: string | null | undefined linearIssueTitle?: string | null issueTitle?: string | null reviewTitle?: string | null } +function normalizeComparableTitle(value: string | null | undefined): string | null { + const trimmed = value?.trim() + return trimmed ? trimmed : null +} + function normalizeTitle(value: string | null | undefined): string | null { const trimmed = value?.trim() if (!trimmed) { @@ -17,8 +22,17 @@ function normalizeTitle(value: string | null | undefined): string | null { return trimmed } -function isBranchTitle(displayName: string, branchName: string): boolean { - return displayName.trim() === branchName.trim() +function isBranchTitle( + normalizedDisplayName: string | null, + normalizedBranchName: string | null +): boolean { + return normalizedDisplayName !== null && normalizedDisplayName === normalizedBranchName +} + +export function coerceWorktreeCardVisibleTitle(value: string | null | undefined): string { + // Why: the legacy card path can bypass title selection but still feeds trim() + // and inline rename props, so nullish persisted titles stop at this boundary. + return typeof value === 'string' ? value : '' } export function getWorktreeCardTitleDisplay({ @@ -28,8 +42,19 @@ export function getWorktreeCardTitleDisplay({ issueTitle, reviewTitle }: WorktreeCardTitleDisplayInput): string { - if (!branchName || !isBranchTitle(storedDisplayName, branchName)) { - return storedDisplayName + const normalizedStoredDisplayName = normalizeComparableTitle(storedDisplayName) + const normalizedBranchName = normalizeComparableTitle(branchName) + const visibleStoredDisplayName = coerceWorktreeCardVisibleTitle(storedDisplayName) + + if (!normalizedBranchName) { + return normalizedStoredDisplayName ? visibleStoredDisplayName : '' + } + + if ( + normalizedStoredDisplayName && + !isBranchTitle(normalizedStoredDisplayName, normalizedBranchName) + ) { + return visibleStoredDisplayName } // Why: branch names are available in hover/details; the closed card title @@ -38,6 +63,6 @@ export function getWorktreeCardTitleDisplay({ normalizeTitle(linearIssueTitle) ?? normalizeTitle(issueTitle) ?? normalizeTitle(reviewTitle) ?? - storedDisplayName + (normalizedStoredDisplayName ? visibleStoredDisplayName : '') ) }