mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
Highlight the open file in the Source Control sidebar (#5667)
* Highlight the open file in the Source Control sidebar Pending (uncommitted) rows had no persistent indicator for the file currently open in the editor: a plain single click cleared the bulk selection and only opened the diff, leaving no visual cue for which row was active. Derive the active editor file for the worktree from the store and mark its Source Control row as the current row (full bg-accent + data-current), outranking the lighter bulk-selection tint. Matching is area-aware so a partially staged file lights up only the row that is actually open, and the highlight clears when the visible tab is not an editor. Extract the row-key mapping into source-control-active-open-file-keys.ts and cover it with unit tests plus render tests for the highlight wiring. * Update src/renderer/src/components/right-sidebar/SourceControl.open-file-highlight.test.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Refine source control open-file highlighting --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: brennanb2025 <brennankbenson@gmail.com>
This commit is contained in:
co-authored by
coderabbitai[bot]
brennanb2025
parent
5100adca64
commit
99bc04609d
@@ -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<string, unknown>
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/store', () => {
|
||||
const useAppStore = Object.assign(
|
||||
(selector?: (state: Record<string, unknown>) => 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>): 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<unknown> {
|
||||
return vi.fn().mockResolvedValue(value)
|
||||
}
|
||||
|
||||
function resetState(overrides: Partial<Record<string, unknown>> = {}): 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(
|
||||
<TooltipProvider>
|
||||
<SourceControl />
|
||||
</TooltipProvider>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function row(path: string, area: GitStatusEntry['area']): HTMLDivElement | null {
|
||||
return container.querySelector<HTMLDivElement>(
|
||||
`[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)
|
||||
})
|
||||
})
|
||||
@@ -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<string>()
|
||||
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`
|
||||
|
||||
+73
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { DiffSource } from '@/store/slices/editor'
|
||||
|
||||
const EMPTY_OPEN_ROW_KEYS: ReadonlySet<string> = 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<string>
|
||||
): ReadonlySet<string> {
|
||||
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<string> | undefined
|
||||
): ReadonlySet<string> {
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user