mirror of
https://github.com/stablyai/orca.git
synced 2026-10-28 08:02:54 +00:00
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:
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user