Files
apes-sidebar/src/client/changes/DiffPane.tsx
T
Menghuan1918 36fbc5ac7e feat(sidebar): 文件页与文件变动页 UIUX 重构 + 全局组件抽取
文件页(FileTree/TreePanel)
- Ctrl/Cmd 多选 + Shift 按可见行序连选(Esc/点空白/右键收敛),批量条支持复制路径、批量删除(共享 ConfirmDialog + 顺序 fsRemove)
- Git 变更着色:行名按状态上色 + M/A/D/U 徽标,目录按 dirHasChanges 着色,非仓库静默降级(数据来自共享 git 状态存储)
- 新建文件夹:fs.mkdir 路由 + mkdirWorkspaceEntry + 内联输入行(Enter/Esc/blur/IME 守卫)
- 性能:FileRow/DirRow memo(含反向验证)、搜索 hidden 停车不再卸载树、level 拉取世代守卫 + AbortController、fs.tree truncated 提示、use-dir-watch 入参 memo
- 拖拽上传视觉 token 化(硬编码色清零);可见性下传后隐藏页签停止轮询
- 右键「打开方式」改为复用 DSH 自带 open-in-app(宿主探测的关联应用 + 文件管理器显示)

文件变动页(changes/diff)
- 36px 头(宿主 SegmentedControl)+ 单一 ChangeRow + SectionHeader 分组 + 吸底提交条单状态行 + 历史独立空态
- 修掉:双份空态、暂存失败 unhandled rejection、手动刷新被丢、仓库切换刷新两次、worktree 清单缺 repoRoot、工作树被自动切走、预览持旧快照、恢复高度未 clamp、session ops 无谓轮询
- DiffPane/DiffTab 共用 use-git-diff;修掉 DiffPane 的 untracked 预览从不渲染

全局组件与宿主接入
- 新增 src/client/ui(IconButton/Chip/SectionHeader/Notice/ConfirmDialog/StatusBadge + 共享 git 状态存储)
- 新增 src/client/open-in-app.ts(宿主 open-in-app 客户端适配),删除自研 open-with 全链路与 open.external 路由
- 客户端 inject 补 remote / remote.session:缺声明时 ctx.get('remote').session 会抛出并把整个面板打成错误条(真实挂载 lane 抓到)
- 词典:+25 键 / -20 openWith* / -noChanges,20 份同步

验证:typecheck / lint / test(126 文件 1322 通过)/ build 全绿;
DSH_CMD='npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 dsh' pnpm test:mount 7/7 通过(含 tab sweep 与 perf lane)
2026-09-28 00:27:31 +08:00

473 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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
* through the shared {@link useGitDiffTarget} hook (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 { IconCloseOutlineRegular, IconRefreshOutlineRegular, IconRightUpOutlineRegular, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SessionScope } from '../api.ts'
import { htmlUrl } from '../api.ts'
import { t } from '../locales.ts'
import { baseName } from '../paths.ts'
import { resolveSidebarPath } from '../paths.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, parseUnifiedDiff, unifiedSegments, type DiffRow } from '../diff/rows.ts'
import { useGitDiffTarget } from '../diff/use-git-diff.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 { Chip, IconButton, Notice, StatusBadge } from '../ui/index.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
/**
* Clamp one pane height to the pane's own bounds: never below the content
* minimum, never above 70% of the window. Both the drag handler and the
* RESTORE path (a height persisted under a taller window) run through this,
* so a saved value can never open the pane taller than the viewport allows.
*/
export function clampPaneHeight(value: number): number {
return Math.min(Math.max(value, HEIGHT_MIN), Math.round(window.innerHeight * 0.7))
}
/** 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 (
<div className={css.htmlPane}>
<iframe
className={css.htmlFrame}
title={props.title}
src={props.src}
sandbox={HTML_IFRAME_SANDBOX}
referrerPolicy="no-referrer"
allow=""
/>
</div>
)
}
/** One header pill toggle — the redaction / reading / render toggles share the
* shape: the global {@link Chip} (its active state is the "on" picture). */
function PaneToggle(props: { on: boolean; label: string; title?: string; onClick: () => void }) {
return <Chip active={props.on} title={props.title} onClick={props.onClick}>{props.label}</Chip>
}
/** 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'),
codeLabel: t('codeBlockTitle'),
wrapLabel: t('codeBlockWrap'),
unwrapLabel: t('codeBlockUnwrap'),
}
const hasMermaid = splitMermaidBlocks(props.text).some((block) => block.kind === 'mermaid')
return (
<div className={css.paneBody}>
<div className={css.mdBody}>
{hasMermaid
? <LazyMermaidMarkdown text={props.text} codeLabels={codeLabels} />
: <MarkdownText {...markdownTextProps(props.text, codeLabels)} />}
</div>
</div>
)
}
/** 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 (the shared loader: staged-side fallback, the
// untracked full-addition fallback, refresh by tick, fold cache). ─────
const gitRef = target.kind === 'git' ? target.ref : null
const { loading, error, diffText, untracked, refresh, resolveFold } = useGitDiffTarget(gitRef, 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<number | null>(null)
const paneHeight = dragHeight ?? height
const clamp = clampPaneHeight
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<HTMLDivElement>): 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 (
<div className={css.diffPane} style={{ height: paneHeight }}>
<div
className={css.dragHandle}
role="separator"
aria-orientation="horizontal"
aria-label={t('changesResizePreview')}
tabIndex={0}
onPointerDown={onHandleDown}
onKeyDown={(event) => {
if (event.key === 'ArrowUp') { event.preventDefault(); onHeightCommit(clamp(paneHeight + HEIGHT_STEP)) }
if (event.key === 'ArrowDown') { event.preventDefault(); onHeightCommit(clamp(paneHeight - HEIGHT_STEP)) }
}}
/>
<div className={css.diffHead}>
{target.kind === 'op' && (
<StatusBadge tone={target.op.kind}>
{t(target.op.kind === 'read' ? 'changesRead' : target.op.kind === 'write' ? 'changesWrite' : 'changesEdit')}
</StatusBadge>
)}
{target.kind === 'git' && target.ref.kind === 'worktree' && (
<StatusBadge tone="neutral">{target.ref.staged ? t('staged') : t('unstaged')}</StatusBadge>
)}
{target.kind === 'git' && target.ref.kind === 'commit' && (
<StatusBadge tone="neutral">{target.ref.hash}</StatusBadge>
)}
<span className={css.diffPath} title={title}>{title}</span>
{stats !== null && (stats.added > 0 || stats.deleted > 0) && (
<span className={css.diffStats}>
{stats.added > 0 && <span className={diffCss.statAdd}>+{String(stats.added)}</span>}
{stats.deleted > 0 && <span className={diffCss.statDel}>−{String(stats.deleted)}</span>}
</span>
)}
{target.kind === 'git' && (
<>
<IconButton
size="sm"
icon={<IconRefreshOutlineRegular size={14} />}
label={t('refresh')}
disabled={loading}
onClick={refresh}
/>
<IconButton
size="sm"
icon={<IconRightUpOutlineRegular size={14} />}
label={t('changesOpenDiffTab')}
onClick={onExpand}
/>
</>
)}
{redactionHit && (
<span className={css.redactBanner} role="status">{t('changesRedactBanner')}</span>
)}
{target.kind === 'op' && (
<PaneToggle
on={redactionOn}
label={redactionOn ? t('changesRedactOnLabel') : t('changesRedactOffLabel')}
title={redactionOn ? t('changesRedactOff') : t('changesRedactOn')}
onClick={toggleRedaction}
/>
)}
{mdOp && (
<PaneToggle
on={reading}
label={t(reading ? 'changesMdRaw' : 'changesMdReading')}
onClick={() => { setReading(value => !value) }}
/>
)}
{htmlOp && (
<PaneToggle
on={rendering}
label={t(rendering ? 'changesHtmlRaw' : 'changesHtmlRender')}
onClick={() => { setRendering(value => !value) }}
/>
)}
{pdfOp && (
<PaneToggle
on={renderingPdf}
label={t(renderingPdf ? 'changesPdfRaw' : 'changesPdfRender')}
onClick={() => { setRenderingPdf(value => !value) }}
/>
)}
<IconButton
size="sm"
icon={<IconCloseOutlineRegular size={14} />}
label={t('changesClosePreview')}
onClick={onClose}
/>
</div>
{target.kind === 'op' && htmlOp && rendering && htmlRenderSrc !== ''
? <HtmlRenderPreview src={htmlRenderSrc} title={target.path} />
: target.kind === 'op' && pdfOp && renderingPdf && pdfRenderPath !== ''
? (
<div className={css.htmlPane}>
<PdfView scope={scope} path={pdfRenderPath} title={target.path} />
</div>
)
: target.kind === 'op' && mdOp && reading && readingText !== ''
? <MdReadingView text={readingText} />
: target.kind === 'op' && op !== null && op.isError
? (
<div className={css.paneBody}>
<Notice kind="error" className={css.readError}>
{op.errorText ?? t('changesError')}
</Notice>
</div>
)
: target.kind === 'op' && op !== null && op.kind === 'read'
? (
<div className={css.paneBody}>
<ReadRows lines={opReadLines} lang={opLang} />
</div>
)
: target.kind === 'op'
? (
<div className={css.paneBody}>
{op !== null && op.kind === 'write'
&& prior === undefined
&& <Notice kind="hint" tone="inline">{t('changesPriorUnknown')}</Notice>}
<DiffRows key={target.op.callId} segments={opSegments} lang={opLang} />
</div>
)
: loading
? <div className={css.paneBody}><Notice kind="loading" tone="page">{t('loading')}</Notice></div>
: error !== null
? (
<div className={css.paneBody}>
<Notice kind="error">{t('diffLoadError')}: {error}</Notice>
</div>
)
: (
<div className={css.paneBody}>
{/* The untracked fallback has NO diff text (git diff never
lists the file): its content is the whole render. */}
{((diffText !== null && diffText !== '') || untracked !== undefined) && (
<DiffFiles
diff={diffText ?? ''}
resolveFold={resolveFold}
untrackedPath={untracked !== undefined && target.ref.kind === 'worktree' ? target.ref.path : undefined}
untrackedContent={untracked}
/>
)}
{diffText === '' && untracked === undefined && (
<Notice kind="empty" tone="page">{t('diffEmpty')}</Notice>
)}
</div>
)}
</div>
)
}