refactor(changes): dedup the op-preview path and align conventions

- redaction useMemo: one redactText pass per field (the outcome carries
  both text and hit; was a hit-scan plus a mask pass), and the banner
  condition drops the redundant redactionOn check (hit implies on)
- three copy-pasted header toggles -> PaneToggle; the md reading body
  -> MdReadingView (absorbing the mermaid branch + codeLabels)
- redaction preference persisted under the repo's dsh-sidebar:v1: prefix
- render toggle limited to .html/.htm (the editor html viewer's ext set;
  .xhtml went beyond any claim)
- tsx/jsx share TS_WORDS/JS_WORDS; CSS word list loses its duplicate
  entries and its nonexistent true/false constants
- parseReadContent/parseReadLines share the contentLines() envelope core
  (content extraction + Showing-lines filter)
This commit is contained in:
Menghuan1918
2026-09-06 23:46:46 +08:00
parent 04693192df
commit 157e02e768
3 changed files with 107 additions and 86 deletions
+89 -65
View File
@@ -32,10 +32,14 @@ import { createFrameBatcher } from '../frame-batcher.ts'
import css from './changes.module.css'
import diffCss from '../diff/diff.module.css'
/** The drag handle height clamp (px) and keyboard-resize step. */
/** 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 }
@@ -80,6 +84,43 @@ export function HtmlRenderPreview(props: { src: string; title: string }) {
)
}
/** 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 (
<button
type="button"
className={css.mdToggle}
data-on={props.on ? 'true' : undefined}
aria-pressed={props.on}
title={props.title}
onClick={props.onClick}
>
{props.label}
</button>
)
}
/** 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 (
<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') {
@@ -180,33 +221,44 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
// 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('dsh-better-sidebar.redaction') !== '0' } catch { return true }
try { return localStorage.getItem(REDACTION_KEY) !== '0' } catch { return true }
})
const toggleRedaction = (): void => {
setRedactionOn((prev) => {
const next = !prev
try { localStorage.setItem('dsh-better-sidebar.redaction', next ? '1' : '0') } catch { /* storage unavailable */ }
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 : ''
if (opRaw === null || !redactionOn) {
return { op: opRaw, prior: priorRaw, redactionHit: false }
// 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 mask = (text: string): string => redactText(path, text).text
const hit = [opRaw.read, opRaw.content, opRaw.edit?.oldString, opRaw.edit?.newString, opRaw.errorText, priorRaw]
.some((text) => text !== undefined && redactText(path, text).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,
...(opRaw.read !== undefined ? { read: mask(opRaw.read) } : {}),
...(opRaw.content !== undefined ? { content: mask(opRaw.content) } : {}),
...(opRaw.edit !== undefined ? { edit: { oldString: mask(opRaw.edit.oldString), newString: mask(opRaw.edit.newString) } } : {}),
...(opRaw.errorText !== undefined ? { errorText: mask(opRaw.errorText) } : {}),
...(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: priorRaw === undefined ? undefined : mask(priorRaw), redactionHit: true }
}, [opRaw, priorRaw, target, redactionOn]);
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])
@@ -240,23 +292,15 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
: ''),
[mdOp, reading, readingSrc, scope, target],
)
// Mermaid fences render through the same chunk-resident renderer the editor
// preview uses (one MarkdownText pass with the mermaid fences lifted out);
// the plain single-pass path stays byte-for-byte for documents without any.
const readingHasMermaid = useMemo(
() => (readingText !== '' ? splitMermaidBlocks(readingText).some((block) => block.kind === 'mermaid') : false),
[readingText],
)
const codeLabels = { copyLabel: t('copy'), copiedLabel: t('copied') }
// ── HTML render mode: .html/.htm/.xhtml op targets 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?|xhtml)$/i.test(target.path)
// ── 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 ''
@@ -380,42 +424,30 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
</button>
</>
)}
{redactionHit && redactionOn && (
{redactionHit && (
<span className={css.redactBanner} role="status">{t('changesRedactBanner')}</span>
)}
{target.kind === 'op' && (
<button
type="button"
className={css.mdToggle}
data-on={redactionOn ? 'true' : undefined}
aria-pressed={redactionOn}
onClick={toggleRedaction}
<PaneToggle
on={redactionOn}
label={redactionOn ? t('changesRedactOnLabel') : t('changesRedactOffLabel')}
title={redactionOn ? t('changesRedactOff') : t('changesRedactOn')}
>
{redactionOn ? t('changesRedactOnLabel') : t('changesRedactOffLabel')}
</button>
onClick={toggleRedaction}
/>
)}
{mdOp && (
<button
type="button"
className={css.mdToggle}
data-on={reading ? 'true' : undefined}
aria-pressed={reading}
<PaneToggle
on={reading}
label={t(reading ? 'changesMdRaw' : 'changesMdReading')}
onClick={() => { setReading(value => !value) }}
>
{t(reading ? 'changesMdRaw' : 'changesMdReading')}
</button>
/>
)}
{htmlOp && (
<button
type="button"
className={css.mdToggle}
data-on={rendering ? 'true' : undefined}
aria-pressed={rendering}
<PaneToggle
on={rendering}
label={t(rendering ? 'changesHtmlRaw' : 'changesHtmlRender')}
onClick={() => { setRendering(value => !value) }}
>
{t(rendering ? 'changesHtmlRaw' : 'changesHtmlRender')}
</button>
/>
)}
{pdfOp && (
<button
@@ -447,15 +479,7 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
</div>
)
: target.kind === 'op' && mdOp && reading && readingText !== ''
? (
<div className={css.paneBody}>
<div className={css.mdBody}>
{readingHasMermaid
? <LazyMermaidMarkdown text={readingText} codeLabels={codeLabels} />
: <MarkdownText {...markdownTextProps(readingText, codeLabels)} />}
</div>
</div>
)
? <MdReadingView text={readingText} />
: target.kind === 'op' && op !== null && op.isError
? (
<div className={css.paneBody}>
+14 -9
View File
@@ -201,6 +201,18 @@ export function knownContentBefore(ops: readonly FileOp[], path: string, before:
return undefined
}
/**
* The read-tool response's `<content>` body split into lines with the
* "(Showing lines …)" note dropped — the shared envelope core both read
* parsers consume: {@link parseReadLines} recovers line numbers from the
* "<n>: " prefixes, {@link parseReadContent} keeps the raw text.
*/
function contentLines(raw: string): string[] {
const contentMatch = raw.match(/<content>([\s\S]*?)<\/content>/)
const body = contentMatch ? contentMatch[1]! : raw
return body.split('\n').filter((line) => !/^\s*\(Showing lines .*\)\s*$/.test(line))
}
/**
* Strip the DSH read-tool response envelope down to the file's raw content,
* PRESERVING blank lines (unlike {@link parseReadLines}, which drops them):
@@ -209,11 +221,7 @@ export function knownContentBefore(ops: readonly FileOp[], path: string, before:
* because markdown block structure depends on them.
*/
export function parseReadContent(raw: string): string {
const contentMatch = raw.match(/<content>([\s\S]*?)<\/content>/)
const body = contentMatch ? contentMatch[1]! : raw
return body
.split('\n')
.filter((line) => !/^\s*\(Showing lines .*\)\s*$/.test(line))
return contentLines(raw)
.map((line) => line.replace(/^\s*\d+:\s/, ''))
.join('\n')
.replace(/^[\n]+/, '')
@@ -229,12 +237,9 @@ export interface ReadLine { readonly line: number; readonly text: string }
* "<n>: " prefix as the line number, falling back to sequential counting.
*/
export function parseReadLines(raw: string): ReadLine[] {
const contentMatch = raw.match(/<content>([\s\S]*?)<\/content>/)
const body = contentMatch ? contentMatch[1]! : raw
const result: ReadLine[] = []
let fallback = 1
for (const line of body.split('\n')) {
if (/^\s*\(Showing lines .*\)\s*$/.test(line)) continue
for (const line of contentLines(raw)) {
if (line.length === 0) continue
const match = line.match(/^\s*(\d+):\s?(.*)$/)
if (match !== null) {
+4 -12
View File
@@ -172,8 +172,8 @@ const CSS_LANG: LangConfig = {
html body p a div span li ul ol table tr td th form input button label select textarea img
section header footer main nav article aside h1 h2 h3 h4 h5 h6 i b em strong small pre code
inherit initial unset auto none fixed absolute relative sticky static flex block inline
inline-block inline-flex grid hidden visible hidden bold normal inherit root var calc env`),
constants: kw('true false'),
inline-block inline-flex grid hidden visible bold normal root var calc env`),
constants: new Set(),
macro: false,
wordStart: /[A-Za-z-]/u,
wordBody: /[A-Za-z0-9-]/u,
@@ -200,17 +200,9 @@ const MARKUP_LANG: LangConfig = {
/** Extension → language id, mirroring the read tool's hint table. */
const LANGS: Readonly<Record<string, LangConfig>> = {
ts: C_FAMILY(TS_WORDS),
tsx: C_FAMILY(`abstract any as asserts async await boolean break case catch class const constructor
continue declare default delete do else enum export extends false finally for from function get
if implements import in infer instanceof interface is keyof let module namespace never new null
number object of override private protected public readonly return satisfies set static string
super switch symbol this throw true try type typeof undefined union unknown var void while with
yield`),
tsx: C_FAMILY(TS_WORDS),
js: C_FAMILY(JS_WORDS),
jsx: C_FAMILY(`async await break case catch class const continue debugger default delete do else
export extends false finally for from function get if implements import in instanceof interface
let new null of return set static super switch this throw true try typeof undefined var void
while with yield`),
jsx: C_FAMILY(JS_WORDS),
json: CONFIG_LANG,
py: HASH_FAMILY(`and as assert async await break class continue def del elif else except finally
for from global if import in is lambda nonlocal not or pass raise return try while with yield