feat(diff): HTML preview + always-visible open actions in View all

Expose Open Preview to the Side for HTML sections in combined diffs
when the working-tree file still exists, and keep the open-file
external-link icon visible without hover. Split DiffSectionItem
props/lifecycle helpers to stay under the max-lines limit.
This commit is contained in:
Jinjing
2026-08-06 18:40:20 -07:00
parent f23e6f2f90
commit f3b2498b5c
7 changed files with 228 additions and 81 deletions
@@ -13,6 +13,8 @@ import { getVirtualizedScrollAnchorForOffset } from '@/hooks/virtualized-scroll-
import { createProgrammaticScrollMarks } from '@/hooks/programmatic-scroll-marks'
import { joinPath } from '@/lib/path'
import { detectLanguage } from '@/lib/language-detect'
import { openFilePreviewToSide } 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'
@@ -1284,6 +1286,29 @@ export default function CombinedDiffViewer({
]
)
// 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
})
) {
return
}
openFilePreviewToSide({
language: detectLanguage(section.path),
filePath: joinPath(file.filePath, section.path),
worktreeId: file.worktreeId,
sourceGroupId: activeGroupId ?? null
})
},
[activeGroupId, file.filePath, file.worktreeId, isCommitMode]
)
const handleSectionSave = useCallback(
async (index: number) => {
const section = sections[index]
@@ -2024,6 +2049,15 @@ export default function CombinedDiffViewer({
openSectionTitle={
isAllMode || isBranchMode || isCommitMode ? 'Open diff' : 'Open in editor'
}
onOpenPreview={
canOpenDiffSectionPreviewToSide({
path: section.path,
status: section.status,
isCommitSurface: isCommitMode
})
? (previewSection) => openSectionPreview(previewSection)
: undefined
}
setSectionHeights={setSectionHeights}
setSections={setSections}
modifiedEditorsRef={modifiedEditorsRef}
@@ -1,4 +1,4 @@
import { ChevronDown, ChevronRight, ExternalLink } from 'lucide-react'
import { ChevronDown, ChevronRight, ExternalLink, Eye } from 'lucide-react'
import type { MouseEvent, ReactElement, ReactNode } from 'react'
import { translate } from '@/i18n/i18n'
@@ -11,6 +11,7 @@ export function DiffSectionHeader({
onToggle,
onOpenSection,
openSectionTitle,
onOpenPreview,
trailingContent
}: {
path: string
@@ -21,6 +22,7 @@ export function DiffSectionHeader({
onToggle: () => void
onOpenSection: (event: MouseEvent) => void
openSectionTitle: string
onOpenPreview?: (event: MouseEvent) => void
trailingContent?: ReactNode
}): ReactElement {
return (
@@ -71,8 +73,29 @@ export function DiffSectionHeader({
</span>
<div className="flex items-center gap-1 shrink-0 ml-2">
{trailingContent}
{onOpenPreview != null && (
<button
type="button"
// Why: always visible (unlike other hover-reveal section actions) so
// HTML preview matches the single-file editor header affordance.
className="p-0.5 rounded text-muted-foreground hover:text-foreground transition-colors"
onClick={onOpenPreview}
title={translate(
'auto.components.editor.EditorPanelHeader.fb8331694e',
'Open Preview to the Side'
)}
aria-label={translate(
'auto.components.editor.EditorPanelHeader.fb8331694e',
'Open Preview to the Side'
)}
>
<Eye className="size-3.5" />
</button>
)}
<button
className="p-0.5 rounded text-muted-foreground hover:text-foreground can-hover:opacity-0 group-hover:opacity-100 transition-opacity"
// Why: always visible so open-file matches Eye preview and works without hover (touch).
type="button"
className="p-0.5 rounded text-muted-foreground hover:text-foreground transition-colors"
onClick={onOpenSection}
title={openSectionTitle}
>
@@ -1,12 +1,4 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type MutableRefObject,
type ReactNode
} from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import type { DiffOnMount } from '@monaco-editor/react'
import type { editor as monacoEditor } from 'monaco-editor'
import { monaco } from '@/lib/monaco-setup'
@@ -14,26 +6,23 @@ import { detectLanguage } from '@/lib/language-detect'
import { useAppStore } from '@/store'
import { computeDiffEditorFontSize, resolveEditorFontFamily } from '@/lib/editor-font-zoom'
import { selectWorktreeDiffComments } from '@/store/worktree-diff-comments-selector'
import {
useDiffCommentDecorator,
type DecoratedDiffComment
} from '../diff-comments/useDiffCommentDecorator'
import { useDiffCommentDecorator } from '../diff-comments/useDiffCommentDecorator'
import {
getDiffCommentPopoverLeft,
getDiffCommentPopoverTop
} from '../diff-comments/diff-comment-popover-position'
import { applyDiffEditorLineNumberOptions } from './diff-editor-line-number-options'
import { DiffSectionHeader } from './DiffSectionHeader'
import type { DiffSection } from './diff-section-types'
import type { DiffComment } from '../../../../shared/types'
import { isDiffComment } from '@/lib/diff-comment-compat'
import { installEditorSaveShortcut, installMonacoEditorFindShortcut } from './editor-shortcuts'
import { DiffSectionBody } from './DiffSectionBody'
import { useDiffSectionLayoutMetrics } from './useDiffSectionLayoutMetrics'
import { disposeUnattachedMonacoModelPaths } from './diff-monaco-model-disposal'
import { getLiveDiffSectionRenderLimit } from './diff-section-live-render-limit'
import { useDiffSectionFallbackCleanup } from './useDiffSectionFallbackCleanup'
import { submitDiffSectionComment } from './diff-section-comment-submit'
import type { DiffSectionItemProps } from './diff-section-item-props'
import { useDiffSectionModelLifecycle } from './use-diff-section-model-lifecycle'
export function DiffSectionItem({
section,
@@ -49,6 +38,7 @@ export function DiffSectionItem({
toggleSection,
openSection,
openSectionTitle,
onOpenPreview,
renderHeaderTrailingContent,
onAddLineComment,
addLineCommentLabel,
@@ -59,42 +49,7 @@ export function DiffSectionItem({
setSections,
modifiedEditorsRef,
handleSectionSaveRef
}: {
section: DiffSection
index: number
isBranchMode: boolean
sideBySide: boolean
isDark: boolean
settings: {
terminalFontSize?: number
terminalFontFamily?: string
diffWordWrap?: boolean
} | null
sectionHeight: number | undefined
worktreeId?: string
loadSection: (index: number) => void
retrySection: (index: number) => void
toggleSection: (index: number) => void
openSection: (index: number) => void
openSectionTitle: string
renderHeaderTrailingContent?: (section: DiffSection, index: number) => ReactNode
onAddLineComment?: (
section: DiffSection,
args: {
lineNumber: number
startLine?: number
body: string
}
) => Promise<boolean>
addLineCommentLabel?: string
addLineCommentPlaceholder?: string
inlineComments?: readonly DecoratedDiffComment[]
getCommentableLineNumbers?: (section: DiffSection) => readonly number[] | undefined
setSectionHeights: React.Dispatch<React.SetStateAction<Record<number, number>>>
setSections: React.Dispatch<React.SetStateAction<DiffSection[]>>
modifiedEditorsRef: MutableRefObject<Map<number, monacoEditor.IStandaloneCodeEditor>>
handleSectionSaveRef: MutableRefObject<(index: number) => Promise<void>>
}): React.JSX.Element {
}: DiffSectionItemProps): React.JSX.Element {
const editorFontZoomLevel = useAppStore((s) => s.editorFontZoomLevel)
const addDiffComment = useAppStore((s) => s.addDiffComment)
const deleteDiffComment = useAppStore((s) => s.deleteDiffComment)
@@ -137,31 +92,10 @@ export function DiffSectionItem({
} | null>(null)
const hasLineCommentAction = Boolean(worktreeId || onAddLineComment)
const disposeDiffModels = useCallback(() => {
window.setTimeout(() => {
disposeUnattachedMonacoModelPaths(monaco, [
`${modelPathBase}:original`,
`${modelPathBase}:modified`
])
}, 0)
}, [modelPathBase])
const disposeDiffModelsRef = useRef(disposeDiffModels)
disposeDiffModelsRef.current = disposeDiffModels
const setSectionRootNode = useCallback((node: HTMLDivElement | null): void => {
if (node) {
return
}
// Why: virtualized diff rows remount as their keyed section/collapse state
// changes; the row root is the owner of the detached Monaco models.
disposeDiffModelsRef.current()
}, [])
useEffect(() => {
if (section.collapsed) {
disposeDiffModels()
}
}, [disposeDiffModels, section.collapsed])
const { disposeDiffModels, setSectionRootNode } = useDiffSectionModelLifecycle({
modelPathBase,
collapsed: section.collapsed
})
// Why: only forward the pending scroll id when it matches a comment in this
// section so unrelated sections don't keep re-rendering their decorator
@@ -410,6 +344,14 @@ export function DiffSectionItem({
openSection(index)
}}
openSectionTitle={openSectionTitle}
onOpenPreview={
onOpenPreview
? (event) => {
event.stopPropagation()
onOpenPreview(section, index)
}
: undefined
}
trailingContent={renderHeaderTrailingContent?.(section, index)}
/>
@@ -435,9 +377,7 @@ export function DiffSectionItem({
onCancelComment={() => setPopover(null)}
onSubmitComment={handleSubmitComment}
onRetrySection={retrySection}
onSaveLimitedDiff={() => {
void handleSectionSaveRef.current(index)
}}
onSaveLimitedDiff={() => void handleSectionSaveRef.current(index)}
onMount={handleMount}
/>
)}
@@ -0,0 +1,38 @@
import type { Dispatch, MutableRefObject, ReactNode, SetStateAction } from 'react'
import type { editor as monacoEditor } from 'monaco-editor'
import type { DecoratedDiffComment } from '../diff-comments/useDiffCommentDecorator'
import type { DiffSection } from './diff-section-types'
export type DiffSectionItemProps = {
section: DiffSection
index: number
isBranchMode: boolean
sideBySide: boolean
isDark: boolean
settings: {
terminalFontSize?: number
terminalFontFamily?: string
diffWordWrap?: boolean
} | null
sectionHeight: number | undefined
worktreeId?: string
loadSection: (index: number) => void
retrySection: (index: number) => void
toggleSection: (index: number) => void
openSection: (index: number) => void
openSectionTitle: string
onOpenPreview?: (section: DiffSection, index: number) => void
renderHeaderTrailingContent?: (section: DiffSection, index: number) => ReactNode
onAddLineComment?: (
section: DiffSection,
args: { lineNumber: number; startLine?: number; body: string }
) => Promise<boolean>
addLineCommentLabel?: string
addLineCommentPlaceholder?: string
inlineComments?: readonly DecoratedDiffComment[]
getCommentableLineNumbers?: (section: DiffSection) => readonly number[] | undefined
setSectionHeights: Dispatch<SetStateAction<Record<number, number>>>
setSections: Dispatch<SetStateAction<DiffSection[]>>
modifiedEditorsRef: MutableRefObject<Map<number, monacoEditor.IStandaloneCodeEditor>>
handleSectionSaveRef: MutableRefObject<(index: number) => Promise<void>>
}
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import { canOpenDiffSectionPreviewToSide } from './diff-section-preview'
describe('canOpenDiffSectionPreviewToSide', () => {
it('enables HTML working-tree sections that still exist on disk', () => {
expect(
canOpenDiffSectionPreviewToSide({
path: 'docs/demo.html',
status: 'modified',
isCommitSurface: false
})
).toBe(true)
})
it('enables .htm paths', () => {
expect(
canOpenDiffSectionPreviewToSide({
path: 'index.htm',
status: 'added',
isCommitSurface: false
})
).toBe(true)
})
it('disables deleted HTML files', () => {
expect(
canOpenDiffSectionPreviewToSide({
path: 'gone.html',
status: 'deleted',
isCommitSurface: false
})
).toBe(false)
})
it('disables commit surfaces whose content may not match disk', () => {
expect(
canOpenDiffSectionPreviewToSide({
path: 'docs/demo.html',
status: 'modified',
isCommitSurface: true
})
).toBe(false)
})
it('disables non-previewable languages', () => {
expect(
canOpenDiffSectionPreviewToSide({
path: 'src/app.ts',
status: 'modified',
isCommitSurface: false
})
).toBe(false)
})
})
@@ -0,0 +1,19 @@
import { detectLanguage } from '@/lib/language-detect'
import { canPreviewLanguage } from '@/lib/file-preview'
// Why: HTML preview renders the on-disk working-tree file, so combined-diff
// sections only get the affordance when that file still exists and the surface
// is not a historical commit snapshot whose content may not match disk.
export function canOpenDiffSectionPreviewToSide(params: {
path: string
status: string
isCommitSurface: boolean
}): boolean {
if (params.isCommitSurface) {
return false
}
if (params.status === 'deleted') {
return false
}
return canPreviewLanguage(detectLanguage(params.path))
}
@@ -0,0 +1,39 @@
import { useCallback, useEffect, useRef } from 'react'
import { monaco } from '@/lib/monaco-setup'
import { disposeUnattachedMonacoModelPaths } from './diff-monaco-model-disposal'
// Why: virtualized section rows own Monaco model paths for their lifetime;
// dispose on unmount/collapse so remounts do not leak detached models.
export function useDiffSectionModelLifecycle(params: {
modelPathBase: string
collapsed: boolean
}): {
disposeDiffModels: () => void
setSectionRootNode: (node: HTMLDivElement | null) => void
} {
const disposeDiffModels = useCallback(() => {
window.setTimeout(() => {
disposeUnattachedMonacoModelPaths(monaco, [
`${params.modelPathBase}:original`,
`${params.modelPathBase}:modified`
])
}, 0)
}, [params.modelPathBase])
const disposeDiffModelsRef = useRef(disposeDiffModels)
disposeDiffModelsRef.current = disposeDiffModels
const setSectionRootNode = useCallback((node: HTMLDivElement | null): void => {
if (node) {
return
}
disposeDiffModelsRef.current()
}, [])
useEffect(() => {
if (params.collapsed) {
disposeDiffModels()
}
}, [disposeDiffModels, params.collapsed])
return { disposeDiffModels, setSectionRootNode }
}