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