mirror of
https://github.com/stablyai/orca.git
synced 2026-10-04 08:02:09 +00:00
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:
@@ -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
+82
@@ -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)
|
||||
}
|
||||
+47
@@ -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
|
||||
}
|
||||
+3
-3
@@ -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,
|
||||
|
||||
+7
-5
@@ -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', () => {
|
||||
|
||||
+6
-11
@@ -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',
|
||||
|
||||
+1
-1
@@ -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'
|
||||
|
||||
|
||||
+98
@@ -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
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -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()
|
||||
|
||||
+79
@@ -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
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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
|
||||
|
||||
+2
-2
@@ -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.
|
||||
|
||||
+34
@@ -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.'
|
||||
}
|
||||
+95
@@ -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'
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
+220
@@ -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
|
||||
}
|
||||
+152
@@ -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 }
|
||||
}
|
||||
+108
@@ -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
|
||||
}
|
||||
+45
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
+56
@@ -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
|
||||
])
|
||||
}
|
||||
+169
@@ -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
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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', () => {
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+38
@@ -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
|
||||
}
|
||||
]
|
||||
)
|
||||
}
|
||||
+3
-3
@@ -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 {
|
||||
|
||||
+5
-5
@@ -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,
|
||||
|
||||
+2
-118
@@ -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)
|
||||
}
|
||||
|
||||
+166
@@ -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
|
||||
}
|
||||
}
|
||||
+44
@@ -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>
|
||||
)
|
||||
}
|
||||
+165
@@ -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>
|
||||
)
|
||||
}
|
||||
+103
@@ -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>
|
||||
)
|
||||
}
|
||||
+193
@@ -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>
|
||||
)
|
||||
}
|
||||
+138
@@ -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
|
||||
}
|
||||
}
|
||||
+238
@@ -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 }
|
||||
}
|
||||
+102
@@ -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
|
||||
}
|
||||
}
|
||||
+165
@@ -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>
|
||||
)
|
||||
}
|
||||
+21
@@ -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 }
|
||||
}
|
||||
+188
@@ -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
|
||||
}
|
||||
}
|
||||
+213
@@ -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
|
||||
])
|
||||
}
|
||||
+140
@@ -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 }
|
||||
}
|
||||
+88
@@ -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'
|
||||
})
|
||||
|
||||
+2
-2
@@ -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'
|
||||
|
||||
+4
-8
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user