diff --git a/src/renderer/src/components/right-sidebar/SourceControl.open-file-highlight.test.tsx b/src/renderer/src/components/right-sidebar/SourceControl.open-file-highlight.test.tsx new file mode 100644 index 00000000000..6d43872ba66 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/SourceControl.open-file-highlight.test.tsx @@ -0,0 +1,385 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import type { GitStatusEntry } from '../../../../shared/types' +import SourceControl from './SourceControl' + +const mocks = vi.hoisted(() => { + const activeRepo = { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: '#000', + addedAt: 0 + } + const activeWorktree = { + id: 'wt-1', + repoId: 'repo-1', + path: '/repo/wt', + head: 'abcdef123', + branch: 'refs/heads/feature/open-file-highlight', + isBare: false, + isMainWorktree: false, + displayName: 'feature/open-file-highlight', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0 + } + return { + activeRepo, + activeWorktree, + state: {} as Record + } +}) + +vi.mock('@/store', () => { + const useAppStore = Object.assign( + (selector?: (state: Record) => unknown) => + selector ? selector(mocks.state) : mocks.state, + { + getState: () => mocks.state + } + ) + return { useAppStore } +}) + +vi.mock('@/store/selectors', () => ({ + useActiveWorktree: () => mocks.activeWorktree, + useRepoById: (repoId: string | null) => + repoId === mocks.activeRepo.id ? mocks.activeRepo : null, + useWorktreeMap: () => new Map([[mocks.activeWorktree.id, mocks.activeWorktree]]) +})) + +vi.mock('@/components/confirmation-dialog', () => ({ + useConfirmationDialog: () => vi.fn().mockResolvedValue(true) +})) + +vi.mock('./git-status-refresh', () => ({ + refreshGitStatusForWorktree: vi.fn().mockResolvedValue(undefined) +})) + +function gitEntry(overrides: Partial): GitStatusEntry { + return { + path: 'src/file.ts', + area: 'unstaged', + status: 'modified', + added: 1, + removed: 0, + ...overrides + } +} + +type OpenFileStub = { + id: string + worktreeId: string + relativePath: string + diffSource?: string +} + +function noopAsync(value: unknown = undefined): () => Promise { + return vi.fn().mockResolvedValue(value) +} + +function resetState(overrides: Partial> = {}): void { + vi.clearAllMocks() + mocks.state = { + activeWorktreeId: mocks.activeWorktree.id, + activeGroupIdByWorktree: { [mocks.activeWorktree.id]: 'group-1' }, + groupsByWorktree: { [mocks.activeWorktree.id]: [{ id: 'group-1', activeTabId: null }] }, + repos: [mocks.activeRepo], + worktreesByRepo: { [mocks.activeRepo.id]: [mocks.activeWorktree] }, + rightSidebarOpen: false, + rightSidebarTab: 'source-control', + gitStatusByWorktree: { [mocks.activeWorktree.id]: [] }, + gitBranchChangesByWorktree: { [mocks.activeWorktree.id]: [] }, + gitBranchCompareSummaryByWorktree: { [mocks.activeWorktree.id]: null }, + gitConflictOperationByWorktree: {}, + remoteStatusesByWorktree: {}, + isRemoteOperationActive: false, + inFlightRemoteOpKind: null, + settings: null, + hostedReviewCache: {}, + prCache: {}, + commitMessageGenerationRecords: {}, + pullRequestGenerationRecords: {}, + // Why: the open-file highlight reads the active editor tab for this worktree. + openFiles: [] as OpenFileStub[], + activeFileIdByWorktree: {}, + activeTabTypeByWorktree: {}, + getDiffComments: vi.fn(() => []), + updateSettings: noopAsync(), + openSettingsTarget: vi.fn(), + openSettingsPage: vi.fn(), + fetchHostedReviewForBranch: noopAsync(), + getHostedReviewCreationEligibility: noopAsync(null), + createHostedReview: noopAsync({ ok: false, error: 'not available' }), + updateWorktreeMeta: noopAsync(), + fetchPRForBranch: noopAsync(), + enqueueGitHubPRRefresh: vi.fn(), + updateRepo: noopAsync(), + setGitStatus: vi.fn(), + updateWorktreeGitIdentity: vi.fn(), + beginGitBranchCompareRequest: vi.fn(() => 'request-key'), + setGitBranchCompareResult: vi.fn(), + fetchUpstreamStatus: noopAsync(), + setUpstreamStatus: vi.fn(), + pushBranch: noopAsync(), + pullBranch: noopAsync(), + fastForwardBranch: noopAsync(), + syncBranch: noopAsync(), + rebaseFromBase: noopAsync(), + fetchBranch: noopAsync(), + revealInExplorer: vi.fn(), + trackConflictPath: vi.fn(), + openDiff: vi.fn(), + openFile: vi.fn(), + setEditorViewMode: vi.fn(), + setMarkdownViewMode: vi.fn(), + setPendingEditorReveal: vi.fn(), + openConflictFile: vi.fn(), + openConflictReview: vi.fn(), + openBranchDiff: vi.fn(), + createEmptySplitGroup: vi.fn(() => 'group-2'), + openAllDiffs: vi.fn(), + openBranchAllDiffs: vi.fn(), + openCommitAllDiffs: vi.fn(), + deleteDiffComment: noopAsync(true), + clearDiffComments: noopAsync(true), + clearDiffCommentsForFile: noopAsync(true), + setScrollToDiffCommentId: vi.fn(), + setRightSidebarOpen: vi.fn(), + setRightSidebarTab: vi.fn(), + allocateCommitMessageGenerationRequestId: vi.fn(() => 'commit-generation-1'), + setCommitMessageGenerationRecord: vi.fn(), + updateCommitMessageGenerationRecord: vi.fn(), + pruneCommitMessageGenerationRecords: vi.fn(), + allocatePullRequestGenerationRequestId: vi.fn(() => 'pr-generation-1'), + setPullRequestGenerationRecord: vi.fn(), + updatePullRequestGenerationRecord: vi.fn(), + prunePullRequestGenerationRecords: vi.fn(), + ...overrides + } +} + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + resetState() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +function renderSourceControl(): void { + act(() => { + root.render( + + + + ) + }) +} + +function row(path: string, area: GitStatusEntry['area']): HTMLDivElement | null { + return container.querySelector( + `[data-source-control-path="${path}"][data-source-control-area="${area}"]` + ) +} + +function isHighlighted(element: HTMLElement | null): boolean { + if (!element) { + return false + } + return ( + element.getAttribute('data-current') === 'true' && + element.classList.contains('bg-accent') && + !element.classList.contains('bg-accent/60') + ) +} + +describe('SourceControl open-file highlight', () => { + it('highlights the row whose unstaged diff is the active editor tab', () => { + resetState({ + gitStatusByWorktree: { + [mocks.activeWorktree.id]: [ + gitEntry({ path: 'src/file.ts', area: 'unstaged' }), + gitEntry({ path: 'src/other.ts', area: 'unstaged' }) + ] + }, + openFiles: [ + { + id: 'tab-1', + worktreeId: mocks.activeWorktree.id, + relativePath: 'src/file.ts', + diffSource: 'unstaged' + } + ], + activeFileIdByWorktree: { [mocks.activeWorktree.id]: 'tab-1' }, + activeTabTypeByWorktree: { [mocks.activeWorktree.id]: 'editor' } + }) + renderSourceControl() + + expect(isHighlighted(row('src/file.ts', 'unstaged'))).toBe(true) + expect(isHighlighted(row('src/other.ts', 'unstaged'))).toBe(false) + }) + + it('matches by area so only the staged row of a partially staged file lights up', () => { + resetState({ + gitStatusByWorktree: { + [mocks.activeWorktree.id]: [ + gitEntry({ path: 'src/file.ts', area: 'staged' }), + gitEntry({ path: 'src/file.ts', area: 'unstaged' }) + ] + }, + openFiles: [ + { + id: 'tab-1', + worktreeId: mocks.activeWorktree.id, + relativePath: 'src/file.ts', + diffSource: 'staged' + } + ], + activeFileIdByWorktree: { [mocks.activeWorktree.id]: 'tab-1' }, + activeTabTypeByWorktree: { [mocks.activeWorktree.id]: 'editor' } + }) + renderSourceControl() + + expect(isHighlighted(row('src/file.ts', 'staged'))).toBe(true) + expect(isHighlighted(row('src/file.ts', 'unstaged'))).toBe(false) + }) + + it('highlights the untracked row when an untracked file is open as an unstaged diff', () => { + resetState({ + gitStatusByWorktree: { + [mocks.activeWorktree.id]: [ + gitEntry({ path: 'src/new.ts', area: 'untracked', status: 'untracked' }) + ] + }, + openFiles: [ + { + id: 'tab-1', + worktreeId: mocks.activeWorktree.id, + relativePath: 'src/new.ts', + diffSource: 'unstaged' + } + ], + activeFileIdByWorktree: { [mocks.activeWorktree.id]: 'tab-1' }, + activeTabTypeByWorktree: { [mocks.activeWorktree.id]: 'editor' } + }) + renderSourceControl() + + expect(isHighlighted(row('src/new.ts', 'untracked'))).toBe(true) + }) + + it('does not highlight any row when the visible tab is not an editor', () => { + resetState({ + gitStatusByWorktree: { + [mocks.activeWorktree.id]: [gitEntry({ path: 'src/file.ts', area: 'unstaged' })] + }, + openFiles: [ + { + id: 'tab-1', + worktreeId: mocks.activeWorktree.id, + relativePath: 'src/file.ts', + diffSource: 'unstaged' + } + ], + activeFileIdByWorktree: { [mocks.activeWorktree.id]: 'tab-1' }, + activeTabTypeByWorktree: { [mocks.activeWorktree.id]: 'terminal' } + }) + renderSourceControl() + + expect(isHighlighted(row('src/file.ts', 'unstaged'))).toBe(false) + }) + + it('does not highlight pending rows for branch compare tabs with the same path', () => { + resetState({ + gitStatusByWorktree: { + [mocks.activeWorktree.id]: [gitEntry({ path: 'src/file.ts', area: 'unstaged' })] + }, + openFiles: [ + { + id: 'tab-1', + worktreeId: mocks.activeWorktree.id, + relativePath: 'src/file.ts', + diffSource: 'branch' + } + ], + activeFileIdByWorktree: { [mocks.activeWorktree.id]: 'tab-1' }, + activeTabTypeByWorktree: { [mocks.activeWorktree.id]: 'editor' } + }) + renderSourceControl() + + expect(isHighlighted(row('src/file.ts', 'unstaged'))).toBe(false) + }) + + it('falls back to a staged-only row for an ordinary editor tab', () => { + resetState({ + gitStatusByWorktree: { + [mocks.activeWorktree.id]: [gitEntry({ path: 'src/file.ts', area: 'staged' })] + }, + openFiles: [ + { + id: 'tab-1', + worktreeId: mocks.activeWorktree.id, + relativePath: 'src/file.ts' + } + ], + activeFileIdByWorktree: { [mocks.activeWorktree.id]: 'tab-1' }, + activeTabTypeByWorktree: { [mocks.activeWorktree.id]: 'editor' } + }) + renderSourceControl() + + expect(isHighlighted(row('src/file.ts', 'staged'))).toBe(true) + }) + + it('falls back to the visible staged row when the working-tree section is collapsed', () => { + resetState({ + gitStatusByWorktree: { + [mocks.activeWorktree.id]: [ + gitEntry({ path: 'src/file.ts', area: 'staged' }), + gitEntry({ path: 'src/file.ts', area: 'unstaged' }) + ] + }, + openFiles: [ + { + id: 'tab-1', + worktreeId: mocks.activeWorktree.id, + relativePath: 'src/file.ts' + } + ], + activeFileIdByWorktree: { [mocks.activeWorktree.id]: 'tab-1' }, + activeTabTypeByWorktree: { [mocks.activeWorktree.id]: 'editor' } + }) + renderSourceControl() + + const changesHeader = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Changes') + ) + expect(changesHeader).toBeTruthy() + act(() => { + changesHeader?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(row('src/file.ts', 'unstaged')).toBeNull() + expect(isHighlighted(row('src/file.ts', 'staged'))).toBe(true) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index ff31f0ed1ae..f674370b7fa 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -82,6 +82,10 @@ import { flattenSourceControlTree, type SourceControlTreeNode } from './source-control-tree' +import { + buildActiveOpenFileSignature, + buildActiveOpenRowKeys +} from './source-control-active-open-file-keys' import { SourceControlDiscardDialog, type PendingDiscardConfirmation @@ -3879,6 +3883,43 @@ function SourceControlInner(): React.JSX.Element { [activeGroupIdByWorktree, activeWorktreeId, createEmptySplitGroup, groupsByWorktree, isMac] ) + // Why: a stable string signature keeps this selector referentially stable so + // the panel only re-renders when the active editor file (or its diff source) + // actually changes. Gated on the visible tab being an editor so the highlight + // clears when the user switches to a terminal or browser surface. + const activeOpenFileSignature = useAppStore((s) => { + if (!activeWorktreeId) { + return null + } + if (s.activeTabTypeByWorktree?.[activeWorktreeId] !== 'editor') { + return null + } + const activeFileId = s.activeFileIdByWorktree?.[activeWorktreeId] + if (!activeFileId) { + return null + } + const activeFile = s.openFiles?.find( + (file) => file.id === activeFileId && file.worktreeId === activeWorktreeId + ) + if (!activeFile) { + return null + } + return buildActiveOpenFileSignature(activeFile.diffSource, activeFile.relativePath) + }) + + const activeOpenAvailableRowKeys = useMemo(() => { + const keys = new Set() + for (const entry of visibleSelectionEntries) { + keys.add(entry.key) + } + return keys + }, [visibleSelectionEntries]) + + const activeOpenRowKeys = useMemo( + () => buildActiveOpenRowKeys(activeOpenFileSignature, activeOpenAvailableRowKeys), + [activeOpenAvailableRowKeys, activeOpenFileSignature] + ) + const handleOpenDiff = useCallback( (entry: GitStatusEntry, event?: SourceControlRowOpenEvent) => { if (!activeWorktreeId || !worktreePath) { @@ -5526,6 +5567,7 @@ function SourceControlInner(): React.JSX.Element { worktreePath={worktreePath} depth={node.depth} selected={selectedKeySet.has(node.key)} + isOpenFile={activeOpenRowKeys.has(node.key)} onSelect={handleSelect} onContextMenu={handleContextMenu} onRevealInExplorer={revealInExplorer} @@ -5548,6 +5590,7 @@ function SourceControlInner(): React.JSX.Element { currentWorktreeId={currentWorktreeId} worktreePath={worktreePath} selected={selectedKeySet.has(key)} + isOpenFile={activeOpenRowKeys.has(key)} onSelect={handleSelect} onContextMenu={handleContextMenu} onRevealInExplorer={revealInExplorer} @@ -7436,6 +7479,7 @@ const UncommittedEntryRow = React.memo(function UncommittedEntryRow({ worktreePath, depth = 0, selected, + isOpenFile = false, onSelect, onContextMenu, onRevealInExplorer, @@ -7452,6 +7496,7 @@ const UncommittedEntryRow = React.memo(function UncommittedEntryRow({ worktreePath: string depth?: number selected?: boolean + isOpenFile?: boolean onSelect?: (e: React.MouseEvent, key: string, entry: GitStatusEntry) => void onContextMenu?: (key: string) => void onRevealInExplorer: (worktreeId: string, absolutePath: string) => void @@ -7506,9 +7551,14 @@ const UncommittedEntryRow = React.memo(function UncommittedEntryRow({ data-testid="source-control-entry" data-source-control-path={entry.path} data-source-control-area={entry.area} + // Why: the currently open file gets the strongest "current row" accent + // (full `bg-accent` + `data-current`) per the styleguide, outranking the + // lighter bulk-selection tint so the open file always reads as active. + data-current={isOpenFile ? 'true' : undefined} className={cn( - 'group relative flex cursor-pointer items-center gap-1 pr-3 py-1 transition-colors hover:bg-accent/40', - selected && 'bg-accent/60' + 'group relative flex cursor-pointer items-center gap-1 pr-3 py-1 transition-colors', + isOpenFile ? 'bg-accent hover:bg-accent' : 'hover:bg-accent/40', + !isOpenFile && selected && 'bg-accent/60' )} style={{ paddingLeft: `${depth * SOURCE_CONTROL_TREE_INDENT_PX + SOURCE_CONTROL_TREE_FILE_PADDING_PX}px` diff --git a/src/renderer/src/components/right-sidebar/source-control-active-open-file-keys.test.ts b/src/renderer/src/components/right-sidebar/source-control-active-open-file-keys.test.ts new file mode 100644 index 00000000000..a2bdccc6eaa --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control-active-open-file-keys.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' +import { + buildActiveOpenFileSignature, + buildActiveOpenRowKeys +} from './source-control-active-open-file-keys' + +describe('buildActiveOpenFileSignature', () => { + it('encodes the diff source and relative path', () => { + expect(buildActiveOpenFileSignature('staged', 'src/file.ts')).toBe('staged::src/file.ts') + expect(buildActiveOpenFileSignature('unstaged', 'src/file.ts')).toBe('unstaged::src/file.ts') + }) + + it('falls back to the edit source when the tab has no diff source', () => { + expect(buildActiveOpenFileSignature(undefined, 'docs/readme.md')).toBe('edit::docs/readme.md') + }) + + it('keeps separators that appear inside the path intact', () => { + const signature = buildActiveOpenFileSignature('unstaged', 'src/a::b.ts') + expect(buildActiveOpenRowKeys(signature)).toEqual( + new Set(['unstaged::src/a::b.ts', 'untracked::src/a::b.ts']) + ) + }) +}) + +describe('buildActiveOpenRowKeys', () => { + it('matches only the staged row for a staged diff', () => { + expect(buildActiveOpenRowKeys('staged::src/file.ts')).toEqual(new Set(['staged::src/file.ts'])) + }) + + it('matches the working-tree rows for an unstaged diff', () => { + expect(buildActiveOpenRowKeys('unstaged::src/file.ts')).toEqual( + new Set(['unstaged::src/file.ts', 'untracked::src/file.ts']) + ) + }) + + it('treats an untracked file opened as an unstaged diff like the working tree', () => { + expect(buildActiveOpenRowKeys('unstaged::docs/readme.md')).toEqual( + new Set(['unstaged::docs/readme.md', 'untracked::docs/readme.md']) + ) + }) + + it('matches edit tabs to working-tree rows before staged-only fallback', () => { + expect(buildActiveOpenRowKeys('edit::docs/readme.md')).toEqual( + new Set(['unstaged::docs/readme.md', 'untracked::docs/readme.md']) + ) + expect( + buildActiveOpenRowKeys( + 'edit::docs/readme.md', + new Set(['staged::docs/readme.md', 'unstaged::docs/readme.md']) + ) + ).toEqual(new Set(['unstaged::docs/readme.md'])) + expect( + buildActiveOpenRowKeys('edit::docs/readme.md', new Set(['staged::docs/readme.md'])) + ).toEqual(new Set(['staged::docs/readme.md'])) + }) + + it('does not match compare or combined diff tabs to pending rows', () => { + expect(buildActiveOpenRowKeys('branch::src/file.ts').size).toBe(0) + expect(buildActiveOpenRowKeys('commit::src/file.ts').size).toBe(0) + expect(buildActiveOpenRowKeys('combined-uncommitted::src/file.ts').size).toBe(0) + expect(buildActiveOpenRowKeys('combined-branch::src/file.ts').size).toBe(0) + expect(buildActiveOpenRowKeys('combined-commit::src/file.ts').size).toBe(0) + }) + + it('returns an empty set when there is no active open file', () => { + expect(buildActiveOpenRowKeys(null).size).toBe(0) + }) + + it('returns an empty set for a malformed signature', () => { + expect(buildActiveOpenRowKeys('no-separator').size).toBe(0) + expect(buildActiveOpenRowKeys('staged::').size).toBe(0) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/source-control-active-open-file-keys.ts b/src/renderer/src/components/right-sidebar/source-control-active-open-file-keys.ts new file mode 100644 index 00000000000..4c28a1062bb --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control-active-open-file-keys.ts @@ -0,0 +1,77 @@ +import type { DiffSource } from '@/store/slices/editor' + +const EMPTY_OPEN_ROW_KEYS: ReadonlySet = new Set() + +const SIGNATURE_SEPARATOR = '::' + +// Why: a plain edit tab carries no diff source, so it is matched against +// visible pending rows by path instead of a specific diff side. +export type ActiveOpenFileDiffSource = DiffSource | 'edit' + +/** + * Build the stable string that identifies the active editor file for highlight + * matching: `${diffSource}::${relativePath}`. Kept as a primitive so the zustand + * selector that produces it stays referentially stable across unrelated store + * updates. + */ +export function buildActiveOpenFileSignature( + diffSource: DiffSource | undefined, + relativePath: string +): string { + return `${diffSource ?? 'edit'}${SIGNATURE_SEPARATOR}${relativePath}` +} + +/** + * Expand an active-open-file signature into the `${area}::${path}` row keys used + * by the Source Control tree/list. Staged/unstaged diffs match their side; plain + * edit tabs prefer working-tree rows and fall back to staged-only rows. + */ +export function buildActiveOpenRowKeys( + signature: string | null, + availableRowKeys?: ReadonlySet +): ReadonlySet { + if (!signature) { + return EMPTY_OPEN_ROW_KEYS + } + + const separatorIndex = signature.indexOf(SIGNATURE_SEPARATOR) + if (separatorIndex === -1) { + return EMPTY_OPEN_ROW_KEYS + } + + const diffSource = signature.slice(0, separatorIndex) + const path = signature.slice(separatorIndex + SIGNATURE_SEPARATOR.length) + if (path.length === 0) { + return EMPTY_OPEN_ROW_KEYS + } + + if (diffSource === 'staged') { + return filterAvailableRowKeys([`staged::${path}`], availableRowKeys) + } + + const workingTreeKeys = [`unstaged::${path}`, `untracked::${path}`] + if (diffSource === 'unstaged') { + return filterAvailableRowKeys(workingTreeKeys, availableRowKeys) + } + + if (diffSource === 'edit') { + const availableWorkingTreeKeys = filterAvailableRowKeys(workingTreeKeys, availableRowKeys) + if (availableWorkingTreeKeys.size > 0 || !availableRowKeys) { + return availableWorkingTreeKeys + } + return filterAvailableRowKeys([`staged::${path}`], availableRowKeys) + } + + return EMPTY_OPEN_ROW_KEYS +} + +function filterAvailableRowKeys( + candidates: string[], + availableRowKeys: ReadonlySet | undefined +): ReadonlySet { + if (!availableRowKeys) { + return new Set(candidates) + } + const keys = candidates.filter((key) => availableRowKeys.has(key)) + return keys.length > 0 ? new Set(keys) : EMPTY_OPEN_ROW_KEYS +}