From f166c3cd84bade5229508c44b3cc2e8be24f1e60 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:31:43 -0700 Subject: [PATCH] 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. --- config/max-lines-baseline.txt | 1 - .../combined-diff/CombinedDiffViewer.tsx | 2403 ++--------------- .../combined-diff-file-tree-filter.ts | 82 + .../combined-diff-file-tree-navigation.ts | 47 + .../combined-diff-file-tree-row.tsx | 6 +- .../combined-diff-file-tree.test.ts | 12 +- .../browse-files/combined-diff-file-tree.tsx | 17 +- .../use-combined-diff-file-tree-resize.ts | 2 +- .../use-combined-diff-tree-navigation.ts | 98 + .../combined-diff-section-connection.test.ts | 8 +- .../combined-diff-section-load-registry.ts | 79 + .../combined-diff-section-load-state.test.ts | 4 +- .../combined-diff-section-load-state.ts | 4 +- .../combined-diff-section-load-timeout.ts | 34 + .../fetch-combined-diff-section.ts | 95 + .../use-combined-diff-section-loader.ts | 220 ++ .../use-combined-diff-section-retry.ts | 152 ++ .../use-combined-diff-section-revalidation.ts | 108 + .../combined-diff-view-memory.ts | 45 + .../use-combined-diff-view-persist.ts | 56 + .../use-combined-diff-view-restore.ts | 169 ++ .../combined-diff-entries.test.ts | 4 +- .../resolve-changes/combined-diff-entries.ts | 6 +- .../combined-diff-git-status-signature.ts | 38 + .../combined-diff-section-cache-match.test.ts | 6 +- .../combined-diff-section-cache-match.ts | 10 +- .../combined-diff-section-identity.ts | 120 +- .../use-combined-diff-entry-set.ts | 166 ++ .../combined-diff-commit-header.tsx | 44 + .../combined-diff-notes-popover.tsx | 165 ++ .../combined-diff-skipped-conflicts.tsx | 103 + .../review-controls/combined-diff-toolbar.tsx | 193 ++ .../use-combined-diff-notes-actions.ts | 138 + .../use-combined-diff-section-actions.ts | 238 ++ .../use-combined-diff-view-preferences.ts | 102 + .../combined-diff-section-list.tsx | 165 ++ .../use-combined-diff-direct-scroll-input.ts | 21 + .../use-combined-diff-scroll-anchors.ts | 188 ++ .../use-combined-diff-scroll-persistence.ts | 213 ++ .../use-combined-diff-scrollbar.ts | 140 + .../use-combined-diff-virtualizer.ts | 88 + .../components/editor/editor-lazy-views.ts | 2 +- .../pr-files-combined-diff-body.tsx | 4 +- .../pr-files-combined-diff-viewer.tsx | 12 +- ...ver-reveal-touch-action-visibility.test.ts | 2 +- .../files/combined-diff-viewer.tsx | 14 +- 46 files changed, 3505 insertions(+), 2319 deletions(-) create mode 100644 src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-filter.ts create mode 100644 src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-navigation.ts create mode 100644 src/renderer/src/components/editor/combined-diff/browse-files/use-combined-diff-tree-navigation.ts create mode 100644 src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-load-registry.ts create mode 100644 src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-load-timeout.ts create mode 100644 src/renderer/src/components/editor/combined-diff/load-sections/fetch-combined-diff-section.ts create mode 100644 src/renderer/src/components/editor/combined-diff/load-sections/use-combined-diff-section-loader.ts create mode 100644 src/renderer/src/components/editor/combined-diff/load-sections/use-combined-diff-section-retry.ts create mode 100644 src/renderer/src/components/editor/combined-diff/load-sections/use-combined-diff-section-revalidation.ts create mode 100644 src/renderer/src/components/editor/combined-diff/remember-view/combined-diff-view-memory.ts create mode 100644 src/renderer/src/components/editor/combined-diff/remember-view/use-combined-diff-view-persist.ts create mode 100644 src/renderer/src/components/editor/combined-diff/remember-view/use-combined-diff-view-restore.ts create mode 100644 src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-git-status-signature.ts create mode 100644 src/renderer/src/components/editor/combined-diff/resolve-changes/use-combined-diff-entry-set.ts create mode 100644 src/renderer/src/components/editor/combined-diff/review-controls/combined-diff-commit-header.tsx create mode 100644 src/renderer/src/components/editor/combined-diff/review-controls/combined-diff-notes-popover.tsx create mode 100644 src/renderer/src/components/editor/combined-diff/review-controls/combined-diff-skipped-conflicts.tsx create mode 100644 src/renderer/src/components/editor/combined-diff/review-controls/combined-diff-toolbar.tsx create mode 100644 src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-notes-actions.ts create mode 100644 src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-section-actions.ts create mode 100644 src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-view-preferences.ts create mode 100644 src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-section-list.tsx create mode 100644 src/renderer/src/components/editor/combined-diff/scroll-viewport/use-combined-diff-direct-scroll-input.ts create mode 100644 src/renderer/src/components/editor/combined-diff/scroll-viewport/use-combined-diff-scroll-anchors.ts create mode 100644 src/renderer/src/components/editor/combined-diff/scroll-viewport/use-combined-diff-scroll-persistence.ts create mode 100644 src/renderer/src/components/editor/combined-diff/scroll-viewport/use-combined-diff-scrollbar.ts create mode 100644 src/renderer/src/components/editor/combined-diff/scroll-viewport/use-combined-diff-virtualizer.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 75e536a2198..061717cbaa0 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -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 diff --git a/src/renderer/src/components/editor/combined-diff/CombinedDiffViewer.tsx b/src/renderer/src/components/editor/combined-diff/CombinedDiffViewer.tsx index b004ef3a799..318a2f3b268 100644 --- a/src/renderer/src/components/editor/combined-diff/CombinedDiffViewer.tsx +++ b/src/renderer/src/components/editor/combined-diff/CombinedDiffViewer.tsx @@ -1,221 +1,41 @@ -/* eslint-disable max-lines -- Why: the whole viewer is one state machine (lazy load, inline edit, restore-on-remount cache, scroll preservation); splitting it hides lifecycle edges. */ -/* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: diff entry changes must reset virtualizer measurement and generation state in lockstep with external scroll restoration. */ -import React, { useState, useEffect, useCallback, useRef, useLayoutEffect, useMemo } from 'react' -import { elementScroll, useVirtualizer } from '@tanstack/react-virtual' -import type { editor as monacoEditor } from 'monaco-editor' +import React, { useCallback, useRef, useState } from 'react' import { useAppStore } from '@/store' -import { - useVirtualizedScrollAnchor, - VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT, - type VirtualizedScrollAnchor -} from '@/hooks/useVirtualizedScrollAnchor' -import { getVirtualizedScrollAnchorForOffset } from '@/hooks/virtualized-scroll-anchor-recording' import { createProgrammaticScrollMarks } from '@/hooks/programmatic-scroll-marks' -import { joinPath } from '@/lib/path' -import { detectLanguage } from '@/lib/language-detect' -import { openFilePreviewToSide, useWorkspaceFileBrowserActionPredicate } from '@/lib/file-preview' -import { canOpenDiffSectionPreviewToSide } from './diff-section-preview' -import { setWithLRU } from '@/lib/scroll-cache' -import { getCombinedDiffSectionConnectionId } from './combined-diff-section-connection' -import { findWorktreeById } from '@/store/slices/worktree-helpers' +import { useWorkspaceFileBrowserActionPredicate } from '@/lib/file-preview' import { selectWorktreeDiffCommentsOrEmpty } from '@/store/worktree-diff-comments-selector' -import { writeRuntimeFile } from '@/runtime/runtime-file-client' -import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' -import { getEditorFileOperationContext } from '@/lib/editor-file-operation-owner' -import { formatDiffComments } from '@/lib/diff-comments-format' -import { getDiffCommentLineLabel } from '@/lib/diff-comment-compat' -import { - getRuntimeGitBranchDiff, - getRuntimeGitCommitDiff, - getRuntimeGitDiff -} from '@/runtime/runtime-git-client' -import '@/lib/monaco-setup' -import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import type { OpenFile } from '@/store/slices/editor' -import type { DiffComment } from '../../../../shared/diff-comment-types' -import type { GitBranchChangeEntry, GitDiffResult } from '../../../../shared/git-diff-compare-types' -import type { GitStatusEntry } from '../../../../shared/git-status-types' -import { Check, Copy, MessageSquare, PanelLeftOpen, Sparkles, Trash2, WrapText } from 'lucide-react' -import { toast } from 'sonner' -import { DiffSectionItem } from './DiffSectionItem' -import { DiffNotesSendMenu } from './DiffNotesSendMenu' +import '@/lib/monaco-setup' +import type { DiffSection } from '../diff-section-types' import { - CombinedDiffFileTree, - createCombinedDiffSectionIndexMap, - handleCombinedDiffFileTreeNavigation -} from './CombinedDiffFileTree' -import { getCombinedDiffFileTreeSectionKey } from './combined-diff-file-tree-model' + EMPTY_GIT_BRANCH_ENTRIES, + EMPTY_GIT_STATUS_ENTRIES, + useCombinedDiffEntrySet +} from './resolve-changes/use-combined-diff-entry-set' +import { useCombinedDiffSectionLoadRegistry } from './load-sections/combined-diff-section-load-registry' +import { useCombinedDiffSectionLoader } from './load-sections/use-combined-diff-section-loader' +import { useCombinedDiffSectionRetry } from './load-sections/use-combined-diff-section-retry' +import { useCombinedDiffSectionRevalidation } from './load-sections/use-combined-diff-section-revalidation' +import { useCombinedDiffViewPersist } from './remember-view/use-combined-diff-view-persist' +import { useCombinedDiffViewRestore } from './remember-view/use-combined-diff-view-restore' +import { useCombinedDiffDirectScrollInput } from './scroll-viewport/use-combined-diff-direct-scroll-input' +import { useCombinedDiffScrollAnchors } from './scroll-viewport/use-combined-diff-scroll-anchors' +import { useCombinedDiffScrollPersistence } from './scroll-viewport/use-combined-diff-scroll-persistence' +import { useCombinedDiffScrollbar } from './scroll-viewport/use-combined-diff-scrollbar' +import { useCombinedDiffVirtualizer } from './scroll-viewport/use-combined-diff-virtualizer' +import { CombinedDiffSectionList } from './scroll-viewport/combined-diff-section-list' +import { CombinedDiffFileTree } from './browse-files/combined-diff-file-tree' +import { useCombinedDiffTreeNavigation } from './browse-files/use-combined-diff-tree-navigation' +import { CombinedDiffCommitHeader } from './review-controls/combined-diff-commit-header' +import { CombinedDiffToolbar } from './review-controls/combined-diff-toolbar' +import { ClearDiffNotesDialog } from './review-controls/combined-diff-notes-popover' import { - ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, - type EditorPathMutationTarget -} from './editor-autosave' -import { - getCombinedBranchEntries, - getCombinedUncommittedEntries, - resolveCombinedUncommittedSnapshotEntries, - shouldAutoReloadCombinedDiffFromGitStatus -} from './combined-diff-entries' -import { getCombinedDiffCommitMessageBody } from './combined-diff-commit-message' -import { getDiffSectionEstimatedHeight, isIntrinsicHeightImageDiff } from './diff-section-layout' -import { getLargeDiffRenderLimit } from './large-diff-render-limit' -import { getStoredTextDiffContent, getStoredTextDiffResult } from './large-diff-section-content' -import type { DiffSection } from './diff-section-types' -import { getInitialCombinedDiffSectionLoadIndices } from './combined-diff-initial-section-load' -import { removeDiffSectionMeasuredHeight } from './diff-section-height-cache' -import { createCombinedDiffLoadScheduler } from './combined-diff-load-scheduler' -import { combinedDiffSectionsMatchEntryMetadata } from './combined-diff-section-cache-match' -import { - beginCombinedDiffScrollbarDrag, - type CombinedDiffScrollbarDragCleanup -} from './combined-diff-scrollbar-drag' -import { - isUnchangedDiffSectionReload, - shouldRequestCombinedDiffSectionLoad -} from './combined-diff-section-load-state' -import { translate } from '@/i18n/i18n' - -type CachedCombinedDiffViewState = { - entrySignature: string - gitStatusSignature: string - sections: DiffSection[] - sectionHeights: Record - loadedIndices: number[] - scrollTop: number - sideBySide: boolean -} - -type CombinedDiffScrollThumb = { - visible: boolean - top: number - height: number -} - -const combinedDiffViewStateCache = new Map() -const combinedDiffScrollTopCache = new Map() -const combinedDiffScrollAnchorCache = new Map() - -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 - })) - ) -} - -function invalidateCombinedDiffCachesForRelativePath(relativePath: string): void { - for (const [key, cached] of combinedDiffViewStateCache.entries()) { - if (cached.sections.some((section) => section.path === relativePath)) { - combinedDiffViewStateCache.delete(key) - } - } -} - -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 - } - ] - ) -} - -if (typeof window !== 'undefined') { - window.addEventListener(ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, (event) => { - const detail = (event as CustomEvent).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) - } - }) -} -const COMBINED_DIFF_OVERSCAN = 5 -const COMBINED_DIFF_SCROLLBAR_THUMB_MIN_HEIGHT = 64 -const EMPTY_GIT_STATUS_ENTRIES: GitStatusEntry[] = [] -const EMPTY_GIT_BRANCH_ENTRIES: GitBranchChangeEntry[] = [] -let combinedDiffCollapsedPreference: boolean | null = null -let combinedDiffSideBySidePreference: boolean | null = null -let combinedDiffFileTreeCollapsedPreference: boolean | null = null -// 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 -// Why: git rewrites a path several times during a rebase; refetch once the writes stop. -const COMBINED_DIFF_SECTION_RELOAD_COALESCE_MS = 300 - -function clearPendingSectionReloadTimers(timers: Map): void { - for (const timer of timers.values()) { - window.clearTimeout(timer) - } - timers.clear() -} - -class CombinedDiffSectionLoadTimeoutError extends Error { - constructor() { - super('Diff did not finish loading.') - this.name = 'CombinedDiffSectionLoadTimeoutError' - } -} - -function withDiffSectionLoadTimeout(promise: Promise): Promise { - let timeoutId: number | null = null - - const timeoutPromise = new Promise((_, 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) - } - }) -} - -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.' -} - -function getInitialCombinedDiffSideBySide(diffDefaultView: string | undefined): boolean { - return combinedDiffSideBySidePreference ?? diffDefaultView === 'side-by-side' -} - -function getInitialCombinedDiffFileTreeCollapsed( - combinedDiffFileTreeVisibleByDefault: boolean | undefined -): boolean { - // Why: the tree is opt-in; only an explicit saved setting should open it while settings are still loading. - return combinedDiffFileTreeCollapsedPreference ?? combinedDiffFileTreeVisibleByDefault !== true -} + CombinedDiffNoChangesEmptyState, + CombinedDiffSkippedConflictNotice, + CombinedDiffSkippedConflictsEmptyState +} from './review-controls/combined-diff-skipped-conflicts' +import { useCombinedDiffNotesActions } from './review-controls/use-combined-diff-notes-actions' +import { useCombinedDiffSectionActions } from './review-controls/use-combined-diff-section-actions' +import { useCombinedDiffViewPreferences } from './review-controls/use-combined-diff-view-preferences' export default function CombinedDiffViewer({ file, @@ -233,9 +53,6 @@ export default function CombinedDiffViewer({ ) const branchSummary = useAppStore((s) => s.gitBranchCompareSummaryByWorktree[file.worktreeId]) const openAllDiffs = useAppStore((s) => s.openAllDiffs) - const openFile = useAppStore((s) => s.openFile) - const openBranchDiff = useAppStore((s) => s.openBranchDiff) - const openCommitDiff = useAppStore((s) => s.openCommitDiff) const openConflictReview = useAppStore((s) => s.openConflictReview) const openBranchAllDiffs = useAppStore((s) => s.openBranchAllDiffs) const updateSettings = useAppStore((s) => s.updateSettings) @@ -249,1343 +66,165 @@ export default function CombinedDiffViewer({ settings?.theme === 'dark' || (settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) - 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 [sections, setSections] = useState([]) - const [sideBySide, setSideBySide] = useState(() => - getInitialCombinedDiffSideBySide(settings?.diffDefaultView) - ) const [sectionHeights, setSectionHeights] = useState>({}) - 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(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) - const [fileTreeCollapsed, setFileTreeCollapsedState] = useState(() => - getInitialCombinedDiffFileTreeCollapsed(settings?.combinedDiffFileTreeVisibleByDefault) - ) - // Why: generation (state) keys DiffSectionItem remounts; generationRef mirrors it so loadSection's stale-async check avoids a stale closure. const [generation, setGeneration] = useState(0) - const scrollContainerRef = useRef(null) - const [scrollThumb, setScrollThumb] = useState({ - visible: false, - top: 0, - height: COMBINED_DIFF_SCROLLBAR_THUMB_MIN_HEIGHT - }) - const scrollOffsetRef = useRef(combinedDiffScrollTopCache.get(viewStateKey) ?? 0) - const scrollAnchorRef = useRef( - combinedDiffScrollAnchorCache.get(viewStateKey) ?? null - ) - const latestDomScrollAnchorRef = useRef( - combinedDiffScrollAnchorCache.get(viewStateKey) ?? null - ) - const directScrollInputUntilRef = useRef(0) - const [programmaticScrollMarks] = useState(createProgrammaticScrollMarks) - // Why: a scroll pinned at a shrunken max is a browser clamp, not user input; bump to ask the anchor restore to re-pin. + // Why: a browser scroll clamp must re-pin the restore without being recorded as user intent. const [clampRestoreCount, setClampRestoreCount] = useState(0) - const lastScrollHeightRef = useRef(0) - const activeScrollbarDragCleanupRef = useRef(null) - const loadedIndicesRef = useRef>(new Set()) - const loadingIndicesRef = useRef>(new Set()) - const sectionsRef = useRef([]) - 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>(new Map()) - const renderedIndicesRef = useRef>(new Set()) - const reloadTimersRef = useRef>(new Map()) - const loadSectionRef = useRef<(index: number) => Promise>(async () => {}) - const retrySectionRef = useRef<(index: number) => void>(() => {}) - const requestSectionReloadRef = useRef<(index: number) => void>(() => {}) - const updateCombinedDiffScrollbar = 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 [programmaticScrollMarks] = useState(createProgrammaticScrollMarks) + const scrollContainerRef = useRef(null) - 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 }) - }, []) - - const markDirectScrollInput = useCallback((): void => { - directScrollInputUntilRef.current = window.performance.now() + 250 - }, []) - - const hasDirectScrollInput = useCallback( - () => window.performance.now() < directScrollInputUntilRef.current, - [] - ) - - const clearNotesCopiedResetTimer = useCallback((): void => { - if (notesCopiedResetTimerRef.current !== null) { - window.clearTimeout(notesCopiedResetTimerRef.current) - notesCopiedResetTimerRef.current = null - } - }, []) - - const cleanupActiveScrollbarDrag = useCallback((): void => { - activeScrollbarDragCleanupRef.current?.() - }, []) - - const setScrollContainerRef = useCallback( - (node: HTMLDivElement | null) => { - scrollContainerRef.current = node - notesCopyMountedRef.current = node !== null - if (node === null) { - // Why: copied feedback is tied to the surface lifetime; the root-ref unmount is where stale feedback gets disabled. - clearNotesCopiedResetTimer() - cleanupActiveScrollbarDrag() - return - } - window.requestAnimationFrame(updateCombinedDiffScrollbar) - }, - [cleanupActiveScrollbarDrag, clearNotesCopiedResetTimer, updateCombinedDiffScrollbar] - ) - - useEffect(() => { - mountedRef.current = true - return () => { - mountedRef.current = false - cleanupActiveScrollbarDrag() - } - }, [cleanupActiveScrollbarDrag]) - const loadSchedulerRef = useRef( - createCombinedDiffLoadScheduler({ - loadSection: (index) => loadSectionRef.current(index) - }) - ) - sectionsRef.current = sections - - // Why: seed from Settings until the user picks a toolbar mode this session, then follow that choice over the global default. - useEffect(() => { - if (settings?.diffDefaultView !== undefined && combinedDiffSideBySidePreference === null) { - setSideBySide(settings.diffDefaultView === 'side-by-side') - } - }, [settings?.diffDefaultView]) - - useEffect(() => { - if ( - settings?.combinedDiffFileTreeVisibleByDefault !== undefined && - combinedDiffFileTreeCollapsedPreference === null - ) { - setFileTreeCollapsedState(settings.combinedDiffFileTreeVisibleByDefault === false) - } - }, [settings?.combinedDiffFileTreeVisibleByDefault]) - - const setFileTreeCollapsed = useCallback((collapsed: boolean) => { - combinedDiffFileTreeCollapsedPreference = collapsed - setFileTreeCollapsedState(collapsed) - }, []) - - 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]) - const branchEntries = React.useMemo(() => { - return getCombinedBranchEntries(file.branchEntriesSnapshot, liveBranchEntries) - }, [file.branchEntriesSnapshot, liveBranchEntries]) - const renderableBranchEntries = React.useMemo( - () => (branchCompare ? branchEntries : []), - [branchCompare, branchEntries] - ) - const commitEntries = React.useMemo( - () => 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 - ] - ) - - // 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 = combinedDiffCollapsedPreference - const restoredSections = - collapsedPreference === null - ? cached.sections - : cached.sections.map((section) => ({ - ...section, - collapsed: collapsedPreference - })) - setSections(restoredSections) - setSectionHeights(cached.sectionHeights) - setSideBySide(combinedDiffSideBySidePreference ?? 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: combinedDiffCollapsedPreference ?? 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, + const registry = useCombinedDiffSectionLoadRegistry(sections) + const entrySet = useCombinedDiffEntrySet({ + file, gitStatusEntries, - hasUncommittedEntriesSnapshot, - shouldAutoReloadFromGitStatus, - treeMode, + liveBranchEntries, + sectionsRef: registry.sectionsRef + }) + const notes = useCombinedDiffNotesActions({ + clearDiffComments, + diffCommentsForWorktree, + worktreeId: file.worktreeId + }) + const preferences = useCombinedDiffViewPreferences({ + combinedDiffFileTreeVisibleByDefault: settings?.combinedDiffFileTreeVisibleByDefault, + diffDefaultView: settings?.diffDefaultView, + diffWordWrap: settings?.diffWordWrap, + registry, + setSections, + updateSettings + }) + const restore = useCombinedDiffViewRestore({ + entrySet, + gitStatusEntries, + registry, + setGeneration, + setSectionHeights, + setSections, + setSideBySide: preferences.setSideBySide, viewStateKey - ]) - - 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 = 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 { - 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) { - result = await 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 - } - ) - ) - } else if (isCommitMode && commitCompare) { - result = await withDiffSectionLoadTimeout( - getRuntimeGitCommitDiff( - { - settings: fileSettings, - worktreeId: file.worktreeId, - worktreePath: file.filePath, - connectionId - }, - { - commitOid: commitCompare.commitOid, - parentOid: commitCompare.parentOid, - filePath: entry.path, - oldPath: entry.oldPath - } - ) - ) - } else { - result = await withDiffSectionLoadTimeout( - getRuntimeGitDiff( - { - settings: fileSettings, - worktreeId: file.worktreeId, - worktreePath: file.filePath, - connectionId - }, - { - filePath: entry.path, - staged: 'area' in entry && entry.area === 'staged' - } - ) - ) - } - } 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 - - 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() - } - }, []) - - // 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) - }, []) - - 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, sections.length]) - - const invalidateCombinedDiffViewStateCache = useCallback((): void => { - combinedDiffViewStateCache.delete(viewStateKey) - }, [viewStateKey]) - - const retrySection = useCallback( - (index: number) => { - const collapsed = sectionsRef.current[index]?.collapsed ?? false - loadedIndicesRef.current.delete(index) - loadingIndicesRef.current.delete(index) - invalidateCombinedDiffViewStateCache() - // 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) - }, - [invalidateCombinedDiffViewStateCache] - ) - retrySectionRef.current = retrySection - - const modifiedEditorsRef = useRef>(new Map()) - - 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}` - } + }) + const loadSection = useCombinedDiffSectionLoader({ + entrySet, + file, + registry, + sectionCount: sections.length, + setSectionHeights, + setSections + }) + const { ensureSectionLoaded, requestSectionReload, retrySection } = useCombinedDiffSectionRetry({ + invalidateViewStateCache: restore.invalidateViewStateCache, + registry, + setSectionHeights, + setSections }) - const combinedDiffTotalSize = virtualizer.getTotalSize() - const combinedDiffVirtualItems = virtualizer.getVirtualItems() - // Why: keep render pure (React Doctor); retrySection still needs the on-screen set without the virtualizer as a dep. - useLayoutEffect(() => { - renderedIndicesRef.current = new Set(combinedDiffVirtualItems.map((item) => item.index)) - }, [combinedDiffVirtualItems]) - const getCombinedDiffSectionKey = useCallback((section: DiffSection): string => section.key, []) - const getCombinedDiffSectionElementKey = useCallback( - (element: Element): string | null => - element instanceof HTMLElement ? (element.dataset.combinedDiffSectionKey ?? null) : null, - [] - ) - const recordCombinedDiffVirtualScrollAnchor = useCallback( - (scrollTop: number): void => { - scrollAnchorRef.current = getVirtualizedScrollAnchorForOffset({ - getRowKey: getCombinedDiffSectionKey, - rows: sectionsRef.current, - scrollTop, - virtualItems: virtualizer.getVirtualItems() - }) - latestDomScrollAnchorRef.current = null - }, - [getCombinedDiffSectionKey, virtualizer] - ) - const recordCombinedDiffDomScrollAnchor = useCallback((): boolean => { - const container = scrollContainerRef.current - if (!container) { - return false - } - const containerRect = container.getBoundingClientRect() - const visibleRows = Array.from( - container.querySelectorAll('[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 = { - 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 - }, []) - const writeCombinedDiffScrollAnchor = useCallback((): void => { - const anchor = scrollAnchorRef.current - if (anchor) { - setWithLRU(combinedDiffScrollAnchorCache, viewStateKey, anchor) - } else { - combinedDiffScrollAnchorCache.delete(viewStateKey) - } - }, [viewStateKey]) - const persistCombinedDiffScrollAnchor = useCallback( - (refreshDomAnchor = true): void => { - if (refreshDomAnchor) { - recordCombinedDiffDomScrollAnchor() - } - writeCombinedDiffScrollAnchor() - }, - [recordCombinedDiffDomScrollAnchor, writeCombinedDiffScrollAnchor] - ) - - // 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]', + const { hasDirectScrollInput, markDirectScrollInput } = useCombinedDiffDirectScrollInput() + const { cleanupActiveScrollbarDrag, handleScrollbarPointerDown, scrollThumb, updateScrollbar } = + useCombinedDiffScrollbar({ markDirectScrollInput, scrollContainerRef }) + const virtualizer = useCombinedDiffVirtualizer({ + generation, programmaticScrollMarks, - recordAnchorOnCleanup: false, - recordAnchorOnScroll: false, - restoreSignal: combinedDiffRestoreSignal, - rows: sections, - scrollElementRef: scrollContainerRef, - shouldSkipRestore: hasDirectScrollInput, - scrollOffsetRef, - totalSize: combinedDiffTotalSize, + renderedIndicesRef: registry.renderedIndicesRef, + scrollContainerRef, + scrollOffsetRef: restore.scrollOffsetRef, + sectionHeights, + sections, + sideBySide: preferences.sideBySide + }) + const anchors = useCombinedDiffScrollAnchors({ + clampRestoreCount, + generation, + hasDirectScrollInput, + latestDomScrollAnchorRef: restore.latestDomScrollAnchorRef, + programmaticScrollMarks, + scrollAnchorRef: restore.scrollAnchorRef, + scrollContainerRef, + scrollOffsetRef: restore.scrollOffsetRef, + sections, + sectionsRef: registry.sectionsRef, + sideBySide: preferences.sideBySide, + totalSize: virtualizer.getTotalSize(), + viewStateKey, virtualizer }) - 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]) - - const toggleSection = useCallback((index: number) => { - const shouldLoadAfterExpand = sectionsRef.current[index]?.collapsed ?? false - setSections((prev) => prev.map((s, i) => (i === index ? { ...s, collapsed: !s.collapsed } : s))) - if (shouldLoadAfterExpand) { - loadSchedulerRef.current.request(index) - } - }, []) - const sectionIndexByKey = React.useMemo( - () => createCombinedDiffSectionIndexMap(sections), - [sections] - ) - const sectionIndexByKeyRef = useRef(sectionIndexByKey) - sectionIndexByKeyRef.current = sectionIndexByKey - // 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 requestCombinedDiffSectionReload = useCallback( - (index: number): void => { - const section = sectionsRef.current[index] - if (!section || section.dirty) { - return - } - loadedIndicesRef.current.delete(index) - invalidateCombinedDiffViewStateCache() - 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) - ) - }, - [invalidateCombinedDiffViewStateCache] - ) - requestSectionReloadRef.current = requestCombinedDiffSectionReload - const ensureCombinedDiffSectionLoaded = 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) - }, []) - 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: ensureCombinedDiffSectionLoaded, - scrollToIndex: (index) => { - 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) - ) - }) - } - }) - if (navigatedIndex !== null) { - setActiveTreeSectionState({ - entrySignature, - key: sectionsRef.current[navigatedIndex]?.key ?? null - }) - } - }, - [ - ensureCombinedDiffSectionLoaded, - entrySignature, - markDirectScrollInput, - sectionIndexByKey, - toggleSection, - treeMode, - virtualizer - ] - ) - - const combinedGitStatusSignature = React.useMemo(() => { - if (!shouldAutoReloadFromGitStatus) { - return '' - } - return buildCombinedGitStatusSignature(sections, gitStatusEntries) - }, [gitStatusEntries, sections, shouldAutoReloadFromGitStatus]) - const prevCombinedGitStatusSignatureRef = useRef(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) { - requestCombinedDiffSectionReload(index) - } - }, [combinedGitStatusSignature, requestCombinedDiffSectionReload, shouldAutoReloadFromGitStatus]) - - useEffect(() => { - if (treeMode !== 'all' && treeMode !== 'uncommitted') { - return - } - const handler = (event: Event): void => { - const detail = (event as CustomEvent).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) { - requestCombinedDiffSectionReload(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, requestCombinedDiffSectionReload, treeMode]) - - const setAllSectionsCollapsed = useCallback((collapsed: boolean) => { - combinedDiffCollapsedPreference = 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) - } - } - }, []) - - const toggleSideBySide = useCallback(() => { - setSideBySide((prev) => { - const next = !prev - combinedDiffSideBySidePreference = next - return next - }) - }, []) - - const toggleDiffWordWrap = useCallback(() => { - void updateSettings({ diffWordWrap: settings?.diffWordWrap !== true }) - }, [settings?.diffWordWrap, updateSettings]) - - const openSection = useCallback( + const toggleSection = useCallback( (index: number) => { - const section = sectionsRef.current[index] - if (!section) { - return + const shouldLoadAfterExpand = registry.sectionsRef.current[index]?.collapsed ?? false + setSections((prev) => + prev.map((s, i) => (i === index ? { ...s, collapsed: !s.collapsed } : s)) + ) + if (shouldLoadAfterExpand) { + registry.loadSchedulerRef.current.request(index) } - - 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 - ] + [registry.loadSchedulerRef, registry.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 - }) - }, - [ + const treeNavigation = useCombinedDiffTreeNavigation({ + ensureSectionLoaded, + entrySignature: entrySet.entrySignature, + markDirectScrollInput, + scrollToIndex: anchors.scrollToSectionIndex, + sections, + sectionsRef: registry.sectionsRef, + toggleSection, + treeMode: entrySet.treeMode + }) + const combinedGitStatusSignature = useCombinedDiffSectionRevalidation({ + file, + gitStatusEntries, + registry, + requestSectionReload, + sectionIndexByKeyRef: treeNavigation.sectionIndexByKeyRef, + sections, + shouldAutoReloadFromGitStatus: entrySet.shouldAutoReloadFromGitStatus, + treeMode: entrySet.treeMode + }) + const { handleSectionSaveRef, modifiedEditorsRef, openSection, openSectionPreview } = + useCombinedDiffSectionActions({ activeGroupId, + branchCompare: entrySet.branchCompare, 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] - ) - - const handleSectionSaveRef = useRef(handleSectionSave) - handleSectionSaveRef.current = handleSectionSave - - useEffect(() => { - if (sections.length === 0 && entries.length > 0) { - return - } - const preservedScrollTop = - combinedDiffScrollTopCache.get(viewStateKey) ?? scrollContainerRef.current?.scrollTop ?? 0 - setWithLRU(combinedDiffViewStateCache, viewStateKey, { - entrySignature, - gitStatusSignature: combinedGitStatusSignature, + commitCompare: entrySet.commitCompare, + file, + isAllMode: entrySet.isAllMode, + isBranchMode: entrySet.isBranchMode, + isCommitMode: entrySet.isCommitMode, sections, - sectionHeights, - loadedIndices: Array.from(loadedIndicesRef.current).filter( - (index) => !sections[index]?.loading - ), - scrollTop: preservedScrollTop, - sideBySide + sectionsRef: registry.sectionsRef, + setSectionHeights, + setSections }) - }, [ + + useCombinedDiffViewPersist({ combinedGitStatusSignature, - entries.length, - entrySignature, + entryCount: entrySet.entries.length, + entrySignature: entrySet.entrySignature, + loadedIndicesRef: registry.loadedIndicesRef, + scrollContainerRef, sectionHeights, sections, - sideBySide, + sideBySide: preferences.sideBySide, viewStateKey - ]) - - 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 - persistCombinedDiffScrollAnchor() - }) - }, 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) { - persistCombinedDiffScrollAnchor() - } else { - writeCombinedDiffScrollAnchor() - } - } - if (scheduleSettled) { - scheduleSettledAnchorPersist() - } - updateCombinedDiffScrollbar() - if (!existing || existing.entrySignature !== entrySignature) { - return - } - setWithLRU(combinedDiffViewStateCache, viewStateKey, { - ...existing, - scrollTop - }) - } - lastScrollHeightRef.current = 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 < lastScrollHeightRef.current - 1 - lastScrollHeightRef.current = scrollHeight - if (programmaticScrollMarks.consume(event, scrollTop, maxScrollTop)) { - updateCombinedDiffScrollbar() - 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) - updateCombinedDiffScrollbar() - return - } - // Why: any unmarked scroll is the user's — even events delayed past their window by main-thread jank. - recordCombinedDiffVirtualScrollAnchor(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. - updateCombinedDiffScrollbar() - const resizeObserver = new ResizeObserver(updateCombinedDiffScrollbar) - 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, + }) + useCombinedDiffScrollPersistence({ + anchors, + entrySignature: entrySet.entrySignature, hasDirectScrollInput, - persistCombinedDiffScrollAnchor, + latestDomScrollAnchorRef: restore.latestDomScrollAnchorRef, programmaticScrollMarks, - recordCombinedDiffVirtualScrollAnchor, - sections.length, - updateCombinedDiffScrollbar, - writeCombinedDiffScrollAnchor, - viewStateKey - ]) - - useLayoutEffect(() => { - updateCombinedDiffScrollbar() - 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 - persistCombinedDiffScrollAnchor() - }) - }, 300) - - return () => { - window.clearTimeout(timerId) - if (frameId !== null) { - window.cancelAnimationFrame(frameId) - } - } - }, [ - hasDirectScrollInput, - persistCombinedDiffScrollAnchor, + scrollAnchorRef: restore.scrollAnchorRef, + scrollContainerRef, + scrollOffsetRef: restore.scrollOffsetRef, + sectionCount: sections.length, sectionHeights, sections, - updateCombinedDiffScrollbar - ]) + setClampRestoreCount, + updateScrollbar, + viewStateKey + }) const openAlternateDiff = useCallback(() => { if (!file.combinedAlternate) { @@ -1604,653 +243,141 @@ export default function CombinedDiffViewer({ } }, [branchSummary, file, openAllDiffs, openBranchAllDiffs]) - const handleCombinedDiffScrollbarPointerDown = useCallback( - (event: React.PointerEvent) => { - const container = scrollContainerRef.current - if (!container) { + const { setScrollSurfaceMounted } = notes + const setScrollContainerRef = useCallback( + (node: HTMLDivElement | null) => { + scrollContainerRef.current = node + setScrollSurfaceMounted(node !== null) + if (node === null) { + cleanupActiveScrollbarDrag() 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) - updateCombinedDiffScrollbar() - } - - const handlePointerMove = (moveEvent: PointerEvent): void => { - moveEvent.preventDefault() - markDirectScrollInput() - container.scrollTop = getScrollTopForPointer(moveEvent.clientY, grabOffset) - updateCombinedDiffScrollbar() - } - cleanupActiveScrollbarDrag() - let cleanupPointerDrag: CombinedDiffScrollbarDragCleanup - cleanupPointerDrag = beginCombinedDiffScrollbarDrag({ - track, - pointerId: event.pointerId, - onPointerMove: handlePointerMove, - onEnd: () => { - if (activeScrollbarDragCleanupRef.current === cleanupPointerDrag) { - activeScrollbarDragCleanupRef.current = null - } - } - }) - activeScrollbarDragCleanupRef.current = cleanupPointerDrag + window.requestAnimationFrame(updateScrollbar) }, - [cleanupActiveScrollbarDrag, markDirectScrollInput, updateCombinedDiffScrollbar] + [cleanupActiveScrollbarDrag, setScrollSurfaceMounted, updateScrollbar] ) - const handleCopyNotes = useCallback(async (): Promise => { - 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 skippedConflicts = file.skippedConflicts + const reviewSkippedConflicts = useCallback(() => { + openConflictReview( + file.worktreeId, + file.filePath, + (skippedConflicts ?? []).map((entry) => ({ + path: entry.path, + conflictKind: entry.conflictKind + })), + 'combined-diff-exclusion' + ) + }, [file.filePath, file.worktreeId, openConflictReview, skippedConflicts]) - const handleConfirmClearNotes = useCallback(async (): Promise => { - if (diffCommentCount === 0 || isClearingNotes) { - return - } - setIsClearingNotes(true) - try { - const ok = await clearDiffComments(file.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, file.worktreeId, isClearingNotes]) - - const commitBody = getCombinedDiffCommitMessageBody( - commitCompare?.message, - commitCompare?.subject - ) const commitHeader = - isCommitMode && commitCompare ? ( -
-
-
- {commitCompare.subject && ( - - -
- {commitCompare.subject} -
-
- - {commitCompare.subject} - -
- )} - {commitBody && ( -
- {commitBody} -
- )} -
- - {commitCompare.compareRef} - -
-
+ entrySet.isCommitMode && entrySet.commitCompare ? ( + ) : null - if (sections.length === 0 && (file.skippedConflicts?.length ?? 0) > 0) { + if (sections.length === 0 && (skippedConflicts?.length ?? 0) > 0) { return ( -
- {commitHeader} -
-
-
- {translate( - 'auto.components.editor.CombinedDiffViewer.820ec01f24', - 'Conflicted files are reviewed separately' - )} -
-
- {translate( - 'auto.components.editor.CombinedDiffViewer.eb5f40e49c', - 'This diff view excludes unresolved conflicts because the normal two-way diff pipeline is not conflict-safe.' - )} -
-
- {file.skippedConflicts!.map((entry) => entry.path).join(', ')} -
-
- -
-
-
-
+ ) } if (sections.length === 0) { - return ( -
- {commitHeader} -
- {translate( - 'auto.components.editor.CombinedDiffViewer.fd8892b120', - 'No changes to display' - )} -
-
- ) + return } const skippedConflictNotice = - (file.skippedConflicts?.length ?? 0) > 0 ? ( -
-
- {translate( - 'auto.components.editor.CombinedDiffViewer.820ec01f24', - 'Conflicted files are reviewed separately' - )} -
-
- {file.skippedConflicts!.length}{' '} - {translate('auto.components.editor.CombinedDiffViewer.689b99f8ad', 'unresolved conflict')} - {file.skippedConflicts!.length === 1 ? '' : 's'}{' '} - {translate( - 'auto.components.editor.CombinedDiffViewer.39e73e7181', - 'were excluded from this diff view.' - )} -
-
- -
-
+ (skippedConflicts?.length ?? 0) > 0 ? ( + ) : null const allSectionsCollapsed = sections.every((section) => section.collapsed) return ( <>
-
-
- {fileTreeCollapsed && ( - - - - - - {translate( - 'auto.components.editor.CombinedDiffViewer.b6c3b84476', - 'Show file tree' - )} - - - )} - - {sections.length}{' '} - {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 } - ) - : ''} - - {diffCommentCount > 0 && ( -
- - - - - - void handleCopyNotes()} - onClear={() => setClearNotesDialogOpen(true)} - /> - - - -
- )} -
-
- {file.combinedAlternate && ( - - )} - - - -
-
+ void notes.handleCopyNotes()} + onOpenAlternateDiff={openAlternateDiff} + onOpenClearNotes={() => notes.setClearNotesDialogOpen(true)} + onShowFileTree={() => preferences.setFileTreeCollapsed(false)} + previewDiffComments={notes.previewDiffComments} + sectionCount={sections.length} + setAllSectionsCollapsed={preferences.setAllSectionsCollapsed} + sideBySide={preferences.sideBySide} + toggleDiffWordWrap={preferences.toggleDiffWordWrap} + toggleSideBySide={preferences.toggleSideBySide} + /> {commitHeader}
+ -
-
- {skippedConflictNotice} -
- {combinedDiffVirtualItems.map((virtualItem) => { - const section = sections[virtualItem.index] - if (!section) { - return null - } - - return ( -
- { - const fileNotes = diffCommentsForWorktree.filter( - (comment) => comment.filePath === section.path - ) - return fileNotes.length > 0 ? ( - - ) : null - }} - /> -
- ) - })} -
-
- {scrollThumb.visible && ( -
- { - if (!open && !isClearingNotes) { - setClearNotesDialogOpen(false) - } else if (open) { - setClearNotesDialogOpen(true) - } - }} - > - - - - {translate('auto.components.editor.CombinedDiffViewer.948a5fd6c8', 'Clear Notes')} - - - {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?' - )} - - - - - - - - + void notes.handleConfirmClearNotes()} + open={notes.clearNotesDialogVisible} + setOpen={notes.setClearNotesDialogOpen} + /> ) } - -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 ( -
-
-
- - - {translate('auto.components.editor.CombinedDiffViewer.bb84b4c374', 'AI notes')} - - - {totalCount} - -
-
- - -
-
-
- {comments.map((comment) => ( -
-
- {comment.filePath} - {comment.sentAt ? ( - - {translate('auto.components.editor.CombinedDiffViewer.1da745c551', 'Sent')} - - ) : null} - - {getDiffCommentLineLabel(comment, true)} - -
-
- {comment.body} -
-
- ))} - {remainingCount > 0 && ( -
- {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')} -
- )} -
-
- ) -} diff --git a/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-filter.ts b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-filter.ts new file mode 100644 index 00000000000..b3ae62d9f56 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-filter.ts @@ -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 + includeViewed: boolean + viewedSectionKeys: ReadonlySet +}): 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, + 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) +} diff --git a/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-navigation.ts b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-navigation.ts new file mode 100644 index 00000000000..7f8a16ef590 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-navigation.ts @@ -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 +}): 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 + 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 +} diff --git a/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-row.tsx b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-row.tsx index eb6dc11fbf0..4cb5cb58ccf 100644 --- a/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-row.tsx +++ b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-row.tsx @@ -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, diff --git a/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree.test.ts b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree.test.ts index 7a21281213d..965fe2d70a3 100644 --- a/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree.test.ts +++ b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree.test.ts @@ -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', () => { diff --git a/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree.tsx b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree.tsx index 99a53af8795..5afb79ccac3 100644 --- a/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree.tsx +++ b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree.tsx @@ -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 = { unstaged: 'Changes', diff --git a/src/renderer/src/components/editor/combined-diff/browse-files/use-combined-diff-file-tree-resize.ts b/src/renderer/src/components/editor/combined-diff/browse-files/use-combined-diff-file-tree-resize.ts index ad87cdf78a2..8b9e9c9b5c1 100644 --- a/src/renderer/src/components/editor/combined-diff/browse-files/use-combined-diff-file-tree-resize.ts +++ b/src/renderer/src/components/editor/combined-diff/browse-files/use-combined-diff-file-tree-resize.ts @@ -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' diff --git a/src/renderer/src/components/editor/combined-diff/browse-files/use-combined-diff-tree-navigation.ts b/src/renderer/src/components/editor/combined-diff/browse-files/use-combined-diff-tree-navigation.ts new file mode 100644 index 00000000000..1d09080ba6b --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/browse-files/use-combined-diff-tree-navigation.ts @@ -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 + sectionIndexByKeyRef: React.RefObject> + viewedSectionKeys: Set +} + +// 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 + toggleSection: (index: number) => void + treeMode: CombinedDiffFileTreeMode +}): CombinedDiffTreeNavigation { + const sectionIndexByKey = React.useMemo( + () => createCombinedDiffSectionIndexMap(sections), + [sections] + ) + const sectionIndexByKeyRef = useRef>(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 + } +} diff --git a/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-connection.test.ts b/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-connection.test.ts index 89254a6f6b1..863ffbc8215 100644 --- a/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-connection.test.ts +++ b/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-connection.test.ts @@ -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() diff --git a/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-load-registry.ts b/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-load-registry.ts new file mode 100644 index 00000000000..2b234c89350 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-load-registry.ts @@ -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): 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 + loadSchedulerRef: React.RefObject + loadSectionRef: React.RefObject<(index: number) => Promise> + loadedIndicesRef: React.RefObject> + loadingIndicesRef: React.RefObject> + reloadTimersRef: React.RefObject> + renderedIndicesRef: React.RefObject> + requestSectionReloadRef: React.RefObject<(index: number) => void> + retrySectionRef: React.RefObject<(index: number) => void> + sectionLoadTokensRef: React.RefObject> + sectionsRef: React.RefObject +} + +export function useCombinedDiffSectionLoadRegistry( + sections: DiffSection[] +): CombinedDiffSectionLoadRegistry { + const loadedIndicesRef = useRef>(new Set()) + const loadingIndicesRef = useRef>(new Set()) + const sectionsRef = useRef([]) + 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>(new Map()) + const renderedIndicesRef = useRef>(new Set()) + const reloadTimersRef = useRef>(new Map()) + const loadSectionRef = useRef<(index: number) => Promise>(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 + } +} diff --git a/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-load-state.test.ts b/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-load-state.test.ts index 0b647561738..c308fbc6da9 100644 --- a/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-load-state.test.ts +++ b/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-load-state.test.ts @@ -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 diff --git a/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-load-state.ts b/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-load-state.ts index 7f789ecd9ed..33bd99ac20f 100644 --- a/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-load-state.ts +++ b/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-load-state.ts @@ -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. diff --git a/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-load-timeout.ts b/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-load-timeout.ts new file mode 100644 index 00000000000..51b69923e18 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-load-timeout.ts @@ -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(promise: Promise): Promise { + let timeoutId: number | null = null + + const timeoutPromise = new Promise((_, 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.' +} diff --git a/src/renderer/src/components/editor/combined-diff/load-sections/fetch-combined-diff-section.ts b/src/renderer/src/components/editor/combined-diff/load-sections/fetch-combined-diff-section.ts new file mode 100644 index 00000000000..7ea5a406a71 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/load-sections/fetch-combined-diff-section.ts @@ -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 | null + commitCompare: NonNullable | null + entry: GitStatusEntry | GitBranchChangeEntry + file: OpenFile + isAllMode: boolean + isBranchMode: boolean + isCommitMode: boolean +}): Promise { + 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' + } + ) + ) +} diff --git a/src/renderer/src/components/editor/combined-diff/load-sections/use-combined-diff-section-loader.ts b/src/renderer/src/components/editor/combined-diff/load-sections/use-combined-diff-section-loader.ts new file mode 100644 index 00000000000..ab5dbe79b0f --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/load-sections/use-combined-diff-section-loader.ts @@ -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>> + setSections: React.Dispatch> +}): (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 +} diff --git a/src/renderer/src/components/editor/combined-diff/load-sections/use-combined-diff-section-retry.ts b/src/renderer/src/components/editor/combined-diff/load-sections/use-combined-diff-section-retry.ts new file mode 100644 index 00000000000..8c798482ecb --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/load-sections/use-combined-diff-section-retry.ts @@ -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>> + setSections: React.Dispatch> +}): 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 } +} diff --git a/src/renderer/src/components/editor/combined-diff/load-sections/use-combined-diff-section-revalidation.ts b/src/renderer/src/components/editor/combined-diff/load-sections/use-combined-diff-section-revalidation.ts new file mode 100644 index 00000000000..d8f080a0bc4 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/load-sections/use-combined-diff-section-revalidation.ts @@ -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> + 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(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).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 +} diff --git a/src/renderer/src/components/editor/combined-diff/remember-view/combined-diff-view-memory.ts b/src/renderer/src/components/editor/combined-diff/remember-view/combined-diff-view-memory.ts new file mode 100644 index 00000000000..8360fa40b33 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/remember-view/combined-diff-view-memory.ts @@ -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 + loadedIndices: number[] + scrollTop: number + sideBySide: boolean +} + +export const combinedDiffViewStateCache = new Map() +export const combinedDiffScrollTopCache = new Map() +export const combinedDiffScrollAnchorCache = new Map() + +// 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).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) + } + }) +} diff --git a/src/renderer/src/components/editor/combined-diff/remember-view/use-combined-diff-view-persist.ts b/src/renderer/src/components/editor/combined-diff/remember-view/use-combined-diff-view-persist.ts new file mode 100644 index 00000000000..63875af0ecd --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/remember-view/use-combined-diff-view-persist.ts @@ -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 + sectionHeights: Record + sections: DiffSection[] + loadedIndicesRef: React.RefObject> + 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 + ]) +} diff --git a/src/renderer/src/components/editor/combined-diff/remember-view/use-combined-diff-view-restore.ts b/src/renderer/src/components/editor/combined-diff/remember-view/use-combined-diff-view-restore.ts new file mode 100644 index 00000000000..0677030fff3 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/remember-view/use-combined-diff-view-restore.ts @@ -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 + scrollAnchorRef: React.RefObject + scrollOffsetRef: React.RefObject +} + +export function useCombinedDiffViewRestore({ + entrySet, + gitStatusEntries, + registry, + setGeneration, + setSectionHeights, + setSections, + setSideBySide, + viewStateKey +}: { + entrySet: CombinedDiffEntrySet + gitStatusEntries: GitStatusEntry[] + registry: CombinedDiffSectionLoadRegistry + setGeneration: React.Dispatch> + setSectionHeights: React.Dispatch>> + setSections: React.Dispatch> + setSideBySide: React.Dispatch> + 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( + combinedDiffScrollAnchorCache.get(viewStateKey) ?? null + ) + const latestDomScrollAnchorRef = useRef( + 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 + } +} diff --git a/src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-entries.test.ts b/src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-entries.test.ts index 312d6baa8c2..4ad806d032d 100644 --- a/src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-entries.test.ts +++ b/src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-entries.test.ts @@ -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', () => { diff --git a/src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-entries.ts b/src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-entries.ts index ab64df67d47..0f5f75d615b 100644 --- a/src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-entries.ts +++ b/src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-entries.ts @@ -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 diff --git a/src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-git-status-signature.ts b/src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-git-status-signature.ts new file mode 100644 index 00000000000..b443f8a557e --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-git-status-signature.ts @@ -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 + } + ] + ) +} diff --git a/src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-section-cache-match.test.ts b/src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-section-cache-match.test.ts index 1eea28d9d89..be8305eec23 100644 --- a/src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-section-cache-match.test.ts +++ b/src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-section-cache-match.test.ts @@ -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 { diff --git a/src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-section-cache-match.ts b/src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-section-cache-match.ts index 7f25e96f7c3..9b6a2ff1f04 100644 --- a/src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-section-cache-match.ts +++ b/src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-section-cache-match.ts @@ -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, diff --git a/src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-section-identity.ts b/src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-section-identity.ts index fc3c440fc95..f8e29b6e238 100644 --- a/src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-section-identity.ts +++ b/src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-section-identity.ts @@ -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 -}): 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 - 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 - includeViewed: boolean - viewedSectionKeys: ReadonlySet -}): 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, - 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) -} diff --git a/src/renderer/src/components/editor/combined-diff/resolve-changes/use-combined-diff-entry-set.ts b/src/renderer/src/components/editor/combined-diff/resolve-changes/use-combined-diff-entry-set.ts new file mode 100644 index 00000000000..ece086c9527 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/resolve-changes/use-combined-diff-entry-set.ts @@ -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 | null + commitCompare: NonNullable | 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 +}): 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(() => { + return getCombinedBranchEntries(file.branchEntriesSnapshot, liveBranchEntries) + }, [file.branchEntriesSnapshot, liveBranchEntries]) + const renderableBranchEntries = React.useMemo( + () => (branchCompare ? branchEntries : []), + [branchCompare, branchEntries] + ) + const commitEntries = React.useMemo( + () => 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 + } +} diff --git a/src/renderer/src/components/editor/combined-diff/review-controls/combined-diff-commit-header.tsx b/src/renderer/src/components/editor/combined-diff/review-controls/combined-diff-commit-header.tsx new file mode 100644 index 00000000000..e3ad1689048 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/review-controls/combined-diff-commit-header.tsx @@ -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 +}): React.JSX.Element { + const commitBody = getCombinedDiffCommitMessageBody(commitCompare.message, commitCompare.subject) + + return ( +
+
+
+ {commitCompare.subject && ( + + +
+ {commitCompare.subject} +
+
+ + {commitCompare.subject} + +
+ )} + {commitBody && ( +
+ {commitBody} +
+ )} +
+ + {commitCompare.compareRef} + +
+
+ ) +} diff --git a/src/renderer/src/components/editor/combined-diff/review-controls/combined-diff-notes-popover.tsx b/src/renderer/src/components/editor/combined-diff/review-controls/combined-diff-notes-popover.tsx new file mode 100644 index 00000000000..c19c6042989 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/review-controls/combined-diff-notes-popover.tsx @@ -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 ( +
+
+
+ + + {translate('auto.components.editor.CombinedDiffViewer.bb84b4c374', 'AI notes')} + + + {totalCount} + +
+
+ + +
+
+
+ {comments.map((comment) => ( +
+
+ {comment.filePath} + {comment.sentAt ? ( + + {translate('auto.components.editor.CombinedDiffViewer.1da745c551', 'Sent')} + + ) : null} + + {getDiffCommentLineLabel(comment, true)} + +
+
+ {comment.body} +
+
+ ))} + {remainingCount > 0 && ( +
+ {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')} +
+ )} +
+
+ ) +} + +export function ClearDiffNotesDialog({ + diffCommentCount, + isClearingNotes, + onConfirm, + open, + setOpen +}: { + diffCommentCount: number + isClearingNotes: boolean + onConfirm: () => void + open: boolean + setOpen: React.Dispatch> +}): React.JSX.Element { + return ( + { + if (!nextOpen && !isClearingNotes) { + setOpen(false) + } else if (nextOpen) { + setOpen(true) + } + }} + > + + + + {translate('auto.components.editor.CombinedDiffViewer.948a5fd6c8', 'Clear Notes')} + + + {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?' + )} + + + + + + + + + ) +} diff --git a/src/renderer/src/components/editor/combined-diff/review-controls/combined-diff-skipped-conflicts.tsx b/src/renderer/src/components/editor/combined-diff/review-controls/combined-diff-skipped-conflicts.tsx new file mode 100644 index 00000000000..0328752f53c --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/review-controls/combined-diff-skipped-conflicts.tsx @@ -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 + +export function CombinedDiffSkippedConflictsEmptyState({ + commitHeader, + onReviewConflicts, + skippedConflicts +}: { + commitHeader: React.ReactNode + onReviewConflicts: () => void + skippedConflicts: SkippedConflicts +}): React.JSX.Element { + return ( +
+ {commitHeader} +
+
+
+ {translate( + 'auto.components.editor.CombinedDiffViewer.820ec01f24', + 'Conflicted files are reviewed separately' + )} +
+
+ {translate( + 'auto.components.editor.CombinedDiffViewer.eb5f40e49c', + 'This diff view excludes unresolved conflicts because the normal two-way diff pipeline is not conflict-safe.' + )} +
+
+ {skippedConflicts.map((entry) => entry.path).join(', ')} +
+
+ +
+
+
+
+ ) +} + +export function CombinedDiffNoChangesEmptyState({ + commitHeader +}: { + commitHeader: React.ReactNode +}): React.JSX.Element { + return ( +
+ {commitHeader} +
+ {translate('auto.components.editor.CombinedDiffViewer.fd8892b120', 'No changes to display')} +
+
+ ) +} + +export function CombinedDiffSkippedConflictNotice({ + onReviewConflicts, + skippedConflicts +}: { + onReviewConflicts: () => void + skippedConflicts: SkippedConflicts +}): React.JSX.Element { + return ( +
+
+ {translate( + 'auto.components.editor.CombinedDiffViewer.820ec01f24', + 'Conflicted files are reviewed separately' + )} +
+
+ {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.' + )} +
+
+ +
+
+ ) +} diff --git a/src/renderer/src/components/editor/combined-diff/review-controls/combined-diff-toolbar.tsx b/src/renderer/src/components/editor/combined-diff/review-controls/combined-diff-toolbar.tsx new file mode 100644 index 00000000000..fb5bdd54b8c --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/review-controls/combined-diff-toolbar.tsx @@ -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 | null + commitCompare: NonNullable | 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 ( +
+
+ {fileTreeCollapsed && ( + + + + + + {translate('auto.components.editor.CombinedDiffViewer.b6c3b84476', 'Show file tree')} + + + )} + + {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 + }) + : ''} + + {diffCommentCount > 0 && ( +
+ + + + + + + + + +
+ )} +
+
+ {file.combinedAlternate && ( + + )} + + + +
+
+ ) +} diff --git a/src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-notes-actions.ts b/src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-notes-actions.ts new file mode 100644 index 00000000000..9fec9416b8f --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-notes-actions.ts @@ -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 + handleCopyNotes: () => Promise + isClearingNotes: boolean + notesCopied: boolean + previewDiffComments: DiffComment[] + setClearNotesDialogOpen: React.Dispatch> + setScrollSurfaceMounted: (mounted: boolean) => void +} + +export function useCombinedDiffNotesActions({ + clearDiffComments, + diffCommentsForWorktree, + worktreeId +}: { + clearDiffComments: (worktreeId: string) => Promise + 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(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 => { + 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 => { + 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 + } +} diff --git a/src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-section-actions.ts b/src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-section-actions.ts new file mode 100644 index 00000000000..50648747de1 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-section-actions.ts @@ -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 | null + canOpenWorkspaceFileBrowserForPath: (path: string) => boolean + commitCompare: NonNullable | null + file: OpenFile + isAllMode: boolean + isBranchMode: boolean + isCommitMode: boolean + sections: DiffSection[] + sectionsRef: React.RefObject + setSectionHeights: React.Dispatch>> + setSections: React.Dispatch> +}): CombinedDiffSectionActions { + const openFile = useAppStore((s) => s.openFile) + const openBranchDiff = useAppStore((s) => s.openBranchDiff) + const openCommitDiff = useAppStore((s) => s.openCommitDiff) + const modifiedEditorsRef = useRef>(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 } +} diff --git a/src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-view-preferences.ts b/src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-view-preferences.ts new file mode 100644 index 00000000000..e8768eff212 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-view-preferences.ts @@ -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> + 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> + 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 + } +} diff --git a/src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-section-list.tsx b/src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-section-list.tsx new file mode 100644 index 00000000000..dc9cf5c2930 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-section-list.tsx @@ -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) => void + openSection: (index: number) => void + openSectionPreview: (section: DiffSection) => void + retrySection: (index: number) => void + scrollThumb: CombinedDiffScrollThumb + sectionHeights: Record + sections: DiffSection[] + setScrollContainerRef: (node: HTMLDivElement | null) => void + setSectionHeights: React.Dispatch>> + setSections: React.Dispatch> + settings: DiffSectionItemProps['settings'] + sideBySide: boolean + skippedConflictNotice: React.ReactNode + toggleSection: (index: number) => void + virtualizer: Virtualizer +}): React.JSX.Element { + return ( +
+
+ {skippedConflictNotice} +
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const section = sections[virtualItem.index] + if (!section) { + return null + } + + return ( +
+ { + const fileNotes = diffCommentsForWorktree.filter( + (comment) => comment.filePath === section.path + ) + return fileNotes.length > 0 ? ( + + ) : null + }} + /> +
+ ) + })} +
+
+ {scrollThumb.visible && ( + + ) +} diff --git a/src/renderer/src/components/editor/combined-diff/scroll-viewport/use-combined-diff-direct-scroll-input.ts b/src/renderer/src/components/editor/combined-diff/scroll-viewport/use-combined-diff-direct-scroll-input.ts new file mode 100644 index 00000000000..057825fa442 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/scroll-viewport/use-combined-diff-direct-scroll-input.ts @@ -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 } +} diff --git a/src/renderer/src/components/editor/combined-diff/scroll-viewport/use-combined-diff-scroll-anchors.ts b/src/renderer/src/components/editor/combined-diff/scroll-viewport/use-combined-diff-scroll-anchors.ts new file mode 100644 index 00000000000..9dbf7edec03 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/scroll-viewport/use-combined-diff-scroll-anchors.ts @@ -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 + programmaticScrollMarks: ProgrammaticScrollMarks + scrollAnchorRef: React.RefObject + scrollContainerRef: React.RefObject + scrollOffsetRef: React.RefObject + sections: DiffSection[] + sectionsRef: React.RefObject + sideBySide: boolean + totalSize: number + viewStateKey: string + virtualizer: Virtualizer +}): 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('[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 = { + 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 + } +} diff --git a/src/renderer/src/components/editor/combined-diff/scroll-viewport/use-combined-diff-scroll-persistence.ts b/src/renderer/src/components/editor/combined-diff/scroll-viewport/use-combined-diff-scroll-persistence.ts new file mode 100644 index 00000000000..a0198051238 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/scroll-viewport/use-combined-diff-scroll-persistence.ts @@ -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 + programmaticScrollMarks: ProgrammaticScrollMarks + scrollAnchorRef: React.RefObject + scrollContainerRef: React.RefObject + scrollOffsetRef: React.RefObject + sectionCount: number + sectionHeights: Record + sections: DiffSection[] + setClampRestoreCount: React.Dispatch> + 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 + ]) +} diff --git a/src/renderer/src/components/editor/combined-diff/scroll-viewport/use-combined-diff-scrollbar.ts b/src/renderer/src/components/editor/combined-diff/scroll-viewport/use-combined-diff-scrollbar.ts new file mode 100644 index 00000000000..a7fe20938c4 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/scroll-viewport/use-combined-diff-scrollbar.ts @@ -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) => void + scrollThumb: CombinedDiffScrollThumb + updateScrollbar: () => void +} + +export function useCombinedDiffScrollbar({ + markDirectScrollInput, + scrollContainerRef +}: { + markDirectScrollInput: () => void + scrollContainerRef: React.RefObject +}): CombinedDiffScrollbar { + const [scrollThumb, setScrollThumb] = useState({ + visible: false, + top: 0, + height: COMBINED_DIFF_SCROLLBAR_THUMB_MIN_HEIGHT + }) + const activeScrollbarDragCleanupRef = useRef(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) => { + 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 } +} diff --git a/src/renderer/src/components/editor/combined-diff/scroll-viewport/use-combined-diff-virtualizer.ts b/src/renderer/src/components/editor/combined-diff/scroll-viewport/use-combined-diff-virtualizer.ts new file mode 100644 index 00000000000..3043b4a57d8 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/scroll-viewport/use-combined-diff-virtualizer.ts @@ -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> + scrollContainerRef: React.RefObject + scrollOffsetRef: React.RefObject + sectionHeights: Record + sections: DiffSection[] + sideBySide: boolean +}): Virtualizer { + 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 +} diff --git a/src/renderer/src/components/editor/editor-lazy-views.ts b/src/renderer/src/components/editor/editor-lazy-views.ts index bfdacaba622..093b06317b8 100644 --- a/src/renderer/src/components/editor/editor-lazy-views.ts +++ b/src/renderer/src/components/editor/editor-lazy-views.ts @@ -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' }) diff --git a/src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-body.tsx b/src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-body.tsx index b706ba073bf..2364f966746 100644 --- a/src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-body.tsx +++ b/src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-body.tsx @@ -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' diff --git a/src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-viewer.tsx b/src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-viewer.tsx index f4add6132b6..71772a01754 100644 --- a/src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-viewer.tsx +++ b/src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-viewer.tsx @@ -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' diff --git a/src/renderer/src/components/hover-reveal-touch-action-visibility.test.ts b/src/renderer/src/components/hover-reveal-touch-action-visibility.test.ts index cd925cc1303..eacc97c0c5a 100644 --- a/src/renderer/src/components/hover-reveal-touch-action-visibility.test.ts +++ b/src/renderer/src/components/hover-reveal-touch-action-visibility.test.ts @@ -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'), diff --git a/src/renderer/src/components/pull-request-page/files/combined-diff-viewer.tsx b/src/renderer/src/components/pull-request-page/files/combined-diff-viewer.tsx index 3cee0fee2aa..45419accec3 100644 --- a/src/renderer/src/components/pull-request-page/files/combined-diff-viewer.tsx +++ b/src/renderer/src/components/pull-request-page/files/combined-diff-viewer.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 {