feat: 新增第 8 个内置 tab「文件追踪」——移植 dsh-file-trace 插件

- 数据层:直接折叠会话事件日志(tool/call + tool/result 对)提取文件操作,
  与仓库「读事件日志而非动注册表」的取向一致;含错误文本捕获与
  running 态实时更新(ctx.on('session/event'),不可见时暂停、恢复时追平)
- 视图:操作列表 + 底部可拖拽 diff 面板;行级 diff(删红/增绿/改蓝)+
  行内字符级高亮;读取视图带真实行号;失败操作统一展示真实错误文本
- 语法着色:轻量分词器(关键字/字符串/数字/类型/函数/注释/预处理),
  色相经 color-mix 落在 diff 三色之间的空档;跨行块注释状态按行序推导
- 单实例 tab,+ 菜单 order 55,角标显示操作数;设置页可整体关闭
  (与独立维护的 dsh-file-trace 插件共存)
- i18n:10 个新 key 同步全部 21 个词典;builtins 测试 7→8;
  README/指南内置清单同步
This commit is contained in:
lhh010
2026-08-30 20:59:50 +08:00
parent 3aab7ca3a5
commit 02d3936b92
31 changed files with 1658 additions and 15 deletions
+18 -3
View File
@@ -1,6 +1,7 @@
/**
* The 7 built-in tab descriptors: the plugin registers its own pages
* (editor / git / subagent / sidechat / terminal / browser / diff) through
* The 8 built-in tab descriptors: the plugin registers its own pages
* (editor / git / subagent / sidechat / terminal / browser / diff /
* file-trace) through
* the same {@link BetterSidebarService} external plugins use — eating its
* own dogfood. The terminal descriptor owns its quota (`TERMINAL_LIMIT`)
* and mints `terminal:<uuid>` ids through `createTab`; the browser mints
@@ -21,7 +22,9 @@ import { SubagentView } from '../SubagentView.tsx'
import { consumeSidechatSeed, SideChatView, sidechatThreadIdOf } from '../SideChatView.tsx'
import { api } from '../api.ts'
import { BrowserView } from '../BrowserView.tsx'
import { IconTerminalOutline16, IconDiffOutline16, IconGlobeOutline16 } from '../icons.tsx'
import { IconTerminalOutline16, IconDiffOutline16, IconGlobeOutline16, IconHistoryOutline16 } from '../icons.tsx'
import { FileTraceTab } from '../filetrace/FileTraceTab.tsx'
import { extractFileOps } from '../filetrace/ops.ts'
import { TERMINAL_FONT_SIZE_MAX, TERMINAL_FONT_SIZE_MIN } from '../../prefs-shared.ts'
import type { ComponentType } from 'react'
import type { SessionScope } from '../api.ts'
@@ -335,6 +338,18 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
}),
component: (props) => <BrowserView {...props} />,
},
{
id: 'file-trace',
title: () => t('fileTrace'),
icon: (size: number) => <IconHistoryOutline16 size={size} />,
order: 55,
single: true,
badge: (ctx, scope) => {
const events = ctx.sessions.get(scope.sessionId)?.events
return events === undefined ? null : extractFileOps(events).length
},
component: (props) => <FileTraceTab {...props} />,
},
{
id: 'diff',
title: () => t('git'),
+336
View File
@@ -0,0 +1,336 @@
/**
* The file-trace tab: records every file the model read, wrote, or edited
* in this session (folded from the session event log) and reviews any op as
* a line diff (del red / add green / mod blue) or a line-numbered read view,
* with lightweight syntax coloring whose hues sit between the diff colors.
* Ported from the standalone dsh-file-trace plugin's panel; the tab can be
* disabled per user in the Side card settings when the standalone plugin is
* preferred.
*/
import { useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, type ReactElement, type ReactNode } from 'react'
import type { SidebarSessionEvent } from '../../context-types.ts'
import type { TabComponentProps } from '../service.ts'
import { t } from '../locales.ts'
import { extractFileOps, groupByFile, knownContentBefore, parseReadLines, type FileOp } from './ops.ts'
import { diffLines, formatBytes, buildDiffSegments, diffInline, coalesceInline, MIN_FOLD, type DiffRow, type InlineDiff } from './diff.ts'
import { scanLine, isColored, langOfPath, hasBlockComment, type TokenType, type CodeToken } from './highlight.ts'
import css from './filetrace.module.css'
/** Long diff lines fold to one ellipsized row; the threshold is the char count. */
const FOLD_THRESHOLD = 120
/** Cap on accumulated events: the trace shows the recent window, not eternity. */
const EVENTS_CAP = 4000
/** Token class -> CSS color class ('' inherits the row's diff color). */
const TOKEN_CLASS: Readonly<Record<TokenType, string>> = {
plain: '',
comment: css.tokComment ?? '',
string: css.tokString ?? '',
keyword: css.tokKeyword ?? '',
number: css.tokNumber ?? '',
type: css.tokType ?? '',
function: css.tokFunction ?? '',
macro: css.tokMacro ?? '',
}
/** One token span's class list: its color class, plus the change tint. */
function tokenSpanClass(type: TokenType, changed: boolean): string {
const color = TOKEN_CLASS[type]
return changed ? `${color} ${css.inlineChange}` : color
}
/** Render scanned tokens as colored nodes; uncolored runs stay text. */
function tokensToNodes(tokens: readonly CodeToken[], changed = false): ReactNode[] {
const nodes: ReactNode[] = []
for (const token of tokens) {
if (!changed && !isColored(token)) nodes.push(token.text)
else nodes.push(<span key={String(nodes.length)} className={tokenSpanClass(token.type, changed)}>{token.text}</span>)
}
return nodes
}
/** Per-row block-comment entry state for a diff: the old side threads along
* old-line order and the new side along new-line order (the LCS row order
* preserves both), so multi-line comments color correctly on each side. */
function diffBlockEntries(rows: readonly DiffRow[], lang: string | undefined): Map<DiffRow, boolean> {
const entries = new Map<DiffRow, boolean>()
if (!hasBlockComment(lang)) return entries
let oldIn = false
let newIn = false
for (const row of rows) {
const isOld = row.oldLine !== undefined
const isNew = row.newLine !== undefined
entries.set(row, isOld ? oldIn : newIn)
if (isOld) oldIn = scanLine(row.text, lang, oldIn).inBlock
if (isNew) newIn = scanLine(row.text, lang, newIn).inBlock
}
return entries
}
/** Diff material for one operation: 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 file-trace tab body: op list above, selected diff/read pane below. */
export function FileTraceTab({ ctx, scope, visible }: TabComponentProps) {
// The event log snapshot seeds the trace once; live tool/call and
// tool/result events append (bounded) and re-extract while visible.
const eventsRef = useRef<readonly SidebarSessionEvent[] | null>(null)
if (eventsRef.current === null) {
eventsRef.current = ctx.sessions.get(scope.sessionId)?.events ?? []
}
const [tick, setTick] = useState(0)
useEffect(() => {
if (typeof ctx.on !== 'function') return
const off = ctx.on('session/event', (session: unknown, event: SidebarSessionEvent) => {
const sessionId = (session as { id?: unknown } | null)?.id
if (sessionId !== scope.sessionId) return
if (event.type !== 'tool/call' && event.type !== 'tool/result') return
const list = [...eventsRef.current ?? [], event]
eventsRef.current = list.length > EVENTS_CAP ? list.slice(list.length - EVENTS_CAP) : list
if (visible) setTick(value => value + 1)
})
return () => { off() }
}, [ctx, scope.sessionId, visible])
// Becoming visible again catches up on events appended while paused.
useEffect(() => { if (visible) setTick(value => value + 1) }, [visible])
const ops = useMemo(() => extractFileOps(eventsRef.current ?? []), [tick])
const groups = useMemo(() => groupByFile(ops), [ops])
const [selected, setSelected] = useState<{ path: string; op: FileOp } | null>(null)
// Long diff lines fold to one ellipsized row; the set holds expanded row keys.
const [expandedLines, setExpandedLines] = useState<ReadonlySet<string>>(new Set())
// Hunk-fold segments expanded by index; default collapsed.
const [expandedFolds, setExpandedFolds] = useState<ReadonlySet<number>>(new Set())
// Bottom diff pane height in px; drag the handle to resize (min/max clamp).
const [diffHeight, setDiffHeight] = useState(300)
const selectedOp = selected?.op
const selectedLang = useMemo(
() => selected === null ? undefined : langOfPath(selected.path),
[selected],
)
const diffRows = useMemo(
() => selectedOp === undefined ? [] : diffOf(selectedOp, knownContentBefore(ops, selected?.path ?? '', selectedOp)),
[selectedOp, selected?.path, ops],
)
const segments = useMemo(() => buildDiffSegments(diffRows), [diffRows])
// Char-level highlight for mod-row pairs, keyed by (oldLine|newLine).
const inlineMap = useMemo(() => {
const map = new Map<string, InlineDiff>()
let i = 0
while (i < diffRows.length) {
if (diffRows[i]!.kind !== 'mod') { i += 1; continue }
let j = i
while (j < diffRows.length && diffRows[j]!.kind === 'mod') j += 1
const block = diffRows.slice(i, j)
const k = Math.floor(block.length / 2)
for (let p = 0; p < k; p += 1) {
const delRow = block[p]!
const addRow = block[p + k]!
const r = diffInline(delRow.text, addRow.text)
map.set(`${String(delRow.oldLine ?? '')}|${String(delRow.newLine ?? '')}`, r)
map.set(`${String(addRow.oldLine ?? '')}|${String(addRow.newLine ?? '')}`, r)
}
i = j
}
return map
}, [diffRows])
// Block-comment entry state per row, threaded per side.
const blockEntries = useMemo(() => diffBlockEntries(diffRows, selectedLang), [diffRows, selectedLang])
// Read view rows with block-comment state threaded down the file's lines.
const readRows = useMemo(() => {
if (selectedOp?.kind !== 'read' || selectedOp.read === undefined) return []
let state = false
return parseReadLines(selectedOp.read).map((line) => {
const scan = scanLine(line.text, selectedLang, state)
state = scan.inBlock
return { line: line.line, nodes: tokensToNodes(scan.tokens) }
})
}, [selectedOp, selectedLang])
// Reset folding when selecting a different operation.
useEffect(() => { setExpandedLines(new Set()); setExpandedFolds(new Set()) }, [selectedOp])
/** One diff row: colored sign + syntax-colored text, long-line fold toggle. */
const renderDiffRow = (row: DiffRow, rowKey: string, lang: string | undefined): ReactElement => {
const isLong = row.text.length > FOLD_THRESHOLD
const isFolded = isLong && !expandedLines.has(rowKey)
const blockEntry = blockEntries.get(row) ?? false
return (
<div
key={rowKey}
className={css.diffRow}
data-kind={row.kind}
data-folded={isFolded ? 'true' : undefined}
onClick={isLong ? () => {
setExpandedLines(prev => {
const next = new Set(prev)
if (next.has(rowKey)) next.delete(rowKey)
else next.add(rowKey)
return next
})
} : undefined}
title={isFolded ? row.text : undefined}
>
<span className={css.lineNo}>{row.oldLine !== undefined ? String(row.oldLine) : ''}</span>
<span className={css.lineNo}>{row.newLine !== undefined ? String(row.newLine) : ''}</span>
<span className={css.sign}>{row.kind === 'del' ? '-' : row.kind === 'add' ? '+' : row.kind === 'mod' ? '~' : ' '}</span>
<span className={css.text} data-folded={isFolded ? 'true' : undefined}>
{row.kind === 'mod' && (() => {
const inline = inlineMap.get(`${String(row.oldLine ?? '')}|${String(row.newLine ?? '')}`)
if (inline === undefined) return tokensToNodes(scanLine(row.text, lang, blockEntry).tokens)
// Coalesced change runs, each split into syntax tokens with
// block-comment state threaded across the runs of this line.
const side = coalesceInline(row.oldLine !== undefined ? inline.old : inline.next)
const nodes: ReactNode[] = []
let state = blockEntry
for (const seg of side) {
const scan = scanLine(seg.text, lang, state)
state = scan.inBlock
nodes.push(...tokensToNodes(scan.tokens, seg.changed))
}
return nodes
})()}
{row.kind !== 'mod' && tokensToNodes(scanLine(row.text, lang, blockEntry).tokens)}
</span>
</div>
)
}
const onHandleDown = (e: ReactPointerEvent<HTMLDivElement>): void => {
e.preventDefault()
const startY = e.clientY
const startH = diffHeight
const onMove = (ev: PointerEvent): void => {
setDiffHeight(Math.min(Math.max(startH + (startY - ev.clientY), 140), Math.round(window.innerHeight * 0.7)))
}
const onUp = (): void => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
}
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
}
return (
<div className={css.root}>
<div className={css.list}>
{ops.length === 0 && <div className={css.empty}>{t('fileTraceEmpty')}</div>}
{[...groups.entries()].map(([path, fileOps]) => (
<div key={path} className={css.fileGroup}>
<div className={css.filePath} title={path}>{path}</div>
{fileOps.map(op => (
<button
type="button"
key={op.callId}
className={css.opRow}
data-op-kind={op.kind}
data-op-error={op.isError ? 'true' : undefined}
onClick={() => { setSelected({ path, op }) }}
>
<span className={css.opKind} data-kind={op.kind}>{t(op.kind === 'read' ? 'fileTraceRead' : op.kind === 'write' ? 'fileTraceWrite' : 'fileTraceEdit')}</span>
<span className={css.opTime}>{new Date(op.time).toLocaleTimeString()}</span>
{op.running && <span className={css.opFlag}>{t('fileTraceRunning')}</span>}
{op.isError && <span className={css.opFlagError}>{t('fileTraceError')}</span>}
{op.kind !== 'read' && !op.isError && (
<span className={css.opSize}>
{formatBytes(new Blob([op.edit?.newString ?? op.content ?? '']).size)}
</span>
)}
</button>
))}
</div>
))}
</div>
{selected !== null && (
<div className={css.diffPane} style={{ height: diffHeight }}>
<div className={css.dragHandle} onPointerDown={onHandleDown} role="separator" aria-orientation="horizontal" />
<div className={css.diffHead}>
<span className={css.diffPath}>{selected.path}</span>
<span className={css.diffKind} data-kind={selected.op.kind}>
{t(selected.op.kind === 'read' ? 'fileTraceRead' : selected.op.kind === 'write' ? 'fileTraceWrite' : 'fileTraceEdit')}
</span>
<button type="button" className={css.close} onClick={() => { setSelected(null) }}>×</button>
</div>
{selected.op.isError
? (
<div className={css.readContent} data-error="true">
<div className={css.readError} role="alert">
{selected.op.errorText ?? t('fileTraceError')}
</div>
</div>
)
: selected.op.kind === 'read'
? (
<div className={css.readContent}>
{readRows.map((row) => (
<div key={String(row.line)} className={css.readRow}>
<span className={css.lineNo}>{String(row.line)}</span>
<span className={css.text}>{row.nodes}</span>
</div>
))}
</div>
)
: (
<div className={css.diffRows}>
{selected.op.kind === 'write'
&& knownContentBefore(ops, selected.path, selected.op) === undefined
&& <div className={css.priorUnknown}>{t('fileTracePriorUnknown')}</div>}
{segments.map((segment, segIndex) => {
if (segment.kind === 'fold') {
const shouldFold = segment.rows.length >= MIN_FOLD
const isExpanded = expandedFolds.has(segIndex)
if (!shouldFold) {
return segment.rows.map((row, index) => renderDiffRow(row, `${segIndex}-${String(index)}`, selectedLang))
}
return (
<div
key={`fold-${String(segIndex)}`}
className={css.foldRow}
data-expanded={isExpanded ? 'true' : undefined}
onClick={() => {
setExpandedFolds(prev => {
const next = new Set(prev)
if (next.has(segIndex)) next.delete(segIndex)
else next.add(segIndex)
return next
})
}}
>
{isExpanded
? segment.rows.map((row, index) => renderDiffRow(row, `${segIndex}-${String(index)}`, selectedLang))
: (
<span className={css.foldMarker} title={t('fileTraceContext')}>
{t('fileTraceFold', { count: segment.rows.length })}
</span>
)}
</div>
)
}
return segment.rows.map((row, index) => renderDiffRow(row, `${segIndex}-${String(index)}`, selectedLang))
})}
</div>
)}
</div>
)}
</div>
)
}
+257
View File
@@ -0,0 +1,257 @@
/**
* Line-based diff engine for the file-trace panel: an LCS over lines with
* per-row change classes. Pure functions only — no React, no DOM.
*/
/** One rendered diff row. */
export interface DiffRow {
/** Change class: 'context' shared, 'del' removed, 'add' added, 'mod' rewritten. */
readonly kind: 'context' | 'del' | 'add' | 'mod'
/** Old-side 1-based line number; absent on pure additions. */
readonly oldLine?: number
/** New-side 1-based line number; absent on pure deletions. */
readonly newLine?: number
/** The line's text without its terminator. */
readonly text: string
}
/**
* Longest-common-subsequence table over line equality.
* @param oldLines - old side lines.
* @param newLines - new side lines.
* @returns the LCS length matrix (rows index oldLines, columns newLines).
*/
function lcsTable(oldLines: readonly string[], newLines: readonly string[]): number[][] {
const table: number[][] = Array.from({ length: oldLines.length + 1 }, () => new Array<number>(newLines.length + 1).fill(0))
for (let i = oldLines.length - 1; i >= 0; i -= 1) {
for (let j = newLines.length - 1; j >= 0; j -= 1) {
table[i]![j] = oldLines[i] === newLines[j]
? table[i + 1]![j + 1]! + 1
: Math.max(table[i + 1]![j]!, table[i]![j + 1]!)
}
}
return table
}
/**
* Line diff by LCS walk with rewrite pairing: the raw walk emits context/del/
* add rows; a del-run immediately followed by an add-run marks the overlapping
* min(len) rows on both sides as 'mod' so rewrites tint distinctly.
* @param oldText - the previous content; empty string diffs against nothing.
* @param newText - the next content.
* @returns ordered diff rows, old-side deletions before new-side additions.
*/
export function diffLines(oldText: string, newText: string): DiffRow[] {
const oldLines = oldText.length === 0 ? [] : oldText.split('\n')
const newLines = newText.length === 0 ? [] : newText.split('\n')
const table = lcsTable(oldLines, newLines)
const raw: DiffRow[] = []
let i = 0
let j = 0
while (i < oldLines.length && j < newLines.length) {
if (oldLines[i] === newLines[j]) {
raw.push({ kind: 'context', oldLine: i + 1, newLine: j + 1, text: oldLines[i]! })
i += 1
j += 1
} else if (table[i + 1]![j]! >= table[i]![j + 1]!) {
raw.push({ kind: 'del', oldLine: i + 1, text: oldLines[i]! })
i += 1
} else {
raw.push({ kind: 'add', newLine: j + 1, text: newLines[j]! })
j += 1
}
}
while (i < oldLines.length) {
raw.push({ kind: 'del', oldLine: i + 1, text: oldLines[i]! })
i += 1
}
while (j < newLines.length) {
raw.push({ kind: 'add', newLine: j + 1, text: newLines[j]! })
j += 1
}
// Post-pass: pair each del-run with the add-run that follows it.
const rows = raw.slice()
let k = 0
while (k < rows.length) {
if (rows[k]!.kind !== 'del') { k += 1; continue }
const delStart = k
while (k < rows.length && rows[k]!.kind === 'del') k += 1
const addStart = k
while (k < rows.length && rows[k]!.kind === 'add') k += 1
const pairs = Math.min(addStart - delStart, k - addStart)
for (let p = 0; p < pairs; p += 1) {
rows[delStart + p] = { ...rows[delStart + p]!, kind: 'mod' }
rows[addStart + p] = { ...rows[addStart + p]!, kind: 'mod' }
}
}
return rows
}
/** One rendered hunk: change rows plus the surrounding context window. */
export interface DiffHunk { readonly kind: 'hunk'; readonly rows: readonly DiffRow[] }
/** One collapsed run of unchanged context rows (the "… n 行" fold). */
export interface DiffFold {
readonly kind: 'fold'
/** The context rows this fold hides; re-render them when expanded. */
readonly rows: readonly DiffRow[]
readonly oldStart: number
readonly oldEnd: number
readonly newStart: number
readonly newEnd: number
}
export type DiffSegment = DiffHunk | DiffFold
/** Context window (rows around a change) kept visible in a hunk. */
export const HUNK_CONTEXT = 3
/** Folded context runs shorter than this are shown directly, not collapsed. */
export const MIN_FOLD = 3
/**
* Group a line diff into hunks and folded context runs. Consecutive changes
* whose gap fits within the context window merge into one hunk; unchanged
* regions between hunks (and any surrounding the whole diff) become fold
* segments that default collapsed. This yields the file-hunk presentation
* familiar from terminal diffs (Claude Code / git hunk headers).
* @param rows - the flat diff rows.
* @param context - how many unchanged rows around a change stay visible.
* @returns ordered segments (hunks and folds).
*/
export function buildDiffSegments(rows: readonly DiffRow[], context = HUNK_CONTEXT): DiffSegment[] {
if (rows.length === 0) return []
const changeIndexes = rows.flatMap((row, index) => (row.kind === 'context' ? [] : [index]))
if (changeIndexes.length === 0) {
// Whole diff is unchanged (or a pure no-op): hide it all behind one fold.
return [{ kind: 'fold', rows: [...rows], oldStart: 1, oldEnd: rows.length, newStart: 1, newEnd: rows.length }]
}
// Cluster changes into runs whose gaps fit within 2*context+1 rows.
const hunks: { start: number; end: number }[] = []
for (const ci of changeIndexes) {
const start = Math.max(0, ci - context)
const end = Math.min(rows.length - 1, ci + context)
const last = hunks[hunks.length - 1]
if (last !== undefined && start <= last.end + 1) {
last.end = Math.max(last.end, end)
} else {
hunks.push({ start, end })
}
}
const segments: DiffSegment[] = []
let cursor = 0
for (const hunk of hunks) {
if (hunk.start > cursor) {
const foldRows = rows.slice(cursor, hunk.start)
segments.push({
kind: 'fold',
rows: foldRows,
oldStart: firstOldLine(foldRows) ?? (foldRows.length === 0 ? cursor + 1 : cursor + 1),
oldEnd: lastOldLine(foldRows) ?? 0,
newStart: firstNewLine(foldRows) ?? (foldRows.length === 0 ? cursor + 1 : cursor + 1),
newEnd: lastNewLine(foldRows) ?? 0,
})
}
segments.push({ kind: 'hunk', rows: rows.slice(hunk.start, hunk.end + 1) })
cursor = hunk.end + 1
}
if (cursor < rows.length) {
const foldRows = rows.slice(cursor)
segments.push({
kind: 'fold',
rows: foldRows,
oldStart: firstOldLine(foldRows) ?? cursor + 1,
oldEnd: lastOldLine(foldRows) ?? 0,
newStart: firstNewLine(foldRows) ?? cursor + 1,
newEnd: lastNewLine(foldRows) ?? 0,
})
}
return segments
}
function firstOldLine(rows: readonly DiffRow[]): number | undefined {
for (const row of rows) if (row.oldLine !== undefined) return row.oldLine
return undefined
}
function lastOldLine(rows: readonly DiffRow[]): number | undefined {
for (let i = rows.length - 1; i >= 0; i -= 1) if (rows[i]!.oldLine !== undefined) return rows[i]!.oldLine
return undefined
}
function firstNewLine(rows: readonly DiffRow[]): number | undefined {
for (const row of rows) if (row.newLine !== undefined) return row.newLine
return undefined
}
function lastNewLine(rows: readonly DiffRow[]): number | undefined {
for (let i = rows.length - 1; i >= 0; i -= 1) if (rows[i]!.newLine !== undefined) return rows[i]!.newLine
return undefined
}
/** One intra-line segment: a run of chars with a changed flag. */
export interface InlineSegment { readonly text: string; readonly changed: boolean }
/** Each side's intra-line segments. */
export interface InlineDiff { readonly old: readonly InlineSegment[]; readonly next: readonly InlineSegment[] }
/** Char-diff table guard: above this, inline highlighting is skipped. */
const INLINE_MAX = 2000
/**
* Character-level diff between two line texts, used to highlight the exact
* changed substring inside a "mod" (rewritten) line. Long lines degrade to a
* single all-changed segment. Pure, no React/DOM.
* @param oldText - the old line.
* @param newText - the new line.
* @returns per-side segments marking changed runs.
*/
/**
* Intra-line diff by common prefix/suffix: the shared leading and trailing
* characters stay unchanged, and only the differing middle is marked changed
* on both sides. This never highlights identical characters and keeps a small
* edit inside a long line immediately visible. Pure, no React/DOM.
* @param oldText - the old line.
* @param newText - the new line.
* @returns per-side segments marking changed runs.
*/
export function diffInline(oldText: string, newText: string): InlineDiff {
const minLen = Math.min(oldText.length, newText.length)
let prefix = 0
while (prefix < minLen && oldText[prefix] === newText[prefix]) prefix += 1
let suffix = 0
while (suffix < minLen - prefix && oldText[oldText.length - 1 - suffix] === newText[newText.length - 1 - suffix]) suffix += 1
const oldMidStart = prefix
const oldMidEnd = oldText.length - suffix
const newMidStart = prefix
const newMidEnd = newText.length - suffix
const segments = (text: string, midStart: number, midEnd: number): InlineSegment[] => {
const out: InlineSegment[] = []
if (midStart > 0) out.push({ text: text.slice(0, midStart), changed: false })
const mid = text.slice(midStart, midEnd)
if (mid.length > 0) out.push({ text: mid, changed: true })
if (midEnd < text.length) out.push({ text: text.slice(midEnd), changed: false })
return out
}
return { old: segments(oldText, oldMidStart, oldMidEnd), next: segments(newText, newMidStart, newMidEnd) }
}
/**
* Merge adjacent inline segments sharing the same changed flag, so rendering
* wraps each run — not each character — in one span.
* @param segments - the raw per-character-heavy inline segments.
* @returns coalesced segments; identical text joined into runs.
*/
export function coalesceInline(segments: readonly InlineSegment[]): InlineSegment[] {
const out: InlineSegment[] = []
for (const seg of segments) {
const last = out[out.length - 1]
if (last !== undefined && last.changed === seg.changed) out[out.length - 1] = { text: last.text + seg.text, changed: seg.changed }
else out.push(seg)
}
return out
}
/** Human byte count for the panel meta row. */
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${String(bytes)} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
+76
View File
@@ -0,0 +1,76 @@
/* File-trace tab: terminal-style op list + diff pane. Semantic --dsw-alias
tokens only (skin contract); the syntax-token hues derive from the alias
state tokens via color-mix so they sit BETWEEN the diff colors (violet
keywords, teal strings, brand-tinted types, warn-family numbers/functions,
magenta macros) instead of colliding with red del / green add / blue mod. */
.root { display: flex; flex-direction: column; min-height: 0; height: 100%; background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary); font-size: 12px; }
.list { flex: 1; min-height: 0; overflow-y: auto; padding: 6px 6px 10px; }
.empty { color: var(--dsw-alias-label-tertiary); padding: 24px 8px; text-align: center; }
.fileGroup { margin-bottom: 8px; }
.filePath { display: block; color: var(--dsw-alias-label-secondary); font-family: var(--ds-font-family-code, monospace); font-size: 11px; padding: 2px 4px; word-break: break-all; }
.opRow { display: flex; align-items: center; gap: 6px; width: 100%; border: 0; border-radius: 6px; background: transparent; color: var(--dsw-alias-label-primary); font: inherit; font-size: 11px; padding: 3px 5px; cursor: pointer; text-align: left; }
.opRow:hover { background: var(--dsw-alias-interactive-bg-hover); }
.opKind { border-radius: 4px; font-size: 10px; line-height: 16px; padding: 0 5px; background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-secondary); flex: none; }
.opKind[data-kind='write'] { color: var(--dsw-alias-state-success-primary); }
.opKind[data-kind='edit'] { color: var(--dsw-alias-state-business-primary); }
.opTime { color: var(--dsw-alias-label-tertiary); font-size: 10px; flex: none; }
.opFlag { color: var(--dsw-alias-state-business-primary); font-size: 10px; flex: none; }
.opFlagError { color: var(--dsw-alias-state-error-primary); font-size: 10px; flex: none; }
.opSize { margin-left: auto; color: var(--dsw-alias-label-tertiary); font-size: 10px; flex: none; }
.diffPane { border-top: 1px solid var(--dsw-alias-border-l1); display: flex; flex-direction: column; flex: none; min-height: 0; }
.dragHandle { flex: none; height: 8px; cursor: ns-resize; position: relative; background: var(--dsw-alias-bg-base); }
.dragHandle::after { content: ''; position: absolute; left: calc(50% - 18px); top: 3px; width: 36px; height: 2px; border-radius: 1px; background: var(--dsw-alias-border-l2); }
.dragHandle:hover::after { background: var(--dsw-alias-state-business-primary); }
.diffHead { display: flex; align-items: center; gap: 6px; padding: 5px 10px; border-bottom: 1px solid var(--dsw-alias-border-l1); }
.diffPath { flex: 1; font-family: var(--ds-font-family-code, monospace); font-size: 11px; word-break: break-all; color: var(--dsw-alias-label-primary); min-width: 0; }
.diffKind { flex: none; font-size: 10px; }
.diffKind[data-kind='write'] { color: var(--dsw-alias-state-success-primary); }
.diffKind[data-kind='edit'] { color: var(--dsw-alias-state-business-primary); }
.close { border: 0; border-radius: 6px; background: transparent; color: var(--dsw-alias-label-secondary); font: inherit; cursor: pointer; padding: 1px 6px; flex: none; }
.close:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
.diffRows { flex: 1; min-height: 0; overflow-y: auto; padding: 4px 0 8px; font-family: var(--ds-font-family-code, monospace); font-size: 11px; }
.diffRow { display: flex; align-items: flex-start; gap: 5px; padding: 0 8px; line-height: 18px; cursor: default; }
.diffRow[data-folded='true'] { cursor: pointer; }
.lineNo { flex: none; width: 2.5em; color: var(--dsw-alias-label-tertiary); text-align: right; user-select: none; font-size: 10px; }
.sign { flex: none; width: 1em; text-align: center; user-select: none; font-weight: 700; }
.diffRow[data-kind='del'] .sign, .diffRow[data-kind='del'] .text { color: var(--dsw-alias-state-error-primary); }
.diffRow[data-kind='add'] .sign, .diffRow[data-kind='add'] .text { color: var(--dsw-alias-state-success-primary); }
.diffRow[data-kind='mod'] .sign, .diffRow[data-kind='mod'] .text { color: var(--dsw-alias-state-business-primary); }
.diffRow[data-kind='context'] .sign, .diffRow[data-kind='context'] .text { color: var(--dsw-alias-label-secondary); }
.diffRow .text { flex: 1; min-width: 0; white-space: pre-wrap; word-break: break-word; }
.diffRow[data-folded='true'] .text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* Intra-line changed substring inside a rewritten (mod) line. */
.inlineChange { background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 26%, transparent); border-radius: 3px; padding: 0 1px; }
/* Syntax-token colors: alias-token mixes between the diff hues. */
.tokComment { color: var(--dsw-alias-label-tertiary); font-style: italic; }
.tokKeyword { color: color-mix(in oklab, var(--dsw-alias-state-business-primary) 55%, var(--dsw-alias-state-error-primary)); }
.tokString { color: color-mix(in oklab, var(--dsw-alias-state-success-primary) 45%, var(--dsw-alias-state-business-primary)); }
.tokType { color: color-mix(in oklab, var(--dsw-alias-brand-primary) 60%, var(--dsw-alias-state-success-primary)); }
.tokNumber { color: var(--dsw-alias-state-warn-primary); }
.tokFunction { color: color-mix(in oklab, var(--dsw-alias-state-warn-primary) 55%, var(--dsw-alias-state-error-primary)); }
.tokMacro { color: color-mix(in oklab, var(--dsw-alias-state-error-primary) 45%, var(--dsw-alias-state-business-primary)); }
.readContent { flex: 1; min-height: 0; margin: 0; padding: 6px 0 8px; overflow: auto; font-family: var(--ds-font-family-code, monospace); font-size: 11px; line-height: 1.5; }
.readRow { display: flex; gap: 5px; padding: 0 8px; line-height: 18px; color: var(--dsw-alias-label-primary); }
.readRow .text { flex: 1; min-width: 0; white-space: pre-wrap; word-break: break-word; }
.readError { color: var(--dsw-alias-state-error-primary); border-left: 3px solid var(--dsw-alias-state-error-primary); border-radius: 0 6px 6px 0; margin: 6px 8px; padding: 6px 10px; white-space: pre-wrap; word-break: break-word; }
.priorUnknown { color: var(--dsw-alias-label-tertiary); font-size: 10px; padding: 3px 10px; }
/* Hunk-fold row: collapsed marker chip; expanded padding drops to 0 so the
revealed rows keep the x-alignment of hunk rows (they carry their own). */
.foldRow { padding: 0 8px; cursor: pointer; }
.foldRow[data-expanded='true'] { padding: 0; }
.foldRow:hover { background: var(--dsw-alias-interactive-bg-hover); }
.foldMarker { display: inline-block; padding: 2px 6px; border-radius: 4px; margin: 2px 0; color: var(--dsw-alias-label-tertiary); background: var(--dsw-alias-interactive-bg-hover); font-style: italic; }
.foldRow[data-expanded='true'] { cursor: default; }
.foldRow[data-expanded='true'] .foldMarker { display: none; }
+382
View File
@@ -0,0 +1,382 @@
/**
* Lightweight syntax highlighting for the file-trace panel: extension-based
* language detection plus a single-pass line tokenizer that colors comments,
* strings, numbers, keywords, types, functions, and preprocessor directives
* for common languages (C/C++, Java, C#, JS/TS, Python, Go, Rust, shell, batch,
* PowerShell, and config formats). Pure functions only — no React, no DOM.
* Multi-line block-comment state threads across lines via scanLine's
* inBlock parameter, so interior lines of a block comment color correctly.
*/
/** Token classes mapped 1:1 to panel CSS color classes. */
export type TokenType =
| 'plain'
| 'comment'
| 'string'
| 'keyword'
| 'number'
| 'type'
| 'function'
| 'macro'
/** One token span: a run of one line sharing one token class. */
export interface CodeToken {
readonly text: string
readonly type: TokenType
}
/** Token classes that imply their own color; `plain` inherits the row color. */
const COLORED: ReadonlySet<TokenType> = new Set(['comment', 'string', 'keyword', 'number', 'type', 'function', 'macro'])
/** Per-language scanner configuration. */
interface LangConfig {
/** Start-of-line (after blanks) or after-token comment prefixes. */
readonly lineComments: readonly string[]
/** Multi-line comment delimiters; the opener may appear anywhere. */
readonly blockComment?: readonly [open: string, close: string]
/** String delimiter characters; the escape character precedes any literal. */
readonly strings?: readonly string[]
/** Reserved words colored as keywords. */
readonly keywords: ReadonlySet<string>
/** Constant literals colored as keywords (true/false/null). */
readonly constants: ReadonlySet<string>
/** '#' starts a preprocessor directive / directive-like annotation. */
readonly macro: boolean
/** Identifier chars (word body). */
readonly wordStart: RegExp
readonly wordBody: RegExp
}
const LETTER = /[A-Za-z_$]/u
const WORD = /[A-Za-z0-9_$]/u
const ANYWORD = { wordStart: LETTER, wordBody: WORD }
const kw = (words: string): ReadonlySet<string> => new Set(words.split(/\s+/))
const C_FAMILY = (words: string): LangConfig => ({
lineComments: ['//'],
blockComment: ['/*', '*/'],
strings: ['"', "'", '`'],
keywords: kw(words),
constants: kw('true false null NULL nullptr TRUE FALSE'),
macro: true,
...ANYWORD,
})
const HASH_FAMILY = (words: string, constants = ''): LangConfig => ({
lineComments: ['#'],
strings: ['"', "'"],
keywords: kw(words),
constants: kw(constants.length > 0 ? constants : 'True False None true false null'),
macro: false,
...ANYWORD,
})
const CONFIG_LANG: LangConfig = {
lineComments: ['#'],
strings: ['"', "'"],
keywords: new Set(),
constants: new Set(),
macro: false,
wordStart: LETTER,
wordBody: WORD,
}
/** SQL: '--' line comments plus '#', quoting with single quotes. */
const SQL_LANG: LangConfig = {
lineComments: ['--', '#'],
strings: ["'"],
keywords: kw(`select from where insert into values update set delete create table drop alter add column
primary key foreign references index view join inner left right outer on as order by group having
limit offset distinct union all and or not in exists between like is null asc desc count sum avg
min max case when then else end begin commit rollback transaction default constraint unique`),
constants: kw('true false null'),
macro: false,
...ANYWORD,
}
/** Windows batch: 'REM'/'::' comments, '%' variable quoting. */
const CMD_LANG: LangConfig = {
lineComments: ['::'],
strings: ['"'],
keywords: kw(`rem if else for in do goto call exit echo set setlocal endlocal shift
exist defined errorlevel not equ neq lss leq gtr geq nul con defined enabledelayedexpansion`),
constants: new Set(),
macro: false,
wordStart: LETTER,
wordBody: WORD,
}
/** PowerShell: '#' comments, quoted strings including here-string quotes. */
const PS_LANG: LangConfig = {
lineComments: ['#'],
blockComment: ['<#', '#>'],
strings: ['"', "'"],
keywords: kw(`function param begin process end if elseif else foreach for while do until switch
try catch finally throw return break continue filter in workflow class enum interface
dynamicparam data checkpoint systemlanguage default expand`),
constants: kw('true false null'),
macro: false,
...ANYWORD,
}
/** Markdown: no tokenizer; the whole line stays plain. */
const MD_LANG: LangConfig = {
lineComments: [],
strings: [],
keywords: new Set(),
constants: new Set(),
macro: false,
wordStart: LETTER,
wordBody: WORD,
}
/** Extension → language id, mirroring the read tool's hint table. */
const LANGS: Readonly<Record<string, LangConfig>> = {
ts: C_FAMILY(`abstract any as asserts async await boolean break case catch class const constructor
continue debugger 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(`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`),
js: 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(`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`),
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
match case`, 'True False None self cls NotImplemented __name__ __main__'),
go: C_FAMILY(`break case chan const continue default defer else fallthrough for func go goto if
import interface map package range return select struct switch type var nil iota make new len
cap append copy close delete panic print println recover`),
rs: C_FAMILY(`as async await break const continue crate dyn else enum extern false fn for if impl
in let loop match mod move mut pub ref return self Self static struct super trait true type
unsafe use where while`),
java: C_FAMILY(`abstract assert boolean break byte case catch char class const continue default do
double else enum extends final finally float for goto if implements import instanceof int
interface long native new package private protected public return short static strictfp super
switch synchronized this throw throws transient try void volatile while var record sealed
permits yield`),
c: C_FAMILY(`auto break case char const continue default do double else enum extern float for
goto if inline int long register restrict return short signed sizeof static struct switch
typedef union unsigned void volatile while _Bool _Complex _Atomic`),
cpp: C_FAMILY(`alignas alignof and auto break case catch char class co_await co_return co_yield
concept const consteval constexpr constinit const_cast continue decltype default delete
do double dynamic_cast else enum explicit export extern false final float for friend goto if
inline int long mutable namespace new noexcept not nullptr operator or override private
protected public register reinterpret_cast requires return short signed sizeof static
static_assert static_cast struct switch template this thread_local throw true try typedef
typeid typename union unsigned using virtual void volatile wchar_t while`),
cs: C_FAMILY(`abstract as async await base bool break byte case catch char checked class const
continue decimal default delegate do double dynamic else enum event explicit extern false
finally fixed float for foreach get goto if implicit in init int interface internal is lock
long namespace new null not null forgiving object operator out override params partial
private protected public readonly record ref return sbyte sealed set short sizeof stackalloc
static string struct switch this throw true try typeof uint ulong unchecked unsafe ushort
using var virtual void volatile when where while with yield`),
kt: C_FAMILY(`as break by catch class companion const constructor continue crossinline data do
dynamic else enum external false final finally for fun get if import in infix init inline
interface internal is lateinit lazy null object open operator out override package private
protected public reified return sealed set super suspend tailrec this throw true try typealias
val var vararg when where while`),
swift: C_FAMILY(`actor as associatedtype async await break case catch class continue
convenience default defer deinit didSet do dynamic else enum extension fallthrough false
final for func get guard if import in indirect infix init inout internal is lazy let nil
nonmutating open operator optional override postfix precedencegroup prefix private protocol
public repeat required rethrows return self set some static struct subscript super switch
throw throws true try typealias unowned var weak where while willSet`),
php: C_FAMILY(`abstract and array as break callable case catch class clone const continue declare
default do echo else elseif empty enddeclare endfor endforeach endif endswitch endwhile enum
extends final finally fn for foreach function global goto if implements include
include_once instanceof insteadof interface isset list match namespace new or print private
protected public readonly require require_once return static switch throw trait try unset use
var while xor yield true false null int string bool float void mixed never self parent`),
sh: HASH_FAMILY(`if then else elif fi for while until do done case esac function in select time
coproc return break continue local export readonly declare typeset unset shift eval exec trap
exit source alias set`, 'true false'),
bash: HASH_FAMILY(`if then else elif fi for while until do done case esac function in select time
coproc return break continue local export readonly declare typeset unset shift eval exec trap
exit source alias set`, 'true false'),
zsh: HASH_FAMILY(`if then else elif fi for while until do done case esac function in select time
coproc return break continue local export readonly declare typeset unset shift eval exec trap
exit source alias set`, 'true false'),
yaml: CONFIG_LANG,
yml: CONFIG_LANG,
toml: CONFIG_LANG,
ini: CONFIG_LANG,
sql: SQL_LANG,
cmd: CMD_LANG,
bat: CMD_LANG,
ps1: PS_LANG,
psm1: PS_LANG,
psd1: PS_LANG,
md: MD_LANG,
markdown: MD_LANG,
mdx: MD_LANG,
html: CONFIG_LANG,
htm: CONFIG_LANG,
css: HASH_FAMILY(''),
scss: HASH_FAMILY(''),
less: HASH_FAMILY(''),
lua: HASH_FAMILY(`and break do else elseif end false for function goto if in local nil not or
repeat return then true until while`),
}
/**
* Language id for a file path's extension (the read tool's mapping): the
* lowercase extension without its dot; dotfiles and unknown extensions map to
* undefined (plain text).
* @param path - the op's file path exactly as recorded.
* @returns the language id, or undefined for plain text.
*/
export function langOfPath(path: string): string | undefined {
const base = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1)
const dot = base.lastIndexOf('.')
if (dot <= 0) return undefined
const ext = base.slice(dot + 1).toLowerCase()
return Object.hasOwn(LANGS, ext) ? ext : undefined
}
/** One line's scan output: its tokens plus whether a block comment stays open. */
export interface ScanResult {
readonly tokens: CodeToken[]
readonly inBlock: boolean
}
/** Scan one line, entering from (and reporting) block-comment state. */
export function scanLine(line: string, lang: string | undefined, inBlock = false): ScanResult {
if (line.length === 0) return { tokens: [], inBlock }
const cfg = lang !== undefined ? LANGS[lang] : undefined
if (cfg === undefined || (cfg.lineComments.length === 0 && cfg.keywords.size === 0)) {
return { tokens: [{ text: line, type: 'plain' }], inBlock: false }
}
// A language without block delimiters never carries block state forward.
let inComment = inBlock && cfg.blockComment !== undefined
const tokens: CodeToken[] = []
const push = (text: string, type: TokenType): void => {
if (text.length === 0) return
const last = tokens[tokens.length - 1]
if (last !== undefined && last.type === type) tokens[tokens.length - 1] = { text: last.text + text, type }
else tokens.push({ text, type })
}
let i = 0
const atLineComment = (): string | undefined => {
for (const lead of cfg.lineComments) {
if (line.startsWith(lead, i)) return lead
}
return undefined
}
const atBlockOpen = (): string | undefined => cfg.blockComment !== undefined && line.startsWith(cfg.blockComment[0], i) ? cfg.blockComment[0] : undefined
const readString = (): void => {
const quote = line[i]!
i += 1
while (i < line.length && line[i] !== quote) {
if (line[i] === '\\') i += 1
i += 1
}
i = Math.min(i + 1, line.length)
}
while (i < line.length) {
if (inComment) {
const closeIdx = cfg.blockComment !== undefined ? line.indexOf(cfg.blockComment[1], i) : -1
if (closeIdx === -1) {
push(line.slice(i), 'comment')
return { tokens, inBlock: true }
}
const end = closeIdx + (cfg.blockComment?.[1].length ?? 0)
push(line.slice(i, end), 'comment')
i = end
inComment = false
continue
}
const ch = line[i]!
const wsMatch = /\s/u.exec(line.slice(i))
if (wsMatch !== null && wsMatch.index === 0) {
push(ch, 'plain')
i += 1
continue
}
const commentLead = atLineComment()
if (commentLead !== undefined) {
push(line.slice(i), 'comment')
break
}
const blockOpen = atBlockOpen()
if (blockOpen !== undefined && cfg.blockComment !== undefined) {
const close = line.indexOf(cfg.blockComment[1], i + blockOpen.length)
const end = close === -1 ? line.length : close + cfg.blockComment[1].length
push(line.slice(i, end), 'comment')
i = end
inComment = close === -1
continue
}
if (cfg.strings !== undefined && cfg.strings.includes(ch)) {
const start = i
readString()
push(line.slice(start, i), 'string')
continue
}
if (/[0-9]/u.test(ch)) {
const m = /^(?:0[xXbo][0-9a-fA-F_]+|[0-9][0-9_]*(?:\.[0-9_]+)?(?:[eE][+-]?[0-9_]+)?)/u.exec(line.slice(i))
const len = m !== null ? m[0].length : 1
push(line.slice(i, i + len), 'number')
i += len
continue
}
if (cfg.wordStart.test(ch)) {
let j = i + 1
while (j < line.length && cfg.wordBody.test(line[j]!)) j += 1
const word = line.slice(i, j)
// Batch 'REM whatever' runs to end of line as a comment.
if ((lang === 'cmd' || lang === 'bat') && word.toLowerCase() === 'rem') {
push(line.slice(i), 'comment')
break
}
let k = j
while (k < line.length && (line[k] === ' ' || line[k] === '\t')) k += 1
if (cfg.constants.has(word)) push(word, 'keyword')
else if (cfg.keywords.has(word)) push(word, 'keyword')
else if (line[k] === '(') push(word, 'function')
else if (/^[A-Z]/u.test(word) && word.length > 1) push(word, 'type')
else push(word, 'plain')
i = j
continue
}
if (cfg.macro && ch === '#' && (i === 0 || /\s/u.test(line[i - 1]!))) {
let j = i + 1
while (j < line.length && cfg.wordBody.test(line[j]!)) j += 1
push(line.slice(i, j), 'macro')
i = j
continue
}
push(ch, 'plain')
i += 1
}
return { tokens, inBlock: inComment }
}
/** Tokenize one line with no incoming block state (standalone lines). */
export function tokenizeLine(line: string, lang: string | undefined): CodeToken[] {
return scanLine(line, lang).tokens
}
/** True when the language has multi-line block-comment delimiters. */
export function hasBlockComment(lang: string | undefined): boolean {
return lang !== undefined && LANGS[lang]?.blockComment !== undefined
}
/** Token classes worth wrapping in a span; plain runs join the parent text node. */
export function isColored(token: CodeToken): boolean {
return COLORED.has(token.type)
}
+230
View File
@@ -0,0 +1,230 @@
/**
* File-operation extraction for the file-trace tab: folds the session's
* append-only event log (`tool/call` + `tool/result` pairs) into one op per
* file-touching call — the same semantics as the standalone dsh-file-trace
* plugin's Chat-view extraction, reading the authoritative event log instead
* (the repo's stated preference for cross-feature session data). Pure — no
* React, no DOM.
*/
import type { SidebarSessionEvent } from '../../context-types.ts'
/** The file-touching operation kinds the tracer distinguishes. */
export type FileOpKind = 'read' | 'write' | 'edit'
/** One extracted file operation. */
export interface FileOp {
/** Stable identity: the originating tool-call id. */
readonly callId: string
readonly kind: FileOpKind
/** Workspace-relative or absolute path exactly as the model spelled it. */
readonly path: string
/** Unix epoch ms of the call; the result time when only that is known. */
readonly time: number
/** True while the call has no result yet. */
readonly running: boolean
/** True when the result reported an error. */
readonly isError: boolean
/** The result's error text when isError; presented instead of the payload. */
readonly errorText?: string
/** For 'edit': the model's exact replacement payload. */
readonly edit?: { oldString: string; newString: string }
/** For 'write': the full new content. */
readonly content?: string
/** For 'read': the file content returned by the tool result. */
readonly read?: string
}
/** Tool names mapped to each op kind; unknown names are ignored. */
const READ_TOOLS = new Set(['read', 'view', 'see'])
const WRITE_TOOLS = new Set(['write', 'create'])
const EDIT_TOOLS = new Set(['edit', 'str_replace', 'str-replace-editor', 'multi-edit'])
/** Classify one tool name; undefined when the tool touches no file. */
function kindOf(name: string): FileOpKind | undefined {
if (READ_TOOLS.has(name)) return 'read'
if (WRITE_TOOLS.has(name)) return 'write'
if (EDIT_TOOLS.has(name)) return 'edit'
return undefined
}
/**
* Parse one tool-call arguments JSON body defensively: the payload is
* model-emitted wire data, so every field is checked before use.
*/
function parseArgs(argsRaw: string): Record<string, unknown> {
try {
const parsed: unknown = JSON.parse(argsRaw)
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
return parsed as Record<string, unknown>
} catch {
return {}
}
}
/** Extract the path field common to every file tool's arguments. */
function pathOf(args: Record<string, unknown>): string | undefined {
for (const key of ['file_path', 'path', 'filePath']) {
const value = args[key]
if (typeof value === 'string' && value.length > 0) return value
}
return undefined
}
/** The 'tool/result' message envelope inside a session event's data. */
interface ToolResultMessageLike {
source?: { kind?: unknown; callId?: unknown }
content?: unknown
}
/** One 'tool-result' content block (inner blocks carry the text). */
interface ToolResultBlockLike {
type?: unknown
content?: unknown
isError?: unknown
}
/** The finalized plain text of one tool result (inner text blocks joined). */
function resultText(message: ToolResultMessageLike): string | undefined {
if (!Array.isArray(message.content)) return undefined
const parts: string[] = []
for (const block of message.content) {
if (block === null || typeof block !== 'object') continue
const candidate = block as ToolResultBlockLike
if (candidate.type !== 'tool-result') continue
const inner = candidate.content
if (!Array.isArray(inner)) continue
for (const item of inner) {
if (item === null || typeof item !== 'object') continue
const textItem = item as { type?: unknown; text?: unknown }
if (textItem.type === 'text' && typeof textItem.text === 'string') parts.push(textItem.text)
}
}
return parts.length > 0 ? parts.join('\n') : undefined
}
/** Whether a tool result reported an error (the inner block's isError flag). */
function resultIsError(message: ToolResultMessageLike): boolean {
if (!Array.isArray(message.content)) return false
return message.content.some((block) => {
if (block === null || typeof block !== 'object') return false
return (block as ToolResultBlockLike).type === 'tool-result'
&& (block as ToolResultBlockLike).isError === true
})
}
/**
* Fold a session event log into the file operations it contains, newest
* first. A `tool/call` seeds a running op (payload from the model's
* arguments); its `tool/result` settles it (read content, error text).
* Calls dispatched through a host tool such as run_code appear as their own
* `tool/call` rows in the log, so nested file calls fold in naturally.
* @param events - the session's append-only event log (oldest → newest).
* @returns the ordered operation list.
*/
export function extractFileOps(events: readonly SidebarSessionEvent[]): FileOp[] {
const byCall = new Map<string, FileOp>()
for (const event of events) {
if (event.type === 'tool/call') {
const data = event.data as { name?: unknown; callId?: unknown; arguments?: unknown }
if (typeof data.name !== 'string' || typeof data.callId !== 'string') continue
const kind = kindOf(data.name)
if (kind === undefined) continue
const args = parseArgs(typeof data.arguments === 'string' ? data.arguments : '')
const path = pathOf(args)
if (path === undefined) continue
const base: FileOp = { callId: data.callId, kind, path, time: event.time, running: true, isError: false }
if (kind === 'edit') {
const oldString = args.old_string
const newString = args.new_string
byCall.set(data.callId, typeof oldString === 'string' && typeof newString === 'string'
? { ...base, edit: { oldString, newString } }
: base)
continue
}
if (kind === 'write') {
const content = args.content
byCall.set(data.callId, typeof content === 'string' && content.length > 0 ? { ...base, content } : base)
continue
}
byCall.set(data.callId, base)
} else if (event.type === 'tool/result') {
const message = (event.data as { message?: unknown }).message as ToolResultMessageLike | undefined
if (message === undefined) continue
const callId = message.source?.callId
if (typeof callId !== 'string') continue
const op = byCall.get(callId)
if (op === undefined) continue
const text = resultText(message)
const isError = resultIsError(message)
const patch: { running: boolean; isError: boolean; errorText?: string; read?: string; content?: string } = { running: false, isError }
if (text !== undefined && text.length > 0) {
if (isError) patch.errorText = text
else if (op.kind === 'read') patch.read = text
else if (op.kind === 'write' && op.content === undefined) patch.content = text
}
byCall.set(callId, { ...op, ...patch })
}
}
return [...byCall.values()].sort((a, b) => b.time - a.time)
}
/**
* Group operations by path, newest op first per file, files ordered by their
* most recent operation.
*/
export function groupByFile(ops: readonly FileOp[]): Map<string, FileOp[]> {
const groups = new Map<string, FileOp[]>()
for (const op of ops) {
const list = groups.get(op.path) ?? []
list.push(op)
groups.set(op.path, list)
}
const ordered = [...groups.entries()].sort((a, b) => b[1][0]!.time - a[1][0]!.time)
return new Map(ordered)
}
/**
* The last content known for a path before the given operation, synthesized
* from earlier ops: a write's payload is authoritative, an edit implies its
* old side. Best effort — a write with no known prior content diffs against
* nothing (all-added).
*/
export function knownContentBefore(ops: readonly FileOp[], path: string, before: FileOp): string | undefined {
const ofFile = ops.filter(op => op.path === path && op.time <= before.time && op !== before)
for (let i = ofFile.length - 1; i >= 0; i -= 1) {
const op = ofFile[i]!
if (op.kind === 'write' && op.content !== undefined) return op.content
if (op.kind === 'edit' && op.edit !== undefined && i === ofFile.length - 1) {
return op.edit.oldString
}
}
return undefined
}
/** One parsed read line: the file's own line number and its content text. */
export interface ReadLine { readonly line: number; readonly text: string }
/**
* Parse a DSH read result into file lines with their real line numbers:
* drops the <content> envelope and "(Showing lines ...)" note; recovers the
* "<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
if (line.length === 0) continue
const match = line.match(/^\s*(\d+):\s?(.*)$/)
if (match !== null) {
result.push({ line: Number(match[1]), text: match[2] ?? '' })
fallback = Number(match[1]) + 1
} else {
result.push({ line: fallback, text: line })
fallback += 1
}
}
return result
}
+10
View File
@@ -18,6 +18,16 @@
/** The ar dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const ar: Record<string, string> = {
files: 'الملفات',
fileTrace: 'تتبع الملفات',
fileTraceEmpty: 'لا توجد عمليات ملفات في هذه الجلسة بعد',
fileTraceRead: 'قراءة',
fileTraceWrite: 'كتابة',
fileTraceEdit: 'تحرير',
fileTraceRunning: 'قيد التنفيذ',
fileTraceError: 'خطأ',
fileTraceFold: '{count} أسطر… انقر للتوسيع',
fileTraceContext: 'سياق',
fileTracePriorUnknown: 'المحتوى السابق خارج النافذة؛ يُعرض كما لو أُضيف بالكامل',
explorer: 'المستكشف',
git: 'إدارة المصدر',
terminal: 'الطرفية',
+10
View File
@@ -3,6 +3,16 @@
*/
export const de: Record<string, string> = {
files: 'Dateien',
fileTrace: 'Dateiverfolgung',
fileTraceEmpty: 'Noch keine Dateioperationen in dieser Sitzung',
fileTraceRead: 'Lesen',
fileTraceWrite: 'Schreiben',
fileTraceEdit: 'Bearbeiten',
fileTraceRunning: 'läuft',
fileTraceError: 'Fehler',
fileTraceFold: '{count} Zeilen… zum Aufklappen klicken',
fileTraceContext: 'Kontext',
fileTracePriorUnknown: 'Inhalt vor der Änderung liegt außerhalb des Fensters; als vollständig neu angezeigt',
explorer: 'Explorer',
git: 'Quellcodeverwaltung',
terminal: 'Terminal',
+10
View File
@@ -10,6 +10,16 @@
/** fr dictionary for the `betterSidebar` namespace. */
export const fr: Record<string, string> = {
files: 'Fichiers',
fileTrace: 'Suivi des fichiers',
fileTraceEmpty: 'Aucune opération de fichier dans cette session',
fileTraceRead: 'Lecture',
fileTraceWrite: 'Écriture',
fileTraceEdit: 'Modification',
fileTraceRunning: 'en cours',
fileTraceError: 'erreur',
fileTraceFold: '{count} lignes… cliquer pour déplier',
fileTraceContext: 'contexte',
fileTracePriorUnknown: 'Le contenu antérieur est hors de la fenêtre ; affiché comme entièrement ajouté',
explorer: 'Explorateur',
git: 'Gestion de code source',
terminal: 'Terminal',
+10
View File
@@ -17,6 +17,16 @@
/** The hi dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const hi: Record<string, string> = {
files: 'फ़ाइलें',
fileTrace: 'फ़ाइल ट्रेस',
fileTraceEmpty: 'इस सत्र में अभी कोई फ़ाइल ऑपरेशन नहीं',
fileTraceRead: 'पढ़ना',
fileTraceWrite: 'लिखना',
fileTraceEdit: 'संपादन',
fileTraceRunning: 'चल रहा है',
fileTraceError: 'त्रुटि',
fileTraceFold: '{count} पंक्तियाँ… विस्तार के लिए क्लिक करें',
fileTraceContext: 'संदर्भ',
fileTracePriorUnknown: 'बदलाव से पहले की सामग्री विंडो में नहीं है; पूरी नई जोड़ी गई दिखाई जाती है',
explorer: 'एक्सप्लोरर',
git: 'सोर्स कंट्रोल',
terminal: 'टर्मिनल',
+10
View File
@@ -15,6 +15,16 @@
export const id: Record<string, string> = {
files: 'Berkas',
fileTrace: 'Pelacakan file',
fileTraceEmpty: 'Belum ada operasi file di sesi ini',
fileTraceRead: 'Baca',
fileTraceWrite: 'Tulis',
fileTraceEdit: 'Edit',
fileTraceRunning: 'berjalan',
fileTraceError: 'error',
fileTraceFold: '{count} baris… klik untuk meluaskan',
fileTraceContext: 'konteks',
fileTracePriorUnknown: 'Konten sebelum perubahan berada di luar jendela; ditampilkan sebagai sepenuhnya baru',
explorer: 'Explorer',
git: 'Source Control',
terminal: 'Terminal',
+10
View File
@@ -8,6 +8,16 @@
export const it: Record<string, string> = {
files: 'File',
fileTrace: 'Tracciatura file',
fileTraceEmpty: 'Ancora nessuna operazione sui file in questa sessione',
fileTraceRead: 'Lettura',
fileTraceWrite: 'Scrittura',
fileTraceEdit: 'Modifica',
fileTraceRunning: 'in esecuzione',
fileTraceError: 'errore',
fileTraceFold: '{count} righe… clicca per espandere',
fileTraceContext: 'contesto',
fileTracePriorUnknown: 'Il contenuto precedente è fuori dalla finestra; mostrato come interamente aggiunto',
explorer: 'Esplora risorse',
git: 'Controllo del codice sorgente',
terminal: 'Terminale',
+10
View File
@@ -17,6 +17,16 @@
/** The ja dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const ja: Record<string, string> = {
files: 'ファイル',
fileTrace: 'ファイル追跡',
fileTraceEmpty: 'このセッションにはファイル操作がまだありません',
fileTraceRead: '読み取り',
fileTraceWrite: '書き込み',
fileTraceEdit: '編集',
fileTraceRunning: '実行中',
fileTraceError: 'エラー',
fileTraceFold: '{count} 行…クリックで展開',
fileTraceContext: 'コンテキスト',
fileTracePriorUnknown: '変更前の内容がウィンドウ内になく、すべて新規追加として表示されます',
explorer: 'エクスプローラー',
git: 'ソース管理',
terminal: 'ターミナル',
+10
View File
@@ -9,6 +9,16 @@
export const ko: Record<string, string> = {
files: '파일',
fileTrace: '파일 추적',
fileTraceEmpty: '이 세션에 아직 파일 작업이 없습니다',
fileTraceRead: '읽기',
fileTraceWrite: '쓰기',
fileTraceEdit: '편집',
fileTraceRunning: '실행 중',
fileTraceError: '오류',
fileTraceFold: '{count}줄… 클릭하여 펼치기',
fileTraceContext: '컨텍스트',
fileTracePriorUnknown: '변경 전 내용이 창에 없어 모두 새로 추가된 것으로 표시됩니다',
explorer: '탐색기',
git: '소스 제어',
terminal: '터미널',
+10
View File
@@ -15,6 +15,16 @@
*/
export const nl: Record<string, string> = {
files: 'Bestanden',
fileTrace: 'Bestandsspoor',
fileTraceEmpty: 'Nog geen bestandsbewerkingen in deze sessie',
fileTraceRead: 'Lezen',
fileTraceWrite: 'Schrijven',
fileTraceEdit: 'Bewerken',
fileTraceRunning: 'actief',
fileTraceError: 'fout',
fileTraceFold: '{count} regels… klik om uit te klappen',
fileTraceContext: 'context',
fileTracePriorUnknown: 'Eerdere inhoud valt buiten het venster; getoond als volledig nieuw',
explorer: 'Verkenner',
git: 'Bronbeheer',
terminal: 'Terminal',
+10
View File
@@ -19,6 +19,16 @@
/** The pl dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const pl: Record<string, string> = {
files: 'Pliki',
fileTrace: 'Śledzenie plików',
fileTraceEmpty: 'Brak operacji na plikach w tej sesji',
fileTraceRead: 'Odczyt',
fileTraceWrite: 'Zapis',
fileTraceEdit: 'Edycja',
fileTraceRunning: 'w toku',
fileTraceError: 'błąd',
fileTraceFold: '{count} wierszy… kliknij, aby rozwinąć',
fileTraceContext: 'kontekst',
fileTracePriorUnknown: 'Zawartość sprzed zmiany jest poza oknem; pokazana jako w całości nowa',
explorer: 'Eksplorator',
git: 'Kontrola źródła',
terminal: 'Terminal',
+10
View File
@@ -1,5 +1,15 @@
export const pt: Record<string, string> = {
files: 'Arquivos',
fileTrace: 'Rastreio de arquivos',
fileTraceEmpty: 'Ainda sem operações de arquivo nesta sessão',
fileTraceRead: 'Leitura',
fileTraceWrite: 'Escrita',
fileTraceEdit: 'Edição',
fileTraceRunning: 'em execução',
fileTraceError: 'erro',
fileTraceFold: '{count} linhas… clique para expandir',
fileTraceContext: 'contexto',
fileTracePriorUnknown: 'O conteúdo anterior está fora da janela; mostrado como totalmente adicionado',
explorer: 'Explorador',
git: 'Controle de código-fonte',
terminal: 'Terminal',
+10
View File
@@ -13,6 +13,16 @@
export const ru: Record<string, string> = {
files: 'Файлы',
fileTrace: 'Отслеживание файлов',
fileTraceEmpty: 'В этой сессии пока нет файловых операций',
fileTraceRead: 'Чтение',
fileTraceWrite: 'Запись',
fileTraceEdit: 'Изменение',
fileTraceRunning: 'выполняется',
fileTraceError: 'ошибка',
fileTraceFold: '{count} строк… нажмите, чтобы развернуть',
fileTraceContext: 'контекст',
fileTracePriorUnknown: 'Содержимое до изменения вне окна; показано как полностью добавленное',
explorer: 'Проводник',
git: 'Контроль версий',
terminal: 'Терминал',
+10
View File
@@ -1,5 +1,15 @@
export const sv: Record<string, string> = {
files: 'Filer',
fileTrace: 'Filspårning',
fileTraceEmpty: 'Inga filoperationer i den här sessionen ännu',
fileTraceRead: 'Läs',
fileTraceWrite: 'Skriv',
fileTraceEdit: 'Redigera',
fileTraceRunning: 'körs',
fileTraceError: 'fel',
fileTraceFold: '{count} rader… klicka för att expandera',
fileTraceContext: 'kontext',
fileTracePriorUnknown: 'Innehållet före ändringen ligger utanför fönstret; visas som helt nytt',
explorer: 'Utforskare',
git: 'Källkodshantering',
terminal: 'Terminal',
+10
View File
@@ -17,6 +17,16 @@
/** The th dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const th: Record<string, string> = {
files: 'ไฟล์',
fileTrace: 'ติดตามไฟล์',
fileTraceEmpty: 'ยังไม่มีการดำเนินการไฟล์ในเซสชันนี้',
fileTraceRead: 'อ่าน',
fileTraceWrite: 'เขียน',
fileTraceEdit: 'แก้ไข',
fileTraceRunning: 'กำลังทำงาน',
fileTraceError: 'ข้อผิดพลาด',
fileTraceFold: '{count} บรรทัด… คลิกเพื่อขยาย',
fileTraceContext: 'บริบท',
fileTracePriorUnknown: 'เนื้อหาก่อนแก้ไขอยู่นอกหน้าต่าง แสดงเป็นเพิ่มใหม่ทั้งหมด',
explorer: 'ตัวสำรวจ',
git: 'การควบคุมซอร์ส',
terminal: 'เทอร์มินัล',
+10
View File
@@ -17,6 +17,16 @@
/** The tr dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const tr: Record<string, string> = {
files: 'Dosyalar',
fileTrace: 'Dosya izleme',
fileTraceEmpty: 'Bu oturumda henüz dosya işlemi yok',
fileTraceRead: 'Okuma',
fileTraceWrite: 'Yazma',
fileTraceEdit: 'Düzenleme',
fileTraceRunning: 'çalışıyor',
fileTraceError: 'hata',
fileTraceFold: '{count} satır… genişletmek için tıklayın',
fileTraceContext: 'bağlam',
fileTracePriorUnknown: 'Değişiklik öncesi içerik pencere dışında; tamamen yeni eklenmiş olarak gösteriliyor',
explorer: 'Gezgin',
git: 'Kaynak denetimi',
terminal: 'Terminal',
+10
View File
@@ -17,6 +17,16 @@
/** The vi dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const vi: Record<string, string> = {
files: 'Tệp',
fileTrace: 'Theo dõi tệp',
fileTraceEmpty: 'Chưa có thao tác tệp nào trong phiên này',
fileTraceRead: 'Đọc',
fileTraceWrite: 'Ghi',
fileTraceEdit: 'Chỉnh sửa',
fileTraceRunning: 'đang chạy',
fileTraceError: 'lỗi',
fileTraceFold: '{count} dòng… bấm để mở rộng',
fileTraceContext: 'ngữ cảnh',
fileTracePriorUnknown: 'Nội dung trước thay đổi nằm ngoài cửa sổ; hiển thị như toàn bộ mới thêm',
explorer: 'Trình khám phá',
git: 'Quản lý mã nguồn',
terminal: 'Terminal',
+10
View File
@@ -32,6 +32,16 @@
/** The zh-HK dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const zhHK: Record<string, string> = {
files: '檔案',
fileTrace: '檔案追蹤',
fileTraceEmpty: '本工作階段還沒有檔案操作',
fileTraceRead: '讀取',
fileTraceWrite: '寫入',
fileTraceEdit: '編輯',
fileTraceRunning: '執行中',
fileTraceError: '出錯',
fileTraceFold: '{count} 行…點擊展開',
fileTraceContext: '上下文',
fileTracePriorUnknown: '變更前的內容不在視窗內,顯示為全部新增',
explorer: '檔案總管',
git: '原始碼管理',
terminal: '終端',
+10
View File
@@ -32,6 +32,16 @@
/** The zh-HK dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const zhMO: Record<string, string> = {
files: '檔案',
fileTrace: '檔案追蹤',
fileTraceEmpty: '本工作階段還沒有檔案操作',
fileTraceRead: '讀取',
fileTraceWrite: '寫入',
fileTraceEdit: '編輯',
fileTraceRunning: '執行中',
fileTraceError: '出錯',
fileTraceFold: '{count} 行…點擊展開',
fileTraceContext: '上下文',
fileTracePriorUnknown: '變更前的內容不在視窗內,顯示為全部新增',
explorer: '檔案總管',
git: '原始碼管理',
terminal: '終端',
+10
View File
@@ -32,6 +32,16 @@
/** The zh-HK dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const zhTW: Record<string, string> = {
files: '檔案',
fileTrace: '檔案追蹤',
fileTraceEmpty: '本作業階段還沒有檔案操作',
fileTraceRead: '讀取',
fileTraceWrite: '寫入',
fileTraceEdit: '編輯',
fileTraceRunning: '執行中',
fileTraceError: '出錯',
fileTraceFold: '{count} 行…點擊展開',
fileTraceContext: '上下文',
fileTracePriorUnknown: '變更前的內容不在視窗內,顯示為全部新增',
explorer: '檔案總管',
git: '原始碼管理',
terminal: '終端',
+20
View File
@@ -24,6 +24,16 @@
/** The zh dictionary (also registered into the DSH locale registry under {@link LOCALE_NS}). */
export const zh = {
files: '文件',
fileTrace: '文件追踪',
fileTraceEmpty: '本会话还没有文件操作',
fileTraceRead: '读取',
fileTraceWrite: '写入',
fileTraceEdit: '编辑',
fileTraceRunning: '执行中',
fileTraceError: '出错',
fileTraceFold: '{count} 行…点击展开',
fileTraceContext: '上下文',
fileTracePriorUnknown: '变更前的内容不在窗口内,显示为全新增',
explorer: '资源管理器',
git: '源代码管理',
terminal: '终端',
@@ -392,6 +402,16 @@ export const zh = {
/** The en dictionary (key-set-equal to zh, enforced by the type annotation). */
export const en: Record<keyof typeof zh, string> = {
files: 'Files',
fileTrace: 'File trace',
fileTraceEmpty: 'No file operations in this session yet',
fileTraceRead: 'Read',
fileTraceWrite: 'Write',
fileTraceEdit: 'Edit',
fileTraceRunning: 'running',
fileTraceError: 'error',
fileTraceFold: '{count} lines…click to expand',
fileTraceContext: 'context',
fileTracePriorUnknown: 'Prior content is outside the loaded window; shown as all-added',
explorer: 'Explorer',
git: 'Source Control',
terminal: 'Terminal',