diff --git a/README.md b/README.md index fa59121c..42b76c86 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ - **⚡ 按需加载**:启动只拉 ~325KB 核心,终端 / 编辑器 / Mermaid 图表等重依赖用到才按需拉取([设计文档](docs/plans/2026-08-12-lazy-chunks-design.md)) - **🌏 多语言**:界面文案跟随 DSH 语言(zh / en)实时切换;安装 `@huanlin/dsh-plugin-better-locale` 后支持日语(ja)等第三语言覆盖(见下方「🌏 第三语言覆盖」) -> 🔌 **核心理念**:服务优先——内置的 7 tab + 6 viewer 与第三方插件通过同一套 `ctx.betterSidebar` API 注册,能力完全对等;官方不再内置、可由生态提供的功能,交由生态插件实现(已有 **28+ 生态插件**,见下方「🌐 插件生态」)。接入文档见「🔌 服务化扩展」与 [外部插件接入指南](./docs/external-plugin-guide.md)。 +> 🔌 **核心理念**:服务优先——内置的 8 tab + 6 viewer 与第三方插件通过同一套 `ctx.betterSidebar` API 注册,能力完全对等;官方不再内置、可由生态提供的功能,交由生态插件实现(已有 **28+ 生态插件**,见下方「🌐 插件生态」)。接入文档见「🔌 服务化扩展」与 [外部插件接入指南](./docs/external-plugin-guide.md)。 ## 🚀 安装 @@ -165,11 +165,12 @@ dsh registry enable dsh-external/dsh-better-sidebar | **💻 真实终端**
xterm.js + node-pty 真实 shell(不是模拟器):断线重连 transcript 回放、shell / shellArgs 可配置(设置页或 `cordis.patch.yml`)、可选为模型注入 `terminal_*` 工具(agent 可直接开终端跑命令)。
真实终端
| **🌿 Git 面板**
暂存 / 取消暂存 / 提交(`Ctrl+Enter`)/ 还原,历史列表;点击改动文件打开 **VSCode 式 diff tab**(红绿行级对比)。
Git 面板
| | **🌐 内嵌浏览器**
多开网页 tab:后退 / 前进 / 刷新 / 地址栏;内容运行在**不透明源沙箱 iframe**(界面实时显示沙箱状态,可按页面临时解锁);聊天里的外链点击可被接管到侧边栏打开(按协议分流,可配)。
内嵌浏览器
| **🧩 任务页:子代理拓扑 + 后台任务**
子代理树实时拓扑(运行状态、批量实时预览)+ 后台任务清单(退出码 / 实时输出 / 强制终止);新子代理 / 新任务可自动展开侧边栏(可关)。
任务页:子代理拓扑
| | **💬 侧边对话(beta)**
Codex 风格侧边线程:**每个对话一个独立 Tab**;线程继承主会话完整上下文(含进行中回合,以 interrupted 诚实冻结)独立运行,不污染主会话;可持续追问、重启冷恢复;一键「保存为新会话」提升为顶层会话。
侧边对话(beta)
| **🪟 双工作台:右侧栏 + 底部面板 + 分栏**
右侧栏与底部面板可同时展开;拖 Tab 到分栏边缘**拆分**、拖到中间**合并**(可跨面板);面板宽高左缘/上缘拖拽调节;移动端自动合并为全宽抽屉;把 tab 拖到主会话区域可变为**自由窗口**(悬浮 / 缩放 / 置顶,拖回 pane 停靠)。
双工作台(右侧栏 + 底部面板)
| +| **📜 文件追踪**
记录模型在本会话读 / 写 / 编辑过的每一个文件(读取会话事件日志实时折叠),按文件分组、带操作数角标;点开任意操作查看**行级 diff**(删红 / 增绿 / 改蓝 + 行内字符级高亮)或带真实行号的读取视图,常见语言**语法着色**(关键字 / 字符串 / 数字 / 类型 / 函数 / 注释 / 预处理,跨行块注释正确),失败操作直接展示真实错误文本,长行与未变更区间可折叠。移植自独立插件 [dsh-file-trace](https://github.com/lhh010/dsh-file-trace)(仍独立维护,二者可共存;本内置 tab 可在设置页整体关闭)。 | | **⚙️ 声明式设置**
设置页「侧边卡片」分区:每个 tab / 预览器一张小卡片,独立开关(高亮启用态 + 品牌开关滑块);二级设置经卡片底部「功能设置」条弹窗(开关 / 文本 / 数字 / 下拉);插件自有设置持久化在 `pluginSettings`。
声明式设置:侧边卡片
| **📱 移动端**
窄屏(<768px)自动切换为全宽抽屉:底栏 tab 一次性并入右侧栏,触屏拖拽可调。
移动端全宽抽屉
| ## 🌐 插件生态 -`ctx.betterSidebar` 服务向所有插件开放两个扩展点:**`registerTab`(注册侧边栏页面)** 与 **`registerFileViewer`(注册文件预览器)**。内置的 7 tab + 6 viewer 与第三方插件走同一套 API,能力完全对等。 +`ctx.betterSidebar` 服务向所有插件开放两个扩展点:**`registerTab`(注册侧边栏页面)** 与 **`registerFileViewer`(注册文件预览器)**。内置的 8 tab + 6 viewer 与第三方插件走同一套 API,能力完全对等。 ```ts import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并 @@ -459,7 +460,7 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar ## 🔌 服务化扩展 -从 v0.4.0 起暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏页面与文件预览器(内置 7 tab + 6 viewer 亦通过同一服务注册)。v0.12.1 补齐基座能力(完整类型导出、能力探测、状态订阅、tab 角标、生命周期回调、定向打开、插件自有设置等)。 +从 v0.4.0 起暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏页面与文件预览器(内置 8 tab + 6 viewer 亦通过同一服务注册)。v0.12.1 补齐基座能力(完整类型导出、能力探测、状态订阅、tab 角标、生命周期回调、定向打开、插件自有设置等)。 完整接入文档(全字段、匹配算法、HMR 陷阱、声明式设置、版本探测、自由窗口与皮肤契约):**[`docs/external-plugin-guide.md`](./docs/external-plugin-guide.md)**;仓库开发规则(硬约束 / CI / 发版)见 [`AGENTS.md`](./AGENTS.md)。 diff --git a/README_EN.md b/README_EN.md index 64af9e52..f2a4db2d 100644 --- a/README_EN.md +++ b/README_EN.md @@ -55,7 +55,7 @@ - **⚡ On-demand Loading**: only ~325KB core at startup; heavy deps (terminal / editor / mermaid diagrams) load on demand ([design](docs/plans/2026-08-12-lazy-chunks-design.md)) - **🌏 i18n**: UI text follows DSH's language (zh / en) with live switching; with the optional `@huanlin/dsh-plugin-better-locale` peer, 19 third-language overlays (ja / de / fr / …) are available -> 🔌 **Core principle**: service-first — the 7 built-in tabs + 6 viewers register through the same `ctx.betterSidebar` API as third-party plugins, with fully equal capabilities; anything the ecosystem can provide better is delegated to ecosystem plugins (**28+ ecosystem plugins** already — see "🌐 Plugin Ecosystem" below). See "🔌 Service API" and the [external plugin guide](./docs/external-plugin-guide.md). +> 🔌 **Core principle**: service-first — the 8 built-in tabs + 6 viewers register through the same `ctx.betterSidebar` API as third-party plugins, with fully equal capabilities; anything the ecosystem can provide better is delegated to ecosystem plugins (**28+ ecosystem plugins** already — see "🌐 Plugin Ecosystem" below). See "🔌 Service API" and the [external plugin guide](./docs/external-plugin-guide.md). ## 🚀 Installation @@ -165,11 +165,12 @@ Update: `git pull && pnpm install && pnpm build` → `node scripts/package-regis | **💻 Real Terminal**
xterm.js + node-pty real shell (not an emulator): transcript replay on reconnect, configurable shell / shellArgs (settings page or `cordis.patch.yml`), and optional `terminal_*` model tools so the agent can open terminals and run commands itself.
Real terminal
| **🌿 Git Panel**
Stage / unstage / commit (`Ctrl+Enter`) / revert, plus a history list; clicking a changed file opens a **VSCode-style diff tab** (line-level red/green).
Git panel
| | **🌐 Embedded Browser**
Multiple web tabs with back / forward / reload / address bar; content runs in an **opaque-origin sandboxed iframe** (live sandbox status in the UI, per-page temporary unlock available); external-link clicks in the chat can be taken over into the sidebar (protocol-based routing, configurable).
Embedded browser
| **🧩 Tasks: Agent Topology + Background Jobs**
Live subagent-tree topology (run states, batched live previews) plus the background-jobs list (exit codes / live output / force-kill); new subagents / jobs can auto-expand the sidebar (configurable).
Tasks: subagent topology
| | **💬 Side Chat (beta)**
Codex-style side threads: **one independent tab per conversation**; the thread inherits the parent's full context (including the in-progress turn, honestly frozen as "interrupted") and runs independently without polluting the main session; follow-ups survive restarts; one click promotes the thread to a top-level session.
Side Chat (beta)
| **🪟 Dual Workbench: Sidebar + Bottom Panel + Split Panes**
The right sidebar and the bottom panel can stay open together; drag a tab to a pane edge to **split**, to the middle to **merge** (works across panels); panel width/height drag from the left/top edge; on mobile everything merges into a full-width drawer; drag a tab onto the main conversation area to turn it into a **free window** (float / resize / raise, drag back onto a pane to dock).
Dual workbench (right sidebar + bottom panel)
| +| **📜 File Trace**
Records every file the model read, wrote, or edited in this session (folded live from the session event log), grouped by file with an op-count badge; open any op for a **line diff** (del red / add green / mod blue + intra-line character highlighting) or the line-numbered read view, with lightweight **syntax coloring** for common languages (keywords / strings / numbers / types / functions / comments / preprocessor, multi-line block comments included); failed ops show their real error text, and long lines and unchanged regions fold. Ported from the standalone [dsh-file-trace](https://github.com/lhh010/dsh-file-trace) plugin (still maintained independently; both can coexist — this built-in tab can be turned off in settings). | | **⚙️ Declarative Settings**
The "Side card" section in DSH settings: one small card per tab / viewer with an independent toggle (highlighted enabled state + brand switch); secondary settings open from the "Feature settings" strip at the card bottom (switch / text / number / select rows); plugin-owned settings persist under `pluginSettings`.
Declarative settings: side cards
| **📱 Mobile**
On narrow screens (<768px) the panels become a full-width drawer: bottom-panel tabs merge into the sidebar once, with touch-friendly dragging.
Mobile full-width drawer
| ## 🌐 Plugin Ecosystem -The `ctx.betterSidebar` service opens two extension points to every plugin: **`registerTab` (sidebar pages)** and **`registerFileViewer` (file previewers)**. The 7 built-in tabs + 6 viewers register through the exact same API — fully equal capabilities. +The `ctx.betterSidebar` service opens two extension points to every plugin: **`registerTab` (sidebar pages)** and **`registerFileViewer` (file previewers)**. The 8 built-in tabs + 6 viewers register through the exact same API — fully equal capabilities. ```ts import type {} from 'dsh-better-sidebar' // triggers the ctx.betterSidebar type merge @@ -455,7 +456,7 @@ All changes since v0.14.0: ## 🔌 Service API -Since v0.4.0 the plugin exposes the `ctx.betterSidebar` service — other plugins can register sidebar pages and file viewers (the 7 built-in tabs + 6 viewers register through the same service). v0.12.1 completed the base capabilities (complete type exports, capability detection, state subscription, tab badges, lifecycle callbacks, targeted open, plugin-owned settings, etc.). +Since v0.4.0 the plugin exposes the `ctx.betterSidebar` service — other plugins can register sidebar pages and file viewers (the 8 built-in tabs + 6 viewers register through the same service). v0.12.1 completed the base capabilities (complete type exports, capability detection, state subscription, tab badges, lifecycle callbacks, targeted open, plugin-owned settings, etc.). Full integration docs (complete fields, matching algorithm, HMR pitfalls, declarative settings, version detection, float windows and the skinning contract): **[`docs/external-plugin-guide.md`](./docs/external-plugin-guide.md)**; repository rules (hard constraints / CI / release) live in [`AGENTS.md`](./AGENTS.md). diff --git a/docs/external-plugin-guide.md b/docs/external-plugin-guide.md index 5870554a..38879c36 100644 --- a/docs/external-plugin-guide.md +++ b/docs/external-plugin-guide.md @@ -3,7 +3,7 @@ > 面向 **消费插件开发者**:如何让你的插件向 better-sidebar 注册新的侧边栏页面(tab)和文件类型预览器。 > > 适用版本:**v0.4.0+**(`ctx.betterSidebar` 服务);声明式设置 **v0.4.1+**;text/number 设置行 **v0.11.0+**;badge/生命周期/定向打开/插件设置/版本探测 **v0.12.0+**;select 设置行(`settingSelect`)与外链认领(`urlTarget`)**v0.13.0+**;统一 `@deepseek-ai/cordis` 类型基底 **v0.15.2+**;自由窗口(`floatWindows`)**v0.16.0+**;终端固定(pin)**v0.17.0+**。当前版本 **v0.18.0-alpha.0**(alpha 通道,仅支持 DSH 0.1.2-alpha.x;stable 线为 v0.17.1)。 -> 权威代码:`src/client/service.ts`(服务实现)、`src/client/builtins/`(内置 7 tab + 6 viewer 参考实现)、`lib/types/client/service.d.ts`(类型声明)。 +> 权威代码:`src/client/service.ts`(服务实现)、`src/client/builtins/`(内置 8 tab + 6 viewer 参考实现)、`lib/types/client/service.d.ts`(类型声明)。 > 仓库开发规则(硬约束 / CI / 发版)见 [AGENTS.md](../AGENTS.md)。 --- @@ -15,7 +15,7 @@ better-sidebar 从 v0.4.0 起把自己改造成一个**注册表服务**: - **新页面(tab)**:注册一种新的侧边栏 tab 类型,出现在侧边栏 `+` 菜单里,用户点击后在自己的分栏里打开你的 React 页面; - **文件预览器(file viewer)**:注册一种文件类型预览器,让用户在侧边栏打开文件时走你的渲染组件(覆盖或补充内置的 image/pdf/code 等)。 -内置的 7 个 tab(editor / git / subagent / sidechat / terminal / browser / diff)和 6 个 viewer(image / pdf / markdown / html / code / binary-download)**自己也是通过同一套 API 注册的**(吃自己的狗粮),所以外部插件的能力与内置功能完全对等。 +内置的 8 个 tab(editor / git / subagent / sidechat / terminal / browser / file-trace / diff)和 6 个 viewer(image / pdf / markdown / html / code / binary-download)**自己也是通过同一套 API 注册的**(吃自己的狗粮),所以外部插件的能力与内置功能完全对等。 关键机制一句话:better-sidebar 的 client half 在 `apply()` 开头执行 `ctx.provide('betterSidebar', service)`(`src/client/index.tsx`),消费插件在 `inject` 里声明 `'betterSidebar'`,Cordis 保证服务就绪后才激活你的插件,然后你调用 `ctx.betterSidebar.registerTab(...)` / `registerFileViewer(...)` 完成注册,返回的 disposer 由 Cordis fiber 在卸载(HMR / 禁用)时自动调用。 @@ -157,7 +157,7 @@ interface TabDescriptor { title: string | (() => string) /** 图标:ReactNode 或 (size: number) => ReactNode */ icon?: ReactNode | ((size: number) => ReactNode) - /** + 菜单排序(升序);默认 100。内置:editor=10, git=20, subagent=30, sidechat=35, terminal=40, browser=50 */ + /** + 菜单排序(升序);默认 100。内置:editor=10, git=20, subagent=30, sidechat=35, terminal=40, browser=50, file-trace=55=50 */ order?: number /** 从 + 菜单隐藏(editor/diff 用:由其他流程触发打开,不在菜单里) */ hidden?: boolean diff --git a/src/client/builtins/tabs.tsx b/src/client/builtins/tabs.tsx index 58a1d0bb..49632c76 100644 --- a/src/client/builtins/tabs.tsx +++ b/src/client/builtins/tabs.tsx @@ -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:` 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) => , }, + { + id: 'file-trace', + title: () => t('fileTrace'), + icon: (size: number) => , + order: 55, + single: true, + badge: (ctx, scope) => { + const events = ctx.sessions.get(scope.sessionId)?.events + return events === undefined ? null : extractFileOps(events).length + }, + component: (props) => , + }, { id: 'diff', title: () => t('git'), diff --git a/src/client/filetrace/FileTraceTab.tsx b/src/client/filetrace/FileTraceTab.tsx new file mode 100644 index 00000000..33411319 --- /dev/null +++ b/src/client/filetrace/FileTraceTab.tsx @@ -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> = { + 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({token.text}) + } + 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 { + const entries = new Map() + 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(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>(new Set()) + // Hunk-fold segments expanded by index; default collapsed. + const [expandedFolds, setExpandedFolds] = useState>(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() + 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 ( +
{ + 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} + > + {row.oldLine !== undefined ? String(row.oldLine) : ''} + {row.newLine !== undefined ? String(row.newLine) : ''} + {row.kind === 'del' ? '-' : row.kind === 'add' ? '+' : row.kind === 'mod' ? '~' : ' '} + + {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)} + +
+ ) + } + + const onHandleDown = (e: ReactPointerEvent): 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 ( +
+
+ {ops.length === 0 &&
{t('fileTraceEmpty')}
} + {[...groups.entries()].map(([path, fileOps]) => ( +
+
{path}
+ {fileOps.map(op => ( + + ))} +
+ ))} +
+ {selected !== null && ( +
+
+
+ {selected.path} + + {t(selected.op.kind === 'read' ? 'fileTraceRead' : selected.op.kind === 'write' ? 'fileTraceWrite' : 'fileTraceEdit')} + + +
+ {selected.op.isError + ? ( +
+
+ {selected.op.errorText ?? t('fileTraceError')} +
+
+ ) + : selected.op.kind === 'read' + ? ( +
+ {readRows.map((row) => ( +
+ {String(row.line)} + {row.nodes} +
+ ))} +
+ ) + : ( +
+ {selected.op.kind === 'write' + && knownContentBefore(ops, selected.path, selected.op) === undefined + &&
{t('fileTracePriorUnknown')}
} + {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 ( +
{ + 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)) + : ( + + {t('fileTraceFold', { count: segment.rows.length })} + + )} +
+ ) + } + return segment.rows.map((row, index) => renderDiffRow(row, `${segIndex}-${String(index)}`, selectedLang)) + })} +
+ )} +
+ )} +
+ ) +} diff --git a/src/client/filetrace/diff.ts b/src/client/filetrace/diff.ts new file mode 100644 index 00000000..c4500edd --- /dev/null +++ b/src/client/filetrace/diff.ts @@ -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(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` +} \ No newline at end of file diff --git a/src/client/filetrace/filetrace.module.css b/src/client/filetrace/filetrace.module.css new file mode 100644 index 00000000..1032d38d --- /dev/null +++ b/src/client/filetrace/filetrace.module.css @@ -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; } diff --git a/src/client/filetrace/highlight.ts b/src/client/filetrace/highlight.ts new file mode 100644 index 00000000..e9468e94 --- /dev/null +++ b/src/client/filetrace/highlight.ts @@ -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 = 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 + /** Constant literals colored as keywords (true/false/null). */ + readonly constants: ReadonlySet + /** '#' 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 => 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> = { + 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) +} diff --git a/src/client/filetrace/ops.ts b/src/client/filetrace/ops.ts new file mode 100644 index 00000000..522f60d4 --- /dev/null +++ b/src/client/filetrace/ops.ts @@ -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 { + try { + const parsed: unknown = JSON.parse(argsRaw) + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return {} + return parsed as Record + } catch { + return {} + } +} + +/** Extract the path field common to every file tool's arguments. */ +function pathOf(args: Record): 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() + 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 { + const groups = new Map() + 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 envelope and "(Showing lines ...)" note; recovers the + * ": " prefix as the line number, falling back to sequential counting. + */ +export function parseReadLines(raw: string): ReadLine[] { + const contentMatch = raw.match(/([\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 +} diff --git a/src/client/locales-ar.ts b/src/client/locales-ar.ts index 07a7be14..a9dbf177 100644 --- a/src/client/locales-ar.ts +++ b/src/client/locales-ar.ts @@ -18,6 +18,16 @@ /** The ar dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */ export const ar: Record = { files: 'الملفات', + fileTrace: 'تتبع الملفات', + fileTraceEmpty: 'لا توجد عمليات ملفات في هذه الجلسة بعد', + fileTraceRead: 'قراءة', + fileTraceWrite: 'كتابة', + fileTraceEdit: 'تحرير', + fileTraceRunning: 'قيد التنفيذ', + fileTraceError: 'خطأ', + fileTraceFold: '{count} أسطر… انقر للتوسيع', + fileTraceContext: 'سياق', + fileTracePriorUnknown: 'المحتوى السابق خارج النافذة؛ يُعرض كما لو أُضيف بالكامل', explorer: 'المستكشف', git: 'إدارة المصدر', terminal: 'الطرفية', diff --git a/src/client/locales-de.ts b/src/client/locales-de.ts index 71991da9..faf3456b 100644 --- a/src/client/locales-de.ts +++ b/src/client/locales-de.ts @@ -3,6 +3,16 @@ */ export const de: Record = { 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', diff --git a/src/client/locales-fr.ts b/src/client/locales-fr.ts index 26060ddb..ba17e397 100644 --- a/src/client/locales-fr.ts +++ b/src/client/locales-fr.ts @@ -10,6 +10,16 @@ /** fr dictionary for the `betterSidebar` namespace. */ export const fr: Record = { 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', diff --git a/src/client/locales-hi.ts b/src/client/locales-hi.ts index 36711149..79050a30 100644 --- a/src/client/locales-hi.ts +++ b/src/client/locales-hi.ts @@ -17,6 +17,16 @@ /** The hi dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */ export const hi: Record = { files: 'फ़ाइलें', + fileTrace: 'फ़ाइल ट्रेस', + fileTraceEmpty: 'इस सत्र में अभी कोई फ़ाइल ऑपरेशन नहीं', + fileTraceRead: 'पढ़ना', + fileTraceWrite: 'लिखना', + fileTraceEdit: 'संपादन', + fileTraceRunning: 'चल रहा है', + fileTraceError: 'त्रुटि', + fileTraceFold: '{count} पंक्तियाँ… विस्तार के लिए क्लिक करें', + fileTraceContext: 'संदर्भ', + fileTracePriorUnknown: 'बदलाव से पहले की सामग्री विंडो में नहीं है; पूरी नई जोड़ी गई दिखाई जाती है', explorer: 'एक्सप्लोरर', git: 'सोर्स कंट्रोल', terminal: 'टर्मिनल', diff --git a/src/client/locales-id.ts b/src/client/locales-id.ts index da324ea3..b1792b1b 100644 --- a/src/client/locales-id.ts +++ b/src/client/locales-id.ts @@ -15,6 +15,16 @@ export const id: Record = { 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', diff --git a/src/client/locales-it.ts b/src/client/locales-it.ts index 51eb67a0..d926304a 100644 --- a/src/client/locales-it.ts +++ b/src/client/locales-it.ts @@ -8,6 +8,16 @@ export const it: Record = { 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', diff --git a/src/client/locales-ja.ts b/src/client/locales-ja.ts index 4ba4a509..93edb2d6 100644 --- a/src/client/locales-ja.ts +++ b/src/client/locales-ja.ts @@ -17,6 +17,16 @@ /** The ja dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */ export const ja: Record = { files: 'ファイル', + fileTrace: 'ファイル追跡', + fileTraceEmpty: 'このセッションにはファイル操作がまだありません', + fileTraceRead: '読み取り', + fileTraceWrite: '書き込み', + fileTraceEdit: '編集', + fileTraceRunning: '実行中', + fileTraceError: 'エラー', + fileTraceFold: '{count} 行…クリックで展開', + fileTraceContext: 'コンテキスト', + fileTracePriorUnknown: '変更前の内容がウィンドウ内になく、すべて新規追加として表示されます', explorer: 'エクスプローラー', git: 'ソース管理', terminal: 'ターミナル', diff --git a/src/client/locales-ko.ts b/src/client/locales-ko.ts index 1338cfb0..dcff37a4 100644 --- a/src/client/locales-ko.ts +++ b/src/client/locales-ko.ts @@ -9,6 +9,16 @@ export const ko: Record = { files: '파일', + fileTrace: '파일 추적', + fileTraceEmpty: '이 세션에 아직 파일 작업이 없습니다', + fileTraceRead: '읽기', + fileTraceWrite: '쓰기', + fileTraceEdit: '편집', + fileTraceRunning: '실행 중', + fileTraceError: '오류', + fileTraceFold: '{count}줄… 클릭하여 펼치기', + fileTraceContext: '컨텍스트', + fileTracePriorUnknown: '변경 전 내용이 창에 없어 모두 새로 추가된 것으로 표시됩니다', explorer: '탐색기', git: '소스 제어', terminal: '터미널', diff --git a/src/client/locales-nl.ts b/src/client/locales-nl.ts index 4d27d7b5..80493392 100644 --- a/src/client/locales-nl.ts +++ b/src/client/locales-nl.ts @@ -15,6 +15,16 @@ */ export const nl: Record = { 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', diff --git a/src/client/locales-pl.ts b/src/client/locales-pl.ts index cc14f9cc..c6c2aa08 100644 --- a/src/client/locales-pl.ts +++ b/src/client/locales-pl.ts @@ -19,6 +19,16 @@ /** The pl dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */ export const pl: Record = { 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', diff --git a/src/client/locales-pt.ts b/src/client/locales-pt.ts index e0662365..3684eacf 100644 --- a/src/client/locales-pt.ts +++ b/src/client/locales-pt.ts @@ -1,5 +1,15 @@ export const pt: Record = { 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', diff --git a/src/client/locales-ru.ts b/src/client/locales-ru.ts index aa3cadfa..78baecfd 100644 --- a/src/client/locales-ru.ts +++ b/src/client/locales-ru.ts @@ -13,6 +13,16 @@ export const ru: Record = { files: 'Файлы', + fileTrace: 'Отслеживание файлов', + fileTraceEmpty: 'В этой сессии пока нет файловых операций', + fileTraceRead: 'Чтение', + fileTraceWrite: 'Запись', + fileTraceEdit: 'Изменение', + fileTraceRunning: 'выполняется', + fileTraceError: 'ошибка', + fileTraceFold: '{count} строк… нажмите, чтобы развернуть', + fileTraceContext: 'контекст', + fileTracePriorUnknown: 'Содержимое до изменения вне окна; показано как полностью добавленное', explorer: 'Проводник', git: 'Контроль версий', terminal: 'Терминал', diff --git a/src/client/locales-sv.ts b/src/client/locales-sv.ts index a51a0e08..5ce9b758 100644 --- a/src/client/locales-sv.ts +++ b/src/client/locales-sv.ts @@ -1,5 +1,15 @@ export const sv: Record = { 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', diff --git a/src/client/locales-th.ts b/src/client/locales-th.ts index 37e03616..a463c223 100644 --- a/src/client/locales-th.ts +++ b/src/client/locales-th.ts @@ -17,6 +17,16 @@ /** The th dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */ export const th: Record = { files: 'ไฟล์', + fileTrace: 'ติดตามไฟล์', + fileTraceEmpty: 'ยังไม่มีการดำเนินการไฟล์ในเซสชันนี้', + fileTraceRead: 'อ่าน', + fileTraceWrite: 'เขียน', + fileTraceEdit: 'แก้ไข', + fileTraceRunning: 'กำลังทำงาน', + fileTraceError: 'ข้อผิดพลาด', + fileTraceFold: '{count} บรรทัด… คลิกเพื่อขยาย', + fileTraceContext: 'บริบท', + fileTracePriorUnknown: 'เนื้อหาก่อนแก้ไขอยู่นอกหน้าต่าง แสดงเป็นเพิ่มใหม่ทั้งหมด', explorer: 'ตัวสำรวจ', git: 'การควบคุมซอร์ส', terminal: 'เทอร์มินัล', diff --git a/src/client/locales-tr.ts b/src/client/locales-tr.ts index efe71ed7..23e6a9ee 100644 --- a/src/client/locales-tr.ts +++ b/src/client/locales-tr.ts @@ -17,6 +17,16 @@ /** The tr dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */ export const tr: Record = { 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', diff --git a/src/client/locales-vi.ts b/src/client/locales-vi.ts index 5aa773c4..c5bfc5aa 100644 --- a/src/client/locales-vi.ts +++ b/src/client/locales-vi.ts @@ -17,6 +17,16 @@ /** The vi dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */ export const vi: Record = { 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', diff --git a/src/client/locales-zh-HK.ts b/src/client/locales-zh-HK.ts index 0dd42880..f0b5ddba 100644 --- a/src/client/locales-zh-HK.ts +++ b/src/client/locales-zh-HK.ts @@ -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 = { files: '檔案', + fileTrace: '檔案追蹤', + fileTraceEmpty: '本工作階段還沒有檔案操作', + fileTraceRead: '讀取', + fileTraceWrite: '寫入', + fileTraceEdit: '編輯', + fileTraceRunning: '執行中', + fileTraceError: '出錯', + fileTraceFold: '{count} 行…點擊展開', + fileTraceContext: '上下文', + fileTracePriorUnknown: '變更前的內容不在視窗內,顯示為全部新增', explorer: '檔案總管', git: '原始碼管理', terminal: '終端', diff --git a/src/client/locales-zh-MO.ts b/src/client/locales-zh-MO.ts index 224103d2..1fc6065f 100644 --- a/src/client/locales-zh-MO.ts +++ b/src/client/locales-zh-MO.ts @@ -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 = { files: '檔案', + fileTrace: '檔案追蹤', + fileTraceEmpty: '本工作階段還沒有檔案操作', + fileTraceRead: '讀取', + fileTraceWrite: '寫入', + fileTraceEdit: '編輯', + fileTraceRunning: '執行中', + fileTraceError: '出錯', + fileTraceFold: '{count} 行…點擊展開', + fileTraceContext: '上下文', + fileTracePriorUnknown: '變更前的內容不在視窗內,顯示為全部新增', explorer: '檔案總管', git: '原始碼管理', terminal: '終端', diff --git a/src/client/locales-zh-TW.ts b/src/client/locales-zh-TW.ts index 1a3247b9..d5846e32 100644 --- a/src/client/locales-zh-TW.ts +++ b/src/client/locales-zh-TW.ts @@ -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 = { files: '檔案', + fileTrace: '檔案追蹤', + fileTraceEmpty: '本作業階段還沒有檔案操作', + fileTraceRead: '讀取', + fileTraceWrite: '寫入', + fileTraceEdit: '編輯', + fileTraceRunning: '執行中', + fileTraceError: '出錯', + fileTraceFold: '{count} 行…點擊展開', + fileTraceContext: '上下文', + fileTracePriorUnknown: '變更前的內容不在視窗內,顯示為全部新增', explorer: '檔案總管', git: '原始碼管理', terminal: '終端', diff --git a/src/client/locales.ts b/src/client/locales.ts index 55cb8466..c1161c99 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -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 = { 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', diff --git a/tests/builtins.spec.ts b/tests/builtins.spec.ts index a0868b9a..92a795e9 100644 --- a/tests/builtins.spec.ts +++ b/tests/builtins.spec.ts @@ -1,5 +1,5 @@ /** - * Built-in registration tests: the plugin registers 7 tabs and 6 file + * Built-in registration tests: the plugin registers 8 tabs and 6 file * viewers through the same service external plugins use (dogfooding); * the catch-all `code` viewer, the NUL-sniffing `binary-download` viewer, * and the html sandbox settings pin the registry's behavior. (Office @@ -27,13 +27,22 @@ function setup(options: BuiltinTabOptions = {}): { service: ReturnType { - it('registers the 7 built-in tabs', () => { + it('registers the 8 built-in tabs', () => { const { service } = setup() expect(service.getTabs().map(t => t.id).sort()).toEqual( - ['browser', 'diff', 'editor', 'git', 'sidechat', 'subagent', 'terminal'], + ['browser', 'diff', 'editor', 'file-trace', 'git', 'sidechat', 'subagent', 'terminal'], ) }) + it('the file-trace tab is a single-instance badge-carrying visible tab', () => { + const { service } = setup() + const trace = service.getTab('file-trace') + expect(trace?.single).toBe(true) + expect(trace?.hidden).not.toBe(true) + expect(trace?.badge).toBeDefined() + expect(trace?.component).toBeDefined() + }) + it('only diff is hidden from the + menu; editor is the visible files window (order 10)', () => { const { service } = setup() expect(service.getTabs().filter(t => t.hidden).map(t => t.id)).toEqual(['diff']) diff --git a/tests/filetrace.spec.ts b/tests/filetrace.spec.ts new file mode 100644 index 00000000..288f752e --- /dev/null +++ b/tests/filetrace.spec.ts @@ -0,0 +1,126 @@ +/** + * File-trace tab data layer: event-log folding into file operations, plus a + * sanity pass over the ported diff/highlight engines (their full behavior + * suites live in the standalone dsh-file-trace plugin). + */ +import { describe, expect, it } from 'vitest' +import { extractFileOps, groupByFile, knownContentBefore, parseReadLines } from '../src/client/filetrace/ops.ts' +import { diffLines, buildDiffSegments, coalesceInline, diffInline } from '../src/client/filetrace/diff.ts' +import { langOfPath, scanLine } from '../src/client/filetrace/highlight.ts' +import type { SidebarSessionEvent } from '../src/context-types.ts' + +/** One synthetic session event. */ +function ev(type: string, seq: number, time: number, data: Record): SidebarSessionEvent { + return { type, seq, time, data } +} + +/** A tool/call event. */ +function call(seq: number, name: string, callId: string, args: unknown, time = seq): SidebarSessionEvent { + return ev('tool/call', seq, time, { name, callId, arguments: JSON.stringify(args) }) +} + +/** A tool/result event carrying one tool-result block with inner text. */ +function result(seq: number, callId: string, text: string, isError = false, time = seq): SidebarSessionEvent { + return ev('tool/result', seq, time, { + message: { + source: { kind: 'tool', callId }, + content: [{ type: 'tool-result', isError, content: [{ type: 'text', text }] }], + }, + }) +} + +describe('extractFileOps', () => { + it('seeds running ops from tool/call and settles them from tool/result', () => { + const ops = extractFileOps([ + call(1, 'write', 'w1', { file_path: 'a.ts', content: 'body' }), + result(2, 'w1', 'a.ts\nfile\n\nbody\n'), + ]) + expect(ops).toHaveLength(1) + expect(ops[0]!.running).toBe(false) + expect(ops[0]!.isError).toBe(false) + expect(ops[0]!.content).toBe('body') + }) + + it('captures the error text of an errored result for every kind', () => { + const ops = extractFileOps([ + call(1, 'write', 'w', { file_path: 'a', content: 'x' }), + result(2, 'w', '写入失败:目标只读', true), + call(3, 'read', 'r', { file_path: 'b' }), + result(4, 'r', 'cannot read "b": not found', true), + ]) + expect(ops.every(op => op.isError && op.errorText !== undefined)).toBe(true) + expect(ops.find(op => op.kind === 'read')?.errorText).toBe('cannot read "b": not found') + }) + + it('keeps a call without result as running, and reads capture their content', () => { + const ops = extractFileOps([ + call(1, 'read', 'r1', { file_path: 'a.ts' }), + result(2, 'r1', 'a.ts\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n'), + call(3, 'edit', 'e1', { file_path: 'c.ts', old_string: 'x', new_string: 'y' }), + ]) + const read = ops.find(op => op.callId === 'r1') + expect(read?.running).toBe(false) + expect(read?.read).toContain('1: hello') + const edit = ops.find(op => op.callId === 'e1') + expect(edit?.running).toBe(true) + expect(edit?.edit).toEqual({ oldString: 'x', newString: 'y' }) + }) + + it('ignores non-file tools and unrelated results, newest first', () => { + const ops = extractFileOps([ + call(1, 'pwsh', 'p', { command: 'ls' }), + result(2, 'unknown-call', 'orphan'), + call(3, 'read', 'r', { file_path: 'z.ts' }), + ]) + expect(ops.map(op => op.callId)).toEqual(['r']) + }) + + it('groups by file newest-first and recovers prior write content', () => { + const ops = extractFileOps([ + call(1, 'write', 'w1', { file_path: 'a.ts', content: 'first' }), + call(2, 'write', 'w2', { file_path: 'a.ts', content: 'second' }), + call(3, 'write', 'w3', { file_path: 'b.ts', content: 'x' }), + ]) + expect([...groupByFile(ops).keys()]).toEqual(['b.ts', 'a.ts']) + expect(knownContentBefore(ops, 'a.ts', ops.find(op => op.callId === 'w2')!)).toBe('first') + }) +}) + +describe('parseReadLines', () => { + it('recovers real line numbers from the read envelope', () => { + const lines = parseReadLines('a.ts\nfile\n\n10: x\n11: y\n\n(Showing lines 10-11 of 20. Use offset=12 to continue.)\n') + expect(lines).toEqual([{ line: 10, text: 'x' }, { line: 11, text: 'y' }]) + }) +}) + +describe('ported diff/highlight engines (sanity)', () => { + it('diffLines pairs a rewrite as mod and folds context into hunks', () => { + const rows = diffLines('hello world', 'hello dsh') + expect(rows.map(r => r.kind)).toEqual(['mod', 'mod']) + const before = Array.from({ length: 6 }, (_, i) => ({ kind: 'context' as const, oldLine: i + 1, newLine: i + 1, text: 'c' + String(i) })) + const change = [{ kind: 'mod' as const, oldLine: 7, newLine: 7, text: 'X' }] + const after = Array.from({ length: 8 }, (_, i) => ({ kind: 'context' as const, oldLine: i + 8, newLine: i + 8, text: 'c' + String(i + 8) })) + const segments = buildDiffSegments([...before, ...change, ...after], 3) + expect(segments.map(s => s.kind)).toEqual(['fold', 'hunk', 'fold']) + }) + + it('coalesces the ported prefix/suffix inline diff into runs', () => { + const { old: oldSide } = diffInline('hello world', 'hello dsh') + expect(coalesceInline(oldSide)).toEqual([ + { text: 'hello ', changed: false }, + { text: 'world', changed: true }, + ]) + }) + + it('highlights keywords and threads block-comment state', () => { + expect(langOfPath('a.cpp')).toBe('cpp') + const open = scanLine('/* header', 'cpp') + expect(open.inBlock).toBe(true) + const mid = scanLine(' * interior note', 'cpp', true) + expect(mid.tokens).toEqual([{ text: ' * interior note', type: 'comment' }]) + const code = scanLine('int main() { return 42; } // done', 'cpp') + expect(code.tokens).toContainEqual({ text: 'int', type: 'keyword' }) + expect(code.tokens).toContainEqual({ text: '42', type: 'number' }) + expect(code.tokens).toContainEqual({ text: '// done', type: 'comment' }) + }) +})