Split up combined-diff viewer into feature-organized modules

Decompose the 221-line monolithic CombinedDiffViewer into smaller, focused modules organized by feature: entry resolution, section loading, view state memory, file tree navigation, review controls, and scroll viewport handling. Main component now composes these hooks to orchestrate the combined-diff view.
This commit is contained in:
Jinjing
2026-08-29 21:31:43 -07:00
parent bc2def382e
commit f166c3cd84
46 changed files with 3505 additions and 2319 deletions
-1
View File
@@ -47,7 +47,6 @@ inline src/renderer/src/components/Terminal.tsx
inline src/renderer/src/components/WorktreeJumpPalette.tsx
inline src/renderer/src/components/activity/ActivityPrototypePage.tsx
inline src/renderer/src/components/automations/AutomationsPage.tsx
inline src/renderer/src/components/editor/CombinedDiffViewer.tsx
inline src/renderer/src/components/editor/MarkdownPreview.tsx
inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx
inline src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,82 @@
import { basename } from '@/lib/path'
import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types'
import { isClipboardTextByteLengthOverLimit } from '../../../../../../shared/clipboard-text'
import {
buildSourceControlTree,
compactSourceControlTree,
flattenSourceControlTree
} from '@/components/right-sidebar/source-control-tree'
import {
getCombinedDiffFileTreeSectionKey,
isGitStatusEntry,
type CombinedDiffBranchTreeArea,
type CombinedDiffFileTreeEntry,
type CombinedDiffFileTreeMode
} from '../resolve-changes/combined-diff-section-identity'
export const NO_EXTENSION_KEY = '(no extension)'
export const COMBINED_DIFF_FILE_TREE_QUERY_MAX_BYTES = 2 * 1024
export function isCombinedDiffFileTreeQueryTooLarge(
query: string,
maxBytes = COMBINED_DIFF_FILE_TREE_QUERY_MAX_BYTES
): boolean {
return isClipboardTextByteLengthOverLimit(query, maxBytes)
}
export function getEntryExtension(entry: CombinedDiffFileTreeEntry): string {
const name = basename(entry.path)
const index = name.lastIndexOf('.')
if (index <= 0 || index === name.length - 1) {
return NO_EXTENSION_KEY
}
return name.slice(index).toLowerCase()
}
function getEntrySearchText(entry: CombinedDiffFileTreeEntry): string {
return [entry.path, entry.oldPath ?? '', entry.status, isGitStatusEntry(entry) ? entry.area : '']
.join(' ')
.toLowerCase()
}
export function getFilteredCombinedDiffFileTreeEntries({
entries,
mode,
query,
excludedExtensions,
includeViewed,
viewedSectionKeys
}: {
entries: readonly CombinedDiffFileTreeEntry[]
mode: CombinedDiffFileTreeMode
query: string
excludedExtensions: ReadonlySet<string>
includeViewed: boolean
viewedSectionKeys: ReadonlySet<string>
}): CombinedDiffFileTreeEntry[] {
if (isCombinedDiffFileTreeQueryTooLarge(query)) {
return []
}
const trimmedQuery = query.trim()
const normalizedQuery = trimmedQuery.toLowerCase()
return entries.filter((entry) => {
if (excludedExtensions.has(getEntryExtension(entry))) {
return false
}
if (!includeViewed && viewedSectionKeys.has(getCombinedDiffFileTreeSectionKey(mode, entry))) {
return false
}
return normalizedQuery.length === 0 || getEntrySearchText(entry).includes(normalizedQuery)
})
}
export function getCombinedDiffBranchEntriesInTreeOrder(
mode: Extract<CombinedDiffFileTreeMode, 'branch' | 'commit'>,
entries: readonly GitBranchChangeEntry[]
): GitBranchChangeEntry[] {
const area: CombinedDiffBranchTreeArea = mode === 'commit' ? 'combined-commit' : 'combined-branch'
const roots = compactSourceControlTree(buildSourceControlTree(area, [...entries]))
return flattenSourceControlTree(roots, new Set())
.filter((node) => node.type === 'file')
.map((node) => node.entry)
}
@@ -0,0 +1,47 @@
import {
getCombinedDiffFileTreeSectionKey,
type CombinedDiffFileTreeEntry,
type CombinedDiffFileTreeMode
} from '../resolve-changes/combined-diff-section-identity'
export function getCombinedDiffFileTreeNavigationIndex({
mode,
entry,
sectionIndexByKey
}: {
mode: CombinedDiffFileTreeMode
entry: CombinedDiffFileTreeEntry
sectionIndexByKey: ReadonlyMap<string, number>
}): number | null {
return sectionIndexByKey.get(getCombinedDiffFileTreeSectionKey(mode, entry)) ?? null
}
export function handleCombinedDiffFileTreeNavigation({
mode,
entry,
sections,
sectionIndexByKey,
toggleSection,
loadSection,
scrollToIndex
}: {
mode: CombinedDiffFileTreeMode
entry: CombinedDiffFileTreeEntry
sections: readonly { collapsed: boolean }[]
sectionIndexByKey: ReadonlyMap<string, number>
toggleSection: (index: number) => void
loadSection?: (index: number) => void
scrollToIndex: (index: number) => void
}): number | null {
const index = getCombinedDiffFileTreeNavigationIndex({ mode, entry, sectionIndexByKey })
if (index === null || !sections[index]) {
return null
}
if (sections[index].collapsed) {
toggleSection(index)
}
loadSection?.(index)
scrollToIndex(index)
return index
}
@@ -6,18 +6,18 @@ import { getFileTypeIcon } from '@/lib/file-type-icons'
import { basename, dirname, joinPath } from '@/lib/path'
import { cn } from '@/lib/utils'
import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag'
import type { GitBranchChangeEntry } from '../../../../shared/git-diff-compare-types'
import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types'
import type {
GitFileStatus,
GitStagingArea,
GitStatusEntry
} from '../../../../shared/git-status-types'
} from '../../../../../../shared/git-status-types'
import {
getCombinedDiffFileTreeSectionKey,
type CombinedDiffBranchTreeArea,
type CombinedDiffFileTreeEntry,
type CombinedDiffFileTreeMode
} from './combined-diff-file-tree-model'
} from '../resolve-changes/combined-diff-section-identity'
export type CombinedDiffTreeNode = SourceControlTreeNode<
GitStatusEntry | GitBranchChangeEntry,
@@ -1,18 +1,20 @@
import { describe, expect, it, vi } from 'vitest'
import {
createCombinedDiffSectionIndexMap,
getCombinedDiffFileTreeSectionKey
} from '../resolve-changes/combined-diff-section-identity'
import {
getCombinedDiffFileTreeNavigationIndex,
getCombinedDiffFileTreeSectionKey,
handleCombinedDiffFileTreeNavigation
} from './CombinedDiffFileTree'
} from './combined-diff-file-tree-navigation'
import {
COMBINED_DIFF_FILE_TREE_QUERY_MAX_BYTES,
getCombinedDiffBranchEntriesInTreeOrder,
getFilteredCombinedDiffFileTreeEntries,
isCombinedDiffFileTreeQueryTooLarge
} from './combined-diff-file-tree-model'
import type { GitBranchChangeEntry } from '../../../../shared/git-diff-compare-types'
import type { GitStatusEntry } from '../../../../shared/git-status-types'
} from './combined-diff-file-tree-filter'
import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types'
import type { GitStatusEntry } from '../../../../../../shared/git-status-types'
describe('CombinedDiffFileTree navigation mapping', () => {
it('disambiguates uncommitted entries with the same path by area', () => {
@@ -10,27 +10,22 @@ import {
compactSourceControlTree,
flattenSourceControlTree
} from '@/components/right-sidebar/source-control-tree'
import type { GitBranchChangeEntry } from '../../../../shared/git-diff-compare-types'
import type { GitStagingArea, GitStatusEntry } from '../../../../shared/git-status-types'
import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types'
import type { GitStagingArea, GitStatusEntry } from '../../../../../../shared/git-status-types'
import {
getEntryExtension,
getFilteredCombinedDiffFileTreeEntries,
getFilteredCombinedDiffFileTreeEntries
} from './combined-diff-file-tree-filter'
import {
isGitStatusEntry,
type CombinedDiffBranchTreeArea,
type CombinedDiffFileTreeEntry,
type CombinedDiffFileTreeMode
} from './combined-diff-file-tree-model'
} from '../resolve-changes/combined-diff-section-identity'
import { CombinedDiffFileTreeRow, type CombinedDiffTreeNode } from './combined-diff-file-tree-row'
import { useCombinedDiffFileTreeResize } from './use-combined-diff-file-tree-resize'
import { translate } from '@/i18n/i18n'
export {
createCombinedDiffSectionIndexMap,
getCombinedDiffFileTreeNavigationIndex,
getCombinedDiffFileTreeSectionKey,
handleCombinedDiffFileTreeNavigation
} from './combined-diff-file-tree-model'
const UNCOMMITTED_AREA_ORDER: readonly GitStagingArea[] = ['unstaged', 'staged', 'untracked']
const UNCOMMITTED_AREA_LABELS: Record<GitStagingArea, string> = {
unstaged: 'Changes',
@@ -3,7 +3,7 @@ import {
clampCombinedDiffFileTreeWidth,
COMBINED_DIFF_FILE_TREE_RESIZE_STEP,
computeCombinedDiffFileTreeWidthBounds
} from '../../../../shared/combined-diff-file-tree-width'
} from '../../../../../../shared/combined-diff-file-tree-width'
import { useSidebarResize } from '@/hooks/useSidebarResize'
import { useAppStore } from '@/store'
@@ -0,0 +1,98 @@
import React, { useCallback, useRef, useState } from 'react'
import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types'
import type { GitStatusEntry } from '../../../../../../shared/git-status-types'
import type { DiffSection } from '../../diff-section-types'
import {
createCombinedDiffSectionIndexMap,
type CombinedDiffFileTreeMode
} from '../resolve-changes/combined-diff-section-identity'
import { handleCombinedDiffFileTreeNavigation } from './combined-diff-file-tree-navigation'
export type CombinedDiffTreeNavigation = {
activeTreeSectionKey: string | null
handleTreeNavigate: (entry: GitStatusEntry | GitBranchChangeEntry) => void
sectionIndexByKey: Map<string, number>
sectionIndexByKeyRef: React.RefObject<ReadonlyMap<string, number>>
viewedSectionKeys: Set<string>
}
// Why: navigation targets are passed in rather than imported so this stays a leaf of the tree folder.
export function useCombinedDiffTreeNavigation({
ensureSectionLoaded,
entrySignature,
markDirectScrollInput,
scrollToIndex,
sections,
sectionsRef,
toggleSection,
treeMode
}: {
ensureSectionLoaded: (index: number) => void
entrySignature: string
markDirectScrollInput: () => void
scrollToIndex: (index: number) => void
sections: DiffSection[]
sectionsRef: React.RefObject<DiffSection[]>
toggleSection: (index: number) => void
treeMode: CombinedDiffFileTreeMode
}): CombinedDiffTreeNavigation {
const sectionIndexByKey = React.useMemo(
() => createCombinedDiffSectionIndexMap(sections),
[sections]
)
const sectionIndexByKeyRef = useRef<ReadonlyMap<string, number>>(sectionIndexByKey)
sectionIndexByKeyRef.current = sectionIndexByKey
const [activeTreeSectionState, setActiveTreeSectionState] = useState<{
entrySignature: string
key: string | null
}>(() => ({ entrySignature, key: null }))
const activeTreeSectionKey =
activeTreeSectionState.entrySignature === entrySignature ? activeTreeSectionState.key : null
if (activeTreeSectionState.entrySignature !== entrySignature) {
// Why: the tree highlight belongs to one entry set; reset now so it can't flash on another before an Effect would.
setActiveTreeSectionState({ entrySignature, key: null })
}
const viewedSectionKeys = React.useMemo(
() => new Set(sections.filter((section) => !section.loading).map((section) => section.key)),
[sections]
)
const handleTreeNavigate = useCallback(
(entry: GitStatusEntry | GitBranchChangeEntry) => {
markDirectScrollInput()
const navigatedIndex = handleCombinedDiffFileTreeNavigation({
mode: treeMode,
entry,
sections: sectionsRef.current,
sectionIndexByKey,
toggleSection,
loadSection: ensureSectionLoaded,
scrollToIndex
})
if (navigatedIndex !== null) {
setActiveTreeSectionState({
entrySignature,
key: sectionsRef.current[navigatedIndex]?.key ?? null
})
}
},
[
ensureSectionLoaded,
entrySignature,
markDirectScrollInput,
scrollToIndex,
sectionIndexByKey,
sectionsRef,
toggleSection,
treeMode
]
)
return {
activeTreeSectionKey,
handleTreeNavigate,
sectionIndexByKey,
sectionIndexByKeyRef,
viewedSectionKeys
}
}
@@ -1,9 +1,9 @@
import { afterEach, describe, expect, it } from 'vitest'
import type { FolderWorkspace } from '../../../../shared/folder-workspace-types'
import type { ProjectGroup } from '../../../../shared/project-group-types'
import type { Repo } from '../../../../shared/repo-types'
import type { FolderWorkspace } from '../../../../../../shared/folder-workspace-types'
import type { ProjectGroup } from '../../../../../../shared/project-group-types'
import type { Repo } from '../../../../../../shared/repo-types'
import { useAppStore } from '@/store'
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
import { folderWorkspaceKey } from '../../../../../../shared/workspace-scope'
import { getCombinedDiffSectionConnectionId } from './combined-diff-section-connection'
const initialState = useAppStore.getInitialState()
@@ -0,0 +1,79 @@
import { useEffect, useRef } from 'react'
import type { DiffSection } from '../../diff-section-types'
import {
createCombinedDiffLoadScheduler,
type CombinedDiffLoadScheduler
} from './combined-diff-load-scheduler'
// Why: git rewrites a path several times during a rebase; refetch once the writes stop.
export const COMBINED_DIFF_SECTION_RELOAD_COALESCE_MS = 300
export function clearPendingSectionReloadTimers(timers: Map<number, number>): void {
for (const timer of timers.values()) {
window.clearTimeout(timer)
}
timers.clear()
}
// Why: one owner for the load bookkeeping the loader, retry and revalidation hooks all mutate,
// and for the callback refs that break the loader <-> retry cycle.
export type CombinedDiffSectionLoadRegistry = {
generationRef: React.RefObject<number>
loadSchedulerRef: React.RefObject<CombinedDiffLoadScheduler>
loadSectionRef: React.RefObject<(index: number) => Promise<void>>
loadedIndicesRef: React.RefObject<Set<number>>
loadingIndicesRef: React.RefObject<Set<number>>
reloadTimersRef: React.RefObject<Map<number, number>>
renderedIndicesRef: React.RefObject<Set<number>>
requestSectionReloadRef: React.RefObject<(index: number) => void>
retrySectionRef: React.RefObject<(index: number) => void>
sectionLoadTokensRef: React.RefObject<Map<number, number>>
sectionsRef: React.RefObject<DiffSection[]>
}
export function useCombinedDiffSectionLoadRegistry(
sections: DiffSection[]
): CombinedDiffSectionLoadRegistry {
const loadedIndicesRef = useRef<Set<number>>(new Set())
const loadingIndicesRef = useRef<Set<number>>(new Set())
const sectionsRef = useRef<DiffSection[]>([])
const generationRef = useRef(0)
// Why: per-section reload token, so a sibling's reload can't discard this section's in-flight load.
const sectionLoadTokensRef = useRef<Map<number, number>>(new Map())
const renderedIndicesRef = useRef<Set<number>>(new Set())
const reloadTimersRef = useRef<Map<number, number>>(new Map())
const loadSectionRef = useRef<(index: number) => Promise<void>>(async () => {})
const retrySectionRef = useRef<(index: number) => void>(() => {})
const requestSectionReloadRef = useRef<(index: number) => void>(() => {})
const loadSchedulerRef = useRef(
createCombinedDiffLoadScheduler({
loadSection: (index) => loadSectionRef.current(index)
})
)
sectionsRef.current = sections
useEffect(() => {
// Why: React StrictMode replays effect cleanup in dev; reset revives the scheduler for the replayed mount.
const scheduler = loadSchedulerRef.current
const reloadTimers = reloadTimersRef.current
scheduler.reset()
return () => {
clearPendingSectionReloadTimers(reloadTimers)
scheduler.dispose()
}
}, [])
return {
generationRef,
loadSchedulerRef,
loadSectionRef,
loadedIndicesRef,
loadingIndicesRef,
reloadTimersRef,
renderedIndicesRef,
requestSectionReloadRef,
retrySectionRef,
sectionLoadTokensRef,
sectionsRef
}
}
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { GitDiffResult } from '../../../../shared/git-diff-compare-types'
import type { LargeDiffRenderLimit } from './large-diff-render-limit'
import type { GitDiffResult } from '../../../../../../shared/git-diff-compare-types'
import type { LargeDiffRenderLimit } from '../../large-diff-render-limit'
import {
isUnchangedDiffSectionReload,
shouldRequestCombinedDiffSectionLoad
@@ -1,5 +1,5 @@
import type { DiffLineCounts, LargeDiffRenderLimit } from './large-diff-render-limit'
import type { DiffSection } from './diff-section-types'
import type { DiffLineCounts, LargeDiffRenderLimit } from '../../large-diff-render-limit'
import type { DiffSection } from '../../diff-section-types'
// Why: `diffResult === null` subsumes a dirty check — `dirty` is only ever set from a mounted
// editor's content compare, which implies content was already loaded.
@@ -0,0 +1,34 @@
// Why: local Electron IPC has no RPC timeout; a hung git diff must become a retryable row error, not permanent "Loading...".
const COMBINED_DIFF_SECTION_LOAD_TIMEOUT_MS = 30_000
class CombinedDiffSectionLoadTimeoutError extends Error {
constructor() {
super('Diff did not finish loading.')
this.name = 'CombinedDiffSectionLoadTimeoutError'
}
}
export function withDiffSectionLoadTimeout<T>(promise: Promise<T>): Promise<T> {
let timeoutId: number | null = null
const timeoutPromise = new Promise<T>((_, reject) => {
timeoutId = window.setTimeout(() => {
reject(new CombinedDiffSectionLoadTimeoutError())
}, COMBINED_DIFF_SECTION_LOAD_TIMEOUT_MS)
})
return Promise.race([promise, timeoutPromise]).finally(() => {
if (timeoutId !== null) {
window.clearTimeout(timeoutId)
}
})
}
export function getDiffSectionLoadErrorMessage(error: unknown): string {
if (error instanceof CombinedDiffSectionLoadTimeoutError) {
return 'Diff did not finish loading.'
}
return error instanceof Error && error.message.trim().length > 0
? error.message
: 'Unable to load diff.'
}
@@ -0,0 +1,95 @@
import { useAppStore } from '@/store'
import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
import {
getRuntimeGitBranchDiff,
getRuntimeGitCommitDiff,
getRuntimeGitDiff
} from '@/runtime/runtime-git-client'
import type { OpenFile } from '@/store/slices/editor'
import type {
GitBranchChangeEntry,
GitDiffResult
} from '../../../../../../shared/git-diff-compare-types'
import type { GitStatusEntry } from '../../../../../../shared/git-status-types'
import { getCombinedDiffSectionConnectionId } from './combined-diff-section-connection'
import { withDiffSectionLoadTimeout } from './combined-diff-section-load-timeout'
export function fetchCombinedDiffSection({
branchCompare,
commitCompare,
entry,
file,
isAllMode,
isBranchMode,
isCommitMode
}: {
branchCompare: NonNullable<OpenFile['branchCompare']> | null
commitCompare: NonNullable<OpenFile['commitCompare']> | null
entry: GitStatusEntry | GitBranchChangeEntry
file: OpenFile
isAllMode: boolean
isBranchMode: boolean
isCommitMode: boolean
}): Promise<GitDiffResult> {
const connectionId = getCombinedDiffSectionConnectionId(
file.worktreeId,
file.filePath,
entry.path
)
const state = useAppStore.getState()
const fileSettings = settingsForRuntimeOwner(state.settings, file.runtimeEnvironmentId)
if ((isBranchMode || (isAllMode && !('area' in entry))) && branchCompare) {
return withDiffSectionLoadTimeout(
getRuntimeGitBranchDiff(
{
settings: fileSettings,
worktreeId: file.worktreeId,
worktreePath: file.filePath,
connectionId
},
{
compare: {
baseRef: branchCompare.baseRef,
baseOid: branchCompare.baseOid!,
headOid: branchCompare.headOid!,
mergeBase: branchCompare.mergeBase!
},
filePath: entry.path,
oldPath: entry.oldPath
}
)
)
}
if (isCommitMode && commitCompare) {
return withDiffSectionLoadTimeout(
getRuntimeGitCommitDiff(
{
settings: fileSettings,
worktreeId: file.worktreeId,
worktreePath: file.filePath,
connectionId
},
{
commitOid: commitCompare.commitOid,
parentOid: commitCompare.parentOid,
filePath: entry.path,
oldPath: entry.oldPath
}
)
)
}
return withDiffSectionLoadTimeout(
getRuntimeGitDiff(
{
settings: fileSettings,
worktreeId: file.worktreeId,
worktreePath: file.filePath,
connectionId
},
{
filePath: entry.path,
staged: 'area' in entry && entry.area === 'staged'
}
)
)
}
@@ -0,0 +1,220 @@
import { useCallback, useEffect } from 'react'
import type React from 'react'
import type { OpenFile } from '@/store/slices/editor'
import type {
GitBranchChangeEntry,
GitDiffResult
} from '../../../../../../shared/git-diff-compare-types'
import type { GitStatusEntry } from '../../../../../../shared/git-status-types'
import type { DiffSection } from '../../diff-section-types'
import { getLargeDiffRenderLimit } from '../../large-diff-render-limit'
import { getStoredTextDiffContent, getStoredTextDiffResult } from '../../large-diff-section-content'
import { removeDiffSectionMeasuredHeight } from '../../diff-section-height-cache'
import type { CombinedDiffEntrySet } from '../resolve-changes/use-combined-diff-entry-set'
import { fetchCombinedDiffSection } from './fetch-combined-diff-section'
import { getDiffSectionLoadErrorMessage } from './combined-diff-section-load-timeout'
import { getInitialCombinedDiffSectionLoadIndices } from './combined-diff-initial-section-load'
import { isUnchangedDiffSectionReload } from './combined-diff-section-load-state'
import type { CombinedDiffSectionLoadRegistry } from './combined-diff-section-load-registry'
export function useCombinedDiffSectionLoader({
entrySet,
file,
registry,
sectionCount,
setSectionHeights,
setSections
}: {
entrySet: CombinedDiffEntrySet
file: OpenFile
registry: CombinedDiffSectionLoadRegistry
sectionCount: number
setSectionHeights: React.Dispatch<React.SetStateAction<Record<number, number>>>
setSections: React.Dispatch<React.SetStateAction<DiffSection[]>>
}): (index: number) => void {
const {
allEntries,
branchCompare,
commitCompare,
commitEntries,
entrySignature,
isAllMode,
isBranchMode,
isCommitMode,
renderableBranchEntries,
uncommittedEntries
} = entrySet
const {
generationRef,
loadSchedulerRef,
loadSectionRef,
loadedIndicesRef,
loadingIndicesRef,
requestSectionReloadRef,
sectionLoadTokensRef,
sectionsRef
} = registry
const loadSectionNow = useCallback(
async (index: number) => {
if (loadedIndicesRef.current.has(index) || loadingIndicesRef.current.has(index)) {
return
}
loadingIndicesRef.current.add(index)
const gen = generationRef.current
const loadToken = sectionLoadTokensRef.current.get(index) ?? 0
const entries: (GitStatusEntry | GitBranchChangeEntry)[] = isAllMode
? allEntries
: isBranchMode
? renderableBranchEntries
: isCommitMode
? commitEntries
: uncommittedEntries
const entry = entries[index]
if (!entry) {
loadingIndicesRef.current.delete(index)
return
}
let result: GitDiffResult
let error: string | undefined
try {
result = await fetchCombinedDiffSection({
branchCompare,
commitCompare,
entry,
file,
isAllMode,
isBranchMode,
isCommitMode
})
} catch (err) {
error = getDiffSectionLoadErrorMessage(err)
result = {
kind: 'text',
originalContent: '',
modifiedContent: '',
originalIsBinary: false,
modifiedIsBinary: false
} as GitDiffResult
}
const largeDiffRenderLimit =
!error && result.kind === 'text'
? (result.largeDiffRenderLimit ??
getLargeDiffRenderLimit({
originalContent: result.originalContent,
modifiedContent: result.modifiedContent
}))
: null
if (generationRef.current !== gen) {
// Why: the generation reset already cleared the in-flight set, and a newer load for this
// index may own the entry now — deleting it here would hide that load from the guard above.
return
}
loadingIndicesRef.current.delete(index)
if ((sectionLoadTokensRef.current.get(index) ?? 0) !== loadToken) {
// Why: an invalidation landed mid-flight and deferred its reload to this settle point, so
// the refetch happens once here instead of racing a second fetch against this one.
requestSectionReloadRef.current(index)
return
}
const storedContent = getStoredTextDiffContent(result, largeDiffRenderLimit)
const storedResult = getStoredTextDiffResult(result, largeDiffRenderLimit)
loadedIndicesRef.current.add(index)
const current = sectionsRef.current[index]
// A revalidation lands on a section that is already showing content. If the refetch matches
// what's on screen, committing it would swap Monaco models and re-measure for nothing.
const wasShowingContent = current !== undefined && !current.loading
if (
wasShowingContent &&
isUnchangedDiffSectionReload(current, {
diffResult: storedResult,
error,
largeDiffRenderLimit,
originalContent: storedContent.originalContent,
modifiedContent: storedContent.modifiedContent
})
) {
return
}
if (wasShowingContent) {
// Why: content really changed, so the old Monaco height no longer describes this row.
setSectionHeights((prev) => removeDiffSectionMeasuredHeight(prev, index))
}
setSections((prev) => {
return prev.map((s, i) =>
i === index
? {
...s,
diffResult: storedResult,
originalContent: storedContent.originalContent,
modifiedContent: storedContent.modifiedContent,
loading: false,
error,
largeDiffRenderLimit,
// Why: models are keyed by path, so a changed refetch must not reuse the old model.
contentGeneration: wasShowingContent
? (s.contentGeneration ?? 0) + 1
: s.contentGeneration
}
: s
)
})
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
branchCompare?.baseOid,
branchCompare?.headOid,
branchCompare?.mergeBase,
allEntries,
commitCompare?.commitOid,
commitCompare?.parentOid,
commitEntries,
file.filePath,
file.runtimeEnvironmentId,
isAllMode,
isBranchMode,
isCommitMode,
renderableBranchEntries,
uncommittedEntries
]
)
loadSectionRef.current = loadSectionNow
// Progressive loading: queue diff content when a section becomes visible.
const loadSection = useCallback(
(index: number) => {
if (sectionsRef.current[index]?.collapsed) {
return
}
loadSchedulerRef.current.request(index)
},
[loadSchedulerRef, sectionsRef]
)
useEffect(() => {
// Why: queue the first rows deterministically so the visible viewport doesn't depend on IntersectionObserver delivery.
const currentSections = sectionsRef.current
for (let index = 0; index < currentSections.length; index += 1) {
if (currentSections[index]?.loading && loadedIndicesRef.current.has(index)) {
loadedIndicesRef.current.delete(index)
}
}
const initialIndices = getInitialCombinedDiffSectionLoadIndices({
sectionCount: currentSections.length,
loadedIndices: loadedIndicesRef.current
})
for (const index of initialIndices) {
if (!currentSections[index]?.collapsed) {
loadSection(index)
}
}
}, [entrySignature, loadSection, loadedIndicesRef, sectionCount, sectionsRef])
return loadSection
}
@@ -0,0 +1,152 @@
import { useCallback } from 'react'
import type React from 'react'
import type { DiffSection } from '../../diff-section-types'
import { removeDiffSectionMeasuredHeight } from '../../diff-section-height-cache'
import { shouldRequestCombinedDiffSectionLoad } from './combined-diff-section-load-state'
import {
COMBINED_DIFF_SECTION_RELOAD_COALESCE_MS,
type CombinedDiffSectionLoadRegistry
} from './combined-diff-section-load-registry'
export type CombinedDiffSectionRetryActions = {
ensureSectionLoaded: (index: number) => void
requestSectionReload: (index: number) => void
retrySection: (index: number) => void
}
export function useCombinedDiffSectionRetry({
invalidateViewStateCache,
registry,
setSectionHeights,
setSections
}: {
invalidateViewStateCache: () => void
registry: CombinedDiffSectionLoadRegistry
setSectionHeights: React.Dispatch<React.SetStateAction<Record<number, number>>>
setSections: React.Dispatch<React.SetStateAction<DiffSection[]>>
}): CombinedDiffSectionRetryActions {
const {
loadSchedulerRef,
loadedIndicesRef,
loadingIndicesRef,
reloadTimersRef,
renderedIndicesRef,
requestSectionReloadRef,
retrySectionRef,
sectionLoadTokensRef,
sectionsRef
} = registry
const retrySection = useCallback(
(index: number) => {
const collapsed = sectionsRef.current[index]?.collapsed ?? false
loadedIndicesRef.current.delete(index)
loadingIndicesRef.current.delete(index)
invalidateViewStateCache()
// Why: reloading one section must not bump the global generation — that is part of
// the virtualizer item key, so it would remount every rendered Monaco editor (STA-3420).
sectionLoadTokensRef.current.set(index, (sectionLoadTokensRef.current.get(index) ?? 0) + 1)
const coalesced = reloadTimersRef.current.get(index)
if (coalesced !== undefined) {
window.clearTimeout(coalesced)
reloadTimersRef.current.delete(index)
}
setSectionHeights((prev) => removeDiffSectionMeasuredHeight(prev, index))
setSections((prev) =>
prev.map((section, sectionIndex) =>
sectionIndex === index
? {
...section,
loading: !collapsed,
error: undefined,
diffResult: null,
originalContent: '',
modifiedContent: '',
largeDiffRenderLimit: null,
contentGeneration: (section.contentGeneration ?? 0) + 1
}
: section
)
)
if (collapsed) {
return
}
loadSchedulerRef.current.rerequest(index)
},
[
invalidateViewStateCache,
loadSchedulerRef,
loadedIndicesRef,
loadingIndicesRef,
reloadTimersRef,
sectionLoadTokensRef,
sectionsRef,
setSectionHeights,
setSections
]
)
retrySectionRef.current = retrySection
// Why: invalidation (rebase/commit/external write) revalidates in place — it must not tear the
// section down first. Clearing content up front forces a Monaco remodel even when the refetched
// diff is identical, which is what wedged the renderer during a rebase (STA-3420).
const requestSectionReload = useCallback(
(index: number): void => {
const section = sectionsRef.current[index]
if (!section || section.dirty) {
return
}
loadedIndicesRef.current.delete(index)
invalidateViewStateCache()
sectionLoadTokensRef.current.set(index, (sectionLoadTokensRef.current.get(index) ?? 0) + 1)
if (loadingIndicesRef.current.has(index)) {
// Why: the in-flight load now carries a stale token, so it re-drives this reload when it
// settles. Scheduling one here would fetch the same large diff a second time.
return
}
if (section.collapsed || !renderedIndicesRef.current.has(index)) {
// Why: a rebase invalidates every touched path at once. Refetching off-screen sections is
// unbounded work nobody can see; the row reloads on mount once it scrolls into view.
return
}
// Why: a rebase touches the same path many times over a few seconds. Without coalescing
// each touch refetches a whole diff, and the payload churn alone stalls the renderer.
const pending = reloadTimersRef.current.get(index)
if (pending !== undefined) {
window.clearTimeout(pending)
}
reloadTimersRef.current.set(
index,
window.setTimeout(() => {
reloadTimersRef.current.delete(index)
loadSchedulerRef.current.rerequest(index)
}, COMBINED_DIFF_SECTION_RELOAD_COALESCE_MS)
)
},
[
invalidateViewStateCache,
loadSchedulerRef,
loadedIndicesRef,
loadingIndicesRef,
reloadTimersRef,
renderedIndicesRef,
sectionLoadTokensRef,
sectionsRef
]
)
requestSectionReloadRef.current = requestSectionReload
const ensureSectionLoaded = useCallback(
(index: number): void => {
const section = sectionsRef.current[index]
if (!shouldRequestCombinedDiffSectionLoad(section, loadingIndicesRef.current.has(index))) {
return
}
loadedIndicesRef.current.delete(index)
loadSchedulerRef.current.request(index)
},
[loadSchedulerRef, loadedIndicesRef, loadingIndicesRef, sectionsRef]
)
return { ensureSectionLoaded, requestSectionReload, retrySection }
}
@@ -0,0 +1,108 @@
import React, { useEffect, useRef } from 'react'
import type { OpenFile } from '@/store/slices/editor'
import type { GitStatusEntry } from '../../../../../../shared/git-status-types'
import type { DiffSection } from '../../diff-section-types'
import {
ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT,
type EditorPathMutationTarget
} from '../../editor-autosave'
import { buildCombinedGitStatusSignature } from '../resolve-changes/combined-diff-git-status-signature'
import {
getCombinedDiffFileTreeSectionKey,
type CombinedDiffFileTreeMode
} from '../resolve-changes/combined-diff-section-identity'
import type { CombinedDiffSectionLoadRegistry } from './combined-diff-section-load-registry'
// Why: git status and on-disk writes both revalidate loaded rows; one hook owns both watches so
// they cannot disagree about which sections are eligible.
export function useCombinedDiffSectionRevalidation({
file,
gitStatusEntries,
registry,
requestSectionReload,
sectionIndexByKeyRef,
sections,
shouldAutoReloadFromGitStatus,
treeMode
}: {
file: OpenFile
gitStatusEntries: GitStatusEntry[]
registry: CombinedDiffSectionLoadRegistry
requestSectionReload: (index: number) => void
sectionIndexByKeyRef: React.RefObject<ReadonlyMap<string, number>>
sections: DiffSection[]
shouldAutoReloadFromGitStatus: boolean
treeMode: CombinedDiffFileTreeMode
}): string {
const { loadedIndicesRef } = registry
const combinedGitStatusSignature = React.useMemo(() => {
if (!shouldAutoReloadFromGitStatus) {
return ''
}
return buildCombinedGitStatusSignature(sections, gitStatusEntries)
}, [gitStatusEntries, sections, shouldAutoReloadFromGitStatus])
const prevCombinedGitStatusSignatureRef = useRef<string | null>(null)
useEffect(() => {
if (!shouldAutoReloadFromGitStatus) {
prevCombinedGitStatusSignatureRef.current = null
return
}
if (prevCombinedGitStatusSignatureRef.current === null) {
prevCombinedGitStatusSignatureRef.current = combinedGitStatusSignature
return
}
if (prevCombinedGitStatusSignatureRef.current === combinedGitStatusSignature) {
return
}
prevCombinedGitStatusSignatureRef.current = combinedGitStatusSignature
for (const index of loadedIndicesRef.current) {
requestSectionReload(index)
}
}, [
combinedGitStatusSignature,
loadedIndicesRef,
requestSectionReload,
shouldAutoReloadFromGitStatus
])
useEffect(() => {
if (treeMode !== 'all' && treeMode !== 'uncommitted') {
return
}
const handler = (event: Event): void => {
const detail = (event as CustomEvent<EditorPathMutationTarget>).detail
if (!detail || detail.worktreeId !== file.worktreeId) {
return
}
const hasRuntimeOwnerFilter = Object.hasOwn(detail, 'runtimeEnvironmentId')
const targetRuntimeOwner = detail.runtimeEnvironmentId?.trim() || null
const fileRuntimeOwner = file.runtimeEnvironmentId?.trim() || null
if (hasRuntimeOwnerFilter && targetRuntimeOwner !== fileRuntimeOwner) {
return
}
for (const area of ['unstaged', 'staged', 'untracked'] as const) {
const key = getCombinedDiffFileTreeSectionKey('uncommitted', {
path: detail.relativePath,
status: 'modified',
area
})
const index = sectionIndexByKeyRef.current.get(key)
if (index !== undefined) {
requestSectionReload(index)
}
}
}
window.addEventListener(ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, handler as EventListener)
return () =>
window.removeEventListener(ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, handler as EventListener)
}, [
file.runtimeEnvironmentId,
file.worktreeId,
requestSectionReload,
sectionIndexByKeyRef,
treeMode
])
return combinedGitStatusSignature
}
@@ -0,0 +1,45 @@
import type { VirtualizedScrollAnchor } from '@/hooks/useVirtualizedScrollAnchor'
import type { DiffSection } from '../../diff-section-types'
import {
ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT,
type EditorPathMutationTarget
} from '../../editor-autosave'
export type CachedCombinedDiffViewState = {
entrySignature: string
gitStatusSignature: string
sections: DiffSection[]
sectionHeights: Record<number, number>
loadedIndices: number[]
scrollTop: number
sideBySide: boolean
}
export const combinedDiffViewStateCache = new Map<string, CachedCombinedDiffViewState>()
export const combinedDiffScrollTopCache = new Map<string, number>()
export const combinedDiffScrollAnchorCache = new Map<string, VirtualizedScrollAnchor>()
// Why: session-scoped toolbar choices outlive the unmount, so they are module state rather than component state.
export const combinedDiffViewPreferences: {
collapsed: boolean | null
sideBySide: boolean | null
fileTreeCollapsed: boolean | null
} = { collapsed: null, sideBySide: null, fileTreeCollapsed: null }
function invalidateCombinedDiffCachesForRelativePath(relativePath: string): void {
for (const [key, cached] of combinedDiffViewStateCache.entries()) {
if (cached.sections.some((section) => section.path === relativePath)) {
combinedDiffViewStateCache.delete(key)
}
}
}
if (typeof window !== 'undefined') {
window.addEventListener(ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, (event) => {
const detail = (event as CustomEvent<EditorPathMutationTarget>).detail
if (detail?.relativePath) {
// Why: inactive combined-diff tabs are unmounted, so only a module-level cache bust stops a remount replaying stale bodies.
invalidateCombinedDiffCachesForRelativePath(detail.relativePath)
}
})
}
@@ -0,0 +1,56 @@
import { useEffect } from 'react'
import type React from 'react'
import { setWithLRU } from '@/lib/scroll-cache'
import type { DiffSection } from '../../diff-section-types'
import { combinedDiffScrollTopCache, combinedDiffViewStateCache } from './combined-diff-view-memory'
export function useCombinedDiffViewPersist({
combinedGitStatusSignature,
entryCount,
entrySignature,
scrollContainerRef,
sectionHeights,
sections,
loadedIndicesRef,
sideBySide,
viewStateKey
}: {
combinedGitStatusSignature: string
entryCount: number
entrySignature: string
scrollContainerRef: React.RefObject<HTMLDivElement | null>
sectionHeights: Record<number, number>
sections: DiffSection[]
loadedIndicesRef: React.RefObject<Set<number>>
sideBySide: boolean
viewStateKey: string
}): void {
useEffect(() => {
if (sections.length === 0 && entryCount > 0) {
return
}
const preservedScrollTop =
combinedDiffScrollTopCache.get(viewStateKey) ?? scrollContainerRef.current?.scrollTop ?? 0
setWithLRU(combinedDiffViewStateCache, viewStateKey, {
entrySignature,
gitStatusSignature: combinedGitStatusSignature,
sections,
sectionHeights,
loadedIndices: Array.from(loadedIndicesRef.current).filter(
(index) => !sections[index]?.loading
),
scrollTop: preservedScrollTop,
sideBySide
})
}, [
combinedGitStatusSignature,
entryCount,
entrySignature,
loadedIndicesRef,
scrollContainerRef,
sectionHeights,
sections,
sideBySide,
viewStateKey
])
}
@@ -0,0 +1,169 @@
import { useCallback, useLayoutEffect, useRef } from 'react'
import type React from 'react'
import type { VirtualizedScrollAnchor } from '@/hooks/useVirtualizedScrollAnchor'
import type { GitStatusEntry } from '../../../../../../shared/git-status-types'
import type { DiffSection } from '../../diff-section-types'
import { buildCombinedGitStatusSignature } from '../resolve-changes/combined-diff-git-status-signature'
import { combinedDiffSectionsMatchEntryMetadata } from '../resolve-changes/combined-diff-section-cache-match'
import { getCombinedDiffFileTreeSectionKey } from '../resolve-changes/combined-diff-section-identity'
import type { CombinedDiffEntrySet } from '../resolve-changes/use-combined-diff-entry-set'
import type { CombinedDiffSectionLoadRegistry } from '../load-sections/combined-diff-section-load-registry'
import { clearPendingSectionReloadTimers } from '../load-sections/combined-diff-section-load-registry'
import {
combinedDiffScrollAnchorCache,
combinedDiffScrollTopCache,
combinedDiffViewPreferences,
combinedDiffViewStateCache
} from './combined-diff-view-memory'
export type CombinedDiffViewRestore = {
invalidateViewStateCache: () => void
latestDomScrollAnchorRef: React.RefObject<VirtualizedScrollAnchor>
scrollAnchorRef: React.RefObject<VirtualizedScrollAnchor>
scrollOffsetRef: React.RefObject<number>
}
export function useCombinedDiffViewRestore({
entrySet,
gitStatusEntries,
registry,
setGeneration,
setSectionHeights,
setSections,
setSideBySide,
viewStateKey
}: {
entrySet: CombinedDiffEntrySet
gitStatusEntries: GitStatusEntry[]
registry: CombinedDiffSectionLoadRegistry
setGeneration: React.Dispatch<React.SetStateAction<number>>
setSectionHeights: React.Dispatch<React.SetStateAction<Record<number, number>>>
setSections: React.Dispatch<React.SetStateAction<DiffSection[]>>
setSideBySide: React.Dispatch<React.SetStateAction<boolean>>
viewStateKey: string
}): CombinedDiffViewRestore {
const {
entries,
entrySignature,
hasUncommittedEntriesSnapshot,
shouldAutoReloadFromGitStatus,
treeMode
} = entrySet
const {
generationRef,
loadSchedulerRef,
loadedIndicesRef,
loadingIndicesRef,
reloadTimersRef,
sectionLoadTokensRef
} = registry
const scrollOffsetRef = useRef(combinedDiffScrollTopCache.get(viewStateKey) ?? 0)
const scrollAnchorRef = useRef<VirtualizedScrollAnchor>(
combinedDiffScrollAnchorCache.get(viewStateKey) ?? null
)
const latestDomScrollAnchorRef = useRef<VirtualizedScrollAnchor>(
combinedDiffScrollAnchorCache.get(viewStateKey) ?? null
)
// Why: tab/worktree switches unmount this viewer; cache by pane key so remount restores sections+scroll before repaint.
useLayoutEffect(() => {
const cached = combinedDiffViewStateCache.get(viewStateKey)
const canRestoreSnapshotSectionsByKey =
hasUncommittedEntriesSnapshot &&
cached !== undefined &&
combinedDiffSectionsMatchEntryMetadata({
entries,
sections: cached.sections,
treeMode
})
const canRestoreCachedSections =
cached &&
(cached.entrySignature === entrySignature || canRestoreSnapshotSectionsByKey) &&
(!shouldAutoReloadFromGitStatus ||
(cached.gitStatusSignature ?? '') ===
buildCombinedGitStatusSignature(cached.sections, gitStatusEntries)) &&
(cached.sections.length > 0 || entries.length === 0)
if (canRestoreCachedSections && cached) {
const collapsedPreference = combinedDiffViewPreferences.collapsed
const restoredSections =
collapsedPreference === null
? cached.sections
: cached.sections.map((section) => ({
...section,
collapsed: collapsedPreference
}))
setSections(restoredSections)
setSectionHeights(cached.sectionHeights)
setSideBySide(combinedDiffViewPreferences.sideBySide ?? cached.sideBySide)
loadedIndicesRef.current = new Set(
cached.loadedIndices.filter((index) => !restoredSections[index]?.loading)
)
loadingIndicesRef.current.clear()
scrollOffsetRef.current = combinedDiffScrollTopCache.get(viewStateKey) ?? cached.scrollTop
scrollAnchorRef.current = combinedDiffScrollAnchorCache.get(viewStateKey) ?? null
latestDomScrollAnchorRef.current = scrollAnchorRef.current
return
}
scrollOffsetRef.current = combinedDiffScrollTopCache.get(viewStateKey) ?? 0
scrollAnchorRef.current = combinedDiffScrollAnchorCache.get(viewStateKey) ?? null
latestDomScrollAnchorRef.current = scrollAnchorRef.current
setSections(
entries.map((entry) => ({
key: getCombinedDiffFileTreeSectionKey(treeMode, entry),
path: entry.path,
status: entry.status,
area: 'area' in entry ? entry.area : undefined,
oldPath: entry.oldPath,
added: 'added' in entry ? entry.added : undefined,
removed: 'removed' in entry ? entry.removed : undefined,
originalContent: '',
modifiedContent: '',
collapsed: combinedDiffViewPreferences.collapsed ?? false,
loading: true,
error: undefined,
dirty: false,
diffResult: null,
largeDiffRenderLimit: null
}))
)
setSectionHeights({})
loadedIndicesRef.current.clear()
loadingIndicesRef.current.clear()
sectionLoadTokensRef.current.clear()
clearPendingSectionReloadTimers(reloadTimersRef.current)
loadSchedulerRef.current.reset()
generationRef.current += 1
setGeneration((prev) => prev + 1)
}, [
entries,
entrySignature,
generationRef,
gitStatusEntries,
hasUncommittedEntriesSnapshot,
loadSchedulerRef,
loadedIndicesRef,
loadingIndicesRef,
reloadTimersRef,
sectionLoadTokensRef,
setGeneration,
setSectionHeights,
setSections,
setSideBySide,
shouldAutoReloadFromGitStatus,
treeMode,
viewStateKey
])
const invalidateViewStateCache = useCallback((): void => {
combinedDiffViewStateCache.delete(viewStateKey)
}, [viewStateKey])
return {
invalidateViewStateCache,
latestDomScrollAnchorRef,
scrollAnchorRef,
scrollOffsetRef
}
}
@@ -5,8 +5,8 @@ import {
resolveCombinedUncommittedSnapshotEntries,
shouldAutoReloadCombinedDiffFromGitStatus
} from './combined-diff-entries'
import type { GitBranchChangeEntry } from '../../../../shared/git-diff-compare-types'
import type { GitStatusEntry } from '../../../../shared/git-status-types'
import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types'
import type { GitStatusEntry } from '../../../../../../shared/git-status-types'
describe('getCombinedUncommittedEntries', () => {
it('filters unresolved conflicts from live entries', () => {
@@ -1,7 +1,7 @@
import type { OpenFile } from '@/store/slices/editor'
import type { GitBranchChangeEntry } from '../../../../shared/git-diff-compare-types'
import type { GitStatusEntry } from '../../../../shared/git-status-types'
import type { CombinedDiffFileTreeMode } from './combined-diff-file-tree-model'
import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types'
import type { GitStatusEntry } from '../../../../../../shared/git-status-types'
import type { CombinedDiffFileTreeMode } from './combined-diff-section-identity'
/**
* Fallback filtering for combined-diff tabs that were opened before the
@@ -0,0 +1,38 @@
import type { GitStatusEntry } from '../../../../../../shared/git-status-types'
import type { DiffSection } from '../../diff-section-types'
export function buildCombinedGitStatusSignature(
sections: readonly { path: string }[],
gitStatusEntries: readonly GitStatusEntry[]
): string {
const sectionPaths = new Set(sections.map((section) => section.path))
const matching = gitStatusEntries.filter((entry) => sectionPaths.has(entry.path))
return JSON.stringify(
matching.map((entry) => ({
path: entry.path,
area: entry.area,
status: entry.status,
added: entry.added ?? null,
removed: entry.removed ?? null
}))
)
}
export function getRetainedResolvedSnapshotEntries(
sections: readonly DiffSection[]
): GitStatusEntry[] {
return sections.flatMap((section) =>
section.area === undefined
? []
: [
{
path: section.path,
status: section.status as GitStatusEntry['status'],
area: section.area,
oldPath: section.oldPath,
added: section.added,
removed: section.removed
}
]
)
}
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { GitBranchChangeEntry } from '../../../../shared/git-diff-compare-types'
import type { GitStatusEntry } from '../../../../shared/git-status-types'
import type { DiffSection } from './diff-section-types'
import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types'
import type { GitStatusEntry } from '../../../../../../shared/git-status-types'
import type { DiffSection } from '../../diff-section-types'
import { combinedDiffSectionsMatchEntryMetadata } from './combined-diff-section-cache-match'
function section(overrides: Partial<DiffSection>): DiffSection {
@@ -1,8 +1,8 @@
import type { GitBranchChangeEntry } from '../../../../shared/git-diff-compare-types'
import type { GitStatusEntry } from '../../../../shared/git-status-types'
import type { CombinedDiffFileTreeMode } from './combined-diff-file-tree-model'
import { getCombinedDiffFileTreeSectionKey } from './combined-diff-file-tree-model'
import type { DiffSection } from './diff-section-types'
import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types'
import type { GitStatusEntry } from '../../../../../../shared/git-status-types'
import type { CombinedDiffFileTreeMode } from './combined-diff-section-identity'
import { getCombinedDiffFileTreeSectionKey } from './combined-diff-section-identity'
import type { DiffSection } from '../../diff-section-types'
export function combinedDiffSectionsMatchEntryMetadata({
entries,
@@ -1,27 +1,10 @@
import { basename } from '@/lib/path'
import type { GitBranchChangeEntry } from '../../../../shared/git-diff-compare-types'
import type { GitStatusEntry } from '../../../../shared/git-status-types'
import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text'
import {
buildSourceControlTree,
compactSourceControlTree,
flattenSourceControlTree
} from '@/components/right-sidebar/source-control-tree'
import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types'
import type { GitStatusEntry } from '../../../../../../shared/git-status-types'
export type CombinedDiffFileTreeMode = 'all' | 'uncommitted' | 'branch' | 'commit'
export type CombinedDiffFileTreeEntry = GitStatusEntry | GitBranchChangeEntry
export type CombinedDiffBranchTreeArea = 'combined-branch' | 'combined-commit'
export const NO_EXTENSION_KEY = '(no extension)'
export const COMBINED_DIFF_FILE_TREE_QUERY_MAX_BYTES = 2 * 1024
export function isCombinedDiffFileTreeQueryTooLarge(
query: string,
maxBytes = COMBINED_DIFF_FILE_TREE_QUERY_MAX_BYTES
): boolean {
return isClipboardTextByteLengthOverLimit(query, maxBytes)
}
export function getCombinedDiffFileTreeSectionKey(
mode: CombinedDiffFileTreeMode,
entry: CombinedDiffFileTreeEntry
@@ -38,105 +21,6 @@ export function createCombinedDiffSectionIndexMap(
return new Map(sections.map((section, index) => [section.key, index]))
}
export function getCombinedDiffFileTreeNavigationIndex({
mode,
entry,
sectionIndexByKey
}: {
mode: CombinedDiffFileTreeMode
entry: CombinedDiffFileTreeEntry
sectionIndexByKey: ReadonlyMap<string, number>
}): number | null {
return sectionIndexByKey.get(getCombinedDiffFileTreeSectionKey(mode, entry)) ?? null
}
export function handleCombinedDiffFileTreeNavigation({
mode,
entry,
sections,
sectionIndexByKey,
toggleSection,
loadSection,
scrollToIndex
}: {
mode: CombinedDiffFileTreeMode
entry: CombinedDiffFileTreeEntry
sections: readonly { collapsed: boolean }[]
sectionIndexByKey: ReadonlyMap<string, number>
toggleSection: (index: number) => void
loadSection?: (index: number) => void
scrollToIndex: (index: number) => void
}): number | null {
const index = getCombinedDiffFileTreeNavigationIndex({ mode, entry, sectionIndexByKey })
if (index === null || !sections[index]) {
return null
}
if (sections[index].collapsed) {
toggleSection(index)
}
loadSection?.(index)
scrollToIndex(index)
return index
}
export function isGitStatusEntry(entry: CombinedDiffFileTreeEntry): entry is GitStatusEntry {
return 'area' in entry
}
export function getEntryExtension(entry: CombinedDiffFileTreeEntry): string {
const name = basename(entry.path)
const index = name.lastIndexOf('.')
if (index <= 0 || index === name.length - 1) {
return NO_EXTENSION_KEY
}
return name.slice(index).toLowerCase()
}
function getEntrySearchText(entry: CombinedDiffFileTreeEntry): string {
return [entry.path, entry.oldPath ?? '', entry.status, isGitStatusEntry(entry) ? entry.area : '']
.join(' ')
.toLowerCase()
}
export function getFilteredCombinedDiffFileTreeEntries({
entries,
mode,
query,
excludedExtensions,
includeViewed,
viewedSectionKeys
}: {
entries: readonly CombinedDiffFileTreeEntry[]
mode: CombinedDiffFileTreeMode
query: string
excludedExtensions: ReadonlySet<string>
includeViewed: boolean
viewedSectionKeys: ReadonlySet<string>
}): CombinedDiffFileTreeEntry[] {
if (isCombinedDiffFileTreeQueryTooLarge(query)) {
return []
}
const trimmedQuery = query.trim()
const normalizedQuery = trimmedQuery.toLowerCase()
return entries.filter((entry) => {
if (excludedExtensions.has(getEntryExtension(entry))) {
return false
}
if (!includeViewed && viewedSectionKeys.has(getCombinedDiffFileTreeSectionKey(mode, entry))) {
return false
}
return normalizedQuery.length === 0 || getEntrySearchText(entry).includes(normalizedQuery)
})
}
export function getCombinedDiffBranchEntriesInTreeOrder(
mode: Extract<CombinedDiffFileTreeMode, 'branch' | 'commit'>,
entries: readonly GitBranchChangeEntry[]
): GitBranchChangeEntry[] {
const area: CombinedDiffBranchTreeArea = mode === 'commit' ? 'combined-commit' : 'combined-branch'
const roots = compactSourceControlTree(buildSourceControlTree(area, [...entries]))
return flattenSourceControlTree(roots, new Set())
.filter((node) => node.type === 'file')
.map((node) => node.entry)
}
@@ -0,0 +1,166 @@
import React from 'react'
import type { OpenFile } from '@/store/slices/editor'
import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types'
import type { GitStatusEntry } from '../../../../../../shared/git-status-types'
import type { DiffSection } from '../../diff-section-types'
import type { CombinedDiffFileTreeMode } from './combined-diff-section-identity'
import {
getCombinedBranchEntries,
getCombinedUncommittedEntries,
resolveCombinedUncommittedSnapshotEntries,
shouldAutoReloadCombinedDiffFromGitStatus
} from './combined-diff-entries'
import { getRetainedResolvedSnapshotEntries } from './combined-diff-git-status-signature'
// Why: stable identities so the store selectors don't resubscribe on every empty read.
export const EMPTY_GIT_STATUS_ENTRIES: GitStatusEntry[] = []
export const EMPTY_GIT_BRANCH_ENTRIES: GitBranchChangeEntry[] = []
export type CombinedDiffEntrySet = {
allEntries: (GitStatusEntry | GitBranchChangeEntry)[]
branchCompare: NonNullable<OpenFile['branchCompare']> | null
commitCompare: NonNullable<OpenFile['commitCompare']> | null
commitEntries: GitBranchChangeEntry[]
entries: (GitStatusEntry | GitBranchChangeEntry)[]
entrySignature: string
hasUncommittedEntriesSnapshot: boolean
isAllMode: boolean
isBranchMode: boolean
isCommitMode: boolean
renderableBranchEntries: GitBranchChangeEntry[]
shouldAutoReloadFromGitStatus: boolean
treeMode: CombinedDiffFileTreeMode
uncommittedEntries: GitStatusEntry[]
}
export function useCombinedDiffEntrySet({
file,
gitStatusEntries,
liveBranchEntries,
sectionsRef
}: {
file: OpenFile
gitStatusEntries: GitStatusEntry[]
liveBranchEntries: GitBranchChangeEntry[]
sectionsRef: React.RefObject<DiffSection[]>
}): CombinedDiffEntrySet {
const isBranchMode = file.diffSource === 'combined-branch'
const isCommitMode = file.diffSource === 'combined-commit'
const isAllMode = file.diffSource === 'combined-all'
const branchCompare =
file.branchCompare?.baseOid && file.branchCompare.headOid && file.branchCompare.mergeBase
? file.branchCompare
: null
const commitCompare = file.commitCompare?.commitOid ? file.commitCompare : null
// Why: prefer the tab-open snapshot so a commit changing gitStatusByWorktree doesn't rebuild sections and lose loaded content.
const snapshotEntries = React.useMemo(
() => file.uncommittedEntriesSnapshot?.filter((e) => e.conflictStatus !== 'unresolved'),
[file.uncommittedEntriesSnapshot]
)
const uncommittedEntries = React.useMemo(() => {
if (!snapshotEntries) {
return getCombinedUncommittedEntries(gitStatusEntries, file.combinedAreaFilter)
}
// Why: row load-state changes must not rebuild the snapshot list; the ref is consulted only when live Git status changes.
return resolveCombinedUncommittedSnapshotEntries(
snapshotEntries,
gitStatusEntries,
getRetainedResolvedSnapshotEntries(sectionsRef.current)
)
}, [snapshotEntries, gitStatusEntries, file.combinedAreaFilter, sectionsRef])
const branchEntries = React.useMemo<GitBranchChangeEntry[]>(() => {
return getCombinedBranchEntries(file.branchEntriesSnapshot, liveBranchEntries)
}, [file.branchEntriesSnapshot, liveBranchEntries])
const renderableBranchEntries = React.useMemo(
() => (branchCompare ? branchEntries : []),
[branchCompare, branchEntries]
)
const commitEntries = React.useMemo<GitBranchChangeEntry[]>(
() => file.commitEntriesSnapshot ?? [],
[file.commitEntriesSnapshot]
)
const allEntries = React.useMemo(
() => [...uncommittedEntries, ...renderableBranchEntries],
[renderableBranchEntries, uncommittedEntries]
)
const entries = isAllMode
? allEntries
: isBranchMode
? renderableBranchEntries
: isCommitMode
? commitEntries
: uncommittedEntries
const treeMode = isAllMode
? 'all'
: isBranchMode
? 'branch'
: isCommitMode
? 'commit'
: 'uncommitted'
const hasUncommittedEntriesSnapshot = file.uncommittedEntriesSnapshot !== undefined
const shouldAutoReloadFromGitStatus = shouldAutoReloadCombinedDiffFromGitStatus({
mode: treeMode,
hasUncommittedEntriesSnapshot
})
const entrySignature = React.useMemo(
() =>
JSON.stringify({
mode: file.diffSource,
areaFilter: file.combinedAreaFilter ?? null,
compareVersion: file.branchCompare?.compareVersion ?? null,
commitVersion: file.commitCompare?.compareVersion ?? null,
compare:
isBranchMode && branchCompare
? {
baseOid: branchCompare.baseOid,
headOid: branchCompare.headOid,
mergeBase: branchCompare.mergeBase
}
: null,
commit:
isCommitMode && commitCompare
? {
commitOid: commitCompare.commitOid,
parentOid: commitCompare.parentOid ?? null
}
: null,
entries: entries.map((entry) => ({
path: entry.path,
status: entry.status,
oldPath: entry.oldPath ?? null,
area: 'area' in entry ? entry.area : null,
added: 'added' in entry ? (entry.added ?? null) : null,
removed: 'removed' in entry ? (entry.removed ?? null) : null
}))
}),
[
branchCompare,
commitCompare,
entries,
file.branchCompare?.compareVersion,
file.combinedAreaFilter,
file.commitCompare?.compareVersion,
file.diffSource,
isBranchMode,
isCommitMode
]
)
return {
allEntries,
branchCompare,
commitCompare,
commitEntries,
entries,
entrySignature,
hasUncommittedEntriesSnapshot,
isAllMode,
isBranchMode,
isCommitMode,
renderableBranchEntries,
shouldAutoReloadFromGitStatus,
treeMode,
uncommittedEntries
}
}
@@ -0,0 +1,44 @@
import type React from 'react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import type { OpenFile } from '@/store/slices/editor'
import { getCombinedDiffCommitMessageBody } from './combined-diff-commit-message'
export function CombinedDiffCommitHeader({
commitCompare
}: {
commitCompare: NonNullable<OpenFile['commitCompare']>
}): React.JSX.Element {
const commitBody = getCombinedDiffCommitMessageBody(commitCompare.message, commitCompare.subject)
return (
<div className="border-b border-border bg-background px-4 py-3">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
{commitCompare.subject && (
<Tooltip>
<TooltipTrigger asChild>
<div
className="truncate text-sm font-semibold text-foreground"
title={commitCompare.subject}
>
{commitCompare.subject}
</div>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6} className="max-w-96">
{commitCompare.subject}
</TooltipContent>
</Tooltip>
)}
{commitBody && (
<div className="mt-1 max-h-24 overflow-auto whitespace-pre-wrap text-xs leading-5 text-muted-foreground scrollbar-sleek">
{commitBody}
</div>
)}
</div>
<span className="shrink-0 font-mono text-[11px] leading-5 text-muted-foreground">
{commitCompare.compareRef}
</span>
</div>
</div>
)
}
@@ -0,0 +1,165 @@
import type React from 'react'
import { Check, Copy, MessageSquare, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { getDiffCommentLineLabel } from '@/lib/diff-comment-compat'
import { translate } from '@/i18n/i18n'
import type { DiffComment } from '../../../../../../shared/diff-comment-types'
export function DiffNotesPreviewPopover({
comments,
totalCount,
copied,
onCopy,
onClear
}: {
comments: DiffComment[]
totalCount: number
copied: boolean
onCopy: () => void
onClear: () => void
}): React.JSX.Element {
const remainingCount = Math.max(0, totalCount - comments.length)
return (
<div className="text-xs">
<div className="flex items-center justify-between gap-2 border-b border-border/60 px-3 py-2">
<div className="flex min-w-0 items-center gap-1.5 font-medium text-foreground">
<MessageSquare className="size-3.5 shrink-0 text-muted-foreground" />
<span>
{translate('auto.components.editor.CombinedDiffViewer.bb84b4c374', 'AI notes')}
</span>
<span className="text-[11px] font-normal tabular-nums text-muted-foreground">
{totalCount}
</span>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
type="button"
variant="ghost"
size="xs"
className="h-6 text-muted-foreground hover:text-foreground"
onClick={onCopy}
disabled={totalCount === 0}
>
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
{translate('auto.components.editor.CombinedDiffViewer.88b70d0ef5', 'Copy')}
</Button>
<Button
type="button"
variant="ghost"
size="xs"
className="h-6 text-muted-foreground hover:text-destructive"
onClick={onClear}
disabled={totalCount === 0}
>
<Trash2 className="size-3" />
{translate('auto.components.editor.CombinedDiffViewer.84898c548d', 'Clear')}
</Button>
</div>
</div>
<div className="max-h-72 overflow-y-auto p-2 scrollbar-sleek">
{comments.map((comment) => (
<div key={comment.id} className="rounded-md px-2 py-1.5 hover:bg-accent/50">
<div className="flex items-center gap-1.5 text-[11px] leading-none text-muted-foreground">
<span className="min-w-0 flex-1 truncate font-mono">{comment.filePath}</span>
{comment.sentAt ? (
<span className="shrink-0 rounded bg-muted px-1 py-0.5 text-[10px] leading-none">
{translate('auto.components.editor.CombinedDiffViewer.1da745c551', 'Sent')}
</span>
) : null}
<span className="shrink-0 tabular-nums">
{getDiffCommentLineLabel(comment, true)}
</span>
</div>
<div className="mt-1 max-h-10 overflow-hidden whitespace-pre-wrap break-words text-[12px] leading-snug text-foreground">
{comment.body}
</div>
</div>
))}
{remainingCount > 0 && (
<div className="px-2 py-1 text-[11px] text-muted-foreground">
{remainingCount}{' '}
{translate('auto.components.editor.CombinedDiffViewer.e3b9a6ce02', 'more')}
{remainingCount === 1
? translate('auto.components.editor.CombinedDiffViewer.8ab3248fd8', 'note')
: translate('auto.components.editor.CombinedDiffViewer.0fb870a0fe', 'notes')}{' '}
{translate('auto.components.editor.CombinedDiffViewer.35cc27aeb2', 'in Source Control')}
</div>
)}
</div>
</div>
)
}
export function ClearDiffNotesDialog({
diffCommentCount,
isClearingNotes,
onConfirm,
open,
setOpen
}: {
diffCommentCount: number
isClearingNotes: boolean
onConfirm: () => void
open: boolean
setOpen: React.Dispatch<React.SetStateAction<boolean>>
}): React.JSX.Element {
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen && !isClearingNotes) {
setOpen(false)
} else if (nextOpen) {
setOpen(true)
}
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="text-sm">
{translate('auto.components.editor.CombinedDiffViewer.948a5fd6c8', 'Clear Notes')}
</DialogTitle>
<DialogDescription className="text-xs">
{translate('auto.components.editor.CombinedDiffViewer.84898c548d', 'Clear')}{' '}
{diffCommentCount}{' '}
{diffCommentCount === 1
? translate('auto.components.editor.CombinedDiffViewer.8ab3248fd8', 'note')
: translate('auto.components.editor.CombinedDiffViewer.0fb870a0fe', 'notes')}{' '}
{translate(
'auto.components.editor.CombinedDiffViewer.80a286d8f5',
'from this worktree?'
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={isClearingNotes}
>
{translate('auto.components.editor.CombinedDiffViewer.0f806a2ab1', 'Cancel')}
</Button>
<Button
type="button"
variant="destructive"
onClick={onConfirm}
disabled={isClearingNotes || diffCommentCount === 0}
>
<Trash2 className="size-4" />
{translate('auto.components.editor.CombinedDiffViewer.948a5fd6c8', 'Clear Notes')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,103 @@
import type React from 'react'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
import type { OpenFile } from '@/store/slices/editor'
type SkippedConflicts = NonNullable<OpenFile['skippedConflicts']>
export function CombinedDiffSkippedConflictsEmptyState({
commitHeader,
onReviewConflicts,
skippedConflicts
}: {
commitHeader: React.ReactNode
onReviewConflicts: () => void
skippedConflicts: SkippedConflicts
}): React.JSX.Element {
return (
<div className="flex h-full min-h-0 flex-col">
{commitHeader}
<div className="flex flex-1 items-center justify-center px-6 text-center">
<div className="max-w-md space-y-3">
<div className="text-sm font-medium text-foreground">
{translate(
'auto.components.editor.CombinedDiffViewer.820ec01f24',
'Conflicted files are reviewed separately'
)}
</div>
<div className="text-xs text-muted-foreground">
{translate(
'auto.components.editor.CombinedDiffViewer.eb5f40e49c',
'This diff view excludes unresolved conflicts because the normal two-way diff pipeline is not conflict-safe.'
)}
</div>
<div className="text-xs text-muted-foreground">
{skippedConflicts.map((entry) => entry.path).join(', ')}
</div>
<div className="flex justify-center">
<Button type="button" size="sm" variant="outline" onClick={onReviewConflicts}>
{translate(
'auto.components.editor.CombinedDiffViewer.39f8007549',
'Review conflicts'
)}
</Button>
</div>
</div>
</div>
</div>
)
}
export function CombinedDiffNoChangesEmptyState({
commitHeader
}: {
commitHeader: React.ReactNode
}): React.JSX.Element {
return (
<div className="flex h-full min-h-0 flex-col">
{commitHeader}
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
{translate('auto.components.editor.CombinedDiffViewer.fd8892b120', 'No changes to display')}
</div>
</div>
)
}
export function CombinedDiffSkippedConflictNotice({
onReviewConflicts,
skippedConflicts
}: {
onReviewConflicts: () => void
skippedConflicts: SkippedConflicts
}): React.JSX.Element {
return (
<div className="mx-4 mt-3 rounded-md border border-border/60 bg-muted/20 px-3 py-2 text-xs">
<div className="font-medium text-foreground">
{translate(
'auto.components.editor.CombinedDiffViewer.820ec01f24',
'Conflicted files are reviewed separately'
)}
</div>
<div className="mt-1 text-muted-foreground">
{skippedConflicts.length}{' '}
{translate('auto.components.editor.CombinedDiffViewer.689b99f8ad', 'unresolved conflict')}
{skippedConflicts.length === 1 ? '' : 's'}{' '}
{translate(
'auto.components.editor.CombinedDiffViewer.39e73e7181',
'were excluded from this diff view.'
)}
</div>
<div className="mt-2 flex items-center gap-2">
<Button
type="button"
size="sm"
variant="outline"
className="h-7 text-xs"
onClick={onReviewConflicts}
>
{translate('auto.components.editor.CombinedDiffViewer.39f8007549', 'Review conflicts')}
</Button>
</div>
</div>
)
}
@@ -0,0 +1,193 @@
import type React from 'react'
import { PanelLeftOpen, Sparkles, WrapText } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { translate } from '@/i18n/i18n'
import type { OpenFile } from '@/store/slices/editor'
import type { DiffComment } from '../../../../../../shared/diff-comment-types'
import { DiffNotesSendMenu } from '../../DiffNotesSendMenu'
import { DiffNotesPreviewPopover } from './combined-diff-notes-popover'
export function CombinedDiffToolbar({
activeGroupId,
allSectionsCollapsed,
branchCompare,
commitCompare,
diffCommentCount,
diffCommentsForWorktree,
diffWordWrap,
file,
fileTreeCollapsed,
isAllMode,
isBranchMode,
isCommitMode,
notesCopied,
onCopyNotes,
onOpenAlternateDiff,
onOpenClearNotes,
onShowFileTree,
previewDiffComments,
sectionCount,
setAllSectionsCollapsed,
sideBySide,
toggleDiffWordWrap,
toggleSideBySide
}: {
activeGroupId: string | undefined
allSectionsCollapsed: boolean
branchCompare: NonNullable<OpenFile['branchCompare']> | null
commitCompare: NonNullable<OpenFile['commitCompare']> | null
diffCommentCount: number
diffCommentsForWorktree: DiffComment[]
diffWordWrap: boolean | undefined
file: OpenFile
fileTreeCollapsed: boolean
isAllMode: boolean
isBranchMode: boolean
isCommitMode: boolean
notesCopied: boolean
onCopyNotes: () => void
onOpenAlternateDiff: () => void
onOpenClearNotes: () => void
onShowFileTree: () => void
previewDiffComments: DiffComment[]
sectionCount: number
setAllSectionsCollapsed: (collapsed: boolean) => void
sideBySide: boolean
toggleDiffWordWrap: () => void
toggleSideBySide: () => void
}): React.JSX.Element {
return (
<div className="flex items-center justify-between gap-3 px-3 py-1.5 border-b border-border bg-background/50 shrink-0">
<div className="flex min-w-0 items-center gap-2">
{fileTreeCollapsed && (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={translate(
'auto.components.editor.CombinedDiffViewer.b6c3b84476',
'Show file tree'
)}
onClick={onShowFileTree}
>
<PanelLeftOpen className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.components.editor.CombinedDiffViewer.b6c3b84476', 'Show file tree')}
</TooltipContent>
</Tooltip>
)}
<span className="truncate text-xs text-muted-foreground">
{sectionCount}{' '}
{translate('auto.components.editor.CombinedDiffViewer.7e7ca60816', 'changed files')}
{(isAllMode || isBranchMode) && branchCompare
? translate('auto.components.editor.CombinedDiffViewer.6094135eec', ' vs {{value0}}', {
value0: branchCompare.baseRef
})
: ''}
{isCommitMode && commitCompare
? translate('auto.components.editor.CombinedDiffViewer.724a13568d', ' in {{value0}}', {
value0: commitCompare.compareRef
})
: ''}
</span>
{diffCommentCount > 0 && (
<div className="ml-1 flex shrink-0 items-center overflow-hidden rounded-full border border-border/70 bg-muted/40">
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="inline-flex h-6 items-center gap-1 pl-2 pr-1.5 text-[11px] font-medium leading-none text-foreground/80 transition-colors hover:bg-accent hover:text-foreground"
aria-label={translate(
'auto.components.editor.CombinedDiffViewer.8f68ad9ca9',
'Show {{value0}} AI {{value1}}',
{
value0: diffCommentCount,
value1: diffCommentCount === 1 ? 'note' : 'notes'
}
)}
>
<Sparkles className="size-3 text-violet-500 dark:text-violet-400" />
<span>
{translate('auto.components.editor.CombinedDiffViewer.bb84b4c374', 'AI notes')}
</span>
<span className="rounded-full bg-background/80 px-1 text-[10px] tabular-nums text-muted-foreground">
{diffCommentCount}
</span>
</button>
</PopoverTrigger>
<PopoverContent align="start" side="bottom" sideOffset={6} className="w-80 p-0">
<DiffNotesPreviewPopover
comments={previewDiffComments}
totalCount={diffCommentCount}
copied={notesCopied}
onCopy={onCopyNotes}
onClear={onOpenClearNotes}
/>
</PopoverContent>
</Popover>
<DiffNotesSendMenu
worktreeId={file.worktreeId}
groupId={activeGroupId ?? file.worktreeId}
comments={diffCommentsForWorktree}
actionLabel="Send"
triggerClassName="h-6 gap-1 rounded-none border-l border-border/70 px-2 text-[11px] font-medium leading-none text-foreground/80 hover:bg-accent hover:text-foreground"
iconClassName="size-3"
/>
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
{file.combinedAlternate && (
<button
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={onOpenAlternateDiff}
>
{file.combinedAlternate.source === 'combined-branch'
? translate(
'auto.components.editor.CombinedDiffViewer.3d909843bb',
'Open Branch Diff'
)
: translate(
'auto.components.editor.CombinedDiffViewer.982d14bfa5',
'Open All Changes'
)}
</button>
)}
<button
className="w-20 text-left text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setAllSectionsCollapsed(!allSectionsCollapsed)}
>
{allSectionsCollapsed
? translate('auto.components.editor.CombinedDiffViewer.19c45cfdc0', 'Expand All')
: translate('auto.components.editor.CombinedDiffViewer.ea08dae15b', 'Collapse All')}
</button>
<button
className="w-24 px-2 py-0.5 text-center text-xs rounded border border-border text-muted-foreground hover:text-foreground transition-colors"
onClick={toggleSideBySide}
>
{sideBySide
? translate('auto.components.editor.CombinedDiffViewer.f786fd54e1', 'Inline')
: translate('auto.components.editor.CombinedDiffViewer.ec5053c7f5', 'Side by Side')}
</button>
<button
className={`inline-flex h-6 items-center gap-1 rounded border border-border px-2 text-xs transition-colors hover:text-foreground ${
diffWordWrap === true ? 'bg-accent text-foreground' : 'text-muted-foreground'
}`}
onClick={toggleDiffWordWrap}
aria-pressed={diffWordWrap === true}
>
<WrapText className="size-3.5" />
{diffWordWrap === true
? translate('auto.components.editor.CombinedDiffViewer.a4420ca1f7', 'Wrap On')
: translate('auto.components.editor.CombinedDiffViewer.dde325ddfe', 'Wrap Off')}
</button>
</div>
</div>
)
}
@@ -0,0 +1,138 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { formatDiffComments } from '@/lib/diff-comments-format'
import { translate } from '@/i18n/i18n'
import type { DiffComment } from '../../../../../../shared/diff-comment-types'
export type CombinedDiffNotesActions = {
clearNotesDialogVisible: boolean
diffCommentCount: number
handleConfirmClearNotes: () => Promise<void>
handleCopyNotes: () => Promise<void>
isClearingNotes: boolean
notesCopied: boolean
previewDiffComments: DiffComment[]
setClearNotesDialogOpen: React.Dispatch<React.SetStateAction<boolean>>
setScrollSurfaceMounted: (mounted: boolean) => void
}
export function useCombinedDiffNotesActions({
clearDiffComments,
diffCommentsForWorktree,
worktreeId
}: {
clearDiffComments: (worktreeId: string) => Promise<boolean>
diffCommentsForWorktree: DiffComment[]
worktreeId: string
}): CombinedDiffNotesActions {
const diffCommentCount = diffCommentsForWorktree.length
const diffCommentsPrompt = React.useMemo(
() => formatDiffComments(diffCommentsForWorktree),
[diffCommentsForWorktree]
)
const previewDiffComments = React.useMemo(
() =>
[...diffCommentsForWorktree]
.sort((a, b) => a.filePath.localeCompare(b.filePath) || a.lineNumber - b.lineNumber)
.slice(0, 4),
[diffCommentsForWorktree]
)
const [clearNotesDialogOpen, setClearNotesDialogOpen] = useState(false)
const [isClearingNotes, setIsClearingNotes] = useState(false)
const clearNotesDialogVisible = clearNotesDialogOpen && (diffCommentCount > 0 || isClearingNotes)
if (clearNotesDialogOpen && !clearNotesDialogVisible) {
// Why: notes may be cleared outside this dialog; close it this render instead of flashing an empty confirmation.
setClearNotesDialogOpen(false)
}
const [notesCopied, setNotesCopied] = useState(false)
const mountedRef = useRef(true)
// Why: the copy action owns its reset timer instead of repairing copied state after render.
const notesCopiedResetTimerRef = useRef<number | null>(null)
// Why: clipboard IPC can resolve after unmount; skip copied feedback rather than start a reset timer on a stale viewer.
const notesCopyMountedRef = useRef(false)
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
}
}, [])
const clearNotesCopiedResetTimer = useCallback((): void => {
if (notesCopiedResetTimerRef.current !== null) {
window.clearTimeout(notesCopiedResetTimerRef.current)
notesCopiedResetTimerRef.current = null
}
}, [])
const setScrollSurfaceMounted = useCallback(
(mounted: boolean): void => {
notesCopyMountedRef.current = mounted
if (!mounted) {
// Why: copied feedback is tied to the surface lifetime; the root-ref unmount is where stale feedback gets disabled.
clearNotesCopiedResetTimer()
}
},
[clearNotesCopiedResetTimer]
)
const handleCopyNotes = useCallback(async (): Promise<void> => {
if (diffCommentCount === 0) {
return
}
try {
await window.api.ui.writeClipboardText(diffCommentsPrompt)
if (!notesCopyMountedRef.current) {
return
}
clearNotesCopiedResetTimer()
setNotesCopied(true)
notesCopiedResetTimerRef.current = window.setTimeout(() => {
setNotesCopied(false)
notesCopiedResetTimerRef.current = null
}, 1500)
} catch {
// Why: clipboard writes can fail while the app is unfocused; keep the popover non-blocking.
}
}, [clearNotesCopiedResetTimer, diffCommentCount, diffCommentsPrompt])
const handleConfirmClearNotes = useCallback(async (): Promise<void> => {
if (diffCommentCount === 0 || isClearingNotes) {
return
}
setIsClearingNotes(true)
try {
const ok = await clearDiffComments(worktreeId)
if (!mountedRef.current) {
return
}
if (ok) {
setClearNotesDialogOpen(false)
} else {
toast.error(
translate(
'auto.components.editor.CombinedDiffViewer.45cf23b418',
'Failed to clear notes.'
)
)
}
} finally {
if (mountedRef.current) {
setIsClearingNotes(false)
}
}
}, [clearDiffComments, diffCommentCount, isClearingNotes, worktreeId])
return {
clearNotesDialogVisible,
diffCommentCount,
handleConfirmClearNotes,
handleCopyNotes,
isClearingNotes,
notesCopied,
previewDiffComments,
setClearNotesDialogOpen,
setScrollSurfaceMounted
}
}
@@ -0,0 +1,238 @@
import { useCallback, useRef } from 'react'
import type React from 'react'
import type { editor as monacoEditor } from 'monaco-editor'
import { useAppStore } from '@/store'
import { detectLanguage } from '@/lib/language-detect'
import { joinPath } from '@/lib/path'
import { openFilePreviewToSide } from '@/lib/file-preview'
import { getEditorFileOperationContext } from '@/lib/editor-file-operation-owner'
import { writeRuntimeFile } from '@/runtime/runtime-file-client'
import { findWorktreeById } from '@/store/slices/worktree-helpers'
import type { OpenFile } from '@/store/slices/editor'
import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types'
import { canOpenDiffSectionPreviewToSide } from '../../diff-section-preview'
import { getLargeDiffRenderLimit } from '../../large-diff-render-limit'
import { getStoredTextDiffContent, getStoredTextDiffResult } from '../../large-diff-section-content'
import { removeDiffSectionMeasuredHeight } from '../../diff-section-height-cache'
import type { DiffSection } from '../../diff-section-types'
import type { DiffSectionItemProps } from '../../diff-section-item-props'
export type CombinedDiffSectionActions = {
handleSectionSaveRef: DiffSectionItemProps['handleSectionSaveRef']
modifiedEditorsRef: DiffSectionItemProps['modifiedEditorsRef']
openSection: (index: number) => void
openSectionPreview: (section: DiffSection) => void
}
export function useCombinedDiffSectionActions({
activeGroupId,
branchCompare,
canOpenWorkspaceFileBrowserForPath,
commitCompare,
file,
isAllMode,
isBranchMode,
isCommitMode,
sections,
sectionsRef,
setSectionHeights,
setSections
}: {
activeGroupId: string | undefined
branchCompare: NonNullable<OpenFile['branchCompare']> | null
canOpenWorkspaceFileBrowserForPath: (path: string) => boolean
commitCompare: NonNullable<OpenFile['commitCompare']> | null
file: OpenFile
isAllMode: boolean
isBranchMode: boolean
isCommitMode: boolean
sections: DiffSection[]
sectionsRef: React.RefObject<DiffSection[]>
setSectionHeights: React.Dispatch<React.SetStateAction<Record<number, number>>>
setSections: React.Dispatch<React.SetStateAction<DiffSection[]>>
}): CombinedDiffSectionActions {
const openFile = useAppStore((s) => s.openFile)
const openBranchDiff = useAppStore((s) => s.openBranchDiff)
const openCommitDiff = useAppStore((s) => s.openCommitDiff)
const modifiedEditorsRef = useRef<Map<number, monacoEditor.IStandaloneCodeEditor>>(new Map())
const openSection = useCallback(
(index: number) => {
const section = sectionsRef.current[index]
if (!section) {
return
}
const language = detectLanguage(section.path)
const entry: GitBranchChangeEntry = {
path: section.path,
status: section.status as GitBranchChangeEntry['status'],
oldPath: section.oldPath,
added: section.added,
removed: section.removed
}
const isBranchEntry = section.area === undefined
if ((isBranchMode || (isAllMode && isBranchEntry)) && branchCompare) {
openBranchDiff(file.worktreeId, file.filePath, entry, branchCompare, language)
return
}
if (isCommitMode && commitCompare) {
openCommitDiff(file.worktreeId, file.filePath, entry, commitCompare, language)
return
}
openFile({
filePath: joinPath(file.filePath, section.path),
relativePath: section.path,
worktreeId: file.worktreeId,
runtimeEnvironmentId: file.runtimeEnvironmentId,
language,
mode: 'edit'
})
},
[
branchCompare,
commitCompare,
file.filePath,
file.runtimeEnvironmentId,
file.worktreeId,
isAllMode,
isBranchMode,
isCommitMode,
openBranchDiff,
openCommitDiff,
openFile,
sectionsRef
]
)
// Why: match single-file HTML diffs — preview the on-disk working tree file
// beside the combined view when the section is still present on disk.
const openSectionPreview = useCallback(
(section: DiffSection) => {
if (
!canOpenDiffSectionPreviewToSide({
path: section.path,
status: section.status,
isCommitSurface: isCommitMode,
canOpenWorkspaceFileBrowser: canOpenWorkspaceFileBrowserForPath(
joinPath(file.filePath, section.path)
)
})
) {
return
}
// Why: use this combined-diff tab's group, not worktree activeGroupId —
// in a multi-pane layout the active group may be a different split.
const state = useAppStore.getState()
const sourceGroupId =
(state.unifiedTabsByWorktree[file.worktreeId] ?? []).find(
(tab) =>
tab.entityId === file.id && (tab.contentType === 'diff' || tab.contentType === 'editor')
)?.groupId ??
activeGroupId ??
null
openFilePreviewToSide({
language: detectLanguage(section.path),
filePath: joinPath(file.filePath, section.path),
worktreeId: file.worktreeId,
sourceGroupId
})
},
[
activeGroupId,
canOpenWorkspaceFileBrowserForPath,
file.filePath,
file.id,
file.worktreeId,
isCommitMode
]
)
const handleSectionSave = useCallback(
async (index: number) => {
const section = sections[index]
if (!section) {
return
}
const modifiedEditor = modifiedEditorsRef.current.get(index)
if (!modifiedEditor && !section.dirty) {
return
}
const content = modifiedEditor?.getValue() ?? section.modifiedContent
const absolutePath = joinPath(file.filePath, section.path)
try {
const state = useAppStore.getState()
const worktree = file.worktreeId
? findWorktreeById(state.worktreesByRepo, file.worktreeId)
: null
await writeRuntimeFile(
getEditorFileOperationContext(
state,
{
worktreeId: file.worktreeId,
runtimeEnvironmentId: file.runtimeEnvironmentId,
operationProvenance: file.operationProvenance
},
worktree?.path ?? null
),
absolutePath,
content
)
setSectionHeights((prev) => removeDiffSectionMeasuredHeight(prev, index))
setSections((prev) =>
prev.map((s, i) => {
if (i !== index) {
return s
}
if (s.diffResult?.kind !== 'text') {
return {
...s,
modifiedContent: content,
dirty: false,
largeDiffRenderLimit: s.largeDiffRenderLimit
}
}
const nextDiffResult = { ...s.diffResult, modifiedContent: content }
const nextLargeDiffRenderLimit = getLargeDiffRenderLimit({
originalContent: s.originalContent,
modifiedContent: content
})
const storedContent = getStoredTextDiffContent(nextDiffResult, nextLargeDiffRenderLimit)
return {
...s,
modifiedContent: storedContent.modifiedContent,
originalContent: storedContent.originalContent,
dirty: false,
diffResult: getStoredTextDiffResult(nextDiffResult, nextLargeDiffRenderLimit),
largeDiffRenderLimit: nextLargeDiffRenderLimit
}
})
)
} catch (err) {
console.error('Save failed:', err)
}
},
[
file.filePath,
file.operationProvenance,
file.runtimeEnvironmentId,
file.worktreeId,
sections,
setSectionHeights,
setSections
]
)
const handleSectionSaveRef = useRef(handleSectionSave)
handleSectionSaveRef.current = handleSectionSave
return { handleSectionSaveRef, modifiedEditorsRef, openSection, openSectionPreview }
}
@@ -0,0 +1,102 @@
import { useCallback, useEffect, useState } from 'react'
import type React from 'react'
import type { DiffSection } from '../../diff-section-types'
import { combinedDiffViewPreferences } from '../remember-view/combined-diff-view-memory'
import { getInitialCombinedDiffSectionLoadIndices } from '../load-sections/combined-diff-initial-section-load'
import type { CombinedDiffSectionLoadRegistry } from '../load-sections/combined-diff-section-load-registry'
export type CombinedDiffViewPreferences = {
fileTreeCollapsed: boolean
setAllSectionsCollapsed: (collapsed: boolean) => void
setFileTreeCollapsed: (collapsed: boolean) => void
setSideBySide: React.Dispatch<React.SetStateAction<boolean>>
sideBySide: boolean
toggleDiffWordWrap: () => void
toggleSideBySide: () => void
}
export function useCombinedDiffViewPreferences({
combinedDiffFileTreeVisibleByDefault,
diffDefaultView,
diffWordWrap,
registry,
setSections,
updateSettings
}: {
combinedDiffFileTreeVisibleByDefault: boolean | undefined
diffDefaultView: string | undefined
diffWordWrap: boolean | undefined
registry: CombinedDiffSectionLoadRegistry
setSections: React.Dispatch<React.SetStateAction<DiffSection[]>>
updateSettings: (patch: { diffWordWrap: boolean }) => unknown
}): CombinedDiffViewPreferences {
const { loadSchedulerRef, loadedIndicesRef, sectionsRef } = registry
const [sideBySide, setSideBySide] = useState(
() => combinedDiffViewPreferences.sideBySide ?? diffDefaultView === 'side-by-side'
)
const [fileTreeCollapsed, setFileTreeCollapsedState] = useState(
() =>
// Why: the tree is opt-in; only an explicit saved setting should open it while settings are still loading.
combinedDiffViewPreferences.fileTreeCollapsed ?? combinedDiffFileTreeVisibleByDefault !== true
)
// Why: seed from Settings until the user picks a toolbar mode this session, then follow that choice over the global default.
useEffect(() => {
if (diffDefaultView !== undefined && combinedDiffViewPreferences.sideBySide === null) {
setSideBySide(diffDefaultView === 'side-by-side')
}
}, [diffDefaultView])
useEffect(() => {
if (
combinedDiffFileTreeVisibleByDefault !== undefined &&
combinedDiffViewPreferences.fileTreeCollapsed === null
) {
setFileTreeCollapsedState(combinedDiffFileTreeVisibleByDefault === false)
}
}, [combinedDiffFileTreeVisibleByDefault])
const setFileTreeCollapsed = useCallback((collapsed: boolean) => {
combinedDiffViewPreferences.fileTreeCollapsed = collapsed
setFileTreeCollapsedState(collapsed)
}, [])
const setAllSectionsCollapsed = useCallback(
(collapsed: boolean) => {
combinedDiffViewPreferences.collapsed = collapsed
setSections((prev) => prev.map((section) => ({ ...section, collapsed })))
if (!collapsed) {
const initialIndices = getInitialCombinedDiffSectionLoadIndices({
sectionCount: sectionsRef.current.length,
loadedIndices: loadedIndicesRef.current
})
for (const index of initialIndices) {
loadSchedulerRef.current.request(index)
}
}
},
[loadSchedulerRef, loadedIndicesRef, sectionsRef, setSections]
)
const toggleSideBySide = useCallback(() => {
setSideBySide((prev) => {
const next = !prev
combinedDiffViewPreferences.sideBySide = next
return next
})
}, [])
const toggleDiffWordWrap = useCallback(() => {
void updateSettings({ diffWordWrap: diffWordWrap !== true })
}, [diffWordWrap, updateSettings])
return {
fileTreeCollapsed,
setAllSectionsCollapsed,
setFileTreeCollapsed,
setSideBySide,
sideBySide,
toggleDiffWordWrap,
toggleSideBySide
}
}
@@ -0,0 +1,165 @@
import type React from 'react'
import type { Virtualizer } from '@tanstack/react-virtual'
import { joinPath } from '@/lib/path'
import type { OpenFile } from '@/store/slices/editor'
import type { DiffComment } from '../../../../../../shared/diff-comment-types'
import { DiffSectionItem } from '../../DiffSectionItem'
import type { DiffSectionItemProps } from '../../diff-section-item-props'
import { DiffNotesSendMenu } from '../../DiffNotesSendMenu'
import { canOpenDiffSectionPreviewToSide } from '../../diff-section-preview'
import type { DiffSection } from '../../diff-section-types'
import type { CombinedDiffScrollThumb } from './use-combined-diff-scrollbar'
export function CombinedDiffSectionList({
activeGroupId,
canOpenWorkspaceFileBrowserForPath,
diffCommentsForWorktree,
file,
handleSectionSaveRef,
isAllMode,
isBranchMode,
isCommitMode,
isDark,
loadSection,
markDirectScrollInput,
modifiedEditorsRef,
onScrollbarPointerDown,
openSection,
openSectionPreview,
retrySection,
scrollThumb,
sectionHeights,
sections,
setScrollContainerRef,
setSectionHeights,
setSections,
settings,
sideBySide,
skippedConflictNotice,
toggleSection,
virtualizer
}: {
activeGroupId: string | undefined
canOpenWorkspaceFileBrowserForPath: (path: string) => boolean
diffCommentsForWorktree: DiffComment[]
file: OpenFile
handleSectionSaveRef: DiffSectionItemProps['handleSectionSaveRef']
isAllMode: boolean
isBranchMode: boolean
isCommitMode: boolean
isDark: boolean
loadSection: (index: number) => void
markDirectScrollInput: () => void
modifiedEditorsRef: DiffSectionItemProps['modifiedEditorsRef']
onScrollbarPointerDown: (event: React.PointerEvent<HTMLDivElement>) => void
openSection: (index: number) => void
openSectionPreview: (section: DiffSection) => void
retrySection: (index: number) => void
scrollThumb: CombinedDiffScrollThumb
sectionHeights: Record<number, number>
sections: DiffSection[]
setScrollContainerRef: (node: HTMLDivElement | null) => void
setSectionHeights: React.Dispatch<React.SetStateAction<Record<number, number>>>
setSections: React.Dispatch<React.SetStateAction<DiffSection[]>>
settings: DiffSectionItemProps['settings']
sideBySide: boolean
skippedConflictNotice: React.ReactNode
toggleSection: (index: number) => void
virtualizer: Virtualizer<HTMLDivElement, Element>
}): React.JSX.Element {
return (
<div className="relative min-w-0 flex-1">
<div
ref={setScrollContainerRef}
className="combined-diff-scroll-container h-full overflow-auto pr-5 scrollbar-editor"
onWheel={markDirectScrollInput}
onTouchMove={markDirectScrollInput}
>
{skippedConflictNotice}
<div className="relative w-full" style={{ height: `${virtualizer.getTotalSize()}px` }}>
{virtualizer.getVirtualItems().map((virtualItem) => {
const section = sections[virtualItem.index]
if (!section) {
return null
}
return (
<div
key={virtualItem.key}
data-index={virtualItem.index}
data-combined-diff-section-row
data-combined-diff-section-key={section.key}
ref={virtualizer.measureElement}
className="absolute left-0 top-0 w-full"
// Why: position via top, not transform, so sticky file headers don't jump (transform creates a containing block).
style={{ top: `${virtualItem.start}px` }}
>
<DiffSectionItem
section={section}
index={virtualItem.index}
isBranchMode={isBranchMode}
sideBySide={sideBySide}
isDark={isDark}
settings={settings}
sectionHeight={sectionHeights[virtualItem.index]}
worktreeId={file.worktreeId}
loadSection={loadSection}
retrySection={retrySection}
toggleSection={toggleSection}
openSection={openSection}
openSectionTitle={
isAllMode || isBranchMode || isCommitMode ? 'Open diff' : 'Open in editor'
}
onOpenPreview={
canOpenDiffSectionPreviewToSide({
path: section.path,
status: section.status,
isCommitSurface: isCommitMode,
canOpenWorkspaceFileBrowser: canOpenWorkspaceFileBrowserForPath(
joinPath(file.filePath, section.path)
)
})
? openSectionPreview
: undefined
}
setSectionHeights={setSectionHeights}
setSections={setSections}
modifiedEditorsRef={modifiedEditorsRef}
handleSectionSaveRef={handleSectionSaveRef}
renderHeaderTrailingContent={(section) => {
const fileNotes = diffCommentsForWorktree.filter(
(comment) => comment.filePath === section.path
)
return fileNotes.length > 0 ? (
<DiffNotesSendMenu
worktreeId={file.worktreeId}
groupId={activeGroupId ?? file.worktreeId}
comments={diffCommentsForWorktree}
filePath={section.path}
showFileScope
triggerClassName="p-0.5 can-hover:opacity-0 group-hover:opacity-100"
/>
) : null
}}
/>
</div>
)
})}
</div>
</div>
{scrollThumb.visible && (
<div
aria-hidden="true"
className="absolute inset-y-1 right-1 z-20 w-4 cursor-default rounded bg-muted/15 pl-1"
onPointerDown={onScrollbarPointerDown}
>
<div
data-combined-diff-scrollbar-thumb
className="absolute left-1 right-0 rounded bg-muted-foreground/30"
style={{ top: scrollThumb.top, height: scrollThumb.height }}
/>
</div>
)}
</div>
)
}
@@ -0,0 +1,21 @@
import { useCallback, useRef } from 'react'
export type CombinedDiffDirectScrollInput = {
hasDirectScrollInput: () => boolean
markDirectScrollInput: () => void
}
export function useCombinedDiffDirectScrollInput(): CombinedDiffDirectScrollInput {
const directScrollInputUntilRef = useRef(0)
const markDirectScrollInput = useCallback((): void => {
directScrollInputUntilRef.current = window.performance.now() + 250
}, [])
const hasDirectScrollInput = useCallback(
() => window.performance.now() < directScrollInputUntilRef.current,
[]
)
return { hasDirectScrollInput, markDirectScrollInput }
}
@@ -0,0 +1,188 @@
import { useCallback, useMemo } from 'react'
import type React from 'react'
import type { Virtualizer } from '@tanstack/react-virtual'
import {
useVirtualizedScrollAnchor,
VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT,
type VirtualizedScrollAnchor
} from '@/hooks/useVirtualizedScrollAnchor'
import { getVirtualizedScrollAnchorForOffset } from '@/hooks/virtualized-scroll-anchor-recording'
import type { ProgrammaticScrollMarks } from '@/hooks/programmatic-scroll-marks'
import { setWithLRU } from '@/lib/scroll-cache'
import type { DiffSection } from '../../diff-section-types'
import { combinedDiffScrollAnchorCache } from '../remember-view/combined-diff-view-memory'
export type CombinedDiffScrollAnchors = {
persistScrollAnchor: (refreshDomAnchor?: boolean) => void
scrollToSectionIndex: (index: number) => void
recordDomScrollAnchor: () => boolean
recordVirtualScrollAnchor: (scrollTop: number) => void
writeScrollAnchor: () => void
}
export function useCombinedDiffScrollAnchors({
clampRestoreCount,
generation,
hasDirectScrollInput,
latestDomScrollAnchorRef,
programmaticScrollMarks,
scrollAnchorRef,
scrollContainerRef,
scrollOffsetRef,
sections,
sectionsRef,
sideBySide,
totalSize,
viewStateKey,
virtualizer
}: {
clampRestoreCount: number
generation: number
hasDirectScrollInput: () => boolean
latestDomScrollAnchorRef: React.RefObject<VirtualizedScrollAnchor>
programmaticScrollMarks: ProgrammaticScrollMarks
scrollAnchorRef: React.RefObject<VirtualizedScrollAnchor>
scrollContainerRef: React.RefObject<HTMLDivElement | null>
scrollOffsetRef: React.RefObject<number>
sections: DiffSection[]
sectionsRef: React.RefObject<DiffSection[]>
sideBySide: boolean
totalSize: number
viewStateKey: string
virtualizer: Virtualizer<HTMLDivElement, Element>
}): CombinedDiffScrollAnchors {
const getCombinedDiffSectionKey = useCallback((section: DiffSection): string => section.key, [])
const getCombinedDiffSectionElementKey = useCallback(
(element: Element): string | null =>
element instanceof HTMLElement ? (element.dataset.combinedDiffSectionKey ?? null) : null,
[]
)
const recordVirtualScrollAnchor = useCallback(
(scrollTop: number): void => {
scrollAnchorRef.current = getVirtualizedScrollAnchorForOffset({
getRowKey: getCombinedDiffSectionKey,
rows: sectionsRef.current,
scrollTop,
virtualItems: virtualizer.getVirtualItems()
})
latestDomScrollAnchorRef.current = null
},
[getCombinedDiffSectionKey, latestDomScrollAnchorRef, scrollAnchorRef, sectionsRef, virtualizer]
)
const recordDomScrollAnchor = useCallback((): boolean => {
const container = scrollContainerRef.current
if (!container) {
return false
}
const containerRect = container.getBoundingClientRect()
const visibleRows = Array.from(
container.querySelectorAll<HTMLElement>('[data-combined-diff-section-row]')
)
.map((row) => {
const key = row.dataset.combinedDiffSectionKey
if (!key || !row.isConnected) {
return null
}
const rect = row.getBoundingClientRect()
if (
rect.height <= 0 ||
rect.bottom <= containerRect.top ||
rect.top >= containerRect.bottom
) {
return null
}
return { key, rect }
})
.filter((row): row is { key: string; rect: DOMRect } => row !== null)
.sort((a, b) => a.rect.top - b.rect.top)
const firstVisible = visibleRows[0]
if (!firstVisible) {
return false
}
const anchor: NonNullable<VirtualizedScrollAnchor> = {
fallbackKeys: visibleRows.slice(1).map((row) => row.key),
key: firstVisible.key,
offset: Math.min(
firstVisible.rect.height,
Math.max(0, containerRect.top - firstVisible.rect.top)
),
scrollTop: container.scrollTop
}
scrollAnchorRef.current = anchor
latestDomScrollAnchorRef.current = anchor
return true
}, [latestDomScrollAnchorRef, scrollAnchorRef, scrollContainerRef])
const writeScrollAnchor = useCallback((): void => {
const anchor = scrollAnchorRef.current
if (anchor) {
setWithLRU(combinedDiffScrollAnchorCache, viewStateKey, anchor)
} else {
combinedDiffScrollAnchorCache.delete(viewStateKey)
}
}, [scrollAnchorRef, viewStateKey])
const persistScrollAnchor = useCallback(
(refreshDomAnchor = true): void => {
if (refreshDomAnchor) {
recordDomScrollAnchor()
}
writeScrollAnchor()
},
[recordDomScrollAnchor, writeScrollAnchor]
)
// Why: restore only on structural changes — restoring on measurement churn overwrote scrollTop during active wheel input.
const combinedDiffRestoreSignal = useMemo(
() =>
// Why: a single-section reload drops that row's measured height, so it shifts rows
// below it — still a structural change even though `generation` no longer moves.
`${generation}|${sideBySide ? 'sbs' : 'inline'}|${clampRestoreCount}|${sections
.map(
(section) =>
`${section.key}:${section.collapsed ? 'c' : 'e'}:${section.contentGeneration ?? 0}`
)
.join(',')}`,
[clampRestoreCount, generation, sections, sideBySide]
)
useVirtualizedScrollAnchor({
anchorRef: scrollAnchorRef,
getItemElementKey: getCombinedDiffSectionElementKey,
getRowKey: getCombinedDiffSectionKey,
hasDirectScrollInput,
itemElementSelector: '[data-combined-diff-section-row]',
programmaticScrollMarks,
recordAnchorOnCleanup: false,
recordAnchorOnScroll: false,
restoreSignal: combinedDiffRestoreSignal,
rows: sections,
scrollElementRef: scrollContainerRef,
shouldSkipRestore: hasDirectScrollInput,
scrollOffsetRef,
totalSize,
virtualizer
})
const scrollToSectionIndex = useCallback(
(index: number): void => {
scrollAnchorRef.current = null
latestDomScrollAnchorRef.current = null
virtualizer.scrollToIndex(index, { align: 'start' })
// Why: this jump is programmatic (no scroll event records an anchor); snapshot the destination once layout settles.
window.requestAnimationFrame(() => {
scrollContainerRef.current?.dispatchEvent(new Event(VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT))
})
},
[latestDomScrollAnchorRef, scrollAnchorRef, scrollContainerRef, virtualizer]
)
return {
persistScrollAnchor,
recordDomScrollAnchor,
recordVirtualScrollAnchor,
scrollToSectionIndex,
writeScrollAnchor
}
}
@@ -0,0 +1,213 @@
import { useLayoutEffect } from 'react'
import type React from 'react'
import type { VirtualizedScrollAnchor } from '@/hooks/useVirtualizedScrollAnchor'
import type { ProgrammaticScrollMarks } from '@/hooks/programmatic-scroll-marks'
import { setWithLRU } from '@/lib/scroll-cache'
import type { DiffSection } from '../../diff-section-types'
import {
combinedDiffScrollTopCache,
combinedDiffViewStateCache
} from '../remember-view/combined-diff-view-memory'
import type { CombinedDiffScrollAnchors } from './use-combined-diff-scroll-anchors'
export function useCombinedDiffScrollPersistence({
anchors,
entrySignature,
hasDirectScrollInput,
latestDomScrollAnchorRef,
programmaticScrollMarks,
scrollAnchorRef,
scrollContainerRef,
scrollOffsetRef,
sectionCount,
sectionHeights,
sections,
setClampRestoreCount,
updateScrollbar,
viewStateKey
}: {
anchors: CombinedDiffScrollAnchors
entrySignature: string
hasDirectScrollInput: () => boolean
latestDomScrollAnchorRef: React.RefObject<VirtualizedScrollAnchor>
programmaticScrollMarks: ProgrammaticScrollMarks
scrollAnchorRef: React.RefObject<VirtualizedScrollAnchor>
scrollContainerRef: React.RefObject<HTMLDivElement | null>
scrollOffsetRef: React.RefObject<number>
sectionCount: number
sectionHeights: Record<number, number>
sections: DiffSection[]
setClampRestoreCount: React.Dispatch<React.SetStateAction<number>>
updateScrollbar: () => void
viewStateKey: string
}): void {
const { persistScrollAnchor, recordVirtualScrollAnchor, writeScrollAnchor } = anchors
useLayoutEffect(() => {
const container = scrollContainerRef.current
if (!container) {
return
}
const cached = combinedDiffViewStateCache.get(viewStateKey)
if (cached && cached.entrySignature === entrySignature) {
scrollOffsetRef.current = combinedDiffScrollTopCache.get(viewStateKey) ?? cached.scrollTop
}
let anchorIdleTimerId: number | null = null
let anchorFrameId: number | null = null
const cancelScheduledAnchorPersist = (): void => {
if (anchorIdleTimerId !== null) {
window.clearTimeout(anchorIdleTimerId)
anchorIdleTimerId = null
}
if (anchorFrameId !== null) {
window.cancelAnimationFrame(anchorFrameId)
anchorFrameId = null
}
}
const scheduleSettledAnchorPersist = (): void => {
cancelScheduledAnchorPersist()
anchorIdleTimerId = window.setTimeout(() => {
anchorIdleTimerId = null
if (hasDirectScrollInput()) {
// Why: the idle timer can fire mid-wheel while TanStack still shows a transitional virtual window.
scheduleSettledAnchorPersist()
return
}
anchorFrameId = window.requestAnimationFrame(() => {
anchorFrameId = null
persistScrollAnchor()
})
}, 150)
}
const updateCachedScrollPosition = ({
recordDomAnchor,
scheduleSettled,
scrollTop,
writeAnchor
}: {
recordDomAnchor: boolean
scheduleSettled: boolean
scrollTop: number
writeAnchor: boolean
}): void => {
const existing = combinedDiffViewStateCache.get(viewStateKey)
scrollOffsetRef.current = scrollTop
setWithLRU(combinedDiffScrollTopCache, viewStateKey, scrollTop)
if (writeAnchor) {
if (recordDomAnchor) {
persistScrollAnchor()
} else {
writeScrollAnchor()
}
}
if (scheduleSettled) {
scheduleSettledAnchorPersist()
}
updateScrollbar()
if (!existing || existing.entrySignature !== entrySignature) {
return
}
setWithLRU(combinedDiffViewStateCache, viewStateKey, {
...existing,
scrollTop
})
}
let lastScrollHeight = container.scrollHeight
const handleScroll = (event: Event): void => {
const scrollTop = container.scrollTop
const scrollHeight = container.scrollHeight
const maxScrollTop = Math.max(0, scrollHeight - container.clientHeight)
const shrank = scrollHeight < lastScrollHeight - 1
lastScrollHeight = scrollHeight
if (programmaticScrollMarks.consume(event, scrollTop, maxScrollTop)) {
updateScrollbar()
return
}
if (shrank && scrollTop >= maxScrollTop - 1 && scrollOffsetRef.current > maxScrollTop + 1) {
// Why: pinned at a just-shrunk max from an unreachable offset is a browser clamp, not user input — re-pin, don't record it.
setClampRestoreCount((count) => count + 1)
updateScrollbar()
return
}
// Why: any unmarked scroll is the user's — even events delayed past their window by main-thread jank.
recordVirtualScrollAnchor(scrollTop)
updateCachedScrollPosition({
recordDomAnchor: false,
scheduleSettled: true,
scrollTop,
writeAnchor: true
})
}
// Why: detach in the layout phase so the outgoing tab snapshots its real scroll before teardown fires a reset-to-top scroll.
updateScrollbar()
const resizeObserver = new ResizeObserver(updateScrollbar)
resizeObserver.observe(container)
container.addEventListener('scroll', handleScroll)
return () => {
cancelScheduledAnchorPersist()
if (latestDomScrollAnchorRef.current) {
scrollAnchorRef.current = latestDomScrollAnchorRef.current
}
updateCachedScrollPosition({
recordDomAnchor: false,
scheduleSettled: false,
scrollTop: scrollOffsetRef.current,
writeAnchor: true
})
resizeObserver.disconnect()
container.removeEventListener('scroll', handleScroll)
}
}, [
entrySignature,
hasDirectScrollInput,
latestDomScrollAnchorRef,
persistScrollAnchor,
programmaticScrollMarks,
recordVirtualScrollAnchor,
scrollAnchorRef,
scrollContainerRef,
scrollOffsetRef,
sectionCount,
setClampRestoreCount,
updateScrollbar,
writeScrollAnchor,
viewStateKey
])
useLayoutEffect(() => {
updateScrollbar()
const container = scrollContainerRef.current
if (!container || container.scrollTop <= 0) {
return
}
let frameId: number | null = null
const timerId = window.setTimeout(() => {
if (!container.isConnected || hasDirectScrollInput()) {
return
}
frameId = window.requestAnimationFrame(() => {
frameId = null
persistScrollAnchor()
})
}, 300)
return () => {
window.clearTimeout(timerId)
if (frameId !== null) {
window.cancelAnimationFrame(frameId)
}
}
}, [
hasDirectScrollInput,
persistScrollAnchor,
scrollContainerRef,
sectionHeights,
sections,
updateScrollbar
])
}
@@ -0,0 +1,140 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type React from 'react'
import {
beginCombinedDiffScrollbarDrag,
type CombinedDiffScrollbarDragCleanup
} from './combined-diff-scrollbar-drag'
const COMBINED_DIFF_SCROLLBAR_THUMB_MIN_HEIGHT = 64
export type CombinedDiffScrollThumb = {
visible: boolean
top: number
height: number
}
export type CombinedDiffScrollbar = {
cleanupActiveScrollbarDrag: () => void
handleScrollbarPointerDown: (event: React.PointerEvent<HTMLDivElement>) => void
scrollThumb: CombinedDiffScrollThumb
updateScrollbar: () => void
}
export function useCombinedDiffScrollbar({
markDirectScrollInput,
scrollContainerRef
}: {
markDirectScrollInput: () => void
scrollContainerRef: React.RefObject<HTMLDivElement | null>
}): CombinedDiffScrollbar {
const [scrollThumb, setScrollThumb] = useState<CombinedDiffScrollThumb>({
visible: false,
top: 0,
height: COMBINED_DIFF_SCROLLBAR_THUMB_MIN_HEIGHT
})
const activeScrollbarDragCleanupRef = useRef<CombinedDiffScrollbarDragCleanup | null>(null)
const updateScrollbar = useCallback(() => {
const container = scrollContainerRef.current
if (!container || container.scrollHeight <= container.clientHeight + 1) {
setScrollThumb((prev) =>
prev.visible
? {
visible: false,
top: 0,
height: COMBINED_DIFF_SCROLLBAR_THUMB_MIN_HEIGHT
}
: prev
)
return
}
const trackHeight = Math.max(1, container.clientHeight - 8)
const maxScrollTop = Math.max(1, container.scrollHeight - container.clientHeight)
const height = Math.min(
trackHeight,
Math.max(
COMBINED_DIFF_SCROLLBAR_THUMB_MIN_HEIGHT,
(container.clientHeight / container.scrollHeight) * trackHeight
)
)
const top = ((trackHeight - height) * container.scrollTop) / maxScrollTop
setScrollThumb({ visible: true, top, height })
}, [scrollContainerRef])
const cleanupActiveScrollbarDrag = useCallback((): void => {
activeScrollbarDragCleanupRef.current?.()
}, [])
useEffect(() => cleanupActiveScrollbarDrag, [cleanupActiveScrollbarDrag])
const handleScrollbarPointerDown = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
const container = scrollContainerRef.current
if (!container) {
return
}
event.preventDefault()
markDirectScrollInput()
const track = event.currentTarget
const thumb =
event.target instanceof HTMLElement
? event.target.closest('[data-combined-diff-scrollbar-thumb]')
: null
const getLiveThumbHeight = (): number => {
const trackHeight = Math.max(1, track.getBoundingClientRect().height)
return Math.min(
trackHeight,
Math.max(
COMBINED_DIFF_SCROLLBAR_THUMB_MIN_HEIGHT,
(container.clientHeight / container.scrollHeight) * trackHeight
)
)
}
const getScrollTopForPointer = (clientY: number, grabOffset: number): number => {
const trackRect = track.getBoundingClientRect()
const trackHeight = Math.max(1, trackRect.height)
const thumbHeight = getLiveThumbHeight()
const maxThumbTop = Math.max(1, trackHeight - thumbHeight)
const maxScrollTop = Math.max(1, container.scrollHeight - container.clientHeight)
const thumbTop = Math.max(0, Math.min(maxThumbTop, clientY - trackRect.top - grabOffset))
return (thumbTop / maxThumbTop) * maxScrollTop
}
const grabOffset = thumb
? event.clientY - thumb.getBoundingClientRect().top
: getLiveThumbHeight() / 2
if (!thumb) {
container.scrollTop = getScrollTopForPointer(event.clientY, grabOffset)
updateScrollbar()
}
const handlePointerMove = (moveEvent: PointerEvent): void => {
moveEvent.preventDefault()
markDirectScrollInput()
container.scrollTop = getScrollTopForPointer(moveEvent.clientY, grabOffset)
updateScrollbar()
}
cleanupActiveScrollbarDrag()
let cleanupPointerDrag: CombinedDiffScrollbarDragCleanup
cleanupPointerDrag = beginCombinedDiffScrollbarDrag({
track,
pointerId: event.pointerId,
onPointerMove: handlePointerMove,
onEnd: () => {
if (activeScrollbarDragCleanupRef.current === cleanupPointerDrag) {
activeScrollbarDragCleanupRef.current = null
}
}
})
activeScrollbarDragCleanupRef.current = cleanupPointerDrag
},
[cleanupActiveScrollbarDrag, markDirectScrollInput, scrollContainerRef, updateScrollbar]
)
return { cleanupActiveScrollbarDrag, handleScrollbarPointerDown, scrollThumb, updateScrollbar }
}
@@ -0,0 +1,88 @@
import { useLayoutEffect } from 'react'
import type React from 'react'
import { elementScroll, useVirtualizer, type Virtualizer } from '@tanstack/react-virtual'
import type { ProgrammaticScrollMarks } from '@/hooks/programmatic-scroll-marks'
import type { DiffSection } from '../../diff-section-types'
import {
getDiffSectionEstimatedHeight,
isIntrinsicHeightImageDiff
} from '../../diff-section-layout'
const COMBINED_DIFF_OVERSCAN = 5
export function useCombinedDiffVirtualizer({
generation,
programmaticScrollMarks,
renderedIndicesRef,
scrollContainerRef,
scrollOffsetRef,
sectionHeights,
sections,
sideBySide
}: {
generation: number
programmaticScrollMarks: ProgrammaticScrollMarks
renderedIndicesRef: React.RefObject<Set<number>>
scrollContainerRef: React.RefObject<HTMLDivElement | null>
scrollOffsetRef: React.RefObject<number>
sectionHeights: Record<number, number>
sections: DiffSection[]
sideBySide: boolean
}): Virtualizer<HTMLDivElement, Element> {
const virtualizer = useVirtualizer({
count: sections.length,
getScrollElement: () => scrollContainerRef.current,
estimateSize: (index) => {
const section = sections[index]
if (!section) {
return 88
}
return getDiffSectionEstimatedHeight({
collapsed: section.collapsed,
measuredContentHeight: sectionHeights[index],
originalContent: section.originalContent,
modifiedContent: section.modifiedContent,
changedLineCount:
section.added === undefined && section.removed === undefined
? undefined
: (section.added ?? 0) + (section.removed ?? 0),
useIntrinsicImageHeight: isIntrinsicHeightImageDiff(section.diffResult),
isLargeDiffLimited: section.largeDiffRenderLimit?.limited === true,
lineCounts: section.largeDiffRenderLimit?.lineCounts ?? undefined
})
},
overscan: COMBINED_DIFF_OVERSCAN,
initialOffset: () => scrollOffsetRef.current,
// Why: mark every virtualizer-issued scroll so events are attributed to the user only when this code didn't cause them.
scrollToFn: (offset, options, instance) => {
const target = offset + (options.adjustments ?? 0)
// Why: writing the current position emits no scroll event; a mark here would go stale and claim a later user scroll.
if (instance.scrollElement?.scrollTop !== target) {
programmaticScrollMarks.mark(target)
}
elementScroll(offset, options, instance)
},
getItemKey: (index) => {
const section = sections[index]
if (!section) {
return `${index}:${generation}`
}
// Why: contentGeneration is per-section, so a single row's reload remounts only that row.
return `${section.key}:${section.collapsed ? 'collapsed' : 'expanded'}:${generation}:${section.contentGeneration ?? 0}`
}
})
// Why: keep render pure (React Doctor); retrySection still needs the on-screen set without the virtualizer as a dep.
const virtualItems = virtualizer.getVirtualItems()
useLayoutEffect(() => {
renderedIndicesRef.current = new Set(virtualItems.map((item) => item.index))
}, [renderedIndicesRef, virtualItems])
useLayoutEffect(() => {
// Why: inline vs side-by-side changes Monaco row heights; re-measure on the mode flip, not on every section load.
virtualizer.measure()
}, [sideBySide, virtualizer])
return virtualizer
}
@@ -4,7 +4,7 @@ import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry'
// same module is a distinct component type, so it gets its own Suspense boundary and remount.
export const MonacoEditor = lazy(() => import('./MonacoEditor'))
export const DiffViewer = lazy(() => import('./DiffViewer'))
export const CombinedDiffViewer = lazy(() => import('./CombinedDiffViewer'))
export const CombinedDiffViewer = lazy(() => import('./combined-diff/CombinedDiffViewer'))
export const RichMarkdownEditor = lazy(() => import('./RichMarkdownEditor'), {
reloadKey: 'rich-markdown-editor'
})
@@ -4,9 +4,9 @@ import type { editor as monacoEditor } from 'monaco-editor'
import { DiffSectionItem } from '@/components/editor/DiffSectionItem'
import { translate } from '@/i18n/i18n'
import type { DecoratedDiffComment } from '@/components/diff-comments/decorated-diff-comment'
import { CombinedDiffFileTree } from '@/components/editor/CombinedDiffFileTree'
import { CombinedDiffFileTree } from '../../editor/combined-diff/browse-files/combined-diff-file-tree'
import type { DiffSection } from '@/components/editor/diff-section-types'
import type { CombinedDiffFileTreeEntry } from '@/components/editor/combined-diff-file-tree-model'
import type { CombinedDiffFileTreeEntry } from '../../editor/combined-diff/resolve-changes/combined-diff-section-identity'
import type { GitHubPRFile } from '../../../../../shared/github/pull-request-types'
import type { GitBranchChangeEntry } from '../../../../../shared/git-diff-compare-types'
import type { DiffSectionItemProps } from '@/components/editor/diff-section-item-props'
@@ -2,19 +2,15 @@ import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from '
import { useVirtualizer } from '@tanstack/react-virtual'
import type { editor as monacoEditor } from 'monaco-editor'
import type { DecoratedDiffComment } from '@/components/diff-comments/decorated-diff-comment'
import {
createCombinedDiffSectionIndexMap,
handleCombinedDiffFileTreeNavigation
} from '@/components/editor/CombinedDiffFileTree'
import { createCombinedDiffSectionIndexMap } from '../../editor/combined-diff/resolve-changes/combined-diff-section-identity'
import { handleCombinedDiffFileTreeNavigation } from '../../editor/combined-diff/browse-files/combined-diff-file-tree-navigation'
import {
getDiffSectionEstimatedHeight,
isIntrinsicHeightImageDiff
} from '@/components/editor/diff-section-layout'
import type { DiffSection } from '@/components/editor/diff-section-types'
import {
getCombinedDiffBranchEntriesInTreeOrder,
type CombinedDiffFileTreeEntry
} from '@/components/editor/combined-diff-file-tree-model'
import { getCombinedDiffBranchEntriesInTreeOrder } from '../../editor/combined-diff/browse-files/combined-diff-file-tree-filter'
import type { CombinedDiffFileTreeEntry } from '../../editor/combined-diff/resolve-changes/combined-diff-section-identity'
import { useAppStore } from '@/store'
import type { GitBranchChangeEntry } from '../../../../../shared/git-diff-compare-types'
import { isPRFileViewed } from '@/components/github/pr-file-content-size'
@@ -13,7 +13,7 @@ const HOVER_REVEAL_FILES = [
resolve(__dirname, 'browser-pane/annotate/browser-page-annotation-tray.tsx'),
resolve(__dirname, 'dashboard/DashboardAgentRow.tsx'),
resolve(__dirname, 'dashboard/DashboardAgentRowTrailingControls.tsx'),
resolve(__dirname, 'editor/CombinedDiffViewer.tsx'),
resolve(__dirname, 'editor/combined-diff/CombinedDiffViewer.tsx'),
resolve(__dirname, 'editor/DiffSectionHeader.tsx'),
resolve(__dirname, 'github-project/ProjectPicker.tsx'),
resolve(__dirname, 'github-project/ProjectRow.tsx'),
@@ -3,20 +3,16 @@ import { useVirtualizer } from '@tanstack/react-virtual'
import type { editor as monacoEditor } from 'monaco-editor'
import { useAppStore } from '@/store'
import { DiffSectionItem } from '@/components/editor/DiffSectionItem'
import {
CombinedDiffFileTree,
createCombinedDiffSectionIndexMap,
handleCombinedDiffFileTreeNavigation
} from '@/components/editor/CombinedDiffFileTree'
import { CombinedDiffFileTree } from '../../editor/combined-diff/browse-files/combined-diff-file-tree'
import { createCombinedDiffSectionIndexMap } from '../../editor/combined-diff/resolve-changes/combined-diff-section-identity'
import { handleCombinedDiffFileTreeNavigation } from '../../editor/combined-diff/browse-files/combined-diff-file-tree-navigation'
import {
getDiffSectionEstimatedHeight,
isIntrinsicHeightImageDiff
} from '@/components/editor/diff-section-layout'
import type { DiffSection } from '@/components/editor/diff-section-types'
import {
getCombinedDiffBranchEntriesInTreeOrder,
type CombinedDiffFileTreeEntry
} from '@/components/editor/combined-diff-file-tree-model'
import { getCombinedDiffBranchEntriesInTreeOrder } from '../../editor/combined-diff/browse-files/combined-diff-file-tree-filter'
import type { CombinedDiffFileTreeEntry } from '../../editor/combined-diff/resolve-changes/combined-diff-section-identity'
import { PRViewedCheckbox } from '@/components/github/PRViewedCheckbox'
import { isPRFileViewed } from '@/components/github/pr-file-content-size'
import {