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

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