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:
@@ -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'),
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: 'الطرفية',
|
||||
|
||||
@@ -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,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',
|
||||
|
||||
@@ -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: 'टर्मिनल',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'ターミナル',
|
||||
|
||||
@@ -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: '터미널',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'Терминал',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'เทอร์มินัล',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '終端',
|
||||
|
||||
@@ -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: '終端',
|
||||
|
||||
@@ -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: '終端',
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user