Merge pull request #499 from lhh010/feat/changes-md-reading
feat(changes): markdown reading mode + expanded syntax highlighting
This commit is contained in:
@@ -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
@@ -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 出现条件)。
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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`),
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 टैब',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,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 탭',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 標籤',
|
||||
|
||||
@@ -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 標籤',
|
||||
|
||||
@@ -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 標籤',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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([
|
||||
|
||||
@@ -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 |', '', '', ''].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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' }))
|
||||
})
|
||||
})
|
||||
@@ -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() {}')
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user