/**
* The changes tab's shared bottom preview pane: one selected target — a git
* worktree change, a commit patch, or a session file op — rendered through
* the unified diff stack (the same one the diff tab uses). Git targets load
* on demand (refreshable, with the untracked full-addition fallback); op
* targets are pure snapshots (diff / read view / error text). The pane is
* resizable by drag (clamped; the height commits to the tab's persisted
* meta on release) and by keyboard; git targets can expand into a dedicated
* diff tab via the shell.
*/
import { useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'
import { IconCloseOutline16, IconRefreshOutline16, IconRightUpOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SessionScope } from '../api.ts'
import { api, htmlUrl } from '../api.ts'
import { t } from '../locales.ts'
import { baseName } from '../paths.ts'
import { resolveSidebarPath } from '../produced-files.ts'
import { HTML_IFRAME_SANDBOX } from '../html-preview.ts'
import type { SidebarDiffRef, SidebarTab } from '../state.ts'
import { DiffRows, ReadRows } from '../diff/DiffRows.tsx'
import { PdfView } from '../PdfView.tsx'
import { DiffFiles } from '../diff/DiffFiles.tsx'
import { langOfPath } from '../diff/highlight.ts'
import { buildDiffSegments, diffLines, diffStats, displayPath, foldRowsFromContents, parseUnifiedDiff, unifiedSegments, type DiffFile, type DiffRow, type FoldSegment } from '../diff/rows.ts'
import { parseReadContent, parseReadLines, type FileOp } from './ops.ts'
import { redactText } from '../redact.ts'
import { rewriteLocalImageUrls } from '../markdown-images.ts'
import { markdownTextProps } from '../markdown-labels.tsx'
import { splitMermaidBlocks } from '../mermaid-blocks.ts'
import { LazyMermaidMarkdown } from '../mermaid-lazy.tsx'
import { createFrameBatcher } from '../frame-batcher.ts'
import css from './changes.module.css'
import diffCss from '../diff/diff.module.css'
/** Drag handle height clamp (px) and keyboard-resize step. */
const HEIGHT_MIN = 140
const HEIGHT_STEP = 24
/** The redaction preference, persisted under the repo's sidebar storage
* prefix (see state.ts's `dsh-sidebar:v1`). */
const REDACTION_KEY = 'dsh-sidebar:v1:redaction'
/** What the pane is showing right now. */
export type ChangesPreview =
| { kind: 'git'; ref: SidebarDiffRef }
| { kind: 'op'; path: string; op: FileOp; prior?: string }
/** Diff material for one op snapshot: an edit reconstructs the full file
* from the window's known prior content when possible (hunk-style context);
* a write with unknown prior content renders all-added. */
function diffOf(op: FileOp, prior: string | undefined): readonly DiffRow[] {
if (op.kind === 'read') return []
if (op.kind === 'edit' && op.edit !== undefined) {
const { oldString, newString } = op.edit
if (prior !== undefined && prior.includes(oldString)) {
const newFile = prior.replace(oldString, newString)
return diffLines(prior, newFile)
}
return diffLines(oldString, newString)
}
if (op.kind === 'write') {
const content = op.content ?? ''
const old = prior !== undefined && prior !== content ? prior : undefined
return diffLines(old ?? '', content)
}
return []
}
/** The render view of one html op target: the route-src iframe. Extracted
* (and exported) so the always-sandboxed contract is pinned directly by the
* sandbox spec — this surface has NO no-sandbox escape hatch. */
export function HtmlRenderPreview(props: { src: string; title: string }) {
return (
)
}
/** One header pill toggle — the redaction / reading / render toggles share
* the shape (on-state styling + aria-pressed). */
function PaneToggle(props: { on: boolean; label: string; title?: string; onClick: () => void }) {
return (
)
}
/** The reading-mode body of one markdown op target: the shared MarkdownText
* pass (local image destinations already rewritten to the media route by
* the caller). Mermaid fences render through the same chunk-resident
* renderer the editor preview uses (one MarkdownText pass with the fences
* lifted out); the plain path stays byte-for-byte for documents without
* any. */
function MdReadingView(props: { text: string }) {
const codeLabels = { copyLabel: t('copy'), copiedLabel: t('copied') }
const hasMermaid = splitMermaidBlocks(props.text).some((block) => block.kind === 'mermaid')
return (
{hasMermaid
?
: }
)
}
/** The diff tab a git preview expands into (the shell owns placement). */
export function diffTabOf(ref: SidebarDiffRef): SidebarTab {
if (ref.kind === 'worktree') {
return {
id: `diff:w:${encodeURIComponent(ref.worktree ?? '')}:${ref.staged ? 's' : 'u'}:${ref.path}`,
type: 'diff',
title: baseName(ref.path),
diff: ref,
}
}
return {
id: `diff:c:${encodeURIComponent(ref.worktree ?? '')}:${ref.hashFull}`,
type: 'diff',
title: `${ref.hash} ${ref.subject}`,
diff: ref,
}
}
export interface DiffPaneProps {
target: ChangesPreview
scope: SessionScope
/** The persisted pane height (px); drag commits a new one upwards. */
height: number
onHeightCommit: (height: number) => void
onClose: () => void
/** Expand the current git target into a dedicated diff tab. */
onExpand: () => void
}
export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExpand }: DiffPaneProps) {
// ── Git target loading (mirrors the diff tab: staged-side fallback, the
// untracked full-addition fallback, refresh by tick). ─────────────────
const [tick, setTick] = useState(0)
const [loading, setLoading] = useState(target.kind === 'git')
const [error, setError] = useState(null)
const [diffText, setDiffText] = useState(null)
const [untracked, setUntracked] = useState(undefined)
// The staged flag of the side ACTUALLY rendered: when the requested side's
// diff came back empty the load falls back to the other side, and the fold
// expansion must read that side's revisions (else the sliced line numbers
// land on the wrong contents).
const [effectiveStaged, setEffectiveStaged] = useState(null)
const gitRef = target.kind === 'git' ? target.ref : null
// The scope every git call of this target shares (repoRoot folded in when
// the ref carries one, exactly like the load effect's paneScope).
const gitScope = useMemo(() => ({
sessionId: scope.sessionId,
cwd: scope.cwd,
...(gitRef?.repoRoot !== undefined ? { repoRoot: gitRef.repoRoot } : {}),
}), [scope.sessionId, scope.cwd, gitRef?.repoRoot])
useEffect(() => {
if (gitRef === null) return
let cancelled = false
const paneScope: SessionScope = {
sessionId: scope.sessionId,
cwd: scope.cwd,
...(gitRef.repoRoot !== undefined ? { repoRoot: gitRef.repoRoot } : {}),
}
setLoading(true)
setError(null)
setDiffText(null)
setUntracked(undefined)
setEffectiveStaged(null)
const load = async (): Promise => {
try {
if (gitRef.kind === 'commit') {
const result = await api.gitCommitDiff(paneScope, gitRef.hashFull, gitRef.worktree)
if (!cancelled) setDiffText(result.diff)
return
}
let result = await api.gitDiff(paneScope, gitRef.path, gitRef.staged, gitRef.worktree)
if (result.diff === '') {
// The requested side is empty — try the OTHER side once (the change
// may have moved sides after the preview target was minted).
const other = await api.gitDiff(paneScope, gitRef.path, !gitRef.staged, gitRef.worktree)
if (other.diff !== '') {
result = other
if (!cancelled) setEffectiveStaged(!gitRef.staged)
}
}
if (result.diff !== '') {
if (!cancelled) setDiffText(result.diff)
return
}
// Empty diff: an untracked file (git diff never lists it) falls back
// to a full-file addition from its content.
if (gitRef.untracked === true && !gitRef.staged) {
const text = await api.fsRead(paneScope, resolveSidebarPath(gitRef.repoRoot ?? gitRef.worktree ?? scope.cwd, gitRef.path))
if (!cancelled && text.kind === 'text') {
setDiffText('')
setUntracked(text.content)
}
return
}
if (!cancelled) setDiffText('')
} catch (reason) {
if (!cancelled) setError(reason instanceof Error ? reason.message : String(reason))
} finally {
if (!cancelled) setLoading(false)
}
}
void load()
return () => { cancelled = true }
}, [gitRef, scope.sessionId, scope.cwd, tick])
// ── On-demand git fold expansion: a fold's hidden rows come from both
// sides' full contents (git.show / fsRead), fetched ONCE per file so
// sibling folds share the request, then sliced by each fold's line
// ranges. The cache dies with the target or a refresh tick. ───────────
const foldContents = useRef(new Map>())
useEffect(() => { foldContents.current = new Map() }, [gitRef, tick])
const foldLoader = useMemo(() => {
if (gitRef === null) return undefined
const sidesOf = (file: DiffFile): Promise<{ old: string; new: string }> => {
// Both sides empty cannot cover a non-empty fold — treat it as a failed
// fetch so the fold degrades to the unavailable marker instead of
// silently expanding to nothing (the symptom of a bad rev or path
// reading null on both sides).
const ofSides = (oldContent: string | null, newContent: string | null): { old: string; new: string } => {
if ((oldContent ?? '') === '' && (newContent ?? '') === '') throw new Error('no content on either side')
return { old: oldContent ?? '', new: newContent ?? '' }
}
const fetchSides = async (): Promise<{ old: string; new: string }> => {
if (gitRef.kind === 'commit') {
// The patch's -m --first-parent shape: old side from the parent,
// new side from the commit (a root commit's parent read fails → '').
const [oldSide, newSide] = await Promise.all([
file.oldPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(gitScope, `${gitRef.hashFull}^`, displayPath(file.oldPath), gitRef.worktree),
file.newPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(gitScope, gitRef.hashFull, displayPath(file.newPath), gitRef.worktree),
])
return ofSides(oldSide.content, newSide.content)
}
// Worktree change: staged is HEAD vs index, unstaged is index vs
// worktree (the worktree side reads the live file).
const staged = effectiveStaged ?? gitRef.staged
if (staged) {
const [oldSide, newSide] = await Promise.all([
file.oldPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(gitScope, 'HEAD', displayPath(file.oldPath), gitRef.worktree),
file.newPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(gitScope, ':0', displayPath(file.newPath), gitRef.worktree),
])
return ofSides(oldSide.content, newSide.content)
}
const [oldSide, worktree] = await Promise.all([
file.oldPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(gitScope, ':0', displayPath(file.oldPath), gitRef.worktree),
api.fsRead(gitScope, resolveSidebarPath(gitRef.repoRoot ?? gitRef.worktree ?? scope.cwd, displayPath(file.newPath))).catch(() => null),
])
return ofSides(oldSide.content, worktree !== null && worktree.kind === 'text' ? worktree.content : null)
}
const path = displayPath(file.newPath === '/dev/null' ? file.oldPath : file.newPath)
let promise = foldContents.current.get(path)
if (promise === undefined) {
promise = fetchSides()
foldContents.current.set(path, promise)
}
return promise
}
return (file: DiffFile, segment: FoldSegment): Promise =>
sidesOf(file).then(sides => foldRowsFromContents(segment, sides.old, sides.new))
}, [gitRef, gitScope, effectiveStaged, scope])
// ── Op target material (pure snapshots; the prior content came with the
// target so a running op shows what is already known). ────────────────
const opRaw = target.kind === 'op' ? target.op : null
const priorRaw = target.kind === 'op' ? target.prior : undefined
// Secret redaction: on by default, toggle persists per browser (localStorage).
// Every op payload consumer below (diff rows, read rows, markdown source,
// error text) renders from the REDACTED shape, so masked payloads are the
// only thing that can reach the DOM while the toggle is on. Display-only:
// session events and the fs layer keep their original bytes.
const [redactionOn, setRedactionOn] = useState((): boolean => {
try { return localStorage.getItem(REDACTION_KEY) !== '0' } catch { return true }
})
const toggleRedaction = (): void => {
setRedactionOn((prev) => {
const next = !prev
try { localStorage.setItem(REDACTION_KEY, next ? '1' : '0') } catch { /* storage unavailable */ }
return next
})
}
const { op, prior, redactionHit } = useMemo(() => {
if (opRaw === null || !redactionOn) return { op: opRaw, prior: priorRaw, redactionHit: false }
const path = target.kind === 'op' ? target.path : ''
// One redactText pass per field: the outcome carries both the masked
// text and whether anything was hit.
const mask = (text: string | undefined): { masked: string | undefined; hit: boolean } => {
if (text === undefined) return { masked: undefined, hit: false }
const outcome = redactText(path, text)
return { masked: outcome.text, hit: outcome.hit }
}
const read = mask(opRaw.read)
const content = mask(opRaw.content)
const editOld = mask(opRaw.edit?.oldString)
const editNew = mask(opRaw.edit?.newString)
const errorText = mask(opRaw.errorText)
const priorMasked = mask(priorRaw)
const hit = read.hit || content.hit || editOld.hit || editNew.hit || errorText.hit || priorMasked.hit
if (!hit) return { op: opRaw, prior: priorRaw, redactionHit: false }
const redacted: FileOp = {
...opRaw,
...(read.masked !== undefined ? { read: read.masked } : {}),
...(content.masked !== undefined ? { content: content.masked } : {}),
...(opRaw.edit !== undefined
? { edit: { oldString: editOld.masked ?? opRaw.edit.oldString, newString: editNew.masked ?? opRaw.edit.newString } }
: {}),
...(errorText.masked !== undefined ? { errorText: errorText.masked } : {}),
}
return { op: redacted, prior: priorMasked.masked, redactionHit: true }
}, [opRaw, priorRaw, target, redactionOn])
const opLang = useMemo(() => (target.kind === 'op' ? langOfPath(target.path) : undefined), [target])
const opRows = useMemo(() => (op === null ? [] : diffOf(op, prior)), [op, prior])
const opSegments = useMemo(() => buildDiffSegments(opRows), [opRows])
const opStats = useMemo(() => diffStats(opSegments), [opSegments])
const opReadLines = useMemo(
() => (op?.kind === 'read' && op.read !== undefined ? parseReadLines(op.read) : []),
[op],
)
// ── Markdown reading mode: .md op targets (read/write/edit, non-error)
// toggle between the raw/diff view and the rendered document — the same
// shared MarkdownText pass the editor preview uses, with local image
// destinations rewritten through the /sidebar/file media route. ──────
const mdOp = target.kind === 'op' && !target.op.isError && /\.(md|markdown|mdx)$/i.test(target.path)
const [reading, setReading] = useState(false)
const readingSrc = useMemo(() => {
if (!mdOp || op === null) return ''
if (op.kind === 'read') return parseReadContent(op.read ?? '')
if (op.kind === 'write') return op.content ?? ''
if (op.kind === 'edit' && op.edit !== undefined) {
if (prior !== undefined && prior.includes(op.edit.oldString)) {
return prior.replace(op.edit.oldString, op.edit.newString)
}
return op.edit.newString
}
return ''
}, [mdOp, op, prior])
const readingText = useMemo(
() => (mdOp && reading && readingSrc !== '' && target.kind === 'op'
? rewriteLocalImageUrls(readingSrc, scope, target.path, window.location.origin)
: ''),
[mdOp, reading, readingSrc, scope, target],
)
// ── HTML render mode: .html/.htm op targets (the editor html viewer's
// ext set) load the SAVED file through the same /sidebar/html route the
// editor's html viewer uses — relative assets (./style.css, img/x.png)
// resolve inside the route, and a segmented read still renders the
// whole document (the route serves the file, not the op snapshot). The
// frame is always sandboxed (the attribute plus the route's CSP sandbox
// header); the editor tab owns the warned no-sandbox escape hatch. ──
const htmlOp = target.kind === 'op' && !target.op.isError && /\.(html?)$/i.test(target.path)
const [rendering, setRendering] = useState(false)
const htmlRenderSrc = useMemo(() => {
if (!htmlOp || target.kind !== 'op') return ''
return htmlUrl(scope, resolveSidebarPath(scope.cwd, target.path))
}, [htmlOp, scope, target])
// ── PDF render mode: .pdf op targets (read / write / edit, non-error) reuse
// the editor's PdfView verbatim — media-route bytes wrapped into an
// explicitly-typed Blob so the browser's native PDF viewer opens (a
// direct iframe src can fall back to a download). ──────────────────────
const pdfOp = target.kind === 'op' && !target.op.isError && /\.pdf$/i.test(target.path)
const [renderingPdf, setRenderingPdf] = useState(false)
const pdfRenderPath = useMemo(() => {
if (!pdfOp || target.kind !== 'op') return ''
return resolveSidebarPath(scope.cwd, target.path)
}, [pdfOp, scope, target])
// Header stats for git targets come off the parsed patch text.
const gitStats = useMemo(() => {
if (target.kind !== 'git' || diffText === null || diffText === '') return null
let added = 0
let deleted = 0
for (const file of parseUnifiedDiff(diffText).files) {
const stats = diffStats(unifiedSegments(file))
added += stats.added
deleted += stats.deleted
}
return { added, deleted }
}, [target, diffText])
// ── Resize: drag the top handle; commit on release (persisted by the
// shell). Arrow keys resize by a step for keyboard users. ────────────
const [dragHeight, setDragHeight] = useState(null)
const paneHeight = dragHeight ?? height
const clamp = (value: number): number => Math.min(Math.max(value, HEIGHT_MIN), Math.round(window.innerHeight * 0.7))
const dragOrigin = useRef<{ y: number; h: number } | null>(null)
// Pointer streams fire several times per frame; one setState per event
// re-rendered the whole pane at event cadence (see frame-batcher).
const dragBatcher = useRef(createFrameBatcher()).current
useEffect(() => () => dragBatcher.dispose(), [dragBatcher])
const onHandleDown = (event: ReactPointerEvent): void => {
event.preventDefault()
dragOrigin.current = { y: event.clientY, h: paneHeight }
const onMove = (ev: PointerEvent): void => {
if (dragOrigin.current === null) return
const next = clamp(dragOrigin.current.h + (dragOrigin.current.y - ev.clientY))
dragBatcher.schedule(() => { setDragHeight(next) })
}
const onUp = (): void => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
dragOrigin.current = null
dragBatcher.flushNow()
setDragHeight(current => {
if (current !== null) onHeightCommit(current)
return null
})
}
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
}
const title = target.kind === 'op'
? target.path
: target.ref.kind === 'worktree' ? target.ref.path : `${target.ref.hash} ${target.ref.subject}`
const stats = gitStats ?? (target.kind === 'op' && op !== null && op.kind !== 'read' && !op.isError ? opStats : null)
return (