Merge pull request #499 from lhh010/feat/changes-md-reading

feat(changes): markdown reading mode + expanded syntax highlighting
This commit is contained in:
Atlas Zeng
2026-09-07 16:06:27 +08:00
committed by GitHub
37 changed files with 1087 additions and 57 deletions
+1 -1
View File
@@ -44,7 +44,7 @@
- **🌐 内嵌浏览器**:多开网页 tab,后退 / 前进 / 刷新;内容运行在沙箱 iframe;外链默认按协议分流——HTTP 在侧边栏打开、HTTPS 走系统浏览器(设置页可分别调整)
- **💻 真实终端**:xterm.js + node-pty 真实 shell,断线重连回放;可选为模型注入 `terminal_*` 工具
- **📂 模型侧边栏打开(可选)**:全局设置开启后注入 `sidebar_open` 工具——模型可主动在侧边栏打开文件 / 文件夹(树以该目录为根)/ HTTP(S) 网页
- **🌿 文件变动**:Git 视角(真 diff / 历史 / 暂存·提交·还原 / worktree·子仓库选择)与本轮文件视角(模型读 / 写 / 编辑实时追踪,按文件分组、按类型筛选)**双视角合一**;统一 diff 渲染(改蓝配对 + 行内字符级高亮 + 语法着色 + 上下文折叠),底部可拖拽预览面板,可一键展开为独立 diff tab(默认自由浮窗,可在设置改为面板下半 split)
- **🌿 文件变动**:Git 视角(真 diff / 历史 / 暂存·提交·还原 / worktree·子仓库选择)与本轮文件视角(模型读 / 写 / 编辑实时追踪,按文件分组、按类型筛选)**双视角合一**;统一 diff 渲染(改蓝配对 + 行内字符级高亮 + 语法着色(含 mjs/cjs/mts/cts、CSS/SCSS/Less、HTML/XML/SVG/Vue、GraphQL、JSONC/JSON5)+ 上下文折叠),底部可拖拽预览面板,可一键展开为独立 diff tab(默认自由浮窗,可在设置改为面板下半 split);`.md` 操作(读 / 写 / 编辑)预览头部可切换**阅读模式**——经共享 MarkdownText 渲染 GFM 表格 / 任务列表 / 删除线 / 脚注 / 数学公式,本地图片自动改写为 `/sidebar/file` 媒体路由;含 ```mermaid 围栏时走编辑器同款懒加载 mermaid 渲染器(图可点击缩放 / 平移);**敏感内容脱敏**——凭据形态路径整文件遮罩、普通文件按内容形态遮值(api_key: / Bearer / sk- / AKIA / ghp_ / PEM 等,字段名保留),默认开启、预览面板一键开关(localStorage 记忆),仅影响显示、不改会话数据。已知边界:mermaid 无引号节点标签含被遮密钥时,图回退源码(规避:标签加引号);`.html` 操作(读 / 写 / 编辑)预览头部可切换**渲染模式**——复用编辑器同款 `/sidebar/html` 路由 iframe,相对资源(./style.css、img/x.png)同路由解析,分段读取也渲染完整文档,恒定沙箱(opaque origin + CSP 头,无逃生门);`.pdf` 操作(读 / 写 / 编辑)同样可切换**渲染模式**——复用编辑器同款 PDF 预览(媒体路由字节流 + 显式 Blob,浏览器原生查看器内嵌,附下载入口)
- **🧩 后台任务页**:subagent 拓扑 + 后台任务(退出码 / 实时输出 / 强制终止)
- **💬 侧边对话(beta)**:Codex 风格的侧边线程——继承主会话完整上下文(含进行中的回合与工具调用)独立运行,不进入主会话;线程内可持续追问,一键「保存为新会话」提升为顶层会话
- **🪟 双工作台**:右侧栏 + 底部面板;拖 Tab 拆分 / 合并分栏(可跨面板),移动端自动合并全宽抽屉
+1 -1
View File
@@ -44,7 +44,7 @@
- **🌐 Embedded Browser**: multiple web tabs with back / forward / refresh; content runs in a sandboxed iframe; external links are routed by protocol by default — HTTP opens in the sidebar, HTTPS goes to the system browser (both adjustable in settings)
- **💻 Real Terminal**: xterm.js + node-pty real shell, reconnect with transcript replay; optionally injects `terminal_*` tools for the model
- **📂 Model-driven sidebar opens (opt-in)**: with the global setting on, the `sidebar_open` tool lets the model actively open files / folders (tree rooted there) / HTTP(S) pages in the sidebar
- **🌿 Changes**: one tab, two lenses — **Git** (real diff / history / stage·commit·revert / worktree & child-repo selection) and **This Session** (live tracking of every file the model reads / writes / edits, grouped by file with kind filters); a unified diff renderer (mod pairing + intra-line character highlights + syntax coloring + context folding), a draggable bottom preview pane, and one-click expansion into a dedicated diff tab
- **🌿 Changes**: one tab, two lenses — **Git** (real diff / history / stage·commit·revert / worktree & child-repo selection) and **This Session** (live tracking of every file the model reads / writes / edits, grouped by file with kind filters); a unified diff renderer (mod pairing + intra-line character highlights + syntax coloring incl. mjs/cjs/mts/cts, CSS/SCSS/Less, HTML/XML/SVG/Vue, GraphQL, JSONC/JSON5 + context folding), a draggable bottom preview pane, and one-click expansion into a dedicated diff tab; `.md` ops (read / write / edit) offer a **reading-mode** toggle in the preview header — the shared MarkdownText renders GFM tables / task lists / strikethrough / footnotes / math, with local images rewritten through the /sidebar/file media route; documents with ```mermaid fences render through the editor's lazy mermaid renderer (click-to-zoom / pan diagrams); **secret redaction** — credential-shaped paths mask whole files and ordinary files mask secret-shaped values (api_key: / Bearer / sk- / AKIA / ghp_ / PEM …, field names kept), on by default with a one-click preview-pane toggle (persisted in localStorage), display-only — session data untouched. Known edge: an unquoted mermaid label containing a redacted secret breaks the diagram (falls back to source); workaround: quote the label. `.html` ops (read / write / edit) gain a **Render** toggle in the preview head — the editor's `/sidebar/html` route iframe, with relative assets resolving inside the route and segmented reads rendering the full document; always sandboxed (opaque origin + CSP header, no escape hatch). `.pdf` ops (read / write / edit) gain the same **Render** toggle — the editor's PDF preview reused verbatim (media-route bytes + explicit Blob, the browser's native viewer inline, with a download fallback)
- **🧩 Background Tasks**: agent topology + background tasks (exit codes / live output / force-kill)
- **💬 Side Chat (beta)**: Codex-style side threads — the child inherits the parent's FULL context (completed turns + the pending question + the in-progress turn's assistant output and tool activity, honestly frozen as "interrupted") and runs independently without entering the main conversation; threads support continuous follow-ups (auto-resumed after a DSH restart) and one-click "Save as new session" promotion to a top-level session
- **🪟 Dual Workbench**: right sidebar + bottom panel; drag tabs to split / merge panes (cross-panel), mobile auto-merges into a full-width drawer
@@ -0,0 +1,61 @@
# 变更页操作预览增强设计(2026-09-06)
> 状态:已实现(PR #499)。本文档记录六个特性的设计、关键取舍与已知限制,含 2026-09-06 规范化审查后的整改记录。特性移植自 dsh-file-trace v0.2.0/v0.2.1 会话(PDF 渲染为作者后续追加)。
## 背景
变更页(Changes tab)底部预览面板此前只渲染统一 diff / 逐行读取 / 错误文本。本批扩展为:markdown 文档可切「阅读模式」、mermaid 围栏随阅读模式渲染、预览载荷做「敏感内容脱敏」、`.html` / `.pdf` 操作可切「渲染模式」,并把 diff 引擎的语法着色表扩到此前无色/错配的一批语言。全部特性共用同一预览面板与头部 toggle 交互。
## 1. Markdown 阅读模式
- 适用:`.md / .markdown / .mdx` 的 read / write / edit 操作(非错误)→ 头部「阅读 / 原文」toggle。
- 渲染走共享 `MarkdownText`(与编辑器预览、Side Chat 同一栈,labels 契约经 `markdownTextProps()`,见 AGENTS §3.3);本地图片目的地经 `rewriteLocalImageUrls` 改写到会话域 `/sidebar/file` 媒体路由。
- 内容来源:read → `parseReadContent()`(见下);write → 写入内容;edit → 已知 prior 时 `prior.replace(oldString, newString)` 重建结果文档,否则仅展示 newString(设计上「prior 已知才重建」)。
- `parseReadContent`(`src/client/changes/ops.ts`):与 `parseReadLines` 共享 `contentLines()` envelope 核心(`<content>` 提取 + "(Showing lines …)" 过滤);区别在前者剥 `<n>: ` 前缀后**保留空行**(markdown 块结构依赖空行)并修剪首尾空行(渲染不可见),后者解析行号、丢弃空行。
## 2. Mermaid 围栏渲染
- 阅读模式检测到 ```mermaid 围栏时,走编辑器同款懒加载渲染器(`LazyMermaidMarkdown`,mermaid chunk 经 `/sidebar/bundle` 按需下发);无围栏文档保持单次 MarkdownText 直渲染(字节不变)。
- `LazyMermaidMarkdown` 的定义从 `MarkdownHtml.tsx` 移到轻量模块 `src/client/mermaid-lazy.tsx`:变更页(核心 bundle)要引用这个 stub 而不把 `MarkdownHtml` 的 DOMPurify/HTML 分析机械拖进核心 bundle;`MarkdownHtml.tsx` 改为 re-export。
- 已知边界:无引号节点标签内含被遮密钥(`[REDACTED]` 字面)时 mermaid 解析失败,回落到「错误条 + 已遮源码」——该回落是 `mermaid.tsx` 既有机制(catch → 错误 + 源码),本批未新增代码,亦未为该路径加测试(README 已注明规避方式:标签加引号)。
## 3. 语法着色扩展(`src/client/diff/highlight.ts`)
- `mjs/cjs → JS_WORDS`、`mts/cts → TS_WORDS`(此前无映射 → 纯文本);`tsx/jsx` 同用共享词表(规范化整改:原先保留内容相同的内联副本)。
- `css/scss/less → CSS_LANG`:slash-star 块注释跨行、连字符属性词(`background-color` 整词着色)、字符串、十六进制数;此前误用 `#` 行注释配置导致不着色。
- `html/htm/xml/svg/vue → MARKUP_LANG`:`<!-- -->` 注释 + 常见标签/属性;`graphql/gql`、`jsonc/json5` 映射。
- 着色是启发式(词表驱动单遍扫描);CSS/MARKUP 词表混排选择器/属性/标签是刻意为之——`LangConfig` 只有一个 keywords 集,再分类属过度设计。
## 4. 敏感内容脱敏(`src/client/redact.ts`)
- 动机:追踪器把 read/write 工具结果重放进 DOM;模型读取凭据文件时明文不应上屏。**仅显示层**:会话日志与 fs 层字节不动;不是对同上下文恶意插件的安全边界。
- 两层启发式:
1. **路径层**:文件名含凭据形态(`.env`、`secret(s)`、`credential(s)`、`token`、`api-key`、`password(s)`、私钥/证书扩展名…)→ 整文件逐行 `[REDACTED]`。匹配带**右边界**(模式后不得继 `[a-z0-9]`),且复数只收「密钥仓库」义(credentials/secrets/passwords),不收 `tokens`(api-tokens.md 通常是文档)——避免 `tokenizer.ts` / `dev.environment.ts` / `style.keys.ts` / `api-tokens.md` 被整文件误伤。
2. **内容层**:普通文件按行遮值——赋值形(`api_key:` / `TOKEN=` / `"password":`,**字段名保留**)、`Bearer` 头、`sk-`/`AKIA`/`ghp_`/`gho_`/`xox*`/`ya29.` 前缀 token、PEM 私钥块头。字段表**不含裸词 `key`/`auth`/`token`/`pass`**(规范化整改:这些词标注的普通内容远多于密钥——键盘键名、CSS 自定义属性、词法 token;Bearer 场景由 `BEARER_RE` 单独覆盖)。
- 开关:默认开,头部一键切换,localStorage 记忆(key `dsh-sidebar:v1:redaction`,仓库既有前缀)。所有载荷消费方(diff 行、读取行、阅读模式源、错误文本、prior)统一从**遮蔽后**形状渲染;命中时头部显示「已脱敏」条。
## 5. HTML 渲染模式
- 适用:`.html / .htm` 操作(与编辑器 html viewer 的扩展集对齐;规范化整改:去掉越权的 `.xhtml`)→ 头部「渲染 / 原文」toggle。
- 实现:`HtmlRenderPreview`(`DiffPane.tsx` 导出,供沙箱 spec 钉契约)以 route-src iframe 加载 `/sidebar/html`——相对资源(`./style.css`、`img/x.png`)在路由内解析;分段读取也能渲染完整文档。
- **语义边界(刻意)**:路由服务的是**盘上已保存文件**而非操作快照——失败/未落盘的写入会渲染旧内容(README 已声明)。共享常量 `HTML_IFRAME_SANDBOX` 提到 `src/client/html-preview.ts`(编辑器与变更页共用);此面**恒定沙箱**(opaque origin + CSP 头),编辑器 tab 才持有带警告的去沙箱逃生门。
## 6. PDF 渲染模式(作者 2026-09-06 追加)
- 适用:`.pdf` 操作(非错误)→ 头部「渲染 / 原文」toggle,复用编辑器 `PdfView`(媒体路由字节流 + 显式类型 Blob → 浏览器原生 PDF 查看器内嵌,附下载入口)。规范化整改:扩展名正则补 `\.` 转义(原 `/.pdf$/i` 会误命中任何以 pdf 结尾的路径)、toggle 并入 `PaneToggle`。无独立单测(PdfView 行为由编辑器侧既有测试覆盖)。
## 规范化整改(2026-09-06 审查后)
- **皮肤契约**:`.htmlFrame` 背景硬编码 `#fff` → `var(--dsw-alias-bg-base)`(对齐 `.editorHtml`);`.redactBanner` 原用的 `--dsw-alias-state-warning-primary` 在 DSW 词表中**不存在**(实际为 `state-warn-*`),var() 恒回落到硬编码 `#b8860b` → 改用真实令牌 `var(--dsw-alias-state-warn-primary)`。
- **约定对齐**:localStorage key 并入 `dsh-sidebar:v1:` 前缀;`tests/redact.spec.ts` 源码 import 由 `.js` 改 `.ts`(仓库惯例);DiffPane 三份同形 toggle 按钮提取 `PaneToggle`,阅读视图提取 `MdReadingView`(吸收 mermaid 分支与 codeLabels 计算);遮蔽 useMemo 单遍化(每字段一次 `redactText`,此前 hit 扫描 + mask 双遍)。
- **去重**:`tsx/jsx` 共享 `TS_WORDS`/`JS_WORDS`;`parseReadContent`/`parseReadLines` 共享 `contentLines()`;CSS 词表去重(`hidden`/`inherit` 重复项)并去掉 CSS 没有的 `true false` 常量。
## 已知限制
- 脱敏是启发式:右边界规则放行 `mytoken.yaml`(命中遮蔽)而拒绝 `api-tokens.md`(文档);字段级遮蔽不覆盖未知命名的密钥字段——按 token 形态(`sk-` 等前缀)兜底。一键关闭可在受信任环境看原文。
- HTML 渲染读盘上文件(见 §5);mermaid 无引号标签含遮蔽密钥时回落源码(见 §2)。
- 阅读模式 edit 重建依赖窗口内已知 prior;窗口外旧内容不可知时仅展示新串(面板本就以「已知内容」为限)。
## 测试
`tests/redact.spec.ts`(两层遮蔽 + 边界/裸词误伤回归)、`tests/diff-highlight.spec.ts`(后缀映射、CSS 注释穿行/属性、markup 注释/标签、mjs 关键字)、`tests/changes-ops.spec.ts`(`parseReadContent` 空行保留)、`tests/changes-tab.spec.tsx`(阅读模式端到端:h1/GFM 表格/本地图片改写/切回原文)、`tests/sandbox-views.spec.tsx`(HTML 渲染 iframe 恒定沙箱 + toggle 出现条件)。
+6 -8
View File
@@ -19,24 +19,22 @@ import { useLayoutEffect, useMemo, useRef } from 'react'
import { createElement, type ReactNode } from 'react'
import DOMPurify from 'dompurify'
import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ComponentType } from 'react'
import { markdownTextProps } from './markdown-labels.tsx'
import { lazyChunkComponent } from './lazy-chunk.tsx'
import { resolveLocalMediaDest, rewriteLocalImageUrls } from './markdown-images.ts'
import {
analyzeHtmlSegment,
type AnalyzedMarkdownHtml,
} from './markdown-html.ts'
import { splitMermaidBlocks, type MermaidMarkdownProps } from './mermaid-blocks.ts'
import { splitMermaidBlocks } from './mermaid-blocks.ts'
import type { SessionScope } from './api.ts'
import css from './sidebar.module.css'
/** The chunk-resident markdown renderer (mermaid lazy chunk), shared with the
* legacy no-HTML preview path in TextEditor. */
export const LazyMermaidMarkdown = lazyChunkComponent<MermaidMarkdownProps>(
'mermaid',
(mod) => mod.MermaidMarkdown as ComponentType<MermaidMarkdownProps> | undefined,
)
* legacy no-HTML preview path in TextEditor. Defined in mermaid-lazy.tsx (a
* light module) so core-bundle consumers can import the stub without
* dragging DOMPurify along. */
export { LazyMermaidMarkdown } from './mermaid-lazy.tsx'
import { LazyMermaidMarkdown } from './mermaid-lazy.tsx'
/** Everything the sanitizers need to resolve local media + scope the route. */
export interface MarkdownHtmlMedia {
+1 -9
View File
@@ -36,21 +36,13 @@ import { LazyMermaidMarkdown, MarkdownDocument, type MarkdownHtmlMedia } from '.
import { MdToc } from './md-toc.tsx'
import { splitMermaidBlocks } from './mermaid-blocks.ts'
import { t } from './locales.ts'
import { HTML_IFRAME_SANDBOX } from './html-preview.ts'
import type { EditorToolbarState, FileViewerProps } from './service.ts'
import css from './sidebar.module.css'
/** Previewable files (rendered output vs source editing). */
type ViewMode = 'preview' | 'edit'
/**
* The sandbox tokens of the HTML preview iframe. NO allow-same-origin (the
* preview must stay in an opaque origin — with the route's own origin it
* could read session data) and NO allow-top-navigation (a previewed page
* must not hijack the GUI). The user can disable the sandbox per-feature
* in the side card settings (warned); the toggle below reflects it.
*/
export const HTML_IFRAME_SANDBOX = 'allow-scripts allow-popups allow-downloads allow-modals'
/** Per-file preview scroll memory. Module-level so it survives viewer
* remounts: the save-then-switch-to-preview reload (EditorHost #215 case B)
* rebuilds the whole TextEditor instance, and without this the preview
+209 -7
View File
@@ -9,26 +9,37 @@
* diff tab via the shell.
*/
import { useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'
import { IconCloseOutline16, IconRefreshOutline16, IconRightUpOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconCloseOutline16, IconRefreshOutline16, IconRightUpOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SessionScope } from '../api.ts'
import { api } from '../api.ts'
import { api, htmlUrl } from '../api.ts'
import { t } from '../locales.ts'
import { baseName } from '../paths.ts'
import { resolveSidebarPath } from '../produced-files.ts'
import { HTML_IFRAME_SANDBOX } from '../html-preview.ts'
import type { SidebarDiffRef, SidebarTab } from '../state.ts'
import { DiffRows, ReadRows } from '../diff/DiffRows.tsx'
import { PdfView } from '../PdfView.tsx'
import { DiffFiles } from '../diff/DiffFiles.tsx'
import { langOfPath } from '../diff/highlight.ts'
import { buildDiffSegments, diffLines, diffStats, parseUnifiedDiff, unifiedSegments, type DiffRow } from '../diff/rows.ts'
import { parseReadLines, type FileOp } from './ops.ts'
import { parseReadContent, parseReadLines, type FileOp } from './ops.ts'
import { redactText } from '../redact.ts'
import { rewriteLocalImageUrls } from '../markdown-images.ts'
import { markdownTextProps } from '../markdown-labels.tsx'
import { splitMermaidBlocks } from '../mermaid-blocks.ts'
import { LazyMermaidMarkdown } from '../mermaid-lazy.tsx'
import { createFrameBatcher } from '../frame-batcher.ts'
import css from './changes.module.css'
import diffCss from '../diff/diff.module.css'
/** The drag handle height clamp (px) and keyboard-resize step. */
/** Drag handle height clamp (px) and keyboard-resize step. */
const HEIGHT_MIN = 140
const HEIGHT_STEP = 24
/** The redaction preference, persisted under the repo's sidebar storage
* prefix (see state.ts's `dsh-sidebar:v1`). */
const REDACTION_KEY = 'dsh-sidebar:v1:redaction'
/** What the pane is showing right now. */
export type ChangesPreview =
| { kind: 'git'; ref: SidebarDiffRef }
@@ -55,6 +66,61 @@ function diffOf(op: FileOp, prior: string | undefined): readonly DiffRow[] {
return []
}
/** The render view of one html op target: the route-src iframe. Extracted
* (and exported) so the always-sandboxed contract is pinned directly by the
* sandbox spec — this surface has NO no-sandbox escape hatch. */
export function HtmlRenderPreview(props: { src: string; title: string }) {
return (
<div className={css.htmlPane}>
<iframe
className={css.htmlFrame}
title={props.title}
src={props.src}
sandbox={HTML_IFRAME_SANDBOX}
referrerPolicy="no-referrer"
allow=""
/>
</div>
)
}
/** One header pill toggle — the redaction / reading / render toggles share
* the shape (on-state styling + aria-pressed). */
function PaneToggle(props: { on: boolean; label: string; title?: string; onClick: () => void }) {
return (
<button
type="button"
className={css.mdToggle}
data-on={props.on ? 'true' : undefined}
aria-pressed={props.on}
title={props.title}
onClick={props.onClick}
>
{props.label}
</button>
)
}
/** The reading-mode body of one markdown op target: the shared MarkdownText
* pass (local image destinations already rewritten to the media route by
* the caller). Mermaid fences render through the same chunk-resident
* renderer the editor preview uses (one MarkdownText pass with the fences
* lifted out); the plain path stays byte-for-byte for documents without
* any. */
function MdReadingView(props: { text: string }) {
const codeLabels = { copyLabel: t('copy'), copiedLabel: t('copied') }
const hasMermaid = splitMermaidBlocks(props.text).some((block) => block.kind === 'mermaid')
return (
<div className={css.paneBody}>
<div className={css.mdBody}>
{hasMermaid
? <LazyMermaidMarkdown text={props.text} codeLabels={codeLabels} />
: <MarkdownText {...markdownTextProps(props.text, codeLabels)} />}
</div>
</div>
)
}
/** The diff tab a git preview expands into (the shell owns placement). */
export function diffTabOf(ref: SidebarDiffRef): SidebarTab {
if (ref.kind === 'worktree') {
@@ -147,8 +213,52 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
// ── Op target material (pure snapshots; the prior content came with the
// target so a running op shows what is already known). ────────────────
const op = target.kind === 'op' ? target.op : null
const prior = target.kind === 'op' ? target.prior : undefined
const opRaw = target.kind === 'op' ? target.op : null
const priorRaw = target.kind === 'op' ? target.prior : undefined
// Secret redaction: on by default, toggle persists per browser (localStorage).
// Every op payload consumer below (diff rows, read rows, markdown source,
// error text) renders from the REDACTED shape, so masked payloads are the
// only thing that can reach the DOM while the toggle is on. Display-only:
// session events and the fs layer keep their original bytes.
const [redactionOn, setRedactionOn] = useState((): boolean => {
try { return localStorage.getItem(REDACTION_KEY) !== '0' } catch { return true }
})
const toggleRedaction = (): void => {
setRedactionOn((prev) => {
const next = !prev
try { localStorage.setItem(REDACTION_KEY, next ? '1' : '0') } catch { /* storage unavailable */ }
return next
})
}
const { op, prior, redactionHit } = useMemo(() => {
if (opRaw === null || !redactionOn) return { op: opRaw, prior: priorRaw, redactionHit: false }
const path = target.kind === 'op' ? target.path : ''
// One redactText pass per field: the outcome carries both the masked
// text and whether anything was hit.
const mask = (text: string | undefined): { masked: string | undefined; hit: boolean } => {
if (text === undefined) return { masked: undefined, hit: false }
const outcome = redactText(path, text)
return { masked: outcome.text, hit: outcome.hit }
}
const read = mask(opRaw.read)
const content = mask(opRaw.content)
const editOld = mask(opRaw.edit?.oldString)
const editNew = mask(opRaw.edit?.newString)
const errorText = mask(opRaw.errorText)
const priorMasked = mask(priorRaw)
const hit = read.hit || content.hit || editOld.hit || editNew.hit || errorText.hit || priorMasked.hit
if (!hit) return { op: opRaw, prior: priorRaw, redactionHit: false }
const redacted: FileOp = {
...opRaw,
...(read.masked !== undefined ? { read: read.masked } : {}),
...(content.masked !== undefined ? { content: content.masked } : {}),
...(opRaw.edit !== undefined
? { edit: { oldString: editOld.masked ?? opRaw.edit.oldString, newString: editNew.masked ?? opRaw.edit.newString } }
: {}),
...(errorText.masked !== undefined ? { errorText: errorText.masked } : {}),
}
return { op: redacted, prior: priorMasked.masked, redactionHit: true }
}, [opRaw, priorRaw, target, redactionOn])
const opLang = useMemo(() => (target.kind === 'op' ? langOfPath(target.path) : undefined), [target])
const opRows = useMemo(() => (op === null ? [] : diffOf(op, prior)), [op, prior])
const opSegments = useMemo(() => buildDiffSegments(opRows), [opRows])
@@ -158,6 +268,56 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
[op],
)
// ── Markdown reading mode: .md op targets (read/write/edit, non-error)
// toggle between the raw/diff view and the rendered document — the same
// shared MarkdownText pass the editor preview uses, with local image
// destinations rewritten through the /sidebar/file media route. ──────
const mdOp = target.kind === 'op' && !target.op.isError && /\.(md|markdown|mdx)$/i.test(target.path)
const [reading, setReading] = useState(false)
const readingSrc = useMemo(() => {
if (!mdOp || op === null) return ''
if (op.kind === 'read') return parseReadContent(op.read ?? '')
if (op.kind === 'write') return op.content ?? ''
if (op.kind === 'edit' && op.edit !== undefined) {
if (prior !== undefined && prior.includes(op.edit.oldString)) {
return prior.replace(op.edit.oldString, op.edit.newString)
}
return op.edit.newString
}
return ''
}, [mdOp, op, prior])
const readingText = useMemo(
() => (mdOp && reading && readingSrc !== '' && target.kind === 'op'
? rewriteLocalImageUrls(readingSrc, scope, target.path, window.location.origin)
: ''),
[mdOp, reading, readingSrc, scope, target],
)
// ── HTML render mode: .html/.htm op targets (the editor html viewer's
// ext set) load the SAVED file through the same /sidebar/html route the
// editor's html viewer uses — relative assets (./style.css, img/x.png)
// resolve inside the route, and a segmented read still renders the
// whole document (the route serves the file, not the op snapshot). The
// frame is always sandboxed (the attribute plus the route's CSP sandbox
// header); the editor tab owns the warned no-sandbox escape hatch. ──
const htmlOp = target.kind === 'op' && !target.op.isError && /\.(html?)$/i.test(target.path)
const [rendering, setRendering] = useState(false)
const htmlRenderSrc = useMemo(() => {
if (!htmlOp || target.kind !== 'op') return ''
return htmlUrl(scope, resolveSidebarPath(scope.cwd, target.path))
}, [htmlOp, scope, target])
// ── PDF render mode: .pdf op targets (read / write / edit, non-error) reuse
// the editor's PdfView verbatim — media-route bytes wrapped into an
// explicitly-typed Blob so the browser's native PDF viewer opens (a
// direct iframe src can fall back to a download). ──────────────────────
const pdfOp = target.kind === 'op' && !target.op.isError && /\.pdf$/i.test(target.path)
const [renderingPdf, setRenderingPdf] = useState(false)
const pdfRenderPath = useMemo(() => {
if (!pdfOp || target.kind !== 'op') return ''
return resolveSidebarPath(scope.cwd, target.path)
}, [pdfOp, scope, target])
// Header stats for git targets come off the parsed patch text.
const gitStats = useMemo(() => {
if (target.kind !== 'git' || diffText === null || diffText === '') return null
@@ -264,6 +424,38 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
</button>
</>
)}
{redactionHit && (
<span className={css.redactBanner} role="status">{t('changesRedactBanner')}</span>
)}
{target.kind === 'op' && (
<PaneToggle
on={redactionOn}
label={redactionOn ? t('changesRedactOnLabel') : t('changesRedactOffLabel')}
title={redactionOn ? t('changesRedactOff') : t('changesRedactOn')}
onClick={toggleRedaction}
/>
)}
{mdOp && (
<PaneToggle
on={reading}
label={t(reading ? 'changesMdRaw' : 'changesMdReading')}
onClick={() => { setReading(value => !value) }}
/>
)}
{htmlOp && (
<PaneToggle
on={rendering}
label={t(rendering ? 'changesHtmlRaw' : 'changesHtmlRender')}
onClick={() => { setRendering(value => !value) }}
/>
)}
{pdfOp && (
<PaneToggle
on={renderingPdf}
label={t(renderingPdf ? 'changesPdfRaw' : 'changesPdfRender')}
onClick={() => { setRenderingPdf(value => !value) }}
/>
)}
<button
type="button"
className={css.iconButton}
@@ -274,7 +466,17 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
<IconCloseOutline16 size={14} />
</button>
</div>
{target.kind === 'op' && op !== null && op.isError
{target.kind === 'op' && htmlOp && rendering && htmlRenderSrc !== ''
? <HtmlRenderPreview src={htmlRenderSrc} title={target.path} />
: target.kind === 'op' && pdfOp && renderingPdf && pdfRenderPath !== ''
? (
<div className={css.htmlPane}>
<PdfView scope={scope} path={pdfRenderPath} title={target.path} />
</div>
)
: target.kind === 'op' && mdOp && reading && readingText !== ''
? <MdReadingView text={readingText} />
: target.kind === 'op' && op !== null && op.isError
? (
<div className={css.paneBody}>
<div className={css.readError} role="alert">
+8
View File
@@ -342,6 +342,14 @@
/* ── Shared preview pane ───────────────────────────────────────────────── */
.redactBanner { flex: none; font: var(--dsw-font-xxxs-strong-11); padding: 2px 8px; border-radius: 7px; color: var(--dsw-alias-state-warn-primary); background: var(--dsw-alias-interactive-bg-hover); white-space: nowrap; }
.mdToggle { flex: none; border: 1px solid var(--dsw-alias-border-l1); border-radius: 7px; background: transparent; color: var(--dsw-alias-label-secondary); font: var(--dsw-font-xxxs-strong-11); padding: 2px 8px; cursor: pointer; }
.mdToggle:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
.mdToggle[data-on='true'] { border-color: var(--dsw-alias-state-business-primary); color: var(--dsw-alias-state-business-primary); }
.mdBody { padding: 12px 16px 20px; font: var(--dsw-font-xs-regular-13); line-height: 1.6; min-height: 0; overflow-y: auto; }
.htmlPane { flex: 1; min-height: 0; display: flex; }
.htmlFrame { flex: 1; width: 100%; border: none; background: var(--dsw-alias-bg-base); }
.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); }
+28 -4
View File
@@ -201,6 +201,33 @@ export function knownContentBefore(ops: readonly FileOp[], path: string, before:
return undefined
}
/**
* The read-tool response's `<content>` body split into lines with the
* "(Showing lines …)" note dropped — the shared envelope core both read
* parsers consume: {@link parseReadLines} recovers line numbers from the
* "<n>: " prefixes, {@link parseReadContent} keeps the raw text.
*/
function contentLines(raw: string): string[] {
const contentMatch = raw.match(/<content>([\s\S]*?)<\/content>/)
const body = contentMatch ? contentMatch[1]! : raw
return body.split('\n').filter((line) => !/^\s*\(Showing lines .*\)\s*$/.test(line))
}
/**
* Strip the DSH read-tool response envelope down to the file's raw content,
* PRESERVING blank lines (unlike {@link parseReadLines}, which drops them):
* drops the <content> wrapper, the "(Showing lines ...)" note, and the
* per-line "<n>: " prefixes. The markdown reading mode needs the blank lines
* because markdown block structure depends on them.
*/
export function parseReadContent(raw: string): string {
return contentLines(raw)
.map((line) => line.replace(/^\s*\d+:\s/, ''))
.join('\n')
.replace(/^[\n]+/, '')
.replace(/\n+$/, '')
}
/** One parsed read line: the file's own line number and its content text. */
export interface ReadLine { readonly line: number; readonly text: string }
@@ -210,12 +237,9 @@ export interface ReadLine { readonly line: number; readonly text: string }
* "<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
for (const line of contentLines(raw)) {
if (line.length === 0) continue
const match = line.match(/^\s*(\d+):\s?(.*)$/)
if (match !== null) {
+85 -25
View File
@@ -132,28 +132,77 @@ const MD_LANG: LangConfig = {
wordBody: WORD,
}
/** JS keyword list, shared by js/jsx/mjs/cjs. */
const JS_WORDS = `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`
/** TS keyword list, shared by ts/tsx/mts/cts. */
const TS_WORDS = `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`
/** CSS family: block comments delimited by slash-star, hyphenated property words. */
const CSS_LANG: LangConfig = {
lineComments: [],
blockComment: ['/*', '*/'],
strings: ['"', "'"],
keywords: kw(`media import charset keyframes font-face supports page namespace layer scope container
property value at-rule when and not only from to important
background background-color background-image background-position background-size
border border-radius border-color border-width border-style bottom box-shadow box-sizing
color content cursor clip clip-path clear display direction
flex flex-direction flex-wrap flex-flow flex-grow flex-shrink flex-basis
font font-family font-size font-style font-weight float fill
grid grid-area grid-template grid-template-columns grid-template-rows grid-gap gap
height left letter-spacing line-height list-style margin margin-top margin-right
margin-bottom margin-left max-height max-width min-height min-width opacity order
outline overflow padding padding-top padding-right padding-bottom padding-left
position pointer-events right rotate scale translate transform transform-origin
text-align text-decoration text-transform top transition transition-property
user-select vertical-align visibility white-space width word-break word-spacing z-index
align-items align-content align-self justify-content justify-items justify-self
aspect-ratio inset object-fit object-position resize scroll-behavior filter backdrop-filter
animation animation-name animation-duration animation-timing-function animation-delay
will-change
html body p a div span li ul ol table tr td th form input button label select textarea img
section header footer main nav article aside h1 h2 h3 h4 h5 h6 i b em strong small pre code
inherit initial unset auto none fixed absolute relative sticky static flex block inline
inline-block inline-flex grid hidden visible bold normal root var calc env`),
constants: new Set(),
macro: false,
wordStart: /[A-Za-z-]/u,
wordBody: /[A-Za-z0-9-]/u,
}
/** Markup family (HTML/XML/SVG/Vue): markup comments plus common tags. */
const MARKUP_LANG: LangConfig = {
lineComments: [],
blockComment: ['<!--', '-->'],
strings: ['"', "'"],
keywords: kw(`html head body title meta link script style template slot
div span p a img ul ol li table thead tbody tr th td form input button label select
option textarea header footer main nav section article aside figure figcaption
h1 h2 h3 h4 h5 h6 strong em b i u s small br hr pre code blockquote
svg path circle rect line polyline polygon g defs use text symbol
xml doctype class id href src type value name content charset async defer
vue component props setup script-style export import v-if v-for v-bind v-on`),
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`),
ts: C_FAMILY(TS_WORDS),
tsx: C_FAMILY(TS_WORDS),
js: C_FAMILY(JS_WORDS),
jsx: C_FAMILY(JS_WORDS),
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
@@ -225,11 +274,22 @@ const LANGS: Readonly<Record<string, LangConfig>> = {
md: MD_LANG,
markdown: MD_LANG,
mdx: MD_LANG,
html: CONFIG_LANG,
htm: CONFIG_LANG,
css: HASH_FAMILY(''),
scss: HASH_FAMILY(''),
less: HASH_FAMILY(''),
mjs: C_FAMILY(JS_WORDS),
cjs: C_FAMILY(JS_WORDS),
mts: C_FAMILY(TS_WORDS),
cts: C_FAMILY(TS_WORDS),
jsonc: CONFIG_LANG,
json5: CONFIG_LANG,
html: MARKUP_LANG,
htm: MARKUP_LANG,
xml: MARKUP_LANG,
svg: MARKUP_LANG,
vue: MARKUP_LANG,
css: CSS_LANG,
scss: CSS_LANG,
less: CSS_LANG,
graphql: HASH_FAMILY('query mutation fragment on directive enum input interface scalar schema type implements'),
gql: HASH_FAMILY('query mutation fragment on directive enum input interface scalar schema type implements'),
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`),
}
+15
View File
@@ -0,0 +1,15 @@
/**
* Shared vocabulary of the sidebar's HTML preview surfaces (the editor's
* html viewer and the changes tab's op render preview): the sandbox tokens
* both preview iframes load with.
*/
/**
* The sandbox tokens of an HTML preview iframe. NO allow-same-origin (the
* preview must stay in an opaque origin — with the route's own origin it
* could read session data) and NO allow-top-navigation (a previewed page
* must not hijack the GUI). The editor viewer can disable the sandbox
* per-feature in the side card settings (warned); the changes tab's op
* render preview always stays sandboxed.
*/
export const HTML_IFRAME_SANDBOX = 'allow-scripts allow-popups allow-downloads allow-modals'
+11
View File
@@ -20,6 +20,12 @@ export const ar: Record<string, string> = {
files: 'الملفات',
changesSessionEmpty: 'لا توجد عمليات ملفات في هذه الجلسة بعد',
changesRead: 'قراءة',
changesMdReading: 'قراءة',
changesHtmlRender: "عرض",
changesHtmlRaw: "المصدر",
changesPdfRender: "عرض",
changesPdfRaw: "المصدر",
changesMdRaw: 'الخام',
changesWrite: 'كتابة',
changesEdit: 'تحرير',
changesRunning: 'قيد التنفيذ',
@@ -429,6 +435,11 @@ export const ar: Record<string, string> = {
pluginDocsPanelDesc: '«وثائق عامة» في شريط DSH الجانبي: ملاحظات Markdown عامة، قابلة للقراءة من أي مساحة عمل — قائمة ملفات، مخطط تفصيلي، فتح في Chrome / VS Code، وأزرار نسخ؛ دليل الوثائق قابل للتكوين (الافتراضي ~/.dsh/docs)',
pluginEgoBrowserDesc: 'متصفح الوكيل لـ DeepSeek Harness: 32 أداة ego_* تقود متصفح Chromium حقيقي، مع تبويب «متصفح ego» أصلي في الشريط الجانبي يعرض مباشرة كل صفحة يزورها الوكيل — يمكنك النقر والسحب والكتابة لتولي التحكم. يسجّل التبويب تلقائيًا عند وجود better-sidebar، وإلا يظهر كفقاعة عائمة',
pluginBilingualReaderDesc: 'اقرأ ملفات PDF في الشريط الجانبي لـ DSH: عرض PDF أصلي، حدد نصًا لترجمته بواسطة النموذج اللغوي مع السياق، معزولًا تمامًا عن المحادثة الرئيسية',
changesRedactOn: "تنقيع مُفعّل: تظهر الأسرار كـ [REDACTED]؛ انقر للتعطيل (على مسؤوليتك)",
changesRedactOff: "تنقيع مُعطّل: سيُعرض المحتوى كما هو؛ انقر للتفعيل",
changesRedactOnLabel: "تنقيع",
changesRedactOffLabel: "بدون تنقيع",
changesRedactBanner: "مُنقّى",
pluginSentinelName: 'dsh-sentinel نظام الاستيقاظ',
pluginEgoBrowserName: 'ego-browser متصفح الوكيل',
pluginBetterOverleafName: 'dsh-better-overleaf تبويب Overleaf',
+11
View File
@@ -5,6 +5,12 @@ export const de: Record<string, string> = {
files: 'Dateien',
changesSessionEmpty: 'Noch keine Dateioperationen in dieser Sitzung',
changesRead: 'Lesen',
changesMdReading: 'Lesen',
changesHtmlRender: "Rendern",
changesHtmlRaw: "Quelltext",
changesPdfRender: "Rendern",
changesPdfRaw: "Quelltext",
changesMdRaw: 'Rohfassung',
changesWrite: 'Schreiben',
changesEdit: 'Bearbeiten',
changesRunning: 'läuft',
@@ -414,6 +420,11 @@ export const de: Record<string, string> = {
pluginDocsPanelDesc: '„Globale Dokumente“ in der DSH-Seitenleiste: eigene Markdown-Notizen aus jedem Arbeitsbereich lesen – Dateiliste, Gliederung, Öffnen in Chrome / VS Code und Kopieren-Buttons; das Dokumentenverzeichnis ist konfigurierbar (Standard ~/.dsh/docs)',
pluginEgoBrowserDesc: 'Der Agent-Browser für DeepSeek Harness: 32 ego_*-Tools steuern ein echtes Chromium; ein nativer „ego-Browser“-Tab in der Seitenleiste zeigt live jede Seite, die der Agent besucht – Klicken, Ziehen und Tippen zum Übernehmen. Registriert den Tab automatisch, wenn better-sidebar vorhanden ist, sonst eine schwebende Blase',
pluginBilingualReaderDesc: 'PDFs im DSH-Seitenbereich lesen: native PDF-Anzeige, Text auswählen und mit dem LLM übersetzen — mit Kontext, vollständig vom Hauptgespräch isoliert',
changesRedactOn: "Schwärzung aktiv: Geheimnisse als [REDACTED]; Klicken zum Deaktivieren (auf eigenes Risiko)",
changesRedactOff: "Schwärzung aus: Inhalte werden unverändert angezeigt; Klicken zum Aktivieren",
changesRedactOnLabel: "Schwärzung",
changesRedactOffLabel: "Ohne Schwärzung",
changesRedactBanner: "Geschwärzt",
pluginSentinelName: 'dsh-sentinel Wecksystem',
pluginEgoBrowserName: 'ego-browser Agent-Browser',
pluginBetterOverleafName: 'dsh-better-overleaf Overleaf-Tab',
+11
View File
@@ -12,6 +12,12 @@ export const fr: Record<string, string> = {
files: 'Fichiers',
changesSessionEmpty: 'Aucune opération de fichier dans cette session',
changesRead: 'Lecture',
changesMdReading: 'Lecture',
changesHtmlRender: "Rendu",
changesHtmlRaw: "Source",
changesPdfRender: "Rendu",
changesPdfRaw: "Source",
changesMdRaw: 'Brut',
changesWrite: 'Écriture',
changesEdit: 'Modification',
changesRunning: 'en cours',
@@ -421,6 +427,11 @@ export const fr: Record<string, string> = {
pluginDocsPanelDesc: '« Documentation globale » dans la barre latérale DSH : notes Markdown globales, lisibles depuis n’importe quel espace de travail — sélection dans la liste, saut via le plan flottant, ouverture externe Chrome / VS Code, copie de code, répertoire configurable (défaut ~/.dsh/docs)',
pluginEgoBrowserDesc: 'Le navigateur d’agent pour DeepSeek Harness : 32 outils ego_* pilotent un vrai Chromium ; un onglet natif « ego browser » dans la barre latérale montre en direct chaque page visitée par l’agent — cliquez, glissez et tapez pour reprendre la main. Enregistre l’onglet automatiquement si better-sidebar est présent, sinon une bulle flottante',
pluginBilingualReaderDesc: 'Lire des PDF dans la barre latérale DSH : affichage PDF natif, sélectionnez du texte pour le traduire avec le LLM, avec contexte et totalement isolé de la conversation principale',
changesRedactOn: "Masquage actif : les secrets s'affichent en [REDACTED] ; cliquez pour désactiver (à vos risques)",
changesRedactOff: "Masquage désactivé : le contenu s'affiche tel quel ; cliquez pour activer",
changesRedactOnLabel: "Masquage",
changesRedactOffLabel: "Sans masquage",
changesRedactBanner: "Masqué",
pluginSentinelName: 'dsh-sentinel Système de réveil',
pluginEgoBrowserName: 'ego-browser Navigateur d’agents',
pluginBetterOverleafName: 'dsh-better-overleaf Onglet Overleaf',
+11
View File
@@ -19,6 +19,12 @@ export const hi: Record<string, string> = {
files: 'फ़ाइलें',
changesSessionEmpty: 'इस सत्र में अभी कोई फ़ाइल ऑपरेशन नहीं',
changesRead: 'पढ़ना',
changesMdReading: 'पठन',
changesHtmlRender: "रेंडर",
changesHtmlRaw: "मूल",
changesPdfRender: "रेंडर",
changesPdfRaw: "मूल",
changesMdRaw: 'मूल पाठ',
changesWrite: 'लिखना',
changesEdit: 'संपादन',
changesRunning: 'चल रहा है',
@@ -428,6 +434,11 @@ export const hi: Record<string, string> = {
pluginDocsPanelDesc: 'DSH साइडबार में ग्लोबल डॉक्स: किसी भी वर्कस्पेस से अपने Markdown नोट्स पढ़ें — फ़ाइल सूची, रूपरेखा, Chrome / VS Code में खोलें, और कॉपी बटन; docs डायरेक्टरी कॉन्फ़िगर करने योग्य (डिफ़ॉल्ट ~/.dsh/docs)',
pluginEgoBrowserDesc: 'DeepSeek Harness के लिए एजेंट ब्राउज़र: 32 ego_* टूल असली Chromium चलाते हैं; साइडबार में नेटिव «ego browser» टैब एजेंट के हर विज़िट किए गए पेज को लाइव दिखाता है — आप क्लिक, ड्रैग और टाइप करके नियंत्रण ले सकते हैं। better-sidebar मौजूद होने पर टैब स्वतः रजिस्टर होता है, अन्यथा फ्लोटिंग बबल के रूप में दिखता है',
pluginBilingualReaderDesc: 'DSH साइडबार में PDF पढ़ें: मूल PDF प्रदर्शन, टेक्स्ट चुनकर LLM से अनुवाद करें, संदर्भ के साथ और मुख्य संवाद से पूरी तरह अलग',
changesRedactOn: "रिडैक्शन चालू: संवेदनशील मान [REDACTED] दिखेंगे; बंद करने के लिए क्लिक करें (अपने जोखिम पर)",
changesRedactOff: "रिडैक्शन बंद: सामग्री जैसी है वैसी दिखेगी; चालू करने के लिए क्लिक करें",
changesRedactOnLabel: "रिडैक्शन",
changesRedactOffLabel: "बिना रिडैक्शन",
changesRedactBanner: "रिडैक्टेड",
pluginSentinelName: 'dsh-sentinel वेक-अप सिस्टम',
pluginEgoBrowserName: 'ego-browser एजेंट ब्राउज़र',
pluginBetterOverleafName: 'dsh-better-overleaf Overleaf टैब',
+11
View File
@@ -17,6 +17,12 @@ export const id: Record<string, string> = {
files: 'Berkas',
changesSessionEmpty: 'Belum ada operasi file di sesi ini',
changesRead: 'Baca',
changesMdReading: 'Baca',
changesHtmlRender: "Render",
changesHtmlRaw: "Sumber",
changesPdfRender: "Render",
changesPdfRaw: "Sumber",
changesMdRaw: 'Mentah',
changesWrite: 'Tulis',
changesEdit: 'Edit',
changesRunning: 'berjalan',
@@ -426,6 +432,11 @@ export const id: Record<string, string> = {
pluginDocsPanelDesc: 'Dokumen global di sidebar DSH: baca catatan Markdown Anda sendiri dari ruang kerja apa pun — daftar berkas, kerangka, buka di Chrome / VS Code, dan tombol salin; direktori docs dapat dikonfigurasi (default ~/.dsh/docs)',
pluginEgoBrowserDesc: 'Browser agen untuk DeepSeek Harness: 32 alat ego_* menggerakkan Chromium sungguhan; tab «ego browser» native di bilah samping menampilkan langsung setiap halaman yang dikunjungi agen — Anda bisa mengklik, menyeret, dan mengetik untuk mengambil alih. Tab terdaftar otomatis jika better-sidebar terpasang; jika tidak, jatuh ke gelembung mengambang',
pluginBilingualReaderDesc: 'Baca PDF di sidebar DSH: tampilan PDF asli, pilih teks untuk diterjemahkan dengan LLM, dengan konteks dan sepenuhnya terisolasi dari percakapan utama',
changesRedactOn: "Redaksi aktif: rahasia tampil sebagai [REDACTED]; klik untuk mematikan (risiko Anda sendiri)",
changesRedactOff: "Redaksi mati: konten tampil apa adanya; klik untuk mengaktifkan",
changesRedactOnLabel: "Redaksi",
changesRedactOffLabel: "Tanpa redaksi",
changesRedactBanner: "Teredaksi",
pluginSentinelName: 'dsh-sentinel Sistem bangun',
pluginEgoBrowserName: 'ego-browser Peramban agen',
pluginBetterOverleafName: 'dsh-better-overleaf Tab Overleaf',
+11
View File
@@ -10,6 +10,12 @@ export const it: Record<string, string> = {
files: 'File',
changesSessionEmpty: 'Ancora nessuna operazione sui file in questa sessione',
changesRead: 'Lettura',
changesMdReading: 'Lettura',
changesHtmlRender: "Render",
changesHtmlRaw: "Sorgente",
changesPdfRender: "Render",
changesPdfRaw: "Sorgente",
changesMdRaw: 'Grezzo',
changesWrite: 'Scrittura',
changesEdit: 'Modifica',
changesRunning: 'in esecuzione',
@@ -419,6 +425,11 @@ export const it: Record<string, string> = {
pluginDocsPanelDesc: 'Documenti globali nella barra laterale di DSH: legga le sue note Markdown da qualsiasi spazio di lavoro — una lista di file, una struttura, apertura in Chrome / VS Code e pulsanti di copia; la directory dei documenti è configurabile (predefinita ~/.dsh/docs)',
pluginEgoBrowserDesc: 'Il browser agente per DeepSeek Harness: 32 strumenti ego_* pilotano un vero Chromium; una scheda nativa «ego browser» nella barra laterale mostra in tempo reale ogni pagina visitata dall’agente — puoi cliccare, trascinare e digitare per prendere il controllo. Registra la scheda automaticamente se better-sidebar è presente, altrimenti una bolla flottante',
pluginBilingualReaderDesc: 'Leggi PDF nella barra laterale DSH: visualizzazione PDF nativa, seleziona il testo per tradurlo con l’LLM, con contesto e completamente isolato dalla conversazione principale',
changesRedactOn: "Mascheramento attivo: i segreti appaiono come [REDACTED]; clicca per disattivare (a tuo rischio)",
changesRedactOff: "Mascheramento disattivo: il contenuto appare invariato; clicca per attivare",
changesRedactOnLabel: "Mascheramento",
changesRedactOffLabel: "Senza mascheramento",
changesRedactBanner: "Mascherato",
pluginSentinelName: 'dsh-sentinel Sistema di riattivazione',
pluginEgoBrowserName: 'ego-browser Browser dell’agente',
pluginBetterOverleafName: 'dsh-better-overleaf Scheda Overleaf',
+11
View File
@@ -19,6 +19,12 @@ export const ja: Record<string, string> = {
files: 'ファイル',
changesSessionEmpty: 'このセッションにはファイル操作がまだありません',
changesRead: '読み取り',
changesMdReading: '閲覧',
changesHtmlRender: "レンダリング",
changesHtmlRaw: "原文",
changesPdfRender: "レンダリング",
changesPdfRaw: "原文",
changesMdRaw: '原文',
changesWrite: '書き込み',
changesEdit: '編集',
changesRunning: '実行中',
@@ -428,6 +434,11 @@ export const ja: Record<string, string> = {
pluginDocsPanelDesc: 'DSH サイドバー内の「グローバルドキュメント」:任意のワークスペースから読めるグローバル Markdown ノート。リストで選んで閲覧、ホバーでアウトラインジャンプ、Chrome / VS Code で外部オープン、コードコピー。ディレクトリは設定可(デフォルト ~/.dsh/docs)',
pluginEgoBrowserDesc: 'DeepSeek Harness 用エージェントブラウザ:32 個の ego_* ツールが本物の Chromium を操作し、サイドバーのネイティブ「ego ブラウザ」Tab がエージェントの訪れる全ページをライブ表示。クリック・ドラッグ・入力で操作を引き継げます。better-sidebar があれば Tab を自動登録、なければフローティングバブルにフォールバック',
pluginBilingualReaderDesc: 'DSH サイドバーで論文 PDF を読む:ネイティブ PDF 表示、テキストを選択して LLM で翻訳。コンテキストを使い、メイン会話から完全に分離',
changesRedactOn: "マスキング有効:機密値は [REDACTED] で表示。クリックで無効化(自己責任)",
changesRedactOff: "マスキング無効:内容がそのまま表示されます。クリックで有効化",
changesRedactOnLabel: "マスキング中",
changesRedactOffLabel: "マスキング解除",
changesRedactBanner: "マスク済み",
pluginSentinelName: 'dsh-sentinel ウェイクアップシステム',
pluginEgoBrowserName: 'ego-browser エージェントブラウザー',
pluginBetterOverleafName: 'dsh-better-overleaf Overleaf タブ',
+11
View File
@@ -11,6 +11,12 @@ export const ko: Record<string, string> = {
files: '파일',
changesSessionEmpty: '이 세션에 아직 파일 작업이 없습니다',
changesRead: '읽기',
changesMdReading: '읽기',
changesHtmlRender: "렌더링",
changesHtmlRaw: "원문",
changesPdfRender: "렌더링",
changesPdfRaw: "원문",
changesMdRaw: '원문',
changesWrite: '쓰기',
changesEdit: '편집',
changesRunning: '실행 중',
@@ -420,6 +426,11 @@ export const ko: Record<string, string> = {
pluginDocsPanelDesc: 'DSH 사이드바의 "전역 문서": 전역 Markdown 메모로, 어떤 작업 공간에서든 언제든 읽을 수 있습니다. 목록을 클릭해 읽기, 마우스를 올리면 개요로 이동, Chrome / VS Code에서 외부 열기, 코드 복사. 디렉터리를 설정할 수 있습니다(기본 ~/.dsh/docs)',
pluginEgoBrowserDesc: 'DeepSeek Harness용 에이전트 브라우저: 32개의 ego_* 도구가 실제 Chromium을 구동하며, 사이드바의 네이티브 «ego 브라우저» 탭에서 에이전트가 방문하는 모든 페이지를 실시간으로 볼 수 있습니다 — 클릭, 드래그, 타이핑으로 직접 조작할 수 있습니다. better-sidebar가 있으면 탭을 자동 등록하고, 없으면 플로팅 버블로 대체합니다',
pluginBilingualReaderDesc: 'DSH 사이드바에서 PDF 읽기: 네이티브 PDF 표시, 텍스트를 선택해 LLM으로 번역, 컨텍스트 사용 및 메인 대화와 완전 격리',
changesRedactOn: "마스킹 켜짐: 민감 값이 [REDACTED]로 표시됩니다. 클릭하면 꺼집니다(책임 본인)",
changesRedactOff: "마스킹 꺼짐: 내용이 그대로 표시됩니다. 클릭하면 켜집니다",
changesRedactOnLabel: "마스킹 중",
changesRedactOffLabel: "마스킹 해제",
changesRedactBanner: "마스킹됨",
pluginSentinelName: 'dsh-sentinel 웨이크업 시스템',
pluginEgoBrowserName: 'ego-browser 에이전트 브라우저',
pluginBetterOverleafName: 'dsh-better-overleaf Overleaf 탭',
+11
View File
@@ -17,6 +17,12 @@ export const nl: Record<string, string> = {
files: 'Bestanden',
changesSessionEmpty: 'Nog geen bestandsbewerkingen in deze sessie',
changesRead: 'Lezen',
changesMdReading: 'Lezen',
changesHtmlRender: "Renderen",
changesHtmlRaw: "Bron",
changesPdfRender: "Renderen",
changesPdfRaw: "Bron",
changesMdRaw: 'Brontekst',
changesWrite: 'Schrijven',
changesEdit: 'Bewerken',
changesRunning: 'actief',
@@ -426,6 +432,11 @@ export const nl: Record<string, string> = {
pluginDocsPanelDesc: 'Globale docs in de DSH-zijbalk: lees uw eigen Markdown-notities vanuit elke werkruimte — een bestandslijst, een outline, openen in Chrome / VS Code, en kopieerknoppen; de docs-map is configureerbaar (standaard ~/.dsh/docs)',
pluginEgoBrowserDesc: 'De agentbrowser voor DeepSeek Harness: 32 ego_*-tools besturen een echt Chromium; een native «ego-browser»-tab in de zijbalk toont live elke pagina die de agent bezoekt — u kunt klikken, slepen en typen om over te nemen. Registreert de tab automatisch als better-sidebar aanwezig is, anders een zwevende bel',
pluginBilingualReaderDesc: 'Lees PDF’s in de DSH-zijbalk: native PDF-weergave, selecteer tekst om te vertalen met de LLM, met context en volledig geïsoleerd van het hoofdgesprek',
changesRedactOn: "Redactie aan: geheimen worden als [REDACTED] getoond; klik om uit te schakelen (op eigen risico)",
changesRedactOff: "Redactie uit: inhoud wordt ongewijzigd getoond; klik om in te schakelen",
changesRedactOnLabel: "Redactie",
changesRedactOffLabel: "Zonder redactie",
changesRedactBanner: "Geredigeerd",
pluginSentinelName: 'dsh-sentinel Weksysteem',
pluginEgoBrowserName: 'ego-browser Agentbrowser',
pluginBetterOverleafName: 'dsh-better-overleaf Overleaf-tab',
+11
View File
@@ -21,6 +21,12 @@ export const pl: Record<string, string> = {
files: 'Pliki',
changesSessionEmpty: 'Brak operacji na plikach w tej sesji',
changesRead: 'Odczyt',
changesMdReading: 'Czytanie',
changesHtmlRender: "Renderuj",
changesHtmlRaw: "Źródło",
changesPdfRender: "Renderuj",
changesPdfRaw: "Źródło",
changesMdRaw: 'Źródło',
changesWrite: 'Zapis',
changesEdit: 'Edycja',
changesRunning: 'w toku',
@@ -430,6 +436,11 @@ export const pl: Record<string, string> = {
pluginDocsPanelDesc: 'Globalne dokumenty w panelu bocznym DSH: globalne notatki Markdown, czytelne z dowolnego obszaru roboczego — lista z kliknięciem, kontur z przejściami, otwieranie w Chrome / VS Code i przyciski kopiowania kodu; katalog konfigurowalny (domyślnie ~/.dsh/docs)',
pluginEgoBrowserDesc: 'Przeglądarka agenta dla DeepSeek Harness: 32 narzędzia ego_* sterują prawdziwym Chromium; natywna karta «ego browser» na pasku bocznym pokazuje na żywo każdą stronę odwiedzaną przez agenta — możesz klikać, przeciągać i pisać, aby przejąć kontrolę. Karta rejestruje się automatycznie, gdy better-sidebar jest zainstalowany; w przeciwnym razie pływający dymek',
pluginBilingualReaderDesc: 'Czytaj PDF-y w panelu bocznym DSH: natywny podgląd PDF, zaznacz tekst, aby przetłumaczyć go LLM-em, z kontekstem i całkowicie izolowany od głównej rozmowy',
changesRedactOn: "Redakcja włączona: tajne dane pokazywane jako [REDACTED]; kliknij, aby wyłączyć (na własne ryzyko)",
changesRedactOff: "Redakcja wyłączona: treść pokazywana bez zmian; kliknij, aby włączyć",
changesRedactOnLabel: "Redakcja",
changesRedactOffLabel: "Bez redakcji",
changesRedactBanner: "Zredagowano",
pluginSentinelName: 'dsh-sentinel System wybudzania',
pluginEgoBrowserName: 'ego-browser Przeglądarka agenta',
pluginBetterOverleafName: 'dsh-better-overleaf Karta Overleaf',
+11
View File
@@ -2,6 +2,12 @@ export const pt: Record<string, string> = {
files: 'Arquivos',
changesSessionEmpty: 'Ainda sem operações de arquivo nesta sessão',
changesRead: 'Leitura',
changesMdReading: 'Leitura',
changesHtmlRender: "Renderizar",
changesHtmlRaw: "Fonte",
changesPdfRender: "Renderizar",
changesPdfRaw: "Fonte",
changesMdRaw: 'Bruto',
changesWrite: 'Escrita',
changesEdit: 'Edição',
changesRunning: 'em execução',
@@ -411,6 +417,11 @@ export const pt: Record<string, string> = {
pluginDocsPanelDesc: 'Documentos globais na barra lateral do DSH: leia suas próprias notas Markdown de qualquer workspace — uma lista de arquivos, um sumário, abrir no Chrome / VS Code e botões de copiar; o diretório de documentos é configurável (padrão ~/.dsh/docs)',
pluginEgoBrowserDesc: 'O navegador de agente para o DeepSeek Harness: 32 ferramentas ego_* controlam um Chromium real; uma aba nativa «ego browser» na barra lateral mostra ao vivo cada página visitada pelo agente — você pode clicar, arrastar e digitar para assumir. Registra a aba automaticamente quando o better-sidebar está presente; caso contrário, uma bolha flutuante',
pluginBilingualReaderDesc: 'Leia PDFs na barra lateral do DSH: visualização nativa de PDF, selecione o texto para traduzir com o LLM, com contexto e totalmente isolado da conversa principal',
changesRedactOn: "Máscara ativa: segredos exibidos como [REDACTED]; clique para desativar (por sua conta)",
changesRedactOff: "Máscara desativada: conteúdo exibido como está; clique para ativar",
changesRedactOnLabel: "Máscara",
changesRedactOffLabel: "Sem máscara",
changesRedactBanner: "Mascarado",
pluginSentinelName: 'dsh-sentinel Sistema de despertar',
pluginEgoBrowserName: 'ego-browser Navegador de agentes',
pluginBetterOverleafName: 'dsh-better-overleaf Aba Overleaf',
+11
View File
@@ -15,6 +15,12 @@ export const ru: Record<string, string> = {
files: 'Файлы',
changesSessionEmpty: 'В этой сессии пока нет файловых операций',
changesRead: 'Чтение',
changesMdReading: 'Чтение',
changesHtmlRender: "Рендер",
changesHtmlRaw: "Исходник",
changesPdfRender: "Рендер",
changesPdfRaw: "Исходник",
changesMdRaw: 'Исходный текст',
changesWrite: 'Запись',
changesEdit: 'Изменение',
changesRunning: 'выполняется',
@@ -426,6 +432,11 @@ export const ru: Record<string, string> = {
pluginDocsPanelDesc: '«Глобальные документы» в боковой панели DSH: глобальные Markdown-заметки, доступные из любой рабочей области — список для чтения, навигация по плавающему оглавлению, открытие в Chrome / VS Code, копирование кода; каталог настраивается (по умолчанию ~/.dsh/docs)',
pluginEgoBrowserDesc: 'Браузер агента для DeepSeek Harness: 32 инструмента ego_* управляют настоящим Chromium; встроенная вкладка «ego browser» в боковой панели показывает в реальном времени каждую страницу, которую посещает агент — кликайте, перетаскивайте и вводите текст, чтобы перехватить управление. Вкладка регистрируется автоматически при наличии better-sidebar, иначе используется плавающий пузырёк',
pluginBilingualReaderDesc: 'Читайте PDF в боковой панели DSH: нативный просмотр PDF, выделите текст для перевода с помощью LLM, используя контекст и полностью изолированно от основного разговора',
changesRedactOn: "Маскировка включена: секреты показываются как [REDACTED]; нажмите, чтобы отключить (на свой риск)",
changesRedactOff: "Маскировка выключена: содержимое показывается как есть; нажмите, чтобы включить",
changesRedactOnLabel: "Маскировка",
changesRedactOffLabel: "Без маскировки",
changesRedactBanner: "Замаскировано",
pluginSentinelName: 'dsh-sentinel Система пробуждения',
pluginEgoBrowserName: 'ego-browser Браузер агента',
pluginBetterOverleafName: 'dsh-better-overleaf Вкладка Overleaf',
+11
View File
@@ -2,6 +2,12 @@ export const sv: Record<string, string> = {
files: 'Filer',
changesSessionEmpty: 'Inga filoperationer i den här sessionen ännu',
changesRead: 'Läs',
changesMdReading: 'Läsning',
changesHtmlRender: "Rendera",
changesHtmlRaw: "Källa",
changesPdfRender: "Rendera",
changesPdfRaw: "Källa",
changesMdRaw: 'Råtext',
changesWrite: 'Skriv',
changesEdit: 'Redigera',
changesRunning: 'körs',
@@ -411,6 +417,11 @@ export const sv: Record<string, string> = {
pluginDocsPanelDesc: 'Globala dokument i DSH-sidopanelen: läs era egna Markdown-anteckningar från valfri arbetsyta — en fillista, en översikt, öppna i Chrome / VS Code och kopieringsknappar; dokumentkatalogen är konfigurerbar (standard ~/.dsh/docs)',
pluginEgoBrowserDesc: 'Agentwebbläsaren för DeepSeek Harness: 32 ego_*-verktyg styr en riktig Chromium; en inbyggd «ego browser»-flik i sidopanelen visar varje sida agenten besöker i realtid — du kan klicka, dra och skriva för att ta över. Registrerar fliken automatiskt när better-sidebar finns, annars en flytande bubbla',
pluginBilingualReaderDesc: 'Läs PDF-filer i DSH-sidopanelen: inbyggd PDF-visning, markera text för att översätta med LLM, med kontext och helt isolerad från huvudkonversationen',
changesRedactOn: "Maskning på: hemligheter visas som [REDACTED]; klicka för att stänga av (på egen risk)",
changesRedactOff: "Maskning av: innehåll visas som det är; klicka för att aktivera",
changesRedactOnLabel: "Maskning",
changesRedactOffLabel: "Utan maskning",
changesRedactBanner: "Maskerat",
pluginSentinelName: 'dsh-sentinel Väcksystem',
pluginEgoBrowserName: 'ego-browser Agentwebbläsare',
pluginBetterOverleafName: 'dsh-better-overleaf Overleaf-flik',
+11
View File
@@ -19,6 +19,12 @@ export const th: Record<string, string> = {
files: 'ไฟล์',
changesSessionEmpty: 'ยังไม่มีการดำเนินการไฟล์ในเซสชันนี้',
changesRead: 'อ่าน',
changesMdReading: 'อ่าน',
changesHtmlRender: "เรนเดอร์",
changesHtmlRaw: "ต้นฉบับ",
changesPdfRender: "เรนเดอร์",
changesPdfRaw: "ต้นฉบับ",
changesMdRaw: 'ดิบ',
changesWrite: 'เขียน',
changesEdit: 'แก้ไข',
changesRunning: 'กำลังทำงาน',
@@ -428,6 +434,11 @@ export const th: Record<string, string> = {
pluginDocsPanelDesc: 'เอกสารส่วนกลางในแถบด้านข้าง DSH: อ่านบันทึก Markdown ของคุณเองจากพื้นที่ทำงานใดๆ — รายการไฟล์, เค้าโครง, เปิดใน Chrome / VS Code และปุ่มคัดลอก; ไดเรกทอรีเอกสารสามารถกำหนดค่าได้ (ค่าเริ่มต้น ~/.dsh/docs)',
pluginEgoBrowserDesc: 'เบราว์เซอร์เอเจนต์สำหรับ DeepSeek Harness: เครื่องมือ ego_* 32 ตัวขับเคลื่อน Chromium จริง แท็บ «ego browser» เนทีฟในแถบด้านข้างแสดงทุกหน้าที่เอเจนต์เยี่ยมชมแบบสด — คลิก ลาก และพิมพ์เพื่อเข้าไปควบคุมแทนได้ ลงทะเบียนแท็บอัตโนมัติเมื่อมี better-sidebar มิฉะนั้นจะใช้บับเบิลลอย',
pluginBilingualReaderDesc: 'อ่าน PDF ในแถบด้านข้าง DSH: แสดง PDF ต้นฉบับ เลือกข้อความเพื่อแปลด้วย LLM พร้อมบริบทและแยกจากการสนทนาหลักโดยสิ้นเชิง',
changesRedactOn: "ซ่อนข้อมูลอยู่: ค่าลับแสดงเป็น [REDACTED]; คลิกเพื่อปิด (ยอมรับความเสี่ยงเอง)",
changesRedactOff: "ปิดการซ่อนข้อมูล: เนื้อหาจะแสดงตามจริง; คลิกเพื่อเปิด",
changesRedactOnLabel: "กำลังซ่อน",
changesRedactOffLabel: "ไม่ซ่อน",
changesRedactBanner: "ถูกซ่อนแล้ว",
pluginSentinelName: 'dsh-sentinel ระบบปลุกเตือน',
pluginEgoBrowserName: 'ego-browser เบราว์เซอร์เอเจนต์',
pluginBetterOverleafName: 'dsh-better-overleaf แท็บ Overleaf',
+11
View File
@@ -19,6 +19,12 @@ export const tr: Record<string, string> = {
files: 'Dosyalar',
changesSessionEmpty: 'Bu oturumda henüz dosya işlemi yok',
changesRead: 'Okuma',
changesMdReading: 'Okuma',
changesHtmlRender: "Render",
changesHtmlRaw: "Kaynak",
changesPdfRender: "Render",
changesPdfRaw: "Kaynak",
changesMdRaw: 'Ham',
changesWrite: 'Yazma',
changesEdit: 'Düzenleme',
changesRunning: 'çalışıyor',
@@ -428,6 +434,11 @@ export const tr: Record<string, string> = {
pluginDocsPanelDesc: 'DSH kenar çubuğunda «Genel belgeler»: herhangi bir çalışma alanından kendi Markdown notlarınızı okuyun — dosya listesi, ana hat, Chrome / VS Code’da açma ve kopyalama düğmeleri; belgeler dizini yapılandırılabilir (varsayılan ~/.dsh/docs)',
pluginEgoBrowserDesc: 'DeepSeek Harness için aracı tarayıcı: 32 ego_* aracı gerçek bir Chromium’u yönetir; kenar çubuğundaki yerel «ego tarayıcı» sekmesi aracının ziyaret ettiği her sayfayı canlı gösterir — tıklayarak, sürükleyerek ve yazarak kontrolü devralabilirsiniz. better-sidebar varsa sekme otomatik kaydedilir, yoksa yüzen baloncuğa düşer',
pluginBilingualReaderDesc: 'DSH kenar çubuğunda PDF okuyun: yerel PDF görüntüleme, LLM ile çevirmek için metin seçin, bağlamı kullanarak ve ana sohbetten tamamen izole',
changesRedactOn: "Maskeleme açık: gizli değerler [REDACTED] olarak görünür; kapatmak için tıklayın (kendiniz sorumlusunuz)",
changesRedactOff: "Maskeleme kapalı: içerik olduğu gibi görünür; açmak için tıklayın",
changesRedactOnLabel: "Maskeleme",
changesRedactOffLabel: "Maskeleme yok",
changesRedactBanner: "Maskelendi",
pluginSentinelName: 'dsh-sentinel Uyandırma sistemi',
pluginEgoBrowserName: 'ego-browser Ajan tarayıcısı',
pluginBetterOverleafName: 'dsh-better-overleaf Overleaf sekmesi',
+11
View File
@@ -19,6 +19,12 @@ export const vi: Record<string, string> = {
files: 'Tệp',
changesSessionEmpty: 'Chưa có thao tác tệp nào trong phiên này',
changesRead: 'Đọc',
changesMdReading: 'Đọc',
changesHtmlRender: "Kết xuất",
changesHtmlRaw: "Nguyên văn",
changesPdfRender: "Kết xuất",
changesPdfRaw: "Nguyên văn",
changesMdRaw: 'Gốc',
changesWrite: 'Ghi',
changesEdit: 'Chỉnh sửa',
changesRunning: 'đang chạy',
@@ -428,6 +434,11 @@ export const vi: Record<string, string> = {
pluginDocsPanelDesc: '«Tài liệu toàn cục» trong thanh bên DSH: ghi chú Markdown toàn cục, đọc từ bất kỳ workspace nào — danh sách chọn để đọc, outline hover để nhảy, mở ngoài trong Chrome / VS Code, sao chép code, thư mục cấu hình được (mặc định ~/.dsh/docs)',
pluginEgoBrowserDesc: 'Trình duyệt agent cho DeepSeek Harness: 32 công cụ ego_* điều khiển Chromium thật; tab «ego browser» gốc trong thanh bên hiển thị trực tiếp mọi trang agent truy cập — bạn có thể bấm, kéo và gõ để tiếp quản. Tự động đăng ký tab khi có better-sidebar, nếu không thì rơi về bong bóng nổi',
pluginBilingualReaderDesc: 'Đọc PDF trong thanh bên DSH: hiển thị PDF gốc, chọn văn bản để dịch bằng LLM, dùng ngữ cảnh và hoàn toàn tách biệt khỏi cuộc trò chuyện chính',
changesRedactOn: "Đang làm mờ: giá trị nhạy cảm hiển thị [REDACTED]; nhấp để tắt (tự chịu rủi ro)",
changesRedactOff: "Đã tắt làm mờ: nội dung hiển thị nguyên bản; nhấp để bật",
changesRedactOnLabel: "Đang làm mờ",
changesRedactOffLabel: "Không làm mờ",
changesRedactBanner: "Đã làm mờ",
pluginSentinelName: 'dsh-sentinel Hệ thống đánh thức',
pluginEgoBrowserName: 'ego-browser Trình duyệt agent',
pluginBetterOverleafName: 'dsh-better-overleaf Tab Overleaf',
+11
View File
@@ -34,6 +34,12 @@ export const zhHK: Record<string, string> = {
files: '檔案',
changesSessionEmpty: '本工作階段還沒有檔案操作',
changesRead: '讀取',
changesMdReading: '閱讀',
changesHtmlRender: "渲染",
changesHtmlRaw: "原文",
changesPdfRender: "渲染",
changesPdfRaw: "原文",
changesMdRaw: '原文',
changesWrite: '寫入',
changesEdit: '編輯',
changesRunning: '執行中',
@@ -443,6 +449,11 @@ export const zhHK: Record<string, string> = {
pluginDocsPanelDesc: 'DSH 側邊欄裡的「全域文件」:全域 Markdown 筆記,任何工作區隨時可讀——列表點選閱讀、懸浮大綱跳轉、Chrome / VS Code 外部開啟、程式碼複製,目錄可設定(預設 ~/.dsh/docs)',
pluginEgoBrowserDesc: '將 CitroLabs/ego-lite 接進 DeepSeek Harness 的 agent 瀏覽器:32 個 ego_* 工具驅動真實 Chromium,側邊欄原生「ego 瀏覽器」Tab 實時觀察 agent 逛的每個頁面,可直接點擊/拖拽/輸入接管;裝 better-sidebar 時自動註冊 Tab,沒裝則退回浮動浮窗',
pluginBilingualReaderDesc: '喺 DSH 側邊欄讀論文 PDF:原生 PDF 顯示,揀一段文字就用大模型劃詞翻譯,結合上下文、完全隔離主對話,只做閱讀輔助',
changesRedactOn: "已開啟脫敏:敏感內容以 [REDACTED] 顯示;點擊關閉(自擔風險)",
changesRedactOff: "脫敏已關閉:將原樣顯示文件內容;點擊開啟",
changesRedactOnLabel: "脫敏中",
changesRedactOffLabel: "脫敏關",
changesRedactBanner: "已脫敏",
pluginSentinelName: 'dsh-sentinel 喚醒系統',
pluginEgoBrowserName: 'ego-browser Agent 瀏覽器',
pluginBetterOverleafName: 'dsh-better-overleaf Overleaf 標籤',
+11
View File
@@ -34,6 +34,12 @@ export const zhMO: Record<string, string> = {
files: '檔案',
changesSessionEmpty: '本工作階段還沒有檔案操作',
changesRead: '讀取',
changesMdReading: '閱讀',
changesHtmlRender: "渲染",
changesHtmlRaw: "原文",
changesPdfRender: "渲染",
changesPdfRaw: "原文",
changesMdRaw: '原文',
changesWrite: '寫入',
changesEdit: '編輯',
changesRunning: '執行中',
@@ -443,6 +449,11 @@ export const zhMO: Record<string, string> = {
pluginDocsPanelDesc: 'DSH 側邊欄裡的「全域文件」:全域 Markdown 筆記,任何工作區隨時可讀——列表點選閱讀、懸浮大綱跳轉、Chrome / VS Code 外部開啟、程式碼複製,目錄可設定(預設 ~/.dsh/docs)',
pluginEgoBrowserDesc: '將 CitroLabs/ego-lite 接進 DeepSeek Harness 的 agent 瀏覽器:32 個 ego_* 工具驅動真實 Chromium,側邊欄原生「ego 瀏覽器」Tab 實時觀察 agent 逛的每個頁面,可直接點擊/拖拽/輸入接管;裝 better-sidebar 時自動註冊 Tab,沒裝則退回浮動浮窗',
pluginBilingualReaderDesc: '喺 DSH 側邊欄讀論文 PDF:原生 PDF 顯示,揀一段文字就用大模型劃詞翻譯,結合上下文、完全隔離主對話,只做閱讀輔助',
changesRedactOn: "已開啟脫敏:敏感內容以 [REDACTED] 顯示;點擊關閉(自擔風險)",
changesRedactOff: "脫敏已關閉:將原樣顯示文件內容;點擊開啟",
changesRedactOnLabel: "脫敏中",
changesRedactOffLabel: "脫敏關",
changesRedactBanner: "已脫敏",
pluginSentinelName: 'dsh-sentinel 喚醒系統',
pluginEgoBrowserName: 'ego-browser Agent 瀏覽器',
pluginBetterOverleafName: 'dsh-better-overleaf Overleaf 標籤',
+11
View File
@@ -34,6 +34,12 @@ export const zhTW: Record<string, string> = {
files: '檔案',
changesSessionEmpty: '本作業階段還沒有檔案操作',
changesRead: '讀取',
changesMdReading: '閱讀',
changesHtmlRender: "渲染",
changesHtmlRaw: "原文",
changesPdfRender: "渲染",
changesPdfRaw: "原文",
changesMdRaw: '原文',
changesWrite: '寫入',
changesEdit: '編輯',
changesRunning: '執行中',
@@ -443,6 +449,11 @@ export const zhTW: Record<string, string> = {
pluginDocsPanelDesc: 'DSH 側邊欄裡的「全域文件」:全域 Markdown 筆記,任何工作區隨時可讀——列表點選閱讀、懸浮大綱跳轉、Chrome / VS Code 外部開啟、程式碼複製,目錄可設定(預設 ~/.dsh/docs)',
pluginEgoBrowserDesc: '將 CitroLabs/ego-lite 接進 DeepSeek Harness 的 agent 瀏覽器:32 個 ego_* 工具驅動真實 Chromium,側邊欄原生「ego 瀏覽器」Tab 實時觀察 agent 逛的每個頁面,可直接點擊/拖拽/輸入接管;裝 better-sidebar 時自動註冊 Tab,沒裝則退回浮動浮窗',
pluginBilingualReaderDesc: '在 DSH 側邊欄讀論文 PDF:原生 PDF 顯示,選取一段文字即用大模型劃詞翻譯,結合上下文、完全隔離主對話,只作閱讀輔助',
changesRedactOn: "已開啟脫敏:敏感內容以 [REDACTED] 顯示;點擊關閉(自擔風險)",
changesRedactOff: "脫敏已關閉:將原樣顯示文件內容;點擊開啟",
changesRedactOnLabel: "脫敏中",
changesRedactOffLabel: "脫敏關",
changesRedactBanner: "已脫敏",
pluginSentinelName: 'dsh-sentinel 喚醒系統',
pluginEgoBrowserName: 'ego-browser Agent 瀏覽器',
pluginBetterOverleafName: 'dsh-better-overleaf Overleaf 標籤',
+22
View File
@@ -26,10 +26,21 @@ export const zh = {
files: '文件',
changesSessionEmpty: '本会话还没有文件操作',
changesRead: '读取',
changesMdReading: '阅读',
changesMdRaw: '原文',
changesHtmlRender: '渲染',
changesHtmlRaw: '原文',
changesPdfRender: '渲染',
changesPdfRaw: '原文',
changesWrite: '写入',
changesEdit: '编辑',
changesRunning: '执行中',
changesError: '出错',
changesRedactOn: '已开启脱敏:敏感内容以 [REDACTED] 显示;点击关闭(自担风险)',
changesRedactOff: '脱敏已关闭:将原样显示文件内容;点击开启',
changesRedactOnLabel: '脱敏中',
changesRedactOffLabel: '脱敏关',
changesRedactBanner: '已脱敏',
changesFold: '{count} 行…点击展开',
changesContext: '上下文',
changesPriorUnknown: '变更前的内容不在窗口内,显示为全新增',
@@ -462,10 +473,21 @@ export const en: Record<keyof typeof zh, string> = {
files: 'Files',
changesSessionEmpty: 'No file operations in this session yet',
changesRead: 'Read',
changesMdReading: 'Reading',
changesMdRaw: 'Raw',
changesHtmlRender: 'Render',
changesHtmlRaw: 'Raw',
changesPdfRender: 'Render',
changesPdfRaw: 'Raw',
changesWrite: 'Write',
changesEdit: 'Edit',
changesRunning: 'running',
changesError: 'error',
changesRedactOn: 'Redaction on: secrets render as [REDACTED]; click to disable (at your own risk)',
changesRedactOff: 'Redaction off: file content renders verbatim; click to enable',
changesRedactOnLabel: 'Redacting',
changesRedactOffLabel: 'Redaction off',
changesRedactBanner: 'Redacted',
changesFold: '{count} lines…click to expand',
changesContext: 'context',
changesPriorUnknown: 'Prior content is outside the loaded window; shown as all-added',
+16
View File
@@ -0,0 +1,16 @@
/**
* The chunk-resident markdown renderer (mermaid lazy chunk): a light stub
* module so core-bundle consumers (the changes tab's reading mode) can mount
* the mermaid-aware renderer without statically pulling MarkdownHtml's
* DOMPurify/HTML-analysis machinery into the core bundle. The chunk itself
* (src/client/chunks/mermaid) loads on first render via /sidebar/bundle.
*/
import type { ComponentType } from 'react'
import { lazyChunkComponent } from './lazy-chunk.tsx'
import type { MermaidMarkdownProps } from './mermaid-blocks.ts'
/** Shared with the legacy no-HTML preview path in TextEditor (re-exported). */
export const LazyMermaidMarkdown = lazyChunkComponent<MermaidMarkdownProps>(
'mermaid',
(mod) => mod.MermaidMarkdown as ComponentType<MermaidMarkdownProps> | undefined,
)
+148
View File
@@ -0,0 +1,148 @@
/**
* Secret redaction for rendered file content. The tracer replays read/write
* tool results into the DOM (diff, read view, markdown mode) — when the
* model reads a credentials file, that plaintext must not land on screen or
* in inspectable DOM. Two layers, both pure and unit-tested:
*
* 1. Path layer: files whose NAME marks them as secrets (.env, *secret*,
* *credential*, *token*, *api-key*, private keys, …) are masked whole —
* every non-empty line becomes [REDACTED].
* 2. Content layer: for ordinary files, secret-SHAPED lines and tokens are
* masked in place (api_key/access_token/password assignments, sk-/AKIA/
* ghp_/xox prefixes, Bearer headers, PEM private-key block headers).
*
* Redaction is display-only: the session log and the tool results keep their
* original bytes; this layer guarantees only that this plugin never renders
* the secret. It is NOT a security boundary against a same-context malicious
* plugin (which can read anything on the page or call host APIs itself) —
* it removes the tracer's own contribution to secret exposure.
*/
/** Placeholder substituted for every secret found. */
export const REDACTED = '[REDACTED]'
/** Result of redacting one text: the masked text + whether anything was hit. */
export interface RedactionOutcome {
readonly text: string
readonly hit: boolean
}
/**
* Filename substrings that mark a whole file as sensitive. Each pattern is
* matched case-insensitively against the full path with a RIGHT boundary
* (the pattern must not continue into `[a-z0-9]`) so `tokenizer.ts`,
* `dev.environment.ts`, and `style.keys.ts` stay ordinary files while
* `.env.local`, `credentials.json`, and `id_rsa` still mask whole. Plurals
* are listed only where the plural names a secret STORE (credentials /
* secrets / passwords); a bare `tokens` is usually documentation
* (api-tokens.md) and is deliberately absent.
*/
const SENSITIVE_PATH_RES: readonly RegExp[] = [
'.env', 'secret', 'secrets', 'credential', 'credentials', 'token', 'api-key', 'apikey',
'password', 'passwords', 'passwd', 'private_key', 'privatekey', 'id_rsa', 'id_ed25519',
'.pem', '.key', '.p12', '.pfx',
].map((pattern) => new RegExp(`${escapeRe(pattern)}(?![a-z0-9])`, 'i'))
/** Escape literal text for embedding into a RegExp. */
function escapeRe(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/**
* Whether a path's own name marks the file as sensitive (case-insensitive,
* matched against the full path so .env in any directory counts).
*/
export function isSensitivePath(path: string): boolean {
return SENSITIVE_PATH_RES.some((re) => re.test(path))
}
/**
* Assignment-shaped secret lines: "api_key: …", "TOKEN = …", "password": ….
* The key name survives; the value is masked so the user still sees WHICH
* field was secret.
*/
const ASSIGNMENT_RE = /^(\s*["']?[A-Za-z0-9_.-]*["']?\s*[:=]\s*)(\S.*)$/
/**
* Field names whose assigned value is a secret. Stored in NORMALIZED form
* (lower-case, separators stripped — see normalizeField) so api_key/API-KEY/
* "api-key" all match one entry. Generic bare words (key / token / auth /
* pass) are deliberately EXCLUDED: they label far more ordinary content
* (keyboard keys, CSS custom properties, lexical tokens) than secrets —
* Bearer-scheme headers stay covered by BEARER_RE below.
*/
const SECRET_FIELDS = new Set([
'apikey', 'secret', 'secretkey',
'apitoken', 'accesstoken', 'refreshtoken', 'idtoken',
'password', 'passwd', 'pwd',
'clientsecret', 'privatekey',
'accesskey', 'accesskeyid', 'secretaccesskey',
'authorization',
])
/** Bearer-scheme token in free text (covers HTTP headers in logs/configs). */
const BEARER_RE = /(\bBearer\s+)[A-Za-z0-9\-_.+/]{16,}/gi
/** Well-known credential token prefixes (OpenAI/AWS/GitHub/Slack/Google). */
const TOKEN_RES: readonly RegExp[] = [
/\bsk-[A-Za-z0-9_-]{12,}/g,
/\bAKIA[0-9A-Z]{16}\b/g,
/\bghp_[A-Za-z0-9]{20,}\b/g,
/\bgho_[A-Za-z0-9]{20,}\b/g,
/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g,
/\bya29\.[A-Za-z0-9_-]{20,}\b/g,
]
/** Mask an entire non-empty line, keeping whitespace shape. */
function maskLine(line: string): string {
return line.trim().length === 0 ? line : REDACTED
}
/** Normalize a field name for SECRET_FIELDS lookup. */
function normalizeField(raw: string): string {
return raw.replace(/["'\s:=]/g, '').toLowerCase().replace(/[^a-z0-9]/g, '')
}
/** Redact one possibly-secret assignment line; undefined when not a secret field. */
function redactAssignment(line: string): string | undefined {
const match = ASSIGNMENT_RE.exec(line)
const prefix = match?.[1]
if (prefix === undefined || match?.[2] === undefined) return undefined
if (!SECRET_FIELDS.has(normalizeField(prefix))) return undefined
return prefix + REDACTED
}
/** Whether a line is the header of a PEM private-key block. */
function isPemPrivateKeyHeader(line: string): boolean {
return line.includes('-----BEGIN') && line.includes('PRIVATE KEY-----')
}
/**
* Redact secret-shaped content from an ordinary file's text. Only the secret
* itself is masked; surrounding code/config stays readable.
*/
export function redactContentText(text: string): string {
return text
.split('\n')
.map((line) => {
if (isPemPrivateKeyHeader(line)) return REDACTED
const assignment = redactAssignment(line)
if (assignment !== undefined) return assignment
let masked = line.replace(BEARER_RE, (_m, prefix) => prefix + REDACTED)
for (const re of TOKEN_RES) masked = masked.replace(re, () => REDACTED)
return masked
})
.join('\n')
}
/**
* Apply the full two-layer redaction for one file's text. Sensitive paths
* mask whole lines; ordinary files mask only secret-shaped spans.
*/
export function redactText(path: string, text: string): RedactionOutcome {
if (isSensitivePath(path)) {
return { text: text.split('\n').map(maskLine).join('\n'), hit: text.trim().length > 0 }
}
const masked = redactContentText(text)
return { text: masked, hit: masked !== text }
}
+22 -1
View File
@@ -4,7 +4,7 @@
* (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/changes/ops.ts'
import { extractFileOps, groupByFile, knownContentBefore, parseReadContent, parseReadLines } from '../src/client/changes/ops.ts'
import { diffLines, buildDiffSegments, coalesceInline, diffInline } from '../src/client/diff/rows.ts'
import { langOfPath, scanLine } from '../src/client/diff/highlight.ts'
import type { SidebarSessionEvent } from '../src/context-types.ts'
@@ -29,6 +29,27 @@ function result(seq: number, callId: string, text: string, isError = false, time
})
}
describe('parseReadContent', () => {
it('strips the envelope and line-number prefixes but keeps blank lines', () => {
const raw = [
'<path>notes.md</path>',
'<type>file</type>',
'(Showing lines 1 to 4)',
'<content>',
'1: # Title',
'2: ',
'3: para one',
'4: ',
'</content>',
].join('\n')
expect(parseReadContent(raw)).toBe('# Title\n\npara one')
})
it('returns the raw text unchanged when no <content> section is present', () => {
expect(parseReadContent('plain text')).toBe('plain text')
})
})
describe('extractFileOps', () => {
it('seeds running ops from tool/call and settles them from tool/result', () => {
const ops = extractFileOps([
+55
View File
@@ -146,4 +146,59 @@ describe('ChangesTab', () => {
container.remove()
}
})
it('renders a .md read op in reading mode through the shared MarkdownText', async () => {
mockGit([])
const md = ['# Title', '', '| A | B |', '| --- | --- |', '| 1 | 2 |', '', '![pic](./img.png)', ''].join('\n')
const numbered = md.split('\n').map((line, i) => String(i + 1) + ': ' + line).join('\n')
const envelope = '<content>\n' + numbered + '\n</content>'
vi.spyOn(api, 'changesOps').mockResolvedValue({
events: [
{ type: 'tool/call', seq: 1, time: 1, data: { name: 'read', callId: 'r1', arguments: JSON.stringify({ file_path: 'C:/repo/main/notes.md' }) } },
{ type: 'tool/result', seq: 2, time: 2, data: { message: { source: { kind: 'tool', callId: 'r1' }, content: [{ type: 'tool-result', content: [{ type: 'text', text: envelope }] }] } } },
],
lastSeq: 2,
})
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
mount(root)
await flushEffects()
// Session lens: open the read op's preview.
const group = container.querySelector('[role="group"]')
const sessionButton = [...group!.querySelectorAll<HTMLButtonElement>('button')]
.find(button => button.getAttribute('aria-pressed') === 'false')
await act(async () => { sessionButton!.click() })
const row = container.querySelector<HTMLButtonElement>('button[data-op-kind="read"]')
expect(row).toBeDefined()
expect(container.textContent).toContain('notes.md')
await act(async () => { row!.click() })
await flushEffects()
// Reading toggle: raw first (line-numbered rows), then rendered GFM.
const toggle = [...container.querySelectorAll<HTMLButtonElement>('button')]
.find(button => (button.textContent ?? '').match(/阅读|Reading/))
expect(toggle).toBeDefined()
expect(container.querySelector('h1')).toBeNull()
await act(async () => { toggle!.click() })
await flushEffects()
expect(container.querySelector('h1')?.textContent).toContain('Title')
expect(container.querySelector('table')).not.toBeNull()
// The local image destination was rewritten to the media route.
const img = container.querySelector('img')
expect(img?.getAttribute('src')).toContain('/sidebar/file?')
// Toggling back restores the raw line view.
const rawToggle = [...container.querySelectorAll<HTMLButtonElement>('button')]
.find(button => (button.textContent ?? '').match(/原文|Raw/))
await act(async () => { rawToggle!.click() })
await flushEffects()
expect(container.querySelector('h1')).toBeNull()
} finally {
act(() => { root.unmount() })
container.remove()
}
})
})
+70
View File
@@ -0,0 +1,70 @@
/**
* The highlight engine's v0.2.1-ported expansion: JS/TS module suffixes,
* CSS/SCSS/Less (block comments + hyphenated properties), markup files
* (comments + tags), and GraphQL mapping — the same coverage the standalone
* dsh-file-trace plugin ships.
*/
import { describe, expect, it } from 'vitest'
import { langOfPath, tokenizeLine, scanLine, hasBlockComment } from '../src/client/diff/highlight.ts'
describe('langOfPath: expanded suffixes', () => {
it('maps the JS/TS module suffixes to their families', () => {
expect(langOfPath('tool/config.mjs')).toBe('mjs')
expect(langOfPath('tool/config.cjs')).toBe('cjs')
expect(langOfPath('src/env.mts')).toBe('mts')
expect(langOfPath('src/env.cts')).toBe('cts')
})
it('maps markup, style and graphql suffixes', () => {
expect(langOfPath('site/index.html')).toBe('html')
expect(langOfPath('site/logo.svg')).toBe('svg')
expect(langOfPath('app/App.vue')).toBe('vue')
expect(langOfPath('data/feed.xml')).toBe('xml')
expect(langOfPath('style/main.css')).toBe('css')
expect(langOfPath('style/theme.scss')).toBe('scss')
expect(langOfPath('style/vars.less')).toBe('less')
expect(langOfPath('api/schema.graphql')).toBe('graphql')
expect(langOfPath('api/query.gql')).toBe('gql')
expect(langOfPath('cfg/settings.jsonc')).toBe('jsonc')
})
})
describe('tokenizeLine: CSS / markup', () => {
it('colors CSS block comments, hyphenated properties, strings and hex numbers', () => {
const toks = tokenizeLine('body { color: #333; background-color: var(--fg); content: "x" }', 'css')
expect(toks).toContainEqual(expect.objectContaining({ text: 'body', type: 'keyword' }))
expect(toks).toContainEqual(expect.objectContaining({ text: 'color', type: 'keyword' }))
expect(toks).toContainEqual(expect.objectContaining({ text: 'background-color', type: 'keyword' }))
expect(toks).toContainEqual(expect.objectContaining({ text: '"x"', type: 'string' }))
expect(toks).toContainEqual(expect.objectContaining({ text: '333', type: 'number' }))
const cmt = tokenizeLine('/* one line comment */ body', 'css')
expect(cmt[0]).toMatchObject({ text: '/* one line comment */', type: 'comment' })
})
it('threads CSS block comments across lines', () => {
expect(hasBlockComment('css')).toBe(true)
const open = scanLine('/* starts here', 'css')
expect(open.inBlock).toBe(true)
expect(open.tokens[0]).toMatchObject({ type: 'comment' })
const close = scanLine('ends here */ p {}', 'css', true)
expect(close.tokens[0]).toMatchObject({ type: 'comment' })
expect(close.tokens).toContainEqual(expect.objectContaining({ text: 'p', type: 'keyword' }))
})
it('colors markup comments, tags and attributes', () => {
const toks = tokenizeLine('<div class="box">hi</div>', 'html')
expect(toks).toContainEqual(expect.objectContaining({ text: 'div', type: 'keyword' }))
expect(toks).toContainEqual(expect.objectContaining({ text: 'class', type: 'keyword' }))
expect(toks).toContainEqual(expect.objectContaining({ text: '"box"', type: 'string' }))
const cmt = tokenizeLine('<!-- header -->', 'html')
expect(cmt[0]).toMatchObject({ text: '<!-- header -->', type: 'comment' })
expect(hasBlockComment('html')).toBe(true)
})
it('colors mjs like js (await keyword wins over the call-site rule)', () => {
const toks = tokenizeLine('const x = await import("./mod.js")', 'mjs')
expect(toks).toContainEqual(expect.objectContaining({ text: 'const', type: 'keyword' }))
expect(toks).toContainEqual(expect.objectContaining({ text: 'await', type: 'keyword' }))
expect(toks).toContainEqual(expect.objectContaining({ text: 'import', type: 'keyword' }))
})
})
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest'
import { isSensitivePath, redactContentText, redactText, REDACTED } from '../src/client/redact.ts'
describe('isSensitivePath', () => {
it('marks credential-shaped filenames', () => {
expect(isSensitivePath('C:/proj/.env')).toBe(true)
expect(isSensitivePath('config/secrets.yaml')).toBe(true)
expect(isSensitivePath('~/auth/credentials.json')).toBe(true)
expect(isSensitivePath('deploy/api-key.prod.yml')).toBe(true)
expect(isSensitivePath('/home/u/.ssh/id_rsa')).toBe(true)
expect(isSensitivePath('cert/server.pem')).toBe(true)
})
it('passes ordinary filenames', () => {
expect(isSensitivePath('src/index.ts')).toBe(false)
expect(isSensitivePath('docs/readme.md')).toBe(false)
expect(isSensitivePath('package.json')).toBe(false)
})
it('does not mask files whose names merely CONTAIN a pattern inside a longer word', () => {
expect(isSensitivePath('src/tokenizer.ts')).toBe(false)
expect(isSensitivePath('config/dev.environment.ts')).toBe(false)
expect(isSensitivePath('src/style.keys.ts')).toBe(false)
expect(isSensitivePath('docs/api-tokens.md')).toBe(false)
})
it('still masks pattern matches at a word boundary', () => {
expect(isSensitivePath('deploy/.env.local')).toBe(true)
expect(isSensitivePath('app/mytoken.yaml')).toBe(true)
expect(isSensitivePath('config/passwords.yml')).toBe(true)
})
})
describe('redactContentText', () => {
it('masks yaml secret assignments keeping the key name', () => {
const out = redactContentText('name: demo\napi_key: sk-1234567890abcdef\nport: 8080')
expect(out).toContain('name: demo')
expect(out).toContain('api_key: ' + REDACTED)
expect(out).toContain('port: 8080')
expect(out).not.toContain('sk-1234567890abcdef')
})
it('masks quoted json fields and equals-style env lines', () => {
expect(redactContentText('"password": "hunter2"')).toBe('"password": ' + REDACTED)
expect(redactContentText('API_TOKEN=abcdef1234567890')).toBe('API_TOKEN=' + REDACTED)
})
it('masks bare credential tokens in free text', () => {
const out = redactContentText('see sk-abcdefghijklmnop123456 and AKIAIOSFODNN7EXAMPLE')
expect(out).not.toContain('sk-abcdefghijklmnop123456')
expect(out).not.toContain('AKIAIOSFODNN7EXAMPLE')
expect(out).toContain(REDACTED)
})
it('masks bearer headers and PEM key headers', () => {
expect(redactContentText('Authorization: Bearer abcdef1234567890abcd')).toContain(REDACTED)
expect(redactContentText('-----BEGIN RSA PRIVATE KEY-----')).toBe(REDACTED)
})
it('leaves ordinary code untouched', () => {
const code = 'const x = 42\nconsole.log("count", x)'
expect(redactContentText(code)).toBe(code)
})
it('leaves generic bare-word assignments alone (key/auth/token/pass label ordinary content too)', () => {
expect(redactContentText("key: 'ArrowUp'")).toBe("key: 'ArrowUp'")
expect(redactContentText('--key: red')).toBe('--key: red')
expect(redactContentText('token: a lexical unit')).toBe('token: a lexical unit')
expect(redactContentText('auth: none')).toBe('auth: none')
})
it('still masks qualified secret field names', () => {
expect(redactContentText('access_token: ghp_abcdefghijklmnopqrst')).toBe('access_token: ' + REDACTED)
expect(redactContentText('client_secret: shhh-1234567890')).toBe('client_secret: ' + REDACTED)
})
})
describe('redactText', () => {
it('masks a whole sensitive file line-wise', () => {
const out = redactText('/app/.env', 'USER=me\nTOKEN=abc\n\n# comment')
expect(out.text).toBe(REDACTED + '\n' + REDACTED + '\n\n' + REDACTED)
expect(out.hit).toBe(true)
})
it('reports hit=false for clean ordinary content', () => {
const out = redactText('src/main.ts', 'export function main() {}')
expect(out.hit).toBe(false)
expect(out.text).toBe('export function main() {}')
})
})
+50 -1
View File
@@ -12,7 +12,9 @@ import { renderToString } from 'react-dom/server'
import { createElement } from 'react'
import './browser-globals.ts'
import type { Context } from '../src/context-types.ts'
import { TextEditor, HTML_IFRAME_SANDBOX } from '../src/client/TextEditor.tsx'
import { TextEditor } from '../src/client/TextEditor.tsx'
import { HTML_IFRAME_SANDBOX } from '../src/client/html-preview.ts'
import { DiffPane, HtmlRenderPreview } from '../src/client/changes/DiffPane.tsx'
import { BrowserView, BrowserEmbedBlocked, BROWSER_IFRAME_SANDBOX, iframeSandboxFor } from '../src/client/BrowserView.tsx'
import { createSidebarStore } from '../src/client/state.ts'
import type { FileViewerProps } from '../src/client/service.ts'
@@ -109,6 +111,53 @@ describe('HTML preview iframe sandbox', () => {
})
})
describe('changes tab HTML render preview sandbox', () => {
function opProps(path: string) {
return {
target: {
kind: 'op' as const,
path,
op: { callId: 'c1', kind: 'read' as const, path, time: 0, running: false, isError: false, read: '<content>1: <html><body>hi</body></html></content>' },
},
scope: { sessionId: 's1', cwd: '/p' },
height: 300,
onHeightCommit: () => {},
onClose: () => {},
onExpand: () => {},
}
}
it('render iframe is ALWAYS sandboxed (no escape hatch) with the route src, never srcdoc', () => {
const html = renderToString(createElement(HtmlRenderPreview, { src: '/sidebar/html/s1/p/a/index.html', title: '/p/a/index.html' }))
const iframe = /<iframe[^>]*>/.exec(html)?.[0]
expect(iframe).toBeDefined()
// The sandbox tokens are exactly the shared constant — and unlike the
// editor viewer this surface has NO no-sandbox escape hatch.
expect(iframe).toContain(`sandbox="${HTML_IFRAME_SANDBOX}"`)
expect(HTML_IFRAME_SANDBOX).not.toContain('allow-same-origin')
expect(HTML_IFRAME_SANDBOX).not.toContain('allow-top-navigation')
// Cross-origin framing by construction: route-src (never srcdoc).
expect(iframe).toContain('src="/sidebar/html/s1/p/a/index.html"')
expect(iframe).not.toContain('srcdoc=')
expect(iframe).toContain('referrerPolicy="no-referrer"')
expect(iframe).toContain('allow=""')
})
it('html op targets show the render toggle (off by default, no iframe); non-html and error targets show none', () => {
const htmlOpHtml = renderToString(createElement(DiffPane, opProps('/p/a/index.html')))
expect(htmlOpHtml).toContain('渲染')
expect(htmlOpHtml).not.toContain('<iframe')
const md = renderToString(createElement(DiffPane, opProps('/p/a/readme.md')))
expect(md).not.toContain('渲染')
const errorProps = opProps('/p/a/index.html')
const html = renderToString(createElement(DiffPane, {
...errorProps,
target: { ...errorProps.target, op: { ...errorProps.target.op, isError: true, errorText: 'boom' } },
}))
expect(html).not.toContain('渲染')
})
})
describe('browser tab iframe sandbox', () => {
function tabProps(store: ReturnType<typeof createSidebarStore>, path?: string) {
return {