Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
146840bb4f | ||
|
|
364a48df98 | ||
|
|
df1f3b9666 | ||
|
|
5bf7dfe3c4 | ||
|
|
900654b8fd | ||
|
|
d990c10e59 | ||
|
|
6b02285742 | ||
|
|
6fbfeedc01 | ||
|
|
9302d02cf5 | ||
|
|
0c3d1d6e32 | ||
|
|
157e02e768 | ||
|
|
04693192df | ||
|
|
03e19c4cd4 | ||
|
|
dc40f82180 | ||
|
|
6df06717ad | ||
|
|
8456d77a92 | ||
|
|
2dc2dcf418 | ||
|
|
2a99968fa3 | ||
|
|
bb21657eae | ||
|
|
e356791661 | ||
|
|
93ad87dfe7 | ||
|
|
a5c52b3f1b | ||
|
|
e39ea92ade | ||
|
|
76b57508f4 | ||
|
|
e1b9b539e4 | ||
|
|
7f9a040065 | ||
|
|
16d8df2e93 | ||
|
|
a2d3faf7b5 | ||
|
|
f59ffd0741 | ||
|
|
f7332f332b | ||
|
|
e105b01f2b | ||
|
|
b4ab3f154f | ||
|
|
d5b1201ad8 | ||
|
|
6bccd21a49 | ||
|
|
dbc73f0bf3 | ||
|
|
68839aa5fb | ||
|
|
1ec1565b13 | ||
|
|
6ef11ae010 | ||
|
|
aa01140252 | ||
|
|
dc33087ce1 | ||
|
|
8820efd6bf | ||
|
|
7b5c7ddcc4 | ||
|
|
f8bb3013ff | ||
|
|
463e96b6b4 | ||
|
|
98e9a4ff0f | ||
|
|
5a1e75e46b | ||
|
|
f418692ad0 | ||
|
|
e8834bd349 | ||
|
|
040b182b1c | ||
|
|
fa301da29e | ||
|
|
2eb9f66ce8 | ||
|
|
0142ac550b | ||
|
|
b7138cf4fd |
@@ -41,6 +41,8 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
- name: Build
|
||||
@@ -88,6 +90,8 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
- name: Build
|
||||
|
||||
@@ -88,6 +88,9 @@ jobs:
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
|
||||
@@ -95,8 +98,9 @@ jobs:
|
||||
run: pnpm build
|
||||
|
||||
# The consumer-facing declaration surface guard — mirrors the ci.yml
|
||||
# verification order (typecheck → test → build → check:consumer-types)
|
||||
# so a release is gated on the same checks a PR runs.
|
||||
# verification order (typecheck → lint → test → build →
|
||||
# check:consumer-types) so a release is gated on the same checks a PR
|
||||
# runs.
|
||||
- name: Check consumer type surface
|
||||
run: pnpm check:consumer-types
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
8. **e2e scratch profile 的 `minimumReleaseAgeExclude` 含 `'@deepseek-ai/*'`**(`scripts/e2e-mount.sh` / `e2e-aggregate-mount.sh`,与仓库根 `pnpm-workspace.yaml` 同策):alpha 版本常在发布后 24h 内跑 lane,pnpm 11 的 `minimumReleaseAge` 默认会拒装新鲜包。
|
||||
9. **插件开发树的 dsh-* 传递 peer 需提升为 devDependencies**(alpha.3 首见):`dsh-subagent` 等 npm 包把 `dsh-attachment` 等 dsh-* 姊妹包全部声明为 peerDependencies(由宿主 bundle 树统一提供,宿主侧无此问题),插件仓库若只直接依赖其中一部分,其余 peer 在 pnpm 下会解析到树上残留的旧版——如 `dsh-attachment@0.1.1-rc.1` 缺 `admitPromptContent` 导出、`dsh-subagent` 产物 import 它时测试加载即崩。因此 devDependencies 需涵盖 dev 树实际触达的全部 peer(attachment / code-runtime / scope / session-projection / system-prompt / user-approval / util-time 七个即为此提升,与直接依赖同款精确钉版);`@deepseek-ai/cordis` peer 自 alpha.3 起要求 `^4.0.2`(上游全线 peer 已升)。适配新 alpha 版本时先跑 `pnpm peers check`,把新失配的传递 peer 一并提升进 devDependencies。例外:**`dsh-client-locale` 的 peer/devDep 允许落后于基线**(alpha.5 时上游停在 0.1.2-alpha.3 未发新版)——peer 下限 `^0.1.2-alpha.3` 天然容纳同 tuple 的 alpha.5 运行时,此时保持旧钉版并在 `pnpm-workspace.yaml` 注明,待上游发版再追平;rc.1 起上游恢复发版,peer/devDep 已与基线一并钉 `0.1.2-rc.1`,该例外消除。
|
||||
10. **聊天文件打开漏斗是 `remote.session.openWorkspacePath`**(`ctx.workspaces.openPath` 已随 alpha 删除,`IWorkspaces` 无此方法):「聊天区文件在侧边栏打开」拦截在 `ctx.inject(['remote.session'], …)` 内以 **defineProperty 数据属性遮蔽**该命名空间方法(gateway client 的方法是 accessor 属性、异步挂载、contribution 重挂载时服务重建——inject 回调重跑即自愈);实现见 `src/client/openpath-intercept.ts`,设计见 [docs/plans/2026-08-31-openpath-intercept-alpha-design.md](docs/plans/2026-08-31-openpath-intercept-alpha-design.md)。
|
||||
11. **自定义种子必须带 fork 标记对(`meta.isSeeded: true` + `inheritedEventCount`)**:dsh-session 契约「只给 seed 不标 isSeeded,种子算重放历史而非继承前缀」——缺标记时 `Session.ownEvents()` 含整个种子,子会话 `Inbox` 构造器重放种子里的 `agent/inbox/spliced`,**继承父会话切割时刻未领取的 inbox 输入**(排队用户消息 next-turn、长回合中工具结果上下文/steering next-step——后者即「上下文很长时侧边对话先把之前的 User msg 发出去」的根因,幽灵消息排在 boundary 之前最先发给模型)。宿主 `session.fork`(api-session-controller)的调用即规范形态;回归由 `tests/sidechat-seed-validation.spec.ts`(真实 `Session.create` + `Inbox`)守护。0.1.2-rc.1 复评:无更优雅的 sidechat 宿主 API(`ContinuableStartSpec` 仍无 seed 字段),`AgentRegistry.create` 接缝补齐标记即为规范用法。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# dsh-better-sidebar 命令面薄封装:目标仅转发 package.json scripts(唯一事实源),
|
||||
# 新增/改名 script 不需要动这里;本文件只补目标发现(help)与 CI 门禁聚合
|
||||
# (check / mount / mount-aggregate / registry)。
|
||||
# 注:lint 目标留给引入 ESLint 的后续 PR。
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
@@ -17,11 +16,14 @@ build: ## 构建(清 lib/ → tsc → tsdown)
|
||||
typecheck: ## 类型检查(tsc --noEmit)
|
||||
pnpm typecheck
|
||||
|
||||
lint: ## 代码检查(eslint .,flat config 最小规则集)
|
||||
pnpm lint
|
||||
|
||||
test: ## 单元测试(vitest run)
|
||||
pnpm test
|
||||
|
||||
check: ## 聚合校验门禁:typecheck → build → test → check:consumer-types(对齐 CI)
|
||||
pnpm typecheck && pnpm build && pnpm test && pnpm check:consumer-types
|
||||
check: ## 聚合校验门禁:typecheck → lint → build → test → check:consumer-types(对齐 CI)
|
||||
pnpm typecheck && pnpm lint && pnpm build && pnpm test && pnpm check:consumer-types
|
||||
|
||||
clean: ## 清理构建产物与测试报告(lib/、*.tgz、playwright-report/、test-results/)
|
||||
rm -rf lib playwright-report test-results
|
||||
@@ -39,4 +41,4 @@ mount-aggregate: ## 聚合双挂载回归:build + pack → pnpm test:mount:agg
|
||||
registry: ## 组装 plugin-registry 暂存(registry/,不入库):build → node scripts/package-registry.mjs
|
||||
pnpm build && node scripts/package-registry.mjs
|
||||
|
||||
.PHONY: help install build typecheck test check clean pack mount mount-aggregate registry
|
||||
.PHONY: help install build typecheck lint test check clean pack mount mount-aggregate registry
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<a href="https://github.com/omdsh-dev/DSH-better-sidebar/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/omdsh-dev/DSH-better-sidebar" /></a>
|
||||
<a href="https://opensource.org/licenses/MIT"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-yellow.svg" /></a>
|
||||
<a href="https://dshfind.com/zh/plugins/omdsh-dev/DSH-better-sidebar?ref=badge"><img alt="dshfind" src="https://dshfind.com/api/badge/omdsh-dev/DSH-better-sidebar?lang=zh" /></a><br /><br />
|
||||
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本(v0.18.0 正式版):0.1.2-rc.1+" src="https://img.shields.io/badge/DSH-0.1.2--rc.1%2B-4d6bfe" /></a>
|
||||
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本(v0.18.1 正式版):0.1.2-rc.1+" src="https://img.shields.io/badge/DSH-0.1.2--rc.1%2B-4d6bfe" /></a>
|
||||
<a href="https://github.com/topics/dsh-better-sidebar"><img alt="插件生态:GitHub topic dsh-better-sidebar" src="https://img.shields.io/badge/%E6%8F%92%E4%BB%B6%E7%94%9F%E6%80%81-topic%20dsh--better--sidebar-4d6bfe" /></a><br /><br />
|
||||
<img alt="文件管理" src="https://img.shields.io/badge/-文件管理-4d6bfe" /> <img alt="编辑预览" src="https://img.shields.io/badge/-编辑预览-4d6bfe" /> <img alt="内嵌浏览器" src="https://img.shields.io/badge/-内嵌浏览器-4d6bfe" /> <img alt="真实终端" src="https://img.shields.io/badge/-真实终端-4d6bfe" /> <img alt="文件变动" src="https://img.shields.io/badge/-文件变动-4d6bfe" /> <img alt="后台任务" src="https://img.shields.io/badge/-后台任务-4d6bfe" /> <img alt="侧边对话" src="https://img.shields.io/badge/-侧边对话-4d6bfe" /> <img alt="插件接入" src="https://img.shields.io/badge/-插件接入-4d6bfe" /><br /><br />
|
||||
<b>右侧栏 + 底部面板双工作台</b>,并把 <code>ctx.betterSidebar</code> 服务开放给所有插件——<br />
|
||||
@@ -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 拆分 / 合并分栏(可跨面板),移动端自动合并全宽抽屉
|
||||
@@ -62,7 +62,7 @@
|
||||
**前置**:已装好 DSH(`dsh web` 能正常运行),Node.js ≥ 20、pnpm ≥ 10。
|
||||
|
||||
**支持的 DSH 版本**:
|
||||
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本(v0.18.0 正式版):0.1.2-rc.1+" src="https://img.shields.io/badge/DSH-0.1.2--rc.1%2B-4d6bfe" /></a>
|
||||
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本(v0.18.1 正式版):0.1.2-rc.1+" src="https://img.shields.io/badge/DSH-0.1.2--rc.1%2B-4d6bfe" /></a>
|
||||
|
||||
> 📌 **正式版**:`v0.18.0` 起适配 DSH **0.1.2-rc.1+**(npm dist-tag `latest`),不再支持 0.1.0-rc.8 ~ 0.1.1-rc.2——DSH stable(≤ 0.1.1-rc.2)用户请固定安装 `dsh-better-sidebar@0.17.1`(`@latest` 已由 v0.18.0 接管);停留在 0.1.2-alpha.x 的宿主请先升级 DSH,或继续用 `dsh-better-sidebar@alpha`(v0.18.0-alpha.0)。
|
||||
|
||||
@@ -261,18 +261,33 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
|
||||
|
||||
## 🆕 最近更新
|
||||
|
||||
**支持的 DSH 版本**:<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本(v0.18.0 正式版):0.1.2-rc.1+" src="https://img.shields.io/badge/DSH-0.1.2--rc.1%2B-4d6bfe" /></a> · 完整发布历史见 [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases)
|
||||
**支持的 DSH 版本**:<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本(v0.18.1 正式版):0.1.2-rc.1+" src="https://img.shields.io/badge/DSH-0.1.2--rc.1%2B-4d6bfe" /></a> · 完整发布历史见 [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases)
|
||||
|
||||
### v0.18.1
|
||||
|
||||
> 📌 **正式版**(npm `latest`):DSH 基线不变(**0.1.2-rc.1+**,peer 下限 `^0.1.2-rc.1`)——本版是 v0.18.0 之后的增量发布:变更面板预览能力增强、文件树可写,以及五项修复。
|
||||
|
||||
**✨ 新功能**
|
||||
|
||||
- 📄 **变更面板操作预览增强**(#499):`.md` 阅读模式(含 mermaid 渲染)、`.html` 与 `.pdf` 内嵌渲染预览;diff 语法高亮扩展到 mjs/cjs/mts/cts、CSS/SCSS/Less、HTML/XML/SVG/Vue、GraphQL、JSONC/JSON5;新增**密钥脱敏**层(预览默认开启,面板头部可切换)
|
||||
- 🗂️ **文件树重命名 / 删除**(#550):行内重命名 + 确认式删除,右键菜单减重与子菜单视口钳制
|
||||
- 🧩 **插件目录名与 shell 预设文案词典化**(#535):跟随宿主语言
|
||||
|
||||
**🐛 修复**
|
||||
|
||||
- 🔀 **git diff 折叠上下文真实展开**(#576,修复 #577):折叠行此前显示「n 行…点击展开」却点不动(`-U3` 裁剪使 gap 段没有行文本);现按需经 `git.show` 拉取两侧完整内容切片填充,带加载 / 失败降级三态与请求去重;顺带修复该路由的 `rev:path` 寻址(此前恒返空)
|
||||
- 💬 **侧边对话种子不再继承父会话未领取的 inbox 消息**(#562):补 fork 标记对,消除「上下文很长时侧边对话先把之前的 User 消息发出去」的幽灵消息
|
||||
- 🖼️ **Markdown 分栏渲染器内的本地图片**(#569):改写为可访问 URL,不再 404
|
||||
|
||||
**🧰 CI 与内部**
|
||||
|
||||
- ESLint flat config 接入 CI 与 Makefile(#536)、Makefile 命令面规范化(#526)、e2e 脚本加固(#527)、共享组件测试工具收敛样板(#524)
|
||||
- 重构:Sidebar.tsx 按关注点拆分(#542)、四处轮询习语收敛到 `use-polling`(#541)、删除 rc.7 宿主的 `__DSH_MODULES__` 回退路径(#540)、One Dark/Light 语法色板单源化(#534)、重复实现收敛与死代码清理(#525)
|
||||
|
||||
### v0.18.0
|
||||
|
||||
> 📌 **正式版**(npm `latest`):本版仅支持 **DSH 0.1.2-rc.1+**(peer 下限 `^0.1.2-rc.1`);不再支持 0.1.0-rc.8 ~ 0.1.1-rc.2——DSH stable 用户请固定安装 `dsh-better-sidebar@0.17.1`,停留在 0.1.2-alpha.x 的宿主继续用 `dsh-better-sidebar@alpha`(v0.18.0-alpha.0)。
|
||||
|
||||
自 v0.17.1 以来的全部更改(v0.18.1-alpha.0 / v0.19.0-alpha.0 两个中间版本号未发布,内容一并并入本版):
|
||||
|
||||
**🔗 宿主线毕业正式版**
|
||||
|
||||
- **进入 DSH 0.1.2 线并毕业**:v0.18.0-alpha.0 删除 0.1.1-rc.x 兼容层、修复 alpha.1+ 侧边对话转录空白(#472);基线经 alpha.3(#497)、alpha.5(#516,`Session.events` → `snapshotEvents()`)逐步上行;聊天文件打开漏斗迁移 `remote.session.openWorkspacePath`(#494)、「Show in folder」reveal 滚动限界(#453)。本版钉版基线 **DSH 0.1.2-rc.1**(相对 alpha.5 零源码变化、纯版本升级;`dsh-client-locale` 恢复发版,全线对齐 rc.1;CI 真机挂载冒烟 14/14)
|
||||
|
||||
**✨ 新功能**
|
||||
|
||||
- 🌿 **「文件变动」统一 tab**(#475):Git 视角(真 diff / 历史 / 暂存·提交·还原 / worktree·子仓库选择)与本轮文件视角(模型读/写/编辑实时追踪)双视角合一;统一 diff 渲染(改蓝配对 + 行内字符级高亮 + 语法着色 + 上下文折叠)、底部可拖拽预览面板、一键展开独立 diff tab
|
||||
@@ -298,6 +313,9 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
|
||||
|
||||
- 新收录 10+ 插件:dsh-better-sidebar-icons(#441)、dsh-sidenote(#451,原 dsh-sidechat #470)、dsh-github-workbench(#410)、dsh-bilingual-reader(#379)、dsh-server-deck(#413)、dsh-md-export(#405)、dsh-code-nav(#404)、dsh-suhuang-scroll(#392)、dsh-better-overleaf(#370)等(均含 18+ 语言 i18n 补齐)
|
||||
|
||||
<details>
|
||||
<summary><b>历史版本(v0.12.0 – v0.15.2)</b></summary>
|
||||
|
||||
### v0.19.0-alpha.0
|
||||
|
||||
> 🧪 **alpha 通道**:本版仅支持 **DSH 0.1.2-alpha.x**(peer 下限 `^0.1.2-alpha.5`,npm dist-tag `alpha`,安装 `dsh-better-sidebar@alpha`)。该版本号未单独发布,内容已并入 **v0.18.0** 正式版。
|
||||
@@ -370,9 +388,6 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
|
||||
- 🔧 **git 源安装修复 `unrun` devDependency**([#336](https://github.com/omdsh-dev/DSH-better-sidebar/pull/336)):tsdown 0.22 经 `unrun` 加载配置而 pnpm 11 不自动装 peer——git-hosted 安装的 `prepare` 不再报 `Failed to import module "unrun"`(npm tarball 不受影响)
|
||||
- 🍃 **`ctx.effect` 严格化顺手修了 4 处**:拦截注册失败时 effect 体返回 `undefined` 改为 no-op disposer(vendored cordis 的 effect 契约要求返回 disposer,返回 `undefined` 属非法形状)
|
||||
|
||||
<details>
|
||||
<summary><b>历史版本(v0.12.0 – v0.15.2)</b></summary>
|
||||
|
||||
### v0.15.2
|
||||
|
||||
自 v0.15.1 以来的全部更改:
|
||||
@@ -491,7 +506,7 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
|
||||
推荐添加QQ群(577011007)
|
||||
|
||||
<div align="center">
|
||||
<img width="220" alt="微信群二维码" src="https://github.com/user-attachments/assets/39caafc7-9629-4b13-bb2b-eac17eab5b6a" />
|
||||
<img width="220" alt="微信群二维码" src="https://github.com/user-attachments/assets/cbf211c6-e5b8-49c3-a412-7210c0b33d73" />
|
||||
<img width="220" alt="QQ群二维码" src="https://github.com/user-attachments/assets/9be34629-26ef-4537-aad4-1393c147f81c" />
|
||||
</div>
|
||||
|
||||
@@ -524,6 +539,7 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
|
||||
```sh
|
||||
pnpm install # @deepseek-ai/* devDependencies 已发布(基线 0.1.2-rc.1,next dist-tag),直接解析、无需令牌
|
||||
pnpm typecheck # tsc --noEmit
|
||||
pnpm lint # eslint .(flat config:js + typescript-eslint + react-hooks recommended)
|
||||
pnpm build # → lib/index.js + lib/invariant.js + lib/client.js + lib/client-registry.js + lib/types
|
||||
pnpm test # vitest(含 manifest 一致性守卫,需先 build)
|
||||
pnpm watch # tsdown --watch
|
||||
|
||||
+26
-4
@@ -9,7 +9,7 @@
|
||||
<a href="https://github.com/omdsh-dev/DSH-better-sidebar/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/omdsh-dev/DSH-better-sidebar" /></a>
|
||||
<a href="https://opensource.org/licenses/MIT"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-yellow.svg" /></a>
|
||||
<a href="https://dshfind.com/en/plugins/omdsh-dev/DSH-better-sidebar?ref=badge"><img alt="dshfind" src="https://dshfind.com/api/badge/omdsh-dev/DSH-better-sidebar?lang=en" /></a><br /><br />
|
||||
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions (v0.18.0): 0.1.2-rc.1+" src="https://img.shields.io/badge/DSH-0.1.2--rc.1%2B-4d6bfe" /></a>
|
||||
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions (v0.18.1): 0.1.2-rc.1+" src="https://img.shields.io/badge/DSH-0.1.2--rc.1%2B-4d6bfe" /></a>
|
||||
<a href="https://github.com/topics/dsh-better-sidebar"><img alt="Plugin ecosystem: GitHub topic dsh-better-sidebar" src="https://img.shields.io/badge/plugin%20ecosystem-topic%20dsh--better--sidebar-4d6bfe" /></a><br /><br />
|
||||
<img alt="File management" src="https://img.shields.io/badge/-File%20management-4d6bfe" /> <img alt="Edit & preview" src="https://img.shields.io/badge/-Edit%20%26%20preview-4d6bfe" /> <img alt="Embedded browser" src="https://img.shields.io/badge/-Embedded%20browser-4d6bfe" /> <img alt="Real terminal" src="https://img.shields.io/badge/-Real%20terminal-4d6bfe" /> <img alt="Changes" src="https://img.shields.io/badge/-Changes-4d6bfe" /> <img alt="Background tasks" src="https://img.shields.io/badge/-Background%20tasks-4d6bfe" /> <img alt="Side Chat" src="https://img.shields.io/badge/-Side%20Chat-4d6bfe" /> <img alt="Plugin integration" src="https://img.shields.io/badge/-Plugin%20integration-4d6bfe" /><br /><br />
|
||||
<b>A dual workbench (right sidebar + bottom panel)</b> that opens its <code>ctx.betterSidebar</code> service to every plugin —<br />
|
||||
@@ -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
|
||||
@@ -62,7 +62,7 @@
|
||||
**Prerequisites**: DSH installed (`dsh web` boots), Node.js ≥ 20, pnpm ≥ 10.
|
||||
|
||||
**Supported DSH versions**:
|
||||
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions (v0.18.0): 0.1.2-rc.1+" src="https://img.shields.io/badge/DSH-0.1.2--rc.1%2B-4d6bfe" /></a>
|
||||
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions (v0.18.1): 0.1.2-rc.1+" src="https://img.shields.io/badge/DSH-0.1.2--rc.1%2B-4d6bfe" /></a>
|
||||
|
||||
> 📌 **Stable release**: starting with `v0.18.0` the plugin targets DSH **0.1.2-rc.1+** (npm dist-tag `latest`) and drops 0.1.0-rc.8 ~ 0.1.1-rc.2 — stable-DSH (≤ 0.1.1-rc.2) users should stay pinned to `dsh-better-sidebar@0.17.1` (`@latest` now points at v0.18.0); hosts still on 0.1.2-alpha.x should upgrade DSH first, or keep `dsh-better-sidebar@alpha` (v0.18.0-alpha.0).
|
||||
|
||||
@@ -265,7 +265,28 @@ The GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sid
|
||||
<a href="https://github.com/user-attachments/assets/946f7028-4967-461e-a750-d1b5056b62d0"><img width="33%" alt="Service API base screenshot" src="https://github.com/user-attachments/assets/946f7028-4967-461e-a750-d1b5056b62d0" /></a>
|
||||
</div>
|
||||
|
||||
**Supported DSH versions**: <a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions (v0.18.0): 0.1.2-rc.1+" src="https://img.shields.io/badge/DSH-0.1.2--rc.1%2B-4d6bfe" /></a> · full release history on the [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases) page
|
||||
**Supported DSH versions**: <a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions (v0.18.1): 0.1.2-rc.1+" src="https://img.shields.io/badge/DSH-0.1.2--rc.1%2B-4d6bfe" /></a> · full release history on the [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases) page
|
||||
|
||||
### v0.18.1
|
||||
|
||||
> 📌 **Stable release** (npm `latest`): the DSH baseline is unchanged (**0.1.2-rc.1+**, peer floor `^0.1.2-rc.1`) — this is the incremental cut after v0.18.0: richer op previews in the changes panel, a writable file tree, and five fixes.
|
||||
|
||||
**✨ Features**
|
||||
|
||||
- 📄 **Op-preview upgrades in the changes panel** (#499): markdown reading mode with mermaid fences, plus inline `.html` and `.pdf` render previews; diff syntax highlighting extended to mjs/cjs/mts/cts, CSS/SCSS/Less, HTML/XML/SVG/Vue, GraphQL, JSONC/JSON5; new **secret redaction** layer (on by default for previews, toggleable in the pane header)
|
||||
- 🗂️ **File-tree rename / delete** (#550): inline rename plus confirmed delete, slimmer context menus and viewport-clamped submenus
|
||||
- 🧩 **Plugin catalog names and shell preset text localized** (#535): they now follow the host language
|
||||
|
||||
**🐛 Fixes**
|
||||
|
||||
- 🔀 **Git diff gap folds really expand** (#576, fixes #577): the fold row promised "n lines… click to expand" but did nothing (`-U3` leaves gap segments without row text); the hidden rows now load on demand through `git.show`, sliced from both sides' contents, with loading / failure states and request deduplication — plus a fix to that route's `rev:path` addressing (it always returned empty before)
|
||||
- 💬 **Side-chat seeds no longer inherit the parent's unclaimed inbox messages** (#562): the seed carries the fork markers, so the "side chat sends earlier User messages first on long contexts" ghost message is gone
|
||||
- 🖼️ **Local images in the split markdown renderer** (#569): rewritten to reachable URLs instead of 404ing
|
||||
|
||||
**🧰 CI and internals**
|
||||
|
||||
- ESLint flat config wired into CI and the Makefile (#536), Makefile command surface (#526), hardened e2e scripts (#527), shared component-test utilities (#524)
|
||||
- Refactors: Sidebar.tsx split by concern (#542), four polling idioms converged onto `use-polling` (#541), rc.7 `__DSH_MODULES__` fallback removed (#540), One Dark/Light syntax palettes single-sourced (#534), duplicated implementations converged and dead code removed (#525)
|
||||
|
||||
### v0.18.0
|
||||
|
||||
@@ -519,6 +540,7 @@ The dashed cards at the end of the "Sidebar content" / "File viewers" grids in t
|
||||
```sh
|
||||
pnpm install # @deepseek-ai/* devDependencies resolve (baseline 0.1.2-rc.1, next dist-tag) — no token needed
|
||||
pnpm typecheck # tsc --noEmit
|
||||
pnpm lint # eslint . (flat config: js + typescript-eslint + react-hooks recommended)
|
||||
pnpm build # → lib/index.js + lib/invariant.js + lib/client.js + lib/client-registry.js + lib/types
|
||||
pnpm test # vitest (includes manifest consistency guard; build first)
|
||||
pnpm watch # tsdown --watch
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 面向 **消费插件开发者**:如何让你的插件向 better-sidebar 注册新的侧边栏页面(tab)和文件类型预览器。
|
||||
>
|
||||
> 适用版本:**v0.4.0+**(`ctx.betterSidebar` 服务);声明式设置 **v0.4.1+**;text/number 设置行 **v0.11.0+**;badge/生命周期/定向打开/插件设置/版本探测 **v0.12.0+**;select 设置行(`settingSelect`)与外链认领(`urlTarget`)**v0.13.0+**;统一 `@deepseek-ai/cordis` 类型基底 **v0.15.2+**;自由窗口(`floatWindows`)**v0.16.0+**;终端固定(pin)**v0.17.0+**。当前版本 **v0.18.0**(正式版,仅支持 DSH 0.1.2-rc.1+;旧宿主 stable 线为 v0.17.1)。
|
||||
> 适用版本:**v0.4.0+**(`ctx.betterSidebar` 服务);声明式设置 **v0.4.1+**;text/number 设置行 **v0.11.0+**;badge/生命周期/定向打开/插件设置/版本探测 **v0.12.0+**;select 设置行(`settingSelect`)与外链认领(`urlTarget`)**v0.13.0+**;统一 `@deepseek-ai/cordis` 类型基底 **v0.15.2+**;自由窗口(`floatWindows`)**v0.16.0+**;终端固定(pin)**v0.17.0+**。当前版本 **v0.18.1**(正式版,仅支持 DSH 0.1.2-rc.1+;旧宿主 stable 线为 v0.17.1)。
|
||||
> 权威代码:`src/client/service.ts`(服务实现)、`src/client/builtins/`(内置 8 tab + 6 viewer 参考实现)、`lib/types/client/service.d.ts`(类型声明)。
|
||||
> 仓库开发规则(硬约束 / CI / 发版)见 [AGENTS.md](../AGENTS.md)。
|
||||
|
||||
|
||||
@@ -96,3 +96,5 @@
|
||||
3. 重开线程 Tab 丢工具行:根因**不在宿主**(`session.history` cold 路径原样返回 `tool/call`,见 `api-proxy.ts:749` 与 `chunk-rows.ts` 的无损 chunk 压缩)而在首挂回源窗口——旧实现 8 事件/页 × 32 页 = 256 事件上限,而 cold 读会把 chunk-rows 展开回每个流式 delta 一条事件,一轮回答动辄数百事件,较早的 `tool/call` 掉出窗口(settled 文本靠最终 `assistant/message` 幸存)。修复 = 抽出 `collectOwnEvents`(200 事件/页 × 40 页帽、日志穷尽/无标记时落定 `seedBoundary:0` 停止每轮重走),单测覆盖四分支。
|
||||
4. Subagent 页「目录损坏」行:见 §5 修正——种子追加 descriptor + SubagentView/subagent-detect 按前缀过滤(`isSideThreadSummary` 同时修好 autoOpenSubagent 误触发与计数膨胀)。旧版创建的线程仍是 corrupt 诊断行,由 SubagentView 的标题前缀过滤兜底隐藏。
|
||||
- **转录传输迁至自有路由(DSH 0.1.2-alpha.1 适配)**:§6 原设计的时间线轮询走通用 `connection.api.sessions.history` RPC——该客户端面(`ctx.connection.api`)在 0.1.2-alpha.1 的 Remote-gateway 迁移中被整体移除,且其继任者(`session/follow`/`session/page`)对 `origin:'subagent'` 会话强制 subagent 地址寻址(普通 `{kind:'session'}` 地址被 `agent-busy` 拒绝),纯分页也拿不到最新尾页(`throughSeq` 不得超当前游标)。修复 = 新增 **`sidechat.events`** 路由(`src/sidechat-routes.ts`):host 侧读线程日志(live 走 `agent.session.events`,冷线程走 `ctx.get('sessionPersistence').inspect`——两版 DSH 同形,alpha.1 的 inspect 返回展开后的逻辑日志而非磁盘上的打包 chunk-row),服务端切 `session/end-seed`(`threadOwnLogEvents`,`collectOwnEvents` 的 walk 语义下沉),`afterSeq` 增量 + 8000 事件尾部帽(对齐旧 walk 上限 40×200)。客户端 `fetchThread` 单路径化,删除 `collectOwnEvents` 与 `ctx.connection` 镜像/inject。种子切割仍由 `transcriptRows` 客户端二次防御。挂载冒烟补 live+冷读两条断言。
|
||||
- **种子缺 fork 标记 → 子会话继承父会话未领取的 inbox 消息(2026-09-05,用户实测「上下文很长时侧边对话会把之前的 User msg 先发送出去」)**:`sidechat.start` 原只传 `seed` 不传 `meta.isSeeded: true` + `inheritedEventCount`。dsh-session 契约:**只给 seed 不标 isSeeded,种子算重放历史而非继承前缀** → `Session.ownEvents()` 含整个种子 → 子会话 `Inbox` 构造器(重放 `ownEvents()` 中的 `agent/inbox/spliced`)继承父会话切割时刻**任何未领取的 inbox 输入**。两个触发窗口(均以真实 0.1.2-rc.1 包 `Session.create` + `Inbox` 实验证实):① next-turn——父会话排队中的用户消息(长回合运行中补发);② **next-step(用户实测场景:父会话运行中、用户队列已清空)**——长回合里工具结果上下文/steering/运行时快照在步骤边界之间插入 next-step、尚未领取;上下文越长回合越久窗口越宽。后果:首条 `sidechat.prompt` 的 claim 顺序为 `[幽灵消息, boundary, question]`——幽灵消息排在 boundary 之前最先发给模型(工具上下文常为「工具完成,接下来做 X」,模型先继续父会话任务),并在子会话日志落成 `user/message`。修复 = create 补 `meta.isSeeded: true` + `inheritedEventCount: SessionLogOffset(seed.length)`(宿主自己的 `session.fork`——api-session-controller——的规范调用形态;`createAgent` 工厂逐字段透传 `sessions.prepare`)。`threadOwnLogEvents` 按 end-seed 标记切割,标记位置不变,转录行为不变;冷恢复按持久化 header 切割,一致。回归测试进 `tests/sidechat-seed-validation.spec.ts`(真实 Session + Inbox 双向锁定:旧形态继承幽灵 / 新形态两队列皆空)。用户拍板:不迁移既有线程、不加 inbox.clear() 兜底(修复后新线程为无操作;旧「从未发过消息」空线程首次使用可能仍先答一次旧消息,之后自愈)。
|
||||
- **「更优雅 API」复评(2026-09-05,0.1.2-rc.1 = npm latest)**:无 sidechat/sidechain 宿主 API;`ContinuableStartSpec`(`ctx.subagents.startContinuable`)仍无 seed 字段;`session/create` RPC 只建全新会话。§3 的自建结论继续成立,`AgentRegistry.create` 接缝即宿主 fork 自用的公开接缝——补齐 fork 标记即为规范用法。
|
||||
|
||||
@@ -89,6 +89,8 @@ Cursor (SSH)
|
||||
|
||||
**实施偏差(2026-09,#517 / PR #522)**:SSH 远程编辑器链接**不再经宿主路由执行**。DSH 部署在无头远端服务器时,宿主侧 `xdg-open` 无 DISPLAY/无编辑器,`vscode://` 静默失败。`api.openExternal`(`src/client/api.ts`)现把 `<scheme>://vscode-remote/ssh-remote+…` 形态的 URL 在浏览器客户端同步触发 `window.location.assign`(处于用户点击链内,外部协议交给本机编辑器经 Remote-SSH 打开远端文件);reveal 与本地编辑器 URL 仍走本节宿主路由。普通浏览器可处理自定义协议;禁止/未处理 `vscode://` 的 WebView 壳客户端需各自适配(见 #517 补充信息)。
|
||||
|
||||
**实施偏差(2026-09,右键菜单减重与子菜单视口钳制)**:本菜单(及 TabBar / GitLens / FreeWindow 右键菜单、「+」菜单)整体切到 primitives `Menu` 的 **`compact` 密度**(行高 26px / 12px 字 / 164px 卡片 / 7px 圆角),菜单图标随槽位 16→14、子菜单图钉 20→16。同时发现宿主子菜单**不受视口钳制**(`bottom:-4px` 从父行向上生长、`left:calc(100%+10px)` 向右展开,主列表的钳制与 `max-height` 收缩均不覆盖它):树顶部右键时本子菜单伸出视口上沿,窄面板(280–400px)时伸出右沿。修复为插件侧方案 `src/client/menu-flip.ts`——菜单打开期间在 `<body>` 发布 `data-dsh-sidebar-submenu="down|left"` 方向 token(`y < vh/2` → down;`x + 400 > vw` → left,400 镜像宿主几何 218 卡片 + 10 间隙 + 165 子菜单 + 12 边距),`layout.css` 据此翻转子菜单生长方向与 `::before` 悬停桥;**属性的存续期即作用域**(仅我们的菜单打开时存在),宿主自有菜单不受影响。**不做** `max-height` + 滚动钳制:overflow 裁剪会切掉子菜单 `::before` 悬停桥(跨 10px 间隙会触发 mouseLeave 关卡)。残留见「已知限制」末两条。
|
||||
|
||||
## 已知限制
|
||||
|
||||
- 路径含 `#`/`?` 的文件名经 URL 打开可能被浏览器当作 fragment/query(不处理,注释说明)。
|
||||
@@ -96,13 +98,18 @@ Cursor (SSH)
|
||||
- 自定义编辑器仅支持 URL 模板式,不支持 CLI 命令式;无 `{dir}` 占位符。
|
||||
- Linux reveal 退化为打开所在目录(精确 select 需要各文件管理器私有协议)。
|
||||
- SSH 模式为**全局**(非每会话):DSH 无远程会话概念,文件树路径即宿主路径;该模型对应"DSH 跑在远端开发机上"的场景。
|
||||
- 子菜单钳制为方向翻转而非高度裁剪:约 >15 个打开方式目标 + 矮视口 + 不利光标位仍可能溢出(compact 密度已把常规 4–8 目标场景收进安全区)。
|
||||
- 翻转阈值镜像宿主 `Menu.module.css` 几何常量(218/10/165/12):宿主未来改尺寸时阈值偏保守(多翻一次方向),不会产生新越界;菜单打开期间窗口 resize 不重算 token,该次打开最坏退化为原始行为。
|
||||
|
||||
## 测试
|
||||
|
||||
- `tests/open-with.spec.ts`:解析容错、目标解析与 SSH 过滤、URL 构建(本地/SSH/custom/坏模板)、校验器。
|
||||
- `tests/open-external.spec.ts`:三平台命令表、URL 校验、spawn 前校验。
|
||||
- `tests/open-external-client.spec.ts`(#522):SSH remote URL 客户端分流(不触 fetch)、本地/reveal/http(s) 留宿主、导航抛错 reject。
|
||||
- `tests/file-tree-open-with.spec.tsx`(jsdom):右键 → 子菜单/图钉;pin 不选中不关闭;选子项回调关闭菜单;SSH 标签后缀;未接线隐藏。
|
||||
- `tests/file-tree-open-with.spec.tsx`(jsdom):右键 → 子菜单/图钉;pin 不选中不关闭;选子项回调关闭菜单;SSH 标签后缀;未接线隐藏;翻转向量(down / down left / 卸载清理)。
|
||||
- `tests/menu-flip.spec.tsx`:方向 token 纯函数(四象限、边界、窄面板)+ hook 属性生命周期 + layout.css 选择器与属性名的跨文件一致性。
|
||||
- `tests/tab-bar-context-menu.spec.tsx`:追加翻转向量(顶部条 down、右缘 down left、下半空 token、卸载清理)。
|
||||
- `tests/e2e/mount.e2e.ts`:「+」菜单首行 computed 高度 ≤30px(compact 密度的布局级证据)。
|
||||
- `tests/open-with-settings.spec.tsx`:SSH 输入、添加/删除(删除剪枝 pinned)、无效提示、VSCode 系开关。
|
||||
- `tests/side-card-section.spec.tsx`:`SettingsBody` 行列表 + render 共存、仅 render 时不变。
|
||||
- `tests/builtins.spec.ts`:editor 描述符断言补 `settings.render`。
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# 2026-09-03 全方位代码防腐 / 质量优化审查报告
|
||||
|
||||
> 三路探索子代理对 src / tests / scripts / CI / 文档做全方位摸底后,经用户批准按「P0 速修 + P1 行为不变去重 + P2 深度项全做」执行,共 10 个变更流、11 个 PR,全部走分支 + PR + 三 job CI 门禁合入。本文记录审查结论、落地清单、过程中的重要发现与遗留 backlog。
|
||||
|
||||
## 1. 审查结论总览
|
||||
|
||||
底子健康,无大面积腐化:
|
||||
|
||||
- 类型纪律严格:全仓 0 `as any`、0 `@ts-ignore`、0 TODO/FIXME。
|
||||
- 模块边界零违规:懒加载 chunk 隔离、client externals 白名单、tsdown purity gate 全部干净。
|
||||
- 错误 envelope 一致:宿主侧 `wire.ts` / 客户端侧 `api.ts` 双层归一。
|
||||
- 测试规模:118 个 vitest spec(约 3300 断言)+ 6 条 Playwright e2e lane。
|
||||
|
||||
压力点集中在:少数巨型组件、若干字面重复、脚本间复制粘贴、以及缺 lint / task-runner 基础设施。
|
||||
|
||||
## 2. 已落地的变更流
|
||||
|
||||
| PR | 主题 | 要点 |
|
||||
|---|---|---|
|
||||
| #524 | feat/test-utils | 共享 `tests/test-utils.ts`(`setupReactAct` + `renderRoot`),迁移 28 个 spec 的重复样板;`chunk-artifact` / `manifest-consistency` 加 lib 缺失 skipIf 守卫 |
|
||||
| #525 | feat/dedup-core | api envelope 解析、`extOf` 双实现、lineage-walk 四份、GitLens 错误归一化六处收敛;删零引用死代码 `sidechatSeed`;console 分级统一。测试零改动 |
|
||||
| #526 | feat/dev-commands | Makefile(11 目标薄封装,`check` 聚合对齐 CI);package.json 补 `test:mount:aggregate` / `clean` / `packageManager: pnpm@11.8.0`;release.yml 补 consumer-types 并统一步骤序;.editorconfig;双语 README 补文档 |
|
||||
| #527 | fix/scripts-hardening | aggregate 脚本 tarball 改 mtime 选取(修真 bug);DSH_CMD 默认值统一;package-registry.mjs 补 mermaid chunk;抽 `scripts/e2e-common.sh` 消除两 e2e 脚本约 40% 重复(函数级等价性探针 + 双 lane 真机验证) |
|
||||
| #534 | feat/palette-single-source | One Dark/Light 语法色板单源化(`one-dark-palette.ts`),迁移前后映射机器证明逐字节相等 |
|
||||
| #535 | feat/i18n-catalog-names | 目录项 name / shell 预设 desc 词典化(20 key × 21 词典文件,`string | (() => string)` 镜像 desc 模式);兜底错误条改 `var(--dsw-alias-*, 原hex)` 皮肤令牌链 |
|
||||
| #536 | feat/eslint | ESLint 10 flat config(typescript-eslint recommended + 经典 react-hooks 规则),存量 141 错误/21 警告清零(含 24 处真实死代码删除、`_` 前缀 mock 约定入 config);`pnpm lint` / `make lint` / CI 三 job 全部接入;`make check` 同步含 lint |
|
||||
| #540 | feat/drop-rc7-fallback | 删 rc.7 宿主 `window.__DSH_MODULES__` 回退(peer 基线 rc.1 下不可达);测试 fixture 切换到与生产同路径;附带修复 main 上 #535/#536 语义合并冲突导致的 add-plugin-modal spec 断裂 |
|
||||
| #541 | feat/unify-polling | `use-polling.ts` 原语(83 行)统一四处轮询习语(SubagentView 自排程 3s / SideChatView fixed 2s / GitLens fixed 2s / ChangesTab fixed 2.5s),守护 spec 断言零改动 |
|
||||
| #542 | feat/split-sidebar | Sidebar.tsx 1831 → 1176 行,抽 `src/client/sidebar/` 五模块(TabContent 74 / use-center-column 171 / use-host-feeds 269 / use-pinned-tabs 153 / free-windows 167);面板拖拽机约 330 行因 `sidebar-layout-push.spec` 的源码文本计数断言钉住而有意保留主文件 |
|
||||
|
||||
过程中同步发生的外部事件:DSH 0.1.2-rc.1 适配(PR #537)合入后,Wave 2 三个 PR 全部 rebase 到新 main 并重跑门禁再合并。
|
||||
|
||||
## 3. 过程中的重要发现(记录在案)
|
||||
|
||||
### 3.1 Windows agent-pty PTY 超时 flaky(既有,非本轮引入)
|
||||
|
||||
`tests/agent-pty.spec.ts` 的真实 PTY spawn 用例在 ci-windows 上间歇性 5s 超时。证据链:main@`ceed6b2`(Wave 1 首个合并的 push run)挂 agent-pty ×4 + git.spec ×1;#535、#536、#541 的 ci-windows 各挂过 1-2 个同类用例,**重跑均绿**;失败组合在多次运行间漂移。建议后续对该 describe 块在 win32 上提升 testTimeout 或加有界重试。
|
||||
|
||||
### 3.2 desktop-layout.e2e.ts 间歇 flaky(上游时序敏感)
|
||||
|
||||
该用例测「右面板保持桌面 session 头部位置」,区域与上游适配提交 `ab6f960`(toggle cluster 重定位动画)重叠。本机(钉版 rc.1 CLI)稳定因宿主 onboarding 浮层 / workspace 选择对话框不可达而失败,且 main 原样产物 A/B 对照同样失败;CI 上 aa01140 的 push run 也挂过一次(同内容分支 run 与后续 main 验证 run 均绿)。属宿主时序敏感的既有 flaky,建议上游动画稳定后加等待收敛。
|
||||
|
||||
### 3.3 跨分支语义合并冲突的流程教训
|
||||
|
||||
#535 新增了 `builtinTabPlugins` 的使用(依赖既有 import),#536 的 eslint 清理在其各自基线上把当时未使用的同名 import 删除——两个各自全绿的 PR 文本合并成功但语义断裂(typecheck TS2304 + spec 加载即挂),且被并发 run 取消掩盖后带病合入 main,由 Wave 3 两个子代理独立发现并热修(#540 `8820efd`)。教训:**并行分支触碰同一文件时,每合入一个后其余分支需重验(rebase + typecheck/test),不能只信各自 PR 的绿勾**;unused-import 类清理与功能分支并行时尤其如此。
|
||||
|
||||
### 3.4 GitHub push run 丢触发一例
|
||||
|
||||
#541 合并产生的 main push run(`68839aa`)从未被创建(workflows 均 active,按 SHA 查询 0 runs),经 `workflow_dispatch` 手动补跑三 job 全绿。原因不明,遇到「merge 后 main 无 push run」时可按此法补验。
|
||||
|
||||
## 4. 基础设施变化速查
|
||||
|
||||
- **Makefile**:`help`(默认目标)/ `install` / `build` / `typecheck` / `lint` / `test` / `check`(typecheck+build+test+consumer-types+lint 聚合)/ `clean` / `pack` / `mount` / `mount-aggregate` / `registry`。package.json scripts 仍是唯一事实源,Makefile 只做薄封装与依赖链。
|
||||
- **Lint**:`pnpm lint` = `eslint .`(flat config,`reportUnusedDisableDirectives: error`);CI 三处(ci / ci-windows / release)在 Typecheck 后执行。
|
||||
- **本地工具钉版**:`packageManager: pnpm@11.8.0`(CI workflow 的显式 `version:` 输入已移除,统一由该字段供版)。
|
||||
- **e2e 本地跑法**:`make mount` / `make mount-aggregate`;本机无 rc.1 CLI 时 `npm install --prefix /tmp/dsh-rc1-bin @deepseek-ai/dsh@0.1.2-rc.1` 后置 PATH。
|
||||
|
||||
## 5. Backlog(本次仅记录)
|
||||
|
||||
1. mount.e2e 的固定 `waitForTimeout` 与英文文案选择器(`[title="Files"]` 等)——宿主文案/语言变更即碎。
|
||||
2. CI 无 Playwright 浏览器缓存(每 run 重装 Chromium)。
|
||||
3. `state.spec`(1498 行)/ `smoke.spec`(1208)/ `service.spec`(1124)三个千行 spec 拆分。
|
||||
4. FreeWindow / TreePanel / split-pane / DiffPane / DiffRows 等组件缺直接渲染 spec(现靠 state 纯函数 spec + e2e 间接守护)。
|
||||
5. coverage 工具引入评估(当前零覆盖率度量)。
|
||||
6. CHANGELOG / CONTRIBUTING 文件(README「🆕 最近更新」小节手工充当 changelog)。
|
||||
7. Sidebar.tsx 剩余拖拽机(~330 行)拆分——前置条件是先重构 `sidebar-layout-push.spec.tsx` 的源码文本计数断言为行为断言。
|
||||
8. `JobOutputPane` 的 2s 轮询未迁移 `use-polling`(独立形状,见 #541 PR body)。
|
||||
9. install.sh / install.ps1 / check-consumer-types.sh 内部的 say/warn/die 重复——install.sh 必须自包含随 npm 包分发,属有意设计,除非改分发方式否则不动。
|
||||
10. §3.1 / §3.2 两个 flaky 的根治。
|
||||
|
||||
## 6. 验证基线(全部 PR 合并后于 main 复核)
|
||||
|
||||
- `pnpm lint` / `pnpm typecheck` / `pnpm build` / `pnpm test`:119 files / 1243 passed / 9 skipped。
|
||||
- `pnpm check:consumer-types` 通过。
|
||||
- 真机挂载 lane:mount 5/5、drag-layout 7/7、float-window 2/2、toggle-layout 1/1、perf 2/2(desktop-layout 见 §3.2)。
|
||||
- aggregate 双挂载回归 lane 通过。
|
||||
@@ -0,0 +1,62 @@
|
||||
# 文件树重命名 / 删除设计(2026-09-04)
|
||||
|
||||
> 状态:已实现。本文档记录功能设计、关键取舍与已知限制。
|
||||
|
||||
## 背景
|
||||
|
||||
文件树右键菜单此前只有打开/复制/上传下载等只读+导出操作,缺少资源管理器最基本的两个变更操作。本次补全:
|
||||
|
||||
- **重命名**:行内编辑(VS Code/Finder 惯例,用户已确认)——菜单点「重命名」后行内名字变输入框(预选中、Enter/失焦提交、Esc 取消)。
|
||||
- **删除**:弹窗确认(用户指定)——复用 git lens 的 Cancel/Confirm Modal 范式,文件/目录文案区分,确认后永久删除。
|
||||
|
||||
同批修复二级菜单的视觉关系(见下节)。
|
||||
|
||||
## 服务端
|
||||
|
||||
- `src/fs-operations.ts` 新增 `renameWorkspaceEntry` / `removeWorkspaceEntry`(与 `writeWorkspaceUpload` 同层):
|
||||
- **链路感知(link-aware)**:存在性与围栏校验对**完全解析后的目标**进行(fence 开启时指向工作区外的 symlink 一律拒绝),但操作本身作用于**词法行路径**——重命名/删除一个 symlink 行只改/删**链接本身**,绝不动目标(lstat 判定,link-to-dir 不会递归进目标)。与 VS Code 语义一致:行显示什么就操作什么。
|
||||
- `renameWorkspaceEntry`:`name` 必须单段(非空、非 `.`/`..`、无分隔符——重命名不是移动);目标已存在直接 409 拒绝(POSIX rename 会静默覆盖);工作区根本身不可重命名;同名 no-op 直接成功(不触发 409)。返回**规范路径**(realpath 解析后的祖先链)供 tab 改址。
|
||||
- `removeWorkspaceEntry`:目录递归删除、文件/symlink 解链;根拒绝。无回收站(宿主无此 API;等同 git lens discard 的永久语义)。
|
||||
- `src/index.ts` 路由 `'fs.rename'`(`{path, name}`)/ `'fs.remove'`(`{path}`),走 `cwdOf` + `fenceEnabledOf(getSettings)`,与 `fs.write` 同一围栏开关。
|
||||
- `src/client/api.ts`:`fsRename` / `fsRemove`。
|
||||
|
||||
## 客户端(FileTree)
|
||||
|
||||
- 菜单尾部(复制项之后):`separator + 重命名(IconEditOutline16) + 删除(IconTrashOutline16, danger)`;**根行**(`rowMenu.path === cwd`)不渲染这两项(服务端双保险)。
|
||||
- 行内编辑:命中的行换渲染为编辑行(同缩进/图标/行高),**无 role="button"**(编辑器不是点击目标,也避免 button 嵌套 interactive 的非法结构)。裸 `<input>`(primitives `Input` 不转发 ref,而 focus+select 正是要点);ref 回调 `useCallback([])` 稳定身份——内联箭头每渲染重跑会把正在输入的缓冲区重新全选。Enter/失焦提交、Esc 取消;`isImeComposition` 守卫(IME 组合期的 Enter/Esc 属于输入法,不提交不取消——顺手把该守卫的入参 `isComposing` 放宽为可选,React 合成事件类型未声明该字段)。
|
||||
- 删除确认:primitives `Modal`(git lens 同款 outline Cancel + primary Confirm)。
|
||||
- 失败面:树顶一条可点消失的错误条(`role="alert"`),展示服务端原文(与 FenceErrorNotice 同策略);客户端单段校验失败也走这里(`renameInvalid`)。
|
||||
- **落定收尾 `pruneTree(prefix)`**:清掉旧路径前缀的所有缓存层 → `onToggle` 收起 stale 的 expanded 项 → `retryDir(parent)` 单层重载(无需整树 refresh tick)。重命名已展开目录后新目录折叠展示(expanded 不迁移,VS Code 同样)。
|
||||
|
||||
## tab 调解(tree-mutations.ts + EditorHost)
|
||||
|
||||
- `retargetPathTabs(ctx, store, old, new)`:`tab.path === old` → `service.updateTab(id, {path: new, title: baseName(new)})`(编辑内容存活,后续保存落到新路径)。
|
||||
- `closePathTabs(ctx, store, target)`:`tab.path === target || isWithinWorkspace(target, tab.path)` → `service.closeTab(id)`(目录连整个子树的打开文件一起关)。
|
||||
- tab 枚举覆盖 `state.splits` / `state.bottomSplits` / `state.floats`(浮动窗口的 tab 同样算打开)。走 service 路径(而非直改 state)使注册的生命周期回调照常触发。
|
||||
- FileTree/TreePanel 透传 `onPathRenamed?` / `onPathDeleted?`(可选,测试可不接线);EditorHost 两处 `<TreePanel>` 接线。
|
||||
|
||||
## 二级菜单视觉关系(layout.css,同批)
|
||||
|
||||
针对「二级菜单没有视觉关系」的反馈,两条纯 CSS 规则(仍以 `data-dsh-sidebar-submenu` body 属性限定作用域,宿主默认不受影响):
|
||||
|
||||
1. **父行持续高亮**:宿主 Menu 在子菜单打开期间给父行按钮标 `aria-expanded="true"`(宿主已渲染、稳定),用同一 hover token 重新着色——指针移入侧卡后父行不再熄灭。
|
||||
2. **卡片贴近**:子菜单与父卡间距 10px → 4px,`::before` 悬停走廊同步收窄;翻转规则(left/down)的间距一并 4px。规则排序:tether 基础规则在前、翻转覆盖在后(同特异性下源序决胜)。
|
||||
|
||||
`tests/menu-flip.spec.tsx` 的 layout.css 契约断言扩展覆盖这三条规则。
|
||||
|
||||
## i18n
|
||||
|
||||
新增 7 key(rename / renameInvalid / delete / deleteTitle / deleteDescFile / deleteDescDir / dismiss),zh/en/ja 手写;其余 18 个已发第三语言按 `locales.spec.ts` 的 key-set-parity 门禁同步补齐。
|
||||
|
||||
## 测试
|
||||
|
||||
- `tests/fs-operations.spec.ts`:rename(文件/目录/no-op 同名/单段违规/目标已存在 409/根拒绝/缺源/symlink 行改链接不改目标)、remove(文件/目录递归/symlink 只解链目标存活/根拒绝与缺路径)。
|
||||
- `tests/file-tree-rename-delete.spec.tsx`(jsdom,mock api):菜单项出现条件(文件/目录有、根无);重命名流(预填→Enter 提交→`fsRename`+`onPathRenamed`、Esc 取消、非法名走错误条);删除流(弹窗文案文件/目录区分、确认→`fsRemove`+`onPathDeleted`、取消、服务端失败进错误条)。
|
||||
- 真机挂载冒烟(`pnpm test:mount`)不触达这些交互,由 CI 门禁与手动验收覆盖。
|
||||
|
||||
## 已知限制
|
||||
|
||||
- 无回收站:删除即永久(fence 开关不改变这一点,只改变越界拒绝)。
|
||||
- 重命名不迁移 expanded/revealed 状态:改名已展开目录后新目录折叠、reveal 高亮不跟随(一次性视觉状态,成本/收益不划算)。
|
||||
- 并发窗口竞争:存在性检查与 rename/remove 之间有微小 TOCTOU 窗口(单用户侧边栏场景可忽略;服务端错误会进错误条)。
|
||||
- 「在其他编辑器里改名后本树 stale」既有行为不变(refresh tick / focus 刷新机制管)。
|
||||
@@ -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 出现条件)。
|
||||
@@ -0,0 +1,217 @@
|
||||
# Git 折叠行真实展开——修复「点击展开」无反应
|
||||
|
||||
> 日期:2026-09-07 · 状态:计划已定,待实施 · 分支(拟):`fix/diff-fold-expand`
|
||||
> 关联:上游 issue(待提)、上游 PR(待提,Closes issue)· 上游仓库 `omdsh-dev/DSH-better-sidebar`,本地 fork `LaoYueHanNi/DSH-better-sidebar`(main `6fbfeed` 与上游零差异)
|
||||
|
||||
## 1. 背景与 bug 确认(已核实)
|
||||
|
||||
**现象**:changes 组件点击 git 提交(或工作区 diff)后,diff 视图中 hunk 之间的折叠行显示「n 行…点击展开」,点击无反应(无 pointer/hover)。session(会话文件操作)lens 的折叠行可正常展开——仅 git lens 永远不可展开。
|
||||
|
||||
**证据链**:
|
||||
- `src/client/diff/rows.ts` L407-433(`unifiedSegments`):git `-U3` 不输出 hunk 之间的未改动行,gap fold 段**只有行号范围、没有 `rows` 字段**(注释明确 "rows are unknown (non-expandable)");
|
||||
- `src/client/diff/DiffRows.tsx` L193-228:`expandable = segment.rows !== undefined && segment.count >= MIN_FOLD`;`rows === undefined` 走静态分支(L197-202,**无 onClick、无 data-expandable**),但文案复用 `t('changesFold')` = zh「{count} 行…点击展开」/ en「{count} lines…click to expand」——承诺了不存在的交互;
|
||||
- `src/client/diff/diff.module.css` L50-51:仅 `[data-expandable='true']` 有 pointer/hover,静态分支视觉上也不可点,唯独文字撒谎;
|
||||
- session 路径(`diffLines` + `buildDiffSegments` → `foldOf`)的 fold 总有 `rows`,可正常 toggle 展开。
|
||||
|
||||
**上游调研结论**(2026-09-07,9 组中英文关键词 + PR 列表 + `git fetch` 源码比对):无 issue 报告过此问题(最接近的 #346 是「文件级默认折叠要多点一下」,方向相反),无已合并/进行中的修复——**全新发现**。
|
||||
|
||||
**已排除的轻量方案**:
|
||||
- 纯改文案(原方案 A):不解决「看不到被折叠内容」的本质,用户拍板不做;
|
||||
- 调大 `-U` 上下文:大 gap 依旧折叠且依旧不可展开,治标且 diff 变吵。
|
||||
|
||||
## 2. 目标与成功标准
|
||||
|
||||
点击 git 折叠行时**按需拉取两侧完整内容、按行号切片填充真实上下文行**,让「点击展开」名副其实。
|
||||
|
||||
1. git 提交/工作区 diff 折叠行点击后展开未改动上下文行(带双侧行号),再点可折叠;
|
||||
2. session lens 行为完全不变;二进制/untracked 文件不受影响;
|
||||
3. 内容拉取失败降级为无点击暗示的中性文案;
|
||||
4. `pnpm typecheck` / `pnpm test` 全绿,CI 三 job(挂载冒烟/typecheck/test)绿;
|
||||
5. 上游 issue(问题+预计方案)与 PR(实际修复,`Closes #N`)按仓库规范挂接。
|
||||
|
||||
## 3. 已确认的实现地基
|
||||
|
||||
- **服务端 `git.show` 路由已存在**(`src/index.ts` L474-480):payload `{rev, path}`(rev 可为 `:0` / `HEAD` / `<hash>^` / `<hashFull>`),返回 `{content: string | null}`,失败(null)内建于 `git.ts show()` 的 try/catch → **服务端零改动**;
|
||||
- `src/git.ts` `diff()`(L382-388):unstaged = `git diff -- path`(**index vs worktree**),staged = `git diff --cached`(**HEAD vs index**)——决定展开时两侧 rev 取法;
|
||||
- 渲染链:`DiffPane`(底部预览)/ `DiffTab`(独立 tab)→ `DiffFiles`(per-file `unifiedSegments`)→ `DiffRows`;op 路径直接 `DiffRows`(`buildDiffSegments`)。客户端 `api.ts` 尚无 `git.show` 封装(需补一行 call);
|
||||
- `SidebarDiffRef`(`src/client/state.ts` L24-26):`worktree{path, staged, untracked?, worktree?, repoRoot?}` | `commit{hash, hashFull, subject, worktree?, repoRoot?}`;
|
||||
- spawn 数组参数不经 shell,`<hash>^` 的 `^` 无需转义;`git show :0:<path>` 取 index 版本合法。
|
||||
|
||||
## 4. 实现设计(按子系统)
|
||||
|
||||
### 4.1 `src/client/api.ts` — gitShow 客户端封装
|
||||
仿 `gitCommitDiff`(L317-318):
|
||||
```ts
|
||||
gitShow: (scope: SessionScope, rev: string, path: string, worktree?: string, signal?: AbortSignal) =>
|
||||
call<{ content: string | null }>('git.show', gitPayload(scope, worktree, { rev, path }), signal)
|
||||
```
|
||||
走既有 RPC 通道,不触构建纯度门。
|
||||
|
||||
### 4.2 `src/client/diff/rows.ts` — 切片纯函数
|
||||
```ts
|
||||
export function foldRowsFromContents(fold: FoldSegment, oldContent: string, newContent: string): readonly DiffRow[]
|
||||
```
|
||||
- 以 old 侧驱动 `[oldStart..oldEnd]` 逐行生成 `{kind:'context', oldLine, newLine?, text}`,`newLine = oldLine - oldStart + newStart`(仅当落在 `[newStart..newEnd]` 内才带);再以 new 侧驱动补纯新增段(old 映射不到的行,只带 `newLine`);
|
||||
- 内容 `split('\n')` 切;空内容 = 空数组;行尾 `\r` 保留(与 git diff ctx 行行为一致)。
|
||||
|
||||
### 4.3 `src/client/diff/DiffRows.tsx` — 异步展开状态机
|
||||
- 新 prop:`resolveFold?: (segment: FoldSegment) => Promise<readonly DiffRow[]>`(不传 = 现状,op 路径不变);
|
||||
- 新 state:`foldData: Map<number, readonly DiffRow[]>`、`foldLoading: Set<number>`、`foldFailed: Set<number>`,随现有 useEffect(L119-123)在 segments 变化时整体重置;
|
||||
- expandable 判定放宽:`(segment.rows !== undefined || resolveFold !== undefined) && segment.count >= MIN_FOLD`;
|
||||
- 点击未填充的 git fold:置 loading → `resolveFold(segment)` → 成功存 `foldData`(渲染优先于 `segment.rows`)、失败标 `foldFailed`;
|
||||
- 渲染三态:加载中 `t('changesFoldLoading')`;失败 `t('changesFoldUnavailable')`(无 onClick/pointer);正常沿用 `changesFold`(此时承诺为真);已展开的 fold 渲染 rows,再点折叠回。
|
||||
|
||||
### 4.4 `src/client/diff/DiffFiles.tsx` — loader 下传 + Promise 缓存
|
||||
- 新可选 prop `resolveFold?: (file: DiffFile, segment: FoldSegment) => Promise<readonly DiffRow[]>`,转发 `DiffRows`;
|
||||
- 实例级 `useRef<Map<string, Promise<readonly DiffRow[]>>>`,key = `displayPath(newPath)|oldStart|newStart`,同文件多 fold 共享一次内容拉取、并发点击天然去重;`parsed` 变化时重置。
|
||||
|
||||
### 4.5 `src/client/changes/DiffPane.tsx` / `src/client/DiffTab.tsx` — lens loader
|
||||
按 `SidebarDiffRef` 构造传给 `DiffFiles`:
|
||||
- **commit lens**:旧侧 `gitShow(scope, '<hashFull>^', displayPath(file.oldPath))`(root commit → null → ''),新侧 `gitShow(scope, hashFull, displayPath(file.newPath))`;与 `commitDiff` 的 `-m --first-parent` 语义一致;rename 由 `oldPath/newPath` 天然覆盖;
|
||||
- **worktree lens**:`staged=true` → 旧 `HEAD` / 新 `:0`;`staged=false` → 旧 `:0` / 新侧 `api.fsRead` 工作区(`resolveSidebarPath(repoRoot ?? worktree ?? cwd, path)`,同现有 untracked fallback L196/L68 的写法);
|
||||
- **staged 侧漂移修正(关键边缘)**:DiffPane L182-188 / DiffTab L49-57 在请求侧 diff 为空时 fallback 渲染另一侧,但 `gitRef.staged` 不变——把实际生效侧记为 state(如 `effectiveStaged`),loader 依它取数,否则切片行号错位;
|
||||
- untracked 文件走 `untrackedFile` 全加渲染,无 fold,loader 不涉及。
|
||||
|
||||
### 4.6 i18n — 两个新 key,18 个语言文件全补
|
||||
- `changesFoldLoading`:zh `展开中…` / en `Expanding…`
|
||||
- `changesFoldUnavailable`:zh `上下文未加载` / en `Context unavailable`
|
||||
- `locales.ts` 为基准;`locales-ja.ts` 同步是 AGENTS 硬性底线;其余 15 个 `locales-*.ts` 照 `changesFold` 现状全量补译;纯 `t()` 通道,不涉 `markdownTextProps()`。
|
||||
|
||||
### 4.7 测试(vitest,沿用现有模式)
|
||||
- `tests/diff-rows.spec.ts` 增补 `foldRowsFromContents`:对称区间 / 纯删除 gap(old 有 new 无)/ 纯新增 gap / `\r\n` 行尾 / 空内容 / 行号越界截断;
|
||||
- 新组件测试(参照 `tests/diff-files-collapse.spec.tsx`):mock `api.gitShow` → 点击 git fold 断言展开行渲染、再点折叠、失败降级文案、session fold 路径不受影响;
|
||||
- 回归:`diff-rows` / `diff-files-collapse` / `diff-highlight` / `builtins` 全绿。
|
||||
|
||||
## 5. 边缘 case 清单(实现时逐条对照)
|
||||
|
||||
| 场景 | 处置 |
|
||||
|---|---|
|
||||
| root commit 无父 | `git.show` 返 null → 空串(旧侧视为空) |
|
||||
| rename | `parseUnifiedDiff` 保留 oldPath/newPath,两侧各取各的 |
|
||||
| 二进制文件 | 无 hunks,不渲染 DiffRows,不涉及 |
|
||||
| untracked | `untrackedFile` 全加渲染,无 fold |
|
||||
| 无换行文件 | `count` 与切片行数可差 1,展开后以实际行为准 |
|
||||
| `\r\n` 行尾 | blob 行与 diff ctx 行同样保留 `\r`,一致 |
|
||||
| staged fallback 侧漂移 | `effectiveStaged` state(§4.5) |
|
||||
| 并发/重复点击 | Promise 缓存去重 |
|
||||
| refresh / 切目标 | tick + segments 变化重置展开态 |
|
||||
| 大文件 | 按需拉取不预取,失败降级兜底 |
|
||||
|
||||
## 6. 流程步骤(issue / PR 全文草稿)
|
||||
|
||||
### 步骤 1:向上游提 issue
|
||||
|
||||
```bash
|
||||
gh issue create --repo omdsh-dev/DSH-better-sidebar \
|
||||
--title "[Bug] Git 提交/工作区 diff 折叠行显示「n 行…点击展开」,点击无反应" \
|
||||
--body-file <下方正文>
|
||||
```
|
||||
|
||||
模板字段:环境 = `DSH Web(浏览器访问)`;类别 = `🐛 Bug`;DSH 版本可留空;插件版本 = `main(6fbfeed)`。
|
||||
|
||||
**描述字段正文**:
|
||||
|
||||
> **现象**:在「变更」面板点击历史提交(或查看工作区 diff)后,diff 视图中 hunk 之间的折叠行显示「n 行…点击展开」,但点击无任何反应(无 pointer 光标、无 hover);而会话文件操作(文件追踪)lens 的折叠行可以正常展开——仅 git lens 永远不可展开。
|
||||
>
|
||||
> **期望行为**:要么点击真实展开被折叠的上下文行,要么不显示「点击展开」字样。
|
||||
>
|
||||
> **复现步骤(Bug 必填)**:打开侧边栏 → 变更 → 历史 → 点击任一含多个 hunk 的提交 → 观察文件 diff 中 hunk 之间的折叠行 → 点击。
|
||||
>
|
||||
> **根因(代码定位)**:git 的 unified diff(`-U3`)不输出 hunk 之间的未改动行,`unifiedSegments`(`src/client/diff/rows.ts`)为此产生的 fold 段只有行号范围、没有 `rows`;`DiffRows`(`src/client/diff/DiffRows.tsx`)对 `rows === undefined` 渲染静态分支(无 onClick),但复用了含「点击展开」的 `changesFold` 文案,形成「承诺了不存在的交互」。
|
||||
|
||||
**补充信息字段正文**:
|
||||
|
||||
> **预计修正方案**:不采用纯改文案或调大 `-U` 上下文(治标),按需真实展开——点击折叠行时经既有 `git.show` 服务路由拉取该文件两侧完整内容(commit:`<hash>^` 与 `<hash>`;worktree staged:`HEAD` 与 `:0`;unstaged:`:0` 与工作区文件),按 fold 已知行号范围切片为 context 行填充渲染;per 文件 Promise 缓存,失败降级为中性文案。将在 fork 修好后提 PR(Closes 本 issue)。
|
||||
>
|
||||
> 截图:<用户提供的截图,展示「11 行…点击展开」行>
|
||||
|
||||
### 步骤 2:分支与实现
|
||||
|
||||
```bash
|
||||
git checkout -b fix/diff-fold-expand # 基于与上游一致的 main(6fbfeed)
|
||||
```
|
||||
|
||||
按 §4 落地;同时新增本设计文档的正式版(本文件即底稿,文末补「实施偏差」节,惯例参照 `docs/plans/2026-09-06-changes-op-previews-design.md`)。
|
||||
|
||||
**提交拆分**(oyw-commit-style:`type(scope): 中文概括` + 无序列表 body + 背景动机段 + 决策记录伴随行;禁 AI 署名、禁 `--no-verify`):
|
||||
1. `docs(diff): git 折叠上下文按需展开设计文档`
|
||||
2. `feat(api): 暴露 gitShow 单文件版本内容读取`
|
||||
3. `feat(diff): 折叠行异步展开状态机与内容切片`(rows.ts + DiffRows + DiffFiles + 测试)
|
||||
4. `fix(diff): git lens 折叠行接入按需展开,修复点击无反应`(DiffPane/DiffTab + i18n + 组件测试)
|
||||
|
||||
每个 commit 保持 typecheck/test 可绿。
|
||||
|
||||
### 步骤 3:验证
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm test # 全量门禁
|
||||
pnpm build && pnpm pack && pnpm test:mount # 挂载冒烟(CI 同款)
|
||||
```
|
||||
|
||||
真机点开含多 hunk 提交,验证展开/折叠/失败降级,截图用于 PR。
|
||||
|
||||
### 步骤 4:发 PR
|
||||
|
||||
```bash
|
||||
git push -u origin fix/diff-fold-expand
|
||||
gh pr create --repo omdsh-dev/DSH-better-sidebar --head LaoYueHanNi:fix/diff-fold-expand \
|
||||
--title "fix(diff): git 折叠上下文按需展开,修复「点击展开」无反应" --body-file <下方正文>
|
||||
```
|
||||
|
||||
**PR body**:
|
||||
|
||||
> ## 问题
|
||||
> Closes #<N>。git 提交/工作区 diff 的折叠行显示「n 行…点击展开」但点击无反应:`-U3` 裁剪使 `unifiedSegments` 的 gap fold 无行文本,渲染分支复用了含点击承诺的文案。
|
||||
>
|
||||
> ## 方案
|
||||
> 点击时按需经 `git.show` 拉取两侧完整内容,按 fold 行号切片为 context 行填充;不预取(大 lockfile 场景零开销),per 文件 Promise 缓存去重并发,失败降级为「上下文未加载」中性文案。session lens 行为不变,服务端零改动(`git.show` 路由已存在)。
|
||||
>
|
||||
> ## 改动清单
|
||||
> - `src/client/api.ts`:gitShow 封装
|
||||
> - `src/client/diff/rows.ts`:`foldRowsFromContents` 纯函数
|
||||
> - `src/client/diff/DiffRows.tsx`:`resolveFold` prop + 展开/加载/失败状态机
|
||||
> - `src/client/diff/DiffFiles.tsx`:loader 下传 + Promise 缓存
|
||||
> - `src/client/changes/DiffPane.tsx` / `src/client/DiffTab.tsx`:两种 lens 的取数映射(含 staged fallback 侧修正)
|
||||
> - `src/client/locales*.ts` × 18:`changesFoldLoading` / `changesFoldUnavailable`
|
||||
> - `tests/diff-rows.spec.ts` + 新组件测试
|
||||
> - `docs/plans/…`:设计文档
|
||||
>
|
||||
> ## 测试
|
||||
> - 新增:切片纯函数单测(对称/纯删/纯增/\r\n/越界);组件测试(mock gitShow:展开、折叠、降级)
|
||||
> - 回归:`pnpm typecheck`、`pnpm test` 全绿;`pnpm test:mount` 冒烟通过
|
||||
> - 截图:<展开前后对比>
|
||||
|
||||
### 步骤 5:review 跟进
|
||||
|
||||
按上游惯例(#499 经验)预判 review 关注点:皮肤令牌(本改动无颜色,不涉)、约定对齐(prop 命名/状态机形状)、i18n 完整性;整改意见在同分支追加 commit,重跑门禁。
|
||||
|
||||
## 7. 明确不做
|
||||
|
||||
- 不做纯文案拆分的「方案 A」单独 PR(失败降级文案已含在 B 内);
|
||||
- 不调大 `-U` 上下文;
|
||||
- 不改服务端;
|
||||
- 不动 `changesFold` 现有文案(B 落地后「点击展开」对 git fold 也为真)。
|
||||
|
||||
## 8. 仓库规范备忘(实施时遵守)
|
||||
|
||||
- 代码改动必须走 PR(AGENTS §1);fork 侧开发,push 到 `LaoYueHanNi` 后向上游发 PR;
|
||||
- 风格对齐:prop 命名、状态机形状、组件拆分与 CSS 模块组织以 `src/client/diff/` 现有实现为准,不引入新依赖/新工具/新交互模式;有歧义时参照上游最近合入的同类改动;
|
||||
- i18n:zh 新 key 必须同步 ja(其余语言全补);
|
||||
- 皮肤契约:无硬编码颜色(本改动无颜色);
|
||||
- 提交信息遵循 oyw-commit-style,正文无序列表列改动点 + 背景动机段;
|
||||
- 仅在用户明确说「提交」时才 commit;PR 创建前等用户批准。
|
||||
|
||||
---
|
||||
|
||||
## 实施偏差记录
|
||||
|
||||
> 实施过程中与本文设计的任何偏离,逐条记录在此(惯例:文档以本节为准)。
|
||||
|
||||
1. **语言文件数量**:§4.6 / §6 的「18 个语言文件」计数有误——实际变更点为 **20 个文件 21 处**(`locales.ts` 内 zh/en 双块 + 19 个 `locales-*.ts`:ar/de/fr/hi/id/ko/it/ja/pl/nl/pt/ru/sv/th/tr/vi/zh-HK/zh-MO/zh-TW),已全量补齐,PR body 计数随之更正。
|
||||
2. **缓存拆为两层**:§4.4 把「同文件多 fold 共享一次内容拉取」归给 DiffFiles 的 fold 级 Promise 缓存,但 fold 级 key 含行号(`path|oldStart|newStart`),无法去重内容请求;实施时把内容拉取缓存上移到 lens 层(`DiffPane`/`DiffTab` 的 `foldContents`,key = `displayPath`),DiffFiles 的 fold 级缓存保留(价值:文件头折叠再展开导致 DiffRows 重挂载后秒开、并发点击去重,reject 时摘除)。两层各司其职。
|
||||
3. **loading 态交互与竞态防护**:§4.3 三态渲染之外,加载中的 fold 不响应点击且无 pointer(`data-expandable` 移除),防 loading 期间重复触发;另以 `foldEpoch` ref 使 segments 重置后 still-in-flight 的 resolve 回调失效,防「旧 fold 的迟到结果写进新 segments 同下标」竞态(§4.3 未展开)。
|
||||
4. **unstaged 新侧读取失败的宽容降级**:§4.5 未细化——工作区侧 `fsRead` 失败(如文件已删除)或返回二进制时降级为空串(catch → null → `''`)而非整体 failed,让旧侧(index)上下文依然可展开。
|
||||
5. **DiffPane 组件级 gitScope**:fold loader 需要 SessionScope,新增组件级 `gitScope`(useMemo);load effect 的局部 `paneScope` 保持原样不动,最小侵入。
|
||||
6. **本机测试基线**:Windows 本机全量跑 `tests/git.spec.ts` / `tests/git-worktree.spec.ts` 中 3 个真实 git 子进程集成用例会并发抖动失败(stash 后基线复现同样失败,与本次改动无关;CI Linux 绿)。diff 相关 18/18 全绿(新增 `foldRowsFromContents` 6 用例 + 组件测试 4 用例)。
|
||||
7. **本地挂载冒烟未执行**:Windows 本地 Git Bash 下 `e2e-mount.sh` 将 tarball 规范为 MSYS 风格 `/d/...`,`file:` scheme 前缀使 MSYS 自动参数转换失效,Windows node 解析为 `C:\d\...` 而 ENOENT(经 `DSH_CMD` 注入 cygpath wrapper 可绕过,再因依赖下载网络受限放弃);`pnpm build` 产物构建已绿,挂载冒烟由 CI `plugin-mount` job 同款覆盖。
|
||||
8. **服务端零改动前提被证伪(真机修正)**:§3/§5 的「git.show 路由已存在、服务端零改动」不成立——该路由此前无真实调用方,其 path 经 `resolveGitPath` 解析为绝对文件系统路径(工作区命令语义),而 `git show <rev>:<path>` 只接受仓库相对路径,恒 fatal 返 null,两侧空串切片 0 行,展开表现为「marker 消失、零行渲染」(link 真机首测发现)。修正:路由透传仓库相对路径(diff 输出剥前缀的形态,暴露面与 git.diff/git.log 一致,仅本仓库版本树),另在两 lens 加「两侧皆空 → 降级『上下文未加载』」防御,取数异常不再静默空白展开。
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "dsh-external/dsh-better-sidebar",
|
||||
"version": "0.18.0",
|
||||
"version": "0.18.1",
|
||||
"main": "./lib/index.js",
|
||||
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器",
|
||||
"engines": {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// ESLint flat config(最小规则集):
|
||||
// - 基础:@eslint/js recommended + typescript-eslint recommended(非 type-checked 档,
|
||||
// 全量 type-checked 需要项目服务且更慢,当前收益不足以引入);
|
||||
// - react-hooks recommended:client 侧是 React 重镇,依赖数组豁免注释
|
||||
// (react-hooks/exhaustive-deps)由此规则消费;
|
||||
// - globals 按运行域分治:client bundle → browser,host/脚本 → node,tests → 混合。
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
// 构建产物 / 工具目录不参与 lint(node_modules 与点开头目录默认也不进)。
|
||||
{
|
||||
ignores: [
|
||||
'lib/',
|
||||
'dist/',
|
||||
'coverage/',
|
||||
'registry/',
|
||||
'playwright-report/',
|
||||
'test-results/',
|
||||
'node_modules/',
|
||||
'.worktrees/',
|
||||
],
|
||||
},
|
||||
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
|
||||
// react-hooks v7 的 recommended 捆绑了 React Compiler 语义规则(purity /
|
||||
// immutability / refs / set-state-in-effect 等):本仓库对 CodeMirror / xterm
|
||||
// 等命令式集成大量依赖 render 期读 ref、effect 内同步 setState 的既有模式,
|
||||
// 57 处存量报错均为噪音级误报,先整体关闭该档,只保留经典双规则
|
||||
// (rules-of-hooks / exhaustive-deps——后者消费代码里带理由的豁免注释)。
|
||||
{
|
||||
rules: Object.fromEntries(
|
||||
[
|
||||
'config',
|
||||
'error-boundaries',
|
||||
'gating',
|
||||
'globals',
|
||||
'immutability',
|
||||
'incompatible-library',
|
||||
'preserve-manual-memoization',
|
||||
'purity',
|
||||
'refs',
|
||||
'set-state-in-effect',
|
||||
'static-components',
|
||||
'unsupported-syntax',
|
||||
'use-memo',
|
||||
].map(rule => [`react-hooks/${rule}`, 'off']),
|
||||
),
|
||||
},
|
||||
|
||||
// 孤儿 eslint-disable 注释(规则未实际触发)直接报错,防止豁免残留。
|
||||
{ linterOptions: { reportUnusedDisableDirectives: 'error' } },
|
||||
|
||||
// no-unused-vars 对齐仓库既有约定:下划线前缀 = 刻意不用的占位(mock 桩等)。
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': ['error', {
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
}],
|
||||
},
|
||||
},
|
||||
|
||||
// client bundle 运行在浏览器(portal 侧边栏 / 各视图 / 拦截层)。
|
||||
{
|
||||
files: ['src/client/**/*.{js,mjs,cjs,ts,tsx,jsx}'],
|
||||
languageOptions: { globals: globals.browser },
|
||||
},
|
||||
|
||||
// host 侧插件入口与构建/运维脚本跑在 Node。eslint.config.js 自身不 ignore:
|
||||
// 它就是一个普通 ESM 脚本,走 js recommended + node globals 正常通过。
|
||||
{
|
||||
files: ['src/*.{js,mjs,cjs,ts}', 'scripts/**/*.{js,mjs,cjs,ts}', '*.config.ts', 'eslint.config.js'],
|
||||
languageOptions: { globals: globals.node },
|
||||
},
|
||||
|
||||
// tests:Node 里跑(vitest / fs fixtures),jsdom 组件测试又摸浏览器全局。
|
||||
{
|
||||
files: ['tests/**/*.{js,mjs,cjs,ts,tsx,jsx}'],
|
||||
languageOptions: { globals: { ...globals.node, ...globals.browser } },
|
||||
},
|
||||
)
|
||||
+9
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dsh-better-sidebar",
|
||||
"version": "0.18.0",
|
||||
"version": "0.18.1",
|
||||
"description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git / browser), isolated per conversation session. Exposes a service for other plugins to register sidebar tabs and file viewers.",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
@@ -82,7 +82,8 @@
|
||||
"test:mount": "bash scripts/e2e-mount.sh",
|
||||
"test:mount:aggregate": "bash scripts/e2e-aggregate-mount.sh",
|
||||
"clean": "node -e \"require('node:fs').rmSync('lib',{recursive:true,force:true})\"",
|
||||
"check:consumer-types": "bash scripts/check-consumer-types.sh"
|
||||
"check:consumer-types": "bash scripts/check-consumer-types.sh",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
@@ -179,6 +180,11 @@
|
||||
"tsdown": "^0.22.2",
|
||||
"typescript": "^5.6.0",
|
||||
"unrun": "^0.2.39",
|
||||
"vitest": "^4.1.8"
|
||||
"vitest": "^4.1.8",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"eslint": "^10.9.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"globals": "^17.12.0",
|
||||
"typescript-eslint": "^8.69.0"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1317
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -158,9 +158,9 @@ async function classifyTarget(raw: string, cwd: string): Promise<{ kind: AgentOp
|
||||
info = await stat(target)
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code === 'ENOENT') throw new Error(`"${raw}" does not exist (resolved to "${target}")`)
|
||||
if (code === 'EACCES' || code === 'EPERM') throw new Error(`"${target}" is not readable`)
|
||||
throw new Error(`cannot open "${target}": ${error instanceof Error ? error.message : String(error)}`)
|
||||
if (code === 'ENOENT') throw new Error(`"${raw}" does not exist (resolved to "${target}")`, { cause: error })
|
||||
if (code === 'EACCES' || code === 'EPERM') throw new Error(`"${target}" is not readable`, { cause: error })
|
||||
throw new Error(`cannot open "${target}": ${error instanceof Error ? error.message : String(error)}`, { cause: error })
|
||||
}
|
||||
const title = basenameOf(target)
|
||||
return { kind: info.isDirectory() ? 'folder' : 'file', target, title: title === '' ? raw : title }
|
||||
|
||||
+78
-3
@@ -8,12 +8,13 @@
|
||||
* very content it shows. Rendering goes through the shared {@link DiffFiles}
|
||||
* renderer — the same one the changes tab's inline preview uses.
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SessionScope } from './api.ts'
|
||||
import { api } from './api.ts'
|
||||
import type { SidebarDiffRef } from './state.ts'
|
||||
import { DiffFiles } from './diff/DiffFiles.tsx'
|
||||
import { displayPath, foldRowsFromContents, type DiffFile, type DiffRow, type FoldSegment } from './diff/rows.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { resolveSidebarPath } from './produced-files.ts'
|
||||
import css from './sidebar.module.css'
|
||||
@@ -30,6 +31,11 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [data, setData] = useState<DiffData | null>(null)
|
||||
const [tick, setTick] = useState(0)
|
||||
// The staged flag of the side ACTUALLY rendered: when the requested side's
|
||||
// diff came back empty the load falls back to the other side, and the fold
|
||||
// expansion must read that side's revisions (else the sliced line numbers
|
||||
// land on the wrong contents).
|
||||
const [effectiveStaged, setEffectiveStaged] = useState<boolean | null>(null)
|
||||
|
||||
const refresh = useCallback((): void => { setTick(value => value + 1) }, [])
|
||||
|
||||
@@ -39,6 +45,7 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setData(null)
|
||||
setEffectiveStaged(null)
|
||||
const load = async (): Promise<void> => {
|
||||
try {
|
||||
if (diff.kind === 'commit') {
|
||||
@@ -53,7 +60,10 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif
|
||||
// file staged after its tab opened). Both sides empty means the
|
||||
// file genuinely has no text changes.
|
||||
const other = await api.gitDiff(scope, diff.path, !diff.staged, diff.worktree)
|
||||
if (other.diff !== '') result = other
|
||||
if (other.diff !== '') {
|
||||
result = other
|
||||
if (!cancelled) setEffectiveStaged(!diff.staged)
|
||||
}
|
||||
}
|
||||
if (result.diff !== '') {
|
||||
if (!cancelled) setData({ diff: result.diff })
|
||||
@@ -82,6 +92,71 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif
|
||||
return () => { cancelled = true }
|
||||
}, [sessionId, cwd, diff, tick])
|
||||
|
||||
// ── On-demand git fold expansion: a fold's hidden rows come from both
|
||||
// sides' full contents (git.show / fsRead), fetched ONCE per file so
|
||||
// sibling folds share the request, then sliced by each fold's line
|
||||
// ranges. The cache dies with the ref or a refresh tick. ──────────────
|
||||
const foldContents = useRef(new Map<string, Promise<{ old: string; new: string }>>())
|
||||
useEffect(() => { foldContents.current = new Map() }, [diff, tick])
|
||||
const foldLoader = useMemo(() => {
|
||||
const sidesOf = (file: DiffFile): Promise<{ old: string; new: string }> => {
|
||||
// Both sides empty cannot cover a non-empty fold — treat it as a failed
|
||||
// fetch so the fold degrades to the unavailable marker instead of
|
||||
// silently expanding to nothing (the symptom of a bad rev or path
|
||||
// reading null on both sides).
|
||||
const ofSides = (oldContent: string | null, newContent: string | null): { old: string; new: string } => {
|
||||
if ((oldContent ?? '') === '' && (newContent ?? '') === '') throw new Error('no content on either side')
|
||||
return { old: oldContent ?? '', new: newContent ?? '' }
|
||||
}
|
||||
const fetchSides = async (): Promise<{ old: string; new: string }> => {
|
||||
const scope: SessionScope = { sessionId, cwd, ...(diff.repoRoot !== undefined ? { repoRoot: diff.repoRoot } : {}) }
|
||||
if (diff.kind === 'commit') {
|
||||
// The patch's -m --first-parent shape: old side from the parent,
|
||||
// new side from the commit (a root commit's parent read fails → '').
|
||||
const [oldSide, newSide] = await Promise.all([
|
||||
file.oldPath === '/dev/null'
|
||||
? Promise.resolve({ content: null })
|
||||
: api.gitShow(scope, `${diff.hashFull}^`, displayPath(file.oldPath), diff.worktree),
|
||||
file.newPath === '/dev/null'
|
||||
? Promise.resolve({ content: null })
|
||||
: api.gitShow(scope, diff.hashFull, displayPath(file.newPath), diff.worktree),
|
||||
])
|
||||
return ofSides(oldSide.content, newSide.content)
|
||||
}
|
||||
// Worktree change: staged is HEAD vs index, unstaged is index vs
|
||||
// worktree (the worktree side reads the live file).
|
||||
const staged = effectiveStaged ?? diff.staged
|
||||
if (staged) {
|
||||
const [oldSide, newSide] = await Promise.all([
|
||||
file.oldPath === '/dev/null'
|
||||
? Promise.resolve({ content: null })
|
||||
: api.gitShow(scope, 'HEAD', displayPath(file.oldPath), diff.worktree),
|
||||
file.newPath === '/dev/null'
|
||||
? Promise.resolve({ content: null })
|
||||
: api.gitShow(scope, ':0', displayPath(file.newPath), diff.worktree),
|
||||
])
|
||||
return ofSides(oldSide.content, newSide.content)
|
||||
}
|
||||
const [oldSide, worktree] = await Promise.all([
|
||||
file.oldPath === '/dev/null'
|
||||
? Promise.resolve({ content: null })
|
||||
: api.gitShow(scope, ':0', displayPath(file.oldPath), diff.worktree),
|
||||
api.fsRead(scope, resolveSidebarPath(diff.repoRoot ?? diff.worktree ?? cwd, displayPath(file.newPath))).catch(() => null),
|
||||
])
|
||||
return ofSides(oldSide.content, worktree !== null && worktree.kind === 'text' ? worktree.content : null)
|
||||
}
|
||||
const path = displayPath(file.newPath === '/dev/null' ? file.oldPath : file.newPath)
|
||||
let promise = foldContents.current.get(path)
|
||||
if (promise === undefined) {
|
||||
promise = fetchSides()
|
||||
foldContents.current.set(path, promise)
|
||||
}
|
||||
return promise
|
||||
}
|
||||
return (file: DiffFile, segment: FoldSegment): Promise<readonly DiffRow[]> =>
|
||||
sidesOf(file).then(sides => foldRowsFromContents(segment, sides.old, sides.new))
|
||||
}, [sessionId, cwd, diff, effectiveStaged])
|
||||
|
||||
return (
|
||||
<div className={css.gitDiffTab}>
|
||||
<div className={css.gitDiffTabHeader}>
|
||||
@@ -104,7 +179,7 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif
|
||||
<>
|
||||
{data.untracked !== undefined
|
||||
? <DiffFiles diff="" untrackedPath={diff.kind === 'worktree' ? diff.path : ''} untrackedContent={data.untracked} />
|
||||
: <DiffFiles diff={data.diff} />}
|
||||
: <DiffFiles diff={data.diff} resolveFold={foldLoader} />}
|
||||
{data.diff === '' && data.untracked === undefined && (
|
||||
<div className={css.gitEmpty}>{t('diffEmpty')}</div>
|
||||
)}
|
||||
|
||||
@@ -40,6 +40,7 @@ import { TreePanel } from './TreePanel.tsx'
|
||||
import { t } from './locales.ts'
|
||||
import { relativeTo } from './paths.ts'
|
||||
import { resolveSidebarPath } from './produced-files.ts'
|
||||
import { closePathTabs, retargetPathTabs } from './tree-mutations.ts'
|
||||
import type { EditorToolbarControls, EditorToolbarState, FileViewerDescriptor } from './service.ts'
|
||||
import { firstLeaf, insertLeafAt, leafWithTab, mintTabId, treeOf, type SidebarStore, type SidebarTab } from './state.ts'
|
||||
import css from './sidebar.module.css'
|
||||
@@ -225,6 +226,16 @@ export function EditorHost(props: {
|
||||
})
|
||||
}
|
||||
|
||||
// Tree mutations reconcile the OPEN tabs (both split trees, the bottom
|
||||
// panel, free windows): a rename retargets its tab to the new path; a
|
||||
// delete closes tabs at or under the removed path. See tree-mutations.ts.
|
||||
const onPathRenamed = (oldPath: string, newPath: string): void => {
|
||||
retargetPathTabs(ctx, store, oldPath, newPath)
|
||||
}
|
||||
const onPathDeleted = (path: string): void => {
|
||||
closePathTabs(ctx, store, path)
|
||||
}
|
||||
|
||||
// The viewer's toolbar, hoisted into THIS header: the text editor reports
|
||||
// its state and registers its commands (both null/absent for viewers
|
||||
// without a toolbar — image, pdf, binary download).
|
||||
@@ -338,6 +349,9 @@ export function EditorHost(props: {
|
||||
}
|
||||
apply(planFirstMatch(ctx.get('betterSidebar')?.matchFileViewer(path), mediaUrlOf))
|
||||
return () => { cancelled = true; controller.abort() }
|
||||
// The deps are deliberately granular: the scope object's identity churns,
|
||||
// only its sessionId / cwd fields gate the (re)fetch.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [scope.sessionId, scope.cwd, path, ctx, showEmpty, isDir, reloadSeq])
|
||||
|
||||
// Save-then-refresh in preview mode (issue #167 part C): the edge into
|
||||
@@ -386,6 +400,8 @@ export function EditorHost(props: {
|
||||
onOpenWith={openWith}
|
||||
onToggleOpenWithPin={toggleOpenWithPin}
|
||||
onReferenceFile={onReferenceFile}
|
||||
onPathRenamed={onPathRenamed}
|
||||
onPathDeleted={onPathDeleted}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -508,6 +524,8 @@ export function EditorHost(props: {
|
||||
onOpenWith={openWith}
|
||||
onToggleOpenWithPin={toggleOpenWithPin}
|
||||
onReferenceFile={onReferenceFile}
|
||||
onPathRenamed={onPathRenamed}
|
||||
onPathDeleted={onPathDeleted}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+210
-17
@@ -24,14 +24,16 @@ import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type
|
||||
import { createPortal } from 'react-dom'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconChevronRightOutline14, IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16,
|
||||
IconLinkOutline16, Menu, type MenuEntry, type MenuItem, writeClipboard,
|
||||
Button, IconChevronRightOutline14, IconCloseFill14, IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16,
|
||||
IconEditOutline16, IconLinkOutline16, IconTrashOutline16, Menu, Modal, type MenuEntry, type MenuItem, writeClipboard,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { SiCursor, SiZedindustries } from 'react-icons/si'
|
||||
import { VscFile, VscFolder, VscFolderOpened, VscLinkExternal, VscPin, VscPinned } from 'react-icons/vsc'
|
||||
import { api, downloadUrl, isOutsideWorkspaceMessage, type FsEntry } from './api.ts'
|
||||
import { FenceErrorNotice } from './FenceErrorNotice.tsx'
|
||||
import { IconUploadOutline16, IconVscode16 } from './icons.tsx'
|
||||
import { isImeComposition } from './ime-guard.ts'
|
||||
import { useSubmenuFlip } from './menu-flip.ts'
|
||||
import type { OpenWithTarget } from './open-with.ts'
|
||||
import { relativeTo } from './paths.ts'
|
||||
import { t } from './locales.ts'
|
||||
@@ -133,6 +135,10 @@ export function FileTree(props: {
|
||||
onToggleOpenWithPin?: (targetId: string) => void
|
||||
/** Insert `@<relative path>` into the composer draft (file vs directory). */
|
||||
onReferenceFile: (path: string, isDir: boolean) => void
|
||||
/** A rename landed (old row path → new path): the caller retargets open tabs. */
|
||||
onPathRenamed?: (oldPath: string, newPath: string) => void
|
||||
/** A delete landed: the caller closes tabs at or under the removed path. */
|
||||
onPathDeleted?: (path: string, isDir: boolean) => void
|
||||
/** Bump to wipe the level cache and reload the visible set. */
|
||||
refreshTick: number
|
||||
/** Upload into `dir` (absolute, inside the workspace); runs in the caller. */
|
||||
@@ -140,13 +146,22 @@ export function FileTree(props: {
|
||||
/** True while an upload is in flight (drops are ignored). */
|
||||
busy: boolean
|
||||
}) {
|
||||
const { sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, refreshTick, onUploadRequest, busy } = props
|
||||
const { sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, onPathRenamed, onPathDeleted, refreshTick, onUploadRequest, busy } = props
|
||||
const [data, setData] = useState<Record<string, LevelData>>({})
|
||||
const dataRef = useRef(data)
|
||||
/** The row whose path was just copied ("copied" label replaces its button). */
|
||||
const [copiedPath, setCopiedPath] = useState<string | null>(null)
|
||||
/** Open context menu: the row path (and whether it is a directory) plus the cursor position. */
|
||||
const [rowMenu, setRowMenu] = useState<{ path: string; isDir: boolean; x: number; y: number } | null>(null)
|
||||
// The row menu's "open with" submenu is the one submenu that can tower past
|
||||
// the viewport; publish its flip geometry for layout.css while it is open.
|
||||
useSubmenuFlip(rowMenu)
|
||||
/** The row being renamed inline: its path plus the edit buffer. */
|
||||
const [renaming, setRenaming] = useState<{ path: string; value: string } | null>(null)
|
||||
/** The delete awaiting the confirmation modal's yes. */
|
||||
const [confirmDelete, setConfirmDelete] = useState<{ path: string; isDir: boolean; name: string } | null>(null)
|
||||
/** The last mutation failure (dismissable strip above the tree). */
|
||||
const [actionError, setActionError] = useState<string | null>(null)
|
||||
/** Whether a file drag hovers the tree (drives the portaled drop zone). */
|
||||
const [dropOver, setDropOver] = useState(false)
|
||||
/** The directory a drag is hovering right now (null = body, drop to root). */
|
||||
@@ -268,6 +283,63 @@ export function FileTree(props: {
|
||||
loadDir(dir)
|
||||
}, [loadDir])
|
||||
|
||||
/**
|
||||
* Settle the tree after one rename/delete landed at `prefix`: drop every
|
||||
* cached level at or under the old path, collapse the now-stale expanded
|
||||
* directories below it, and reload the immediate parent so the new shape
|
||||
* shows without a full refresh tick.
|
||||
*/
|
||||
const pruneTree = (prefix: string): void => {
|
||||
const parent = parentOf(prefix)
|
||||
const under = (key: string): boolean => key === prefix || key.startsWith(`${prefix}/`) || key.startsWith(`${prefix}\\`)
|
||||
for (const key of Object.keys(dataRef.current)) {
|
||||
if (under(key)) delete dataRef.current[key]
|
||||
}
|
||||
if (parent !== prefix) delete dataRef.current[parent]
|
||||
setData({ ...dataRef.current })
|
||||
for (const dir of expanded) {
|
||||
if (under(dir)) onToggle(dir)
|
||||
}
|
||||
if (parent !== prefix) retryDir(parent)
|
||||
}
|
||||
|
||||
/** Client-side twin of the server's name rule (the server re-validates). */
|
||||
const validRename = (name: string): boolean =>
|
||||
name !== '' && name !== '.' && name !== '..' && !name.includes('/') && !name.includes('\\')
|
||||
|
||||
/** Commit the inline rename: trim, no-op guard, then fs.rename + settle. */
|
||||
const commitRename = (path: string, raw: string): void => {
|
||||
setRenaming(null)
|
||||
const name = raw.trim()
|
||||
if (cwd === undefined || name === baseName(path) || !validRename(name)) {
|
||||
if (name !== baseName(path) && !validRename(name)) setActionError(t('renameInvalid'))
|
||||
return
|
||||
}
|
||||
api.fsRename({ sessionId, cwd }, path, name)
|
||||
.then((result) => {
|
||||
setActionError(null)
|
||||
pruneTree(path)
|
||||
onPathRenamed?.(path, result.path)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
setActionError(error instanceof Error ? error.message : String(error))
|
||||
})
|
||||
}
|
||||
|
||||
/** Run the confirmed delete: fs.remove + settle + close affected tabs. */
|
||||
const performDelete = (target: { path: string; isDir: boolean }): void => {
|
||||
if (cwd === undefined) return
|
||||
api.fsRemove({ sessionId, cwd }, target.path)
|
||||
.then(() => {
|
||||
setActionError(null)
|
||||
pruneTree(target.path)
|
||||
onPathDeleted?.(target.path, target.isDir)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
setActionError(error instanceof Error ? error.message : String(error))
|
||||
})
|
||||
}
|
||||
|
||||
// The caller's refresh tick wipes the cache (declared BEFORE the load
|
||||
// effect so the reload below sees the empty cache).
|
||||
const lastTick = useRef(refreshTick)
|
||||
@@ -373,13 +445,14 @@ export function FileTree(props: {
|
||||
if (openWithTargets === undefined || onOpenWith === undefined || openWithTargets.length === 0) return []
|
||||
const pinnedIds = openWithPinned ?? []
|
||||
/** Brand marks for the built-ins (monochrome silhouettes, currentColor);
|
||||
* reveal gets the folder glyph, custom editors a generic code mark. */
|
||||
* reveal gets the folder glyph, custom editors a generic code mark.
|
||||
* The compact menu's icon slot is 14px, so every mark renders at 14. */
|
||||
const itemIcon = (target: OpenWithTarget): ReactNode => {
|
||||
if (target.kind === 'reveal') return <VscFolderOpened size={16} />
|
||||
if (target.id === 'vscode') return <IconVscode16 size={16} />
|
||||
if (target.id === 'cursor') return <SiCursor size={16} />
|
||||
if (target.id === 'zed') return <SiZedindustries size={16} />
|
||||
return <IconCodeOutline16 size={16} />
|
||||
if (target.kind === 'reveal') return <VscFolderOpened size={14} />
|
||||
if (target.id === 'vscode') return <IconVscode16 size={14} />
|
||||
if (target.id === 'cursor') return <SiCursor size={14} />
|
||||
if (target.id === 'zed') return <SiZedindustries size={14} />
|
||||
return <IconCodeOutline16 size={14} />
|
||||
}
|
||||
const pinned = openWithTargets
|
||||
.filter(target => pinnedIds.includes(target.id))
|
||||
@@ -412,7 +485,7 @@ export function FileTree(props: {
|
||||
onToggleOpenWithPin?.(target.id)
|
||||
}}
|
||||
>
|
||||
{pinnedNow ? <VscPinned size={14} /> : <VscPin size={14} />}
|
||||
{pinnedNow ? <VscPinned size={12} /> : <VscPin size={12} />}
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
@@ -433,7 +506,7 @@ export function FileTree(props: {
|
||||
<IconChevronRightOutline14 size={14} className={css.openWithChevron} aria-hidden />
|
||||
</span>
|
||||
),
|
||||
icon: <VscLinkExternal size={16} />,
|
||||
icon: <VscLinkExternal size={14} />,
|
||||
submenu,
|
||||
},
|
||||
]
|
||||
@@ -447,6 +520,58 @@ export function FileTree(props: {
|
||||
const expandedSet = useMemo(() => new Set(expanded), [expanded])
|
||||
const revealedSet = useMemo(() => new Set(revealed), [revealed])
|
||||
|
||||
/**
|
||||
* Focus + select the rename editor once, on mount. The callback identity
|
||||
* must stay STABLE (useCallback []): an inline arrow re-runs on every
|
||||
* render (detach null → attach el), and focus()/select() mid-keystroke
|
||||
* would reselect the whole buffer while typing. A plain <input>, not the
|
||||
* primitives Input — that one forwards no ref, and focus+select is the
|
||||
* entire point here.
|
||||
*/
|
||||
const renameInputRef = useCallback((el: HTMLInputElement | null): void => {
|
||||
if (el !== null) {
|
||||
el.focus()
|
||||
el.select()
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** The inline rename editor replacing one row (dirs and files alike):
|
||||
* same indent/icon/height for a seamless swap, Enter/blur commits,
|
||||
* Escape cancels, IME composition keys never reach the handlers (the
|
||||
* shared isImeComposition guard). */
|
||||
const renderRenameRow = (entry: FsEntry, depth: number): ReactNode => (
|
||||
<div
|
||||
key={entry.path}
|
||||
className={clsx(css.explorerRow, css.explorerRenaming)}
|
||||
style={{ paddingLeft: depth * 22 + 6 }}
|
||||
>
|
||||
{entry.isDir
|
||||
? (expandedSet.has(entry.path) ? <VscFolderOpened size={14} /> : <VscFolder size={14} />)
|
||||
: <VscFile size={14} />}
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
className={css.explorerRenameInput}
|
||||
value={renaming?.value ?? ''}
|
||||
aria-label={t('rename')}
|
||||
spellCheck={false}
|
||||
onChange={(event) => {
|
||||
setRenaming(prev => prev === null ? prev : { ...prev, value: event.target.value })
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (isImeComposition(event)) return
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
commitRename(entry.path, renaming?.value ?? '')
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
setRenaming(null)
|
||||
}
|
||||
}}
|
||||
onBlur={() => { commitRename(entry.path, renaming?.value ?? '') }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderLevel = (dir: string, depth: number): ReactNode => {
|
||||
const level = data[dir]
|
||||
if (level === undefined) {
|
||||
@@ -471,6 +596,10 @@ export function FileTree(props: {
|
||||
}
|
||||
const entries = level.entries ?? []
|
||||
return entries.map(entry => {
|
||||
// The row being renamed renders as its editor (no button semantics:
|
||||
// an editor is not a click target — and a nested interactive inside
|
||||
// role="button" would be invalid anyway).
|
||||
if (renaming?.path === entry.path) return renderRenameRow(entry, depth)
|
||||
if (entry.isDir) {
|
||||
const isOpen = expandedSet.has(entry.path)
|
||||
return (
|
||||
@@ -551,6 +680,22 @@ export function FileTree(props: {
|
||||
<div className={css.explorerEmpty}>{t('noSession')}</div>
|
||||
) : (
|
||||
<>
|
||||
{/* The last mutation failure (rename/delete): dismissable, raw
|
||||
server text — the same show-the-truth policy the fence notice
|
||||
uses. Any later action or a fresh attempt clears it. */}
|
||||
{actionError !== null && (
|
||||
<div className={css.explorerActionError} role="alert">
|
||||
<span className={css.explorerActionErrorText}>{actionError}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.explorerActionErrorClose}
|
||||
aria-label={t('dismiss')}
|
||||
onClick={() => { setActionError(null) }}
|
||||
>
|
||||
<IconCloseFill14 />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={clsx(css.explorerRow, dropTarget === root && css.explorerRowDropTarget)}
|
||||
style={{ paddingLeft: 6 }}
|
||||
@@ -650,22 +795,31 @@ export function FileTree(props: {
|
||||
items={[
|
||||
// The open escapes head the FILE menu (dirs only get copy).
|
||||
...(rowMenu?.isDir === false && onOpenFileNewTab !== undefined
|
||||
? [{ id: 'open-new-tab', label: t('openFileNewTab'), icon: <IconCodeOutline16 size={16} /> }]
|
||||
? [{ id: 'open-new-tab', label: t('openFileNewTab'), icon: <IconCodeOutline16 size={14} /> }]
|
||||
: []),
|
||||
...(rowMenu?.isDir === false && onOpenFileSide !== undefined
|
||||
? [{ id: 'open-side', label: t('openFileSide'), icon: <VscFolderOpened size={16} /> }]
|
||||
? [{ id: 'open-side', label: t('openFileSide'), icon: <VscFolderOpened size={14} /> }]
|
||||
: []),
|
||||
...openWithEntries(),
|
||||
// Download applies to files only (the host route refuses directories).
|
||||
...(rowMenu?.isDir === false
|
||||
? [{ id: 'download', label: t('download'), icon: <IconDownloadOutline16 size={16} /> }]
|
||||
? [{ id: 'download', label: t('download'), icon: <IconDownloadOutline16 size={14} /> }]
|
||||
: []),
|
||||
// Upload into a directory (incl. the workspace root row).
|
||||
...(rowMenu?.isDir === true
|
||||
? [{ id: 'upload-here', label: t('uploadHere'), icon: <IconUploadOutline16 size={16} /> }]
|
||||
? [{ id: 'upload-here', label: t('uploadHere'), icon: <IconUploadOutline16 size={14} /> }]
|
||||
: []),
|
||||
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={14} /> },
|
||||
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={14} /> },
|
||||
// Explorer mutations close the menu; the workspace ROOT row is the
|
||||
// session itself — never renamable or deletable (server double-guards).
|
||||
...(rowMenu !== null && rowMenu.path !== cwd
|
||||
? [
|
||||
{ id: 'mutate-sep', type: 'separator' } as MenuEntry,
|
||||
{ id: 'rename', label: t('rename'), icon: <IconEditOutline16 size={14} /> },
|
||||
{ id: 'delete', label: t('delete'), icon: <IconTrashOutline16 size={14} />, danger: true },
|
||||
]
|
||||
: []),
|
||||
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={16} /> },
|
||||
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={16} /> },
|
||||
]}
|
||||
onSelect={(id) => {
|
||||
const target = rowMenu
|
||||
@@ -692,16 +846,55 @@ export function FileTree(props: {
|
||||
fileInputRef.current?.click()
|
||||
return
|
||||
}
|
||||
if (id === 'rename') {
|
||||
setRenaming({ path: target.path, value: baseName(target.path) })
|
||||
return
|
||||
}
|
||||
if (id === 'delete') {
|
||||
setConfirmDelete({ path: target.path, isDir: target.isDir, name: baseName(target.path) })
|
||||
return
|
||||
}
|
||||
copyPath(
|
||||
id === 'relative' ? relativeTo(cwd ?? '', target.path) : target.path,
|
||||
target.path,
|
||||
)
|
||||
}}
|
||||
portal
|
||||
compact
|
||||
align="start"
|
||||
getAnchorRect={() => (rowMenu === null ? null : new DOMRect(rowMenu.x, rowMenu.y, 0, 0))}
|
||||
anchor={<span />}
|
||||
/>
|
||||
|
||||
{/* The delete confirmation: destructive and permanent (no host trash),
|
||||
so it always lands here first — the same Cancel/Confirm shape the
|
||||
git lens uses for discard/revert/cherry-pick. */}
|
||||
<Modal
|
||||
open={confirmDelete !== null}
|
||||
onClose={() => { setConfirmDelete(null) }}
|
||||
title={confirmDelete === null ? '' : t('deleteTitle', { name: confirmDelete.name })}
|
||||
closeLabel={t('cancel')}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" onClick={() => { setConfirmDelete(null) }}>{t('cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
const pending = confirmDelete
|
||||
if (pending === null) return
|
||||
setConfirmDelete(null)
|
||||
performDelete(pending)
|
||||
}}
|
||||
>
|
||||
{t('delete')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<p className={css.explorerConfirmDesc}>
|
||||
{confirmDelete === null ? '' : t(confirmDelete.isDir ? 'deleteDescDir' : 'deleteDescFile')}
|
||||
</p>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -279,6 +279,7 @@ export function FreeWindow(props: {
|
||||
else if (id === 'close') onClose()
|
||||
}}
|
||||
portal
|
||||
compact
|
||||
align="start"
|
||||
getAnchorRect={() => (menu === null ? null : new DOMRect(menu.x, menu.y, 0, 0))}
|
||||
anchor={<span />}
|
||||
|
||||
+17
-13
@@ -10,32 +10,31 @@
|
||||
* Security posture: every HTML string (block leaves, inline text, wrapper
|
||||
* open-tag attributes) goes through DOMPurify with an explicit denylist on
|
||||
* top of its defaults (no script/style/iframe/forms), anchors are forced to
|
||||
* open in a new tab with noopener, and local media `src` attributes are
|
||||
* rewritten through the session-scoped `/sidebar/file` media route — the same
|
||||
* trust fence the markdown image rewriter (`markdown-images.ts`) uses.
|
||||
* open in a new tab with noopener, and local media goes through the
|
||||
* session-scoped `/sidebar/file` media route — markdown-syntax image
|
||||
* destinations are rewritten via `markdown-images.ts` and `src` attributes
|
||||
* through `resolveLocalMediaDest` (the same trust fence).
|
||||
*/
|
||||
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 } from './markdown-images.ts'
|
||||
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 {
|
||||
@@ -235,7 +234,12 @@ export function MarkdownDocument({ info, media, codeLabels }: MarkdownDocumentPr
|
||||
const prepared = useMemo<PreparedSegment[]>(() => info.segments.map((segment): PreparedSegment => {
|
||||
if (segment.kind === 'markdown') {
|
||||
const defs = info.referenceDefinitions
|
||||
const text = defs === '' ? segment.text : `${segment.text}\n\n${defs}`
|
||||
const raw = defs === '' ? segment.text : `${segment.text}\n\n${defs}`
|
||||
// MarkdownText drops non-http(s) image destinations (chat-security
|
||||
// stance), so rewrite local ones into /sidebar/file media URLs first —
|
||||
// the same trust fence the sanitized HTML leaves below go through.
|
||||
// Idempotent: already-absolute media URLs pass through untouched.
|
||||
const text = rewriteLocalImageUrls(raw, media.scope, media.path, media.origin)
|
||||
return {
|
||||
kind: 'markdown',
|
||||
text,
|
||||
|
||||
@@ -40,6 +40,9 @@ export function PdfView(props: { scope: SessionScope; path: string; title: strin
|
||||
controller.abort()
|
||||
if (objectUrl !== undefined) URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
// Granular scope fields: the scope object's identity churns, only its
|
||||
// sessionId / cwd fields gate the fetch.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [scope.sessionId, scope.cwd, path])
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -60,7 +60,6 @@ import {
|
||||
WIDTH_PERCENT_MAX,
|
||||
WIDTH_PERCENT_MIN,
|
||||
type SidebarPrefs,
|
||||
type TitleBarScheme,
|
||||
} from '../prefs-shared.ts'
|
||||
import { api } from './api.ts'
|
||||
import { parsePrefs } from './prefs.ts'
|
||||
@@ -947,9 +946,11 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
...getShellPresets().map(preset => ({
|
||||
value: `preset:${preset.id}`,
|
||||
title: preset.title,
|
||||
// The preset desc is i18n-friendly (string or () => string)
|
||||
// — resolve it like every other settings text here.
|
||||
desc: preset.detect?.(detectedEnv) === true
|
||||
? `${preset.desc}(${t('settingsSchemeDetectedSuffix')})`
|
||||
: preset.desc,
|
||||
? `${textOf(preset.desc)}(${t('settingsSchemeDetectedSuffix')})`
|
||||
: textOf(preset.desc),
|
||||
})),
|
||||
{ value: 'custom', title: t('settingsSchemeCustomTitle'), desc: t('settingsSchemeCustomDesc') },
|
||||
]}
|
||||
|
||||
+18
-10
@@ -69,6 +69,7 @@ import {
|
||||
type SidechatTranscriptRow,
|
||||
} from './sidechat-transcript.ts'
|
||||
import { api } from './api.ts'
|
||||
import { usePolling } from './use-polling.ts'
|
||||
import { t } from './locales.ts'
|
||||
import type { SessionScope } from './api.ts'
|
||||
import type { SidebarTab } from './state.ts'
|
||||
@@ -493,17 +494,25 @@ export function SideChatView(props: {
|
||||
}
|
||||
}, [threadId, fetchInfo])
|
||||
|
||||
// Poll while the tab is visible and the thread runs.
|
||||
// One transcript pull on every input change (attach, visibility flip,
|
||||
// run-state flip — the last one catches a thread's terminal state once it
|
||||
// stops running).
|
||||
useEffect(() => {
|
||||
if (!visible || threadId === undefined) return
|
||||
void fetchThread(threadId)
|
||||
if (!running) return
|
||||
const timer = window.setInterval(() => {
|
||||
void fetchThread(threadId)
|
||||
void fetchInfo(threadId)
|
||||
}, POLL_MS)
|
||||
return () => { window.clearInterval(timer) }
|
||||
}, [visible, threadId, running, fetchThread, fetchInfo])
|
||||
// `running` is not read here, but re-triggering this pull on run-state
|
||||
// flips is load-bearing (see above); the badge fetch rides the ticks.
|
||||
}, [visible, threadId, running, fetchThread])
|
||||
|
||||
// Poll while the tab is visible and the thread runs: transcript deltas +
|
||||
// badge refresh on a fixed cadence. Each pull self-guards (fetchThread
|
||||
// aborts its predecessor; a late settle keeps the last rows).
|
||||
const pollTick = useCallback(async (): Promise<void> => {
|
||||
if (threadId === undefined) return
|
||||
void fetchThread(threadId)
|
||||
void fetchInfo(threadId)
|
||||
}, [threadId, fetchThread, fetchInfo])
|
||||
usePolling(visible && running && threadId !== undefined, pollTick, { intervalMs: POLL_MS })
|
||||
|
||||
useEffect(() => () => { controllerRef.current?.abort() }, [])
|
||||
|
||||
@@ -574,7 +583,6 @@ export function SideChatView(props: {
|
||||
}
|
||||
}
|
||||
return items
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [threads])
|
||||
|
||||
const growComposer = (): void => {
|
||||
@@ -688,7 +696,7 @@ export function SideChatView(props: {
|
||||
)}
|
||||
items={menuItems}
|
||||
selectedId={threadId}
|
||||
onSelect={(id) => { id === '$new' ? openNewThread() : openExistingThread(id) }}
|
||||
onSelect={(id) => { if (id === '$new') openNewThread(); else openExistingThread(id) }}
|
||||
onClose={() => { setMenuOpen(false) }}
|
||||
align="end"
|
||||
portal
|
||||
|
||||
+55
-709
@@ -28,57 +28,39 @@
|
||||
* drawer floats). Widening does not migrate back: the tabs keep living in
|
||||
* the right tree.
|
||||
*/
|
||||
import { createElement, memo, useCallback, useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type ReactNode } from 'react'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context, SidebarSessionList } from '../context-types.ts'
|
||||
import type { Context } from '../context-types.ts'
|
||||
import { appendToDraft, insertFileReference } from './conversation-draft.ts'
|
||||
import {
|
||||
BOTTOM_MIN, PANEL_MIN, activateTab, agentUuidOf, allLeaves, closeFloatByTab, closeTab, dockFloat, firstLeaf, floatTab,
|
||||
floatWithTab, isAgentTabId, leafWithTab, migrateBottomTabs,
|
||||
moveFloat, moveTab, moveTabToEdge, openDiffTab, openTabInActivePane, raiseFloat, reconcileAgentTerminals,
|
||||
resizeFloat, resizeSplitIn, setBottomHeight, setTabPin, setWidth, toggleBottomPanel, toggleExpanded, togglePanel,
|
||||
type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode,
|
||||
BOTTOM_MIN, PANEL_MIN, agentUuidOf, firstLeaf, floatTab,
|
||||
isAgentTabId, leafWithTab, migrateBottomTabs,
|
||||
moveTab, moveTabToEdge, openDiffTab, resizeSplitIn,
|
||||
setBottomHeight, setTabPin, setWidth, toggleBottomPanel, toggleExpanded, togglePanel,
|
||||
type DropZone, type SidebarState, type SidebarStore, type SidebarTab,
|
||||
} from './state.ts'
|
||||
import { collectPinnedTabs, createPinnedVirtualTab, getPinnedHomeScope, injectPinnedIntoTree, isPinnedVirtualId, isPinnedVirtualTab, parsePinnedVirtualId, type PinnedTabEntry } from './pinned.ts'
|
||||
import { IconPinOutline16 } from './icons.tsx'
|
||||
import { getPinnedHomeScope } from './pinned.ts'
|
||||
import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx'
|
||||
import { Workbench, type WorkbenchActions } from './split-pane.tsx'
|
||||
import { isNarrowWidth, useViewportSize } from './breakpoints.ts'
|
||||
import { layoutPushSize } from './layout-push.ts'
|
||||
import { resolveCenterColumn } from './center-column.ts'
|
||||
import { parseDesktopEnv } from './desktop-env.ts'
|
||||
import { getWcoSnapshot, subscribeWco } from './wco.ts'
|
||||
import { getShellPreset } from './shell-presets.ts'
|
||||
import { computeTitleBarStrip } from './titlebar-strip.ts'
|
||||
import type { NewTabOption } from './TabBar.tsx'
|
||||
import { TAB_DRAG_TYPE, parseDrag, type TabDragPayload } from './TabBar.tsx'
|
||||
import { FreeWindow } from './FreeWindow.tsx'
|
||||
import { TabContent, buildNewTabOptions } from './sidebar/TabContent.tsx'
|
||||
import { useCenterColumn } from './sidebar/use-center-column.ts'
|
||||
import { useHostFeeds } from './sidebar/use-host-feeds.ts'
|
||||
import { usePinnedTabs } from './sidebar/use-pinned-tabs.ts'
|
||||
import { FreeWindowLayer, useFloatDragout } from './sidebar/free-windows.tsx'
|
||||
import type { TabDragPayload } from './TabBar.tsx'
|
||||
import { relativeTo } from './paths.ts'
|
||||
import { OrphanedTab } from './OrphanedTab.tsx'
|
||||
import { RenderBoundary } from './RenderBoundary.tsx'
|
||||
import { tabContentCompare, type TabContentMemoKey } from './tab-content-memo.ts'
|
||||
import { detectNewDirectSubagent } from './subagent-detect.ts'
|
||||
import { detectNewJob } from './subagent-jobs.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { api, type SessionScope } from './api.ts'
|
||||
import { api } from './api.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** How many consecutive reconnect failures stop the agent-terminals push loop
|
||||
* (mirror of the terminal view's own cap; the loop restarts on session switch). */
|
||||
const FAILURE_LIMIT = 3
|
||||
|
||||
/**
|
||||
* Subagent auto-open debounce (ms). The host delivers a new child's origin
|
||||
* and its title in SEPARATE frames: a Side Chat thread's first visible
|
||||
* frame still shows a fallback title (no 'Side: ' prefix), so an immediate
|
||||
* 0→N decision mistakes it for a genuine subagent and pops the task page.
|
||||
* The trigger therefore re-evaluates against the live snapshot once the
|
||||
* title frame has had time to land.
|
||||
*/
|
||||
const AUTO_OPEN_DEBOUNCE_MS = 500
|
||||
|
||||
/**
|
||||
* OS file drags over the sidebar belong to the sidebar, not to the chat:
|
||||
* DSH's composer (InputBar) listens for file drags on the DOCUMENT and
|
||||
@@ -120,65 +102,6 @@ function injectUserCss(attr: string, id: string, cssText: string): HTMLStyleElem
|
||||
return tag
|
||||
}
|
||||
|
||||
/** Props of one tab's content cell = the memo key (tab-content-memo.ts) plus
|
||||
* the runtime objects/callbacks the cell renders with. The memo comparator
|
||||
* is the pure `tabContentCompare`; anything in the key decides a re-render
|
||||
* must propagate, anything outside it must be a stable object (ctx/store)
|
||||
* or covered by a compared field (paneId covers onOpenDiff's captured
|
||||
* pane; sessionId/cwd cover onReferenceFile). */
|
||||
interface TabContentProps extends TabContentMemoKey {
|
||||
onToggleDir: (path: string) => void
|
||||
onReferenceFile: (path: string, isDir: boolean) => void
|
||||
ctx: Context
|
||||
store: SidebarStore
|
||||
/** Fired before a topology node jumps to its child session (see Sidebar). */
|
||||
onSubagentJump: (childSessionId: string) => void
|
||||
/** Open a diff tab from the git panel (placement handled by the store). */
|
||||
onOpenDiff: (tab: SidebarTab) => void
|
||||
}
|
||||
|
||||
/** Render the content of one tab (dispatched by type). */
|
||||
const TabContent = memo(function TabContent(props: TabContentProps) {
|
||||
const { tab, effectiveTabId, sessionId, cwd, expanded, revealed, onToggleDir, onReferenceFile, ctx, store, visible, onSubagentJump, onOpenDiff } = props
|
||||
const scope = { sessionId, cwd }
|
||||
const descriptor = ctx.get('betterSidebar')?.getTab(tab.type)
|
||||
if (descriptor === undefined) {
|
||||
return <OrphanedTab ctx={ctx} store={store} scope={scope} tab={tab} visible={visible} />
|
||||
}
|
||||
// For pinned virtual tabs, the tab descriptor's component (e.g. TerminalView)
|
||||
// must receive the ORIGINAL tab id so it connects to the home session's PTY.
|
||||
// The virtual tab's own id is a unique display key (prefixed); effectiveTabId
|
||||
// restores the real id at the component boundary.
|
||||
const componentTab = effectiveTabId !== undefined ? { ...tab, id: effectiveTabId } : tab
|
||||
return createElement(
|
||||
RenderBoundary,
|
||||
{ className: css.tabBoundaryError },
|
||||
createElement(descriptor.component, {
|
||||
ctx, store, scope, tab: componentTab, visible, expanded, revealed,
|
||||
onToggleDir, onReferenceFile, onOpenDiff, onSubagentJump,
|
||||
}),
|
||||
)
|
||||
}, tabContentCompare)
|
||||
|
||||
/** The + menu options for the current state, driven by the tab registry.
|
||||
* Hidden tabs (editor/diff) never show; `available` returning false shows
|
||||
* a disabled row (e.g. terminal at capacity) instead of hiding the option.
|
||||
* Tabs the user disabled in the side card settings are filtered out
|
||||
* entirely — re-enabling them is the settings page's job. */
|
||||
function buildNewTabOptions(state: SidebarState, ctx: Context, scope: SessionScope): NewTabOption[] {
|
||||
const service = ctx.get('betterSidebar')
|
||||
if (service === undefined) return []
|
||||
return service.getTabs()
|
||||
.filter(d => !d.hidden && service.isTabEnabled(d.id))
|
||||
.sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
|
||||
.map(d => ({
|
||||
id: d.id,
|
||||
label: typeof d.title === 'function' ? d.title() : d.title,
|
||||
disabled: !(d.available?.(ctx, scope, state) ?? true),
|
||||
icon: typeof d.icon === 'function' ? d.icon(16) : d.icon,
|
||||
}))
|
||||
}
|
||||
|
||||
export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
const { ctx, store } = props
|
||||
|
||||
@@ -418,518 +341,28 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
// for the descriptors' available() callbacks. (The render's own guard
|
||||
// sits below every hook; this memo must handle the no-session case
|
||||
// itself.)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[state, ctx, sessionId, cwd],
|
||||
)
|
||||
|
||||
/**
|
||||
* Agent terminals push: subscribe to the host's live list of agent-owned
|
||||
* terminals for this session (created by the model through the
|
||||
* `terminal_create` tool). The host pushes a JSON array on every
|
||||
* create / close / exit; the sidebar reconciles the list into tabs
|
||||
* (id `agent:<uuid>`, title from the agent). A disconnected socket
|
||||
* retries with a short backoff so a refresh or transient drop reattaches
|
||||
* the same shell without losing the agent's work — capped like the
|
||||
* terminal view's own reconnect loop, so a refused endpoint never spins
|
||||
* forever (the next session switch restarts the loop).
|
||||
* While the terminal tab type is disabled in settings, pushes are
|
||||
* ignored (no auto-added tabs); re-enabling makes the next push converge.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (sessionId === undefined) return
|
||||
let socket: WebSocket | null = null
|
||||
let retry: number | undefined
|
||||
let closed = false
|
||||
let failures = 0
|
||||
const connect = (): void => {
|
||||
if (closed) return
|
||||
const url = new URL('/sidebar/ws/agent-terminals', location.origin)
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
url.search = new URLSearchParams({ sessionId }).toString()
|
||||
socket = new WebSocket(url.toString())
|
||||
socket.onmessage = (event) => {
|
||||
if (typeof event.data !== 'string') return
|
||||
try {
|
||||
const list = JSON.parse(event.data) as Array<{ uuid: string; title: string; command: string; exited: boolean }>
|
||||
if (!Array.isArray(list)) return
|
||||
store.reduce(s => ctx.get('betterSidebar')?.isTabEnabled('terminal') === false
|
||||
? s
|
||||
: reconcileAgentTerminals(s, list))
|
||||
} catch {
|
||||
// Malformed push: ignore (the next push will reconcile).
|
||||
}
|
||||
}
|
||||
socket.onclose = () => {
|
||||
if (closed) return
|
||||
failures += 1
|
||||
if (failures >= FAILURE_LIMIT) {
|
||||
console.error('[dsh-better-sidebar] agent-terminals connection failed; stopping reconnect loop', sessionId)
|
||||
return
|
||||
}
|
||||
retry = window.setTimeout(connect, 2000)
|
||||
}
|
||||
socket.onerror = () => { socket?.close() }
|
||||
}
|
||||
connect()
|
||||
return () => {
|
||||
closed = true
|
||||
window.clearTimeout(retry)
|
||||
socket?.close()
|
||||
}
|
||||
}, [sessionId, store])
|
||||
// Host feeds (sidebar/use-host-feeds.ts): the agent-terminals / agent-opens
|
||||
// WebSocket pushes and the subagent / background-job auto-activation
|
||||
// triggers, all keyed on the current session. The jump-back ref is the one
|
||||
// piece the render side consumes (renderTab's onSubagentJump arms it).
|
||||
const { subagentJumpRef } = useHostFeeds({ ctx, store, sessionList, sessionId })
|
||||
|
||||
/**
|
||||
* Agent opens push: subscribe to the host's `sidebar_open` requests for
|
||||
* this session (the model actively opens a file / folder / HTTP(S) page).
|
||||
* The host pushes one JSON request per open; the sidebar routes it to the
|
||||
* matching built-in tab: a file opens in the editor (per-path dedupe), a
|
||||
* folder opens a file window whose tree is rooted at the folder
|
||||
* (`meta.dir`), and a URL opens in the browser tab. A disconnected socket
|
||||
* retries with a short backoff (mirror of the agent-terminals loop): the
|
||||
* host queue keeps undelivered requests and replays them on the first
|
||||
* attach, so a refresh or a session switch lands the opens the model
|
||||
* queued while no view was connected.
|
||||
* While the side-card setting is off, pushes are ignored as a defensive
|
||||
* gate — the host already unregisters the tool and drains the queue.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (sessionId === undefined) return
|
||||
let socket: WebSocket | null = null
|
||||
let retry: number | undefined
|
||||
let closed = false
|
||||
let failures = 0
|
||||
const connect = (): void => {
|
||||
if (closed) return
|
||||
const url = new URL('/sidebar/ws/agent-opens', location.origin)
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
url.search = new URLSearchParams({ sessionId }).toString()
|
||||
socket = new WebSocket(url.toString())
|
||||
socket.onmessage = (event) => {
|
||||
if (typeof event.data !== 'string') return
|
||||
try {
|
||||
const request = JSON.parse(event.data) as { kind?: unknown; target?: unknown; title?: unknown }
|
||||
if (request === null || typeof request !== 'object') return
|
||||
if (request.kind !== 'file' && request.kind !== 'folder' && request.kind !== 'url') return
|
||||
if (typeof request.target !== 'string' || request.target === '') return
|
||||
if (store.getPrefs().agentOpenTools !== true) return
|
||||
const scope = { sessionId }
|
||||
const title = typeof request.title === 'string' && request.title !== '' ? request.title : undefined
|
||||
if (request.kind === 'url') {
|
||||
ctx.get('betterSidebar')?.openTab({ type: 'browser', url: request.target, title }, scope)
|
||||
} else if (request.kind === 'folder') {
|
||||
ctx.get('betterSidebar')?.openTab({
|
||||
type: 'editor',
|
||||
title,
|
||||
path: request.target,
|
||||
id: `editor:${request.target}`,
|
||||
meta: { dir: true },
|
||||
}, scope)
|
||||
} else {
|
||||
ctx.get('betterSidebar')?.openFile(scope, request.target, title)
|
||||
}
|
||||
} catch {
|
||||
// Malformed push: ignore (the next push carries its own request).
|
||||
}
|
||||
}
|
||||
socket.onclose = () => {
|
||||
if (closed) return
|
||||
failures += 1
|
||||
if (failures >= FAILURE_LIMIT) {
|
||||
console.error('[dsh-better-sidebar] agent-opens connection failed; stopping reconnect loop', sessionId)
|
||||
return
|
||||
}
|
||||
retry = window.setTimeout(connect, 2000)
|
||||
}
|
||||
socket.onerror = () => { socket?.close() }
|
||||
}
|
||||
connect()
|
||||
return () => {
|
||||
closed = true
|
||||
window.clearTimeout(retry)
|
||||
socket?.close()
|
||||
}
|
||||
}, [sessionId, store])
|
||||
// Center-column tracking (sidebar/use-center-column.ts): the bottom panel
|
||||
// spans ONLY the app shell's center column ("squeezes the agent output
|
||||
// area"); the hook locates the AppFrame's center column DOM (host anchor
|
||||
// observers + slow retry), measures its edges into a ref, and writes them
|
||||
// straight to the bottom panel element — per-frame tracking without
|
||||
// re-rendering the shell (the comments live with the hook now).
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null)
|
||||
const { centerColRef, centerRectRef, centerMeasured, measureCenter, draggingRef } = useCenterColumn(bottomRef, state?.bottomOpen)
|
||||
|
||||
/**
|
||||
* Subagent auto-activation: the moment the current conversation spawns its
|
||||
* FIRST direct subagent (a 0 → N transition on the list feed), the "auto
|
||||
* open" pref is on, and the Tasks tab type is enabled in settings, activate
|
||||
* the Tasks page. Single-instance semantics focus an existing pane tab in
|
||||
* place or raise an existing free window; a new tab lands in the right pane
|
||||
* and is never duplicated. On wide viewports the right panel also expands;
|
||||
* on narrow viewports background activity never forces the full-screen
|
||||
* drawer open over the chat.
|
||||
* Switching to a session that already has subagents never triggers — its
|
||||
* baseline starts at the current count — so a deliberate layout is never
|
||||
* fought.
|
||||
*
|
||||
* The decision is DEBOUNCED (AUTO_OPEN_DEBOUNCE_MS): a Side Chat thread
|
||||
* is also a subagent-origin child, and its 'Side: ' title lands one frame
|
||||
* after its origin — an immediate check would misread that first frame as
|
||||
* a new subagent and pop this page on every thread creation. The timer
|
||||
* re-evaluates the ORIGINAL baseline against the live snapshot; by then
|
||||
* the title filter (isSideThreadSummary) sees the settled label.
|
||||
*/
|
||||
const listBaselineRef = useRef<SidebarSessionList | undefined>(undefined)
|
||||
const autoOpenPendingRef = useRef<{ baseline: SidebarSessionList; timer: number } | null>(null)
|
||||
useEffect(() => {
|
||||
const prev = listBaselineRef.current
|
||||
listBaselineRef.current = sessionList
|
||||
if (sessionId === undefined || prev === undefined) return
|
||||
if (autoOpenPendingRef.current !== null) return
|
||||
if (!detectNewDirectSubagent(prev, sessionList, sessionId)) return
|
||||
const baseline = prev
|
||||
const timer = window.setTimeout(() => {
|
||||
autoOpenPendingRef.current = null
|
||||
if (!detectNewDirectSubagent(baseline, ctx.sessions.list.getSnapshot(), sessionId)) return
|
||||
if (!store.getPrefs().autoOpenSubagent) return
|
||||
if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return
|
||||
// Read the viewport when the delayed activation fires: a resize while
|
||||
// the debounce is armed must not let background activity force the
|
||||
// narrow full-screen drawer open over the chat.
|
||||
if (!isNarrowWidth(window.innerWidth)) {
|
||||
store.reduce(s => s.panelOpen ? s : togglePanel(s))
|
||||
}
|
||||
// Choose the right panel as the landing pane for a newly created Tasks
|
||||
// tab. Single-instance dedupe still activates an existing pane tab in
|
||||
// place or raises an existing free window.
|
||||
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
|
||||
ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') })
|
||||
}, AUTO_OPEN_DEBOUNCE_MS)
|
||||
autoOpenPendingRef.current = { baseline, timer }
|
||||
}, [sessionList, sessionId, store, ctx])
|
||||
|
||||
// A session switch (or unmount) voids any armed auto-open recheck.
|
||||
useEffect(() => () => {
|
||||
const pending = autoOpenPendingRef.current
|
||||
if (pending !== null) window.clearTimeout(pending.timer)
|
||||
autoOpenPendingRef.current = null
|
||||
}, [sessionId])
|
||||
|
||||
/**
|
||||
* Job auto-activation: the moment a NEW background job appears for the
|
||||
* current conversation (a job id the previous snapshot lacked), the
|
||||
* auto-open pref is on, and the Tasks tab type is enabled, activate the Tasks
|
||||
* page that contains the background-jobs section. The right panel expands
|
||||
* only on wide viewports. Unlike the subagent trigger (0 → N only), ANY
|
||||
* new job id triggers: the agent may start several jobs in one session, and
|
||||
* each should surface. A fresh page load never triggers — its baseline starts
|
||||
* at the current snapshot.
|
||||
*/
|
||||
const jobBaselineRef = useRef<SidebarSessionList | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
const prev = jobBaselineRef.current
|
||||
jobBaselineRef.current = sessionList
|
||||
if (sessionId === undefined || prev === undefined) return
|
||||
if (!detectNewJob(prev, sessionList, sessionId)) return
|
||||
if (!store.getPrefs().autoOpenJobs) return
|
||||
if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return
|
||||
if (!isNarrowWidth(window.innerWidth)) {
|
||||
store.reduce(s => s.panelOpen ? s : togglePanel(s))
|
||||
}
|
||||
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
|
||||
ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') })
|
||||
}, [sessionList, sessionId, store, ctx])
|
||||
|
||||
/**
|
||||
* Topology jump-back: clicking a subagent node on the Subagent page calls
|
||||
* the official `openSubagent`, which switches the sidebar to that child
|
||||
* session's OWN layout (a fresh child session defaults to the explorer).
|
||||
* The README contract says the Subagent page must stay open with the jumped
|
||||
* node highlighted — so once the current session becomes the recorded jump
|
||||
* target, re-open the Subagent page on top of the child's layout (expanding
|
||||
* the panel first if it is collapsed). Only this explicit node click arms
|
||||
* the flag, so switching to a subagent session by any other means keeps
|
||||
* that session's own layout untouched.
|
||||
*/
|
||||
const subagentJumpRef = useRef<string | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
const pending = subagentJumpRef.current
|
||||
if (pending === undefined || sessionId !== pending) return
|
||||
subagentJumpRef.current = undefined
|
||||
store.reduce(s => s.panelOpen ? s : togglePanel(s))
|
||||
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
|
||||
ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') })
|
||||
}, [sessionId, store, ctx])
|
||||
|
||||
/**
|
||||
/**
|
||||
* Inline pinned terminals (v0.17.0+): pinned tabs from OTHER sessions
|
||||
* inject as VIRTUAL tabs into the first leaf of the right panel's split
|
||||
* tree. The virtual tabs have unique ids (prefixed with the home session)
|
||||
* and carry the home scope in meta. Clicking a virtual tab sets
|
||||
* `activePinnedTabId` — the augmented tree overrides the leaf's `active`
|
||||
* so the pinned tab's content renders in-place (TerminalView connects to
|
||||
* the home session's PTY via WS, no session jump).
|
||||
*
|
||||
* Closing/unpinning a virtual tab targets the HOME session via reduceFor
|
||||
* (which doesn't notify — targeted opens must not re-render the active
|
||||
* session). The `pinnedRevision` state bump forces the pinnedEntries
|
||||
* useMemo to recompute after such an action.
|
||||
*/
|
||||
const [activePinnedTabId, setActivePinnedTabId] = useState<string | null>(null)
|
||||
const [pinnedRevision, setPinnedRevision] = useState(0)
|
||||
|
||||
/**
|
||||
* Cross-session pinned-tab collection. Recomputed on every store notify,
|
||||
* session-list change, and pinned action (the revision bump covers
|
||||
* reduceFor updates that don't notify). Only tabs from OTHER sessions —
|
||||
* the viewer's own pinned tabs are already on its tab strip.
|
||||
*/
|
||||
const pinnedEntries: readonly PinnedTabEntry[] = useMemo(() => {
|
||||
if (sessionId === undefined) return []
|
||||
return collectPinnedTabs(store.getSessionStates(), { sessionId, cwd })
|
||||
}, [store, sessionId, cwd, snapshot, pinnedRevision])
|
||||
|
||||
/** Virtual SidebarTab objects for the pinned entries (stable references
|
||||
* via useMemo so TabContent's memo comparator holds). */
|
||||
const pinnedVirtualTabs = useMemo(
|
||||
() => pinnedEntries.map(createPinnedVirtualTab),
|
||||
[pinnedEntries],
|
||||
)
|
||||
|
||||
/** The right panel's split tree with pinned virtual tabs injected into the
|
||||
* first leaf. When `activePinnedTabId` is set, that leaf's `active` is
|
||||
* overridden so the pinned tab's content is visible. */
|
||||
const augmentedTree = useMemo(
|
||||
() => state === undefined ? undefined : injectPinnedIntoTree(state.splits, pinnedVirtualTabs, activePinnedTabId),
|
||||
[state, pinnedVirtualTabs, activePinnedTabId],
|
||||
)
|
||||
|
||||
// The app shell's center column: the bottom panel spans ONLY that column
|
||||
// ("squeezes the agent output area") — it starts at the app sidebar's
|
||||
// right edge and ends at the details column's left edge (the details
|
||||
// column sits between the center and the right panel). Measured directly
|
||||
// from the AppFrame's center column DOM (the parent of the
|
||||
// [data-slot="conversation"] wrapper — layout.css's center column) so the
|
||||
// bottom panel tracks the column's real
|
||||
// horizontal edges — including the animated AppFrame padding reservation
|
||||
// while the right panel opens/closes; a frame that never appears keeps the
|
||||
// initial zero-size fallback (the panel renders at 0 width until measured).
|
||||
// The rect lives in a REF (not state): the open/close transition resizes
|
||||
// the center column EVERY frame for its duration, and reacting per frame
|
||||
// with setState re-renders the whole Sidebar (every mounted tab) at
|
||||
// animation cadence — the visible toggle jank (#315). measureCenter
|
||||
// writes the bottom panel's edges directly (same DOM-write pattern as
|
||||
// applyDrag), so the panel still tracks the column per frame with zero
|
||||
// React work; `centerMeasured` flips ONCE to gate the hidden→visible
|
||||
// first-paint fallback.
|
||||
const centerRectRef = useRef({ left: 0, right: 0 })
|
||||
const [centerMeasured, setCenterMeasured] = useState(false)
|
||||
// Refs keep the measure step stable across renders and let it skip work
|
||||
// mid-drag: during a width/corner drag the layout push resizes the center
|
||||
// column every frame, and reacting (setCenterRect → re-render) would
|
||||
// re-introduce the drag lag this shell deliberately avoids. applyDrag
|
||||
// writes the bottom panel's edges directly, so measurement pauses then.
|
||||
const centerColRef = useRef<HTMLElement | null>(null)
|
||||
const draggingRef = useRef(false)
|
||||
const measureCenter = useCallback((): void => {
|
||||
if (draggingRef.current) return
|
||||
const col = centerColRef.current
|
||||
if (col === null) return
|
||||
if (!col.isConnected) {
|
||||
// The observed column was detached (HMR re-render swapped the node
|
||||
// in place): its rect is stale garbage. Drop the ref — the locate
|
||||
// chain re-runs on the next mutation/interval tick and picks up the
|
||||
// new column node (issue #248).
|
||||
centerColRef.current = null
|
||||
return
|
||||
}
|
||||
const rect = col.getBoundingClientRect()
|
||||
// Ref + direct DOM write (see the centerRectRef comment): the bottom
|
||||
// panel keeps tracking the center column per frame during the right
|
||||
// panel's open/close animation without re-rendering the shell. The
|
||||
// one-shot measured flip renders the panel visible once (a stale
|
||||
// {0,0} fallback would flash full-width).
|
||||
centerRectRef.current = { left: rect.left, right: rect.right }
|
||||
const bottom = bottomRef.current
|
||||
if (bottom !== null) {
|
||||
bottom.style.setProperty('left', `${rect.left}px`)
|
||||
bottom.style.setProperty('right', `${window.innerWidth - rect.right}px`)
|
||||
}
|
||||
setCenterMeasured(prev => (prev ? prev : true))
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
let disposed = false
|
||||
let observer: ResizeObserver | undefined
|
||||
// Locate the AppFrame's center column. DSH 0.1.x wraps slot hosts in
|
||||
// [data-slot] containers: the conversation slot wrapper
|
||||
// ([data-slot="conversation"]) sits directly inside the center column,
|
||||
// so its parent IS that column — no hashed-class or positional
|
||||
// dependency (layout.css uses the same anchor). The shell swaps the
|
||||
// boot page for the AppFrame only AFTER boot settles, so the first
|
||||
// query may miss it. Never give up: watch #root's subtree (the swap and
|
||||
// HMR re-renders mutate it) and re-run this locator — querying once and
|
||||
// bailing would strand the panel at the zero-size fallback forever
|
||||
// (observed: a 1px sliver at the viewport's left edge).
|
||||
const locate = (): void => {
|
||||
if (disposed) return
|
||||
// Hot path (#403): streaming output mutates #root at token cadence.
|
||||
// Reuse a still-connected center column and only query after boot/HMR
|
||||
// detached the cached node.
|
||||
const col = resolveCenterColumn(centerColRef.current)
|
||||
if (col === undefined || !col.isConnected) {
|
||||
if (centerColRef.current !== null) {
|
||||
centerColRef.current.removeAttribute('data-dsh-center-col')
|
||||
centerColRef.current = null
|
||||
observer?.disconnect()
|
||||
observer = undefined
|
||||
}
|
||||
return
|
||||
}
|
||||
if (centerColRef.current !== col) {
|
||||
// A NEW column node (boot swap, HMR re-render, or a previous locate
|
||||
// that found nothing): attach the ResizeObserver to THIS node and
|
||||
// measure it once. Same-node size changes are the ResizeObserver's
|
||||
// job — no forced measurement here, because a forced
|
||||
// getBoundingClientRect per mutation would reflow the shell at
|
||||
// mutation cadence. The tag retargets with the ref: layout.css's
|
||||
// bottom-push rule anchors on [data-dsh-center-col], so exactly the
|
||||
// measured node carries it (a stale tag on a swapped-out node would
|
||||
// leave the push rule anchorless or doubled).
|
||||
centerColRef.current?.removeAttribute('data-dsh-center-col')
|
||||
centerColRef.current = col
|
||||
col.setAttribute('data-dsh-center-col', '')
|
||||
observer?.disconnect()
|
||||
observer = new ResizeObserver(measureCenter)
|
||||
observer.observe(col)
|
||||
measureCenter()
|
||||
}
|
||||
}
|
||||
locate()
|
||||
// rAF-debounce the mutation watchers: #root's subtree changes at chat
|
||||
// cadence (streaming turns), and locate() itself must stay cheap.
|
||||
let locateFrame: number | null = null
|
||||
const scheduleLocate = (): void => {
|
||||
if (locateFrame !== null) return
|
||||
// Mid-drag every frame writes --dsh-sidebar-* on <html>'s style
|
||||
// attribute, which is the mutation this watcher observes — relocating
|
||||
// per drag frame is pointless (the center column node cannot change
|
||||
// while the pointer is captured) and adds locator work to every
|
||||
// frame's budget (#315). The 1.5s retry below still covers any node
|
||||
// swap that somehow lands mid-drag.
|
||||
if (draggingRef.current) return
|
||||
locateFrame = requestAnimationFrame(() => {
|
||||
locateFrame = null
|
||||
locate()
|
||||
})
|
||||
}
|
||||
const watcher = new MutationObserver(scheduleLocate)
|
||||
const root = document.getElementById('root')
|
||||
if (root !== null) watcher.observe(root, { childList: true, subtree: true })
|
||||
// The layout push writes --dsh-sidebar-* on <html>. A HMR re-activation
|
||||
// clears those variables on teardown and re-writes them on setup — and
|
||||
// that is also the moment the shell may have re-created the center
|
||||
// column under a REUSED #root child (React swaps nodes in place, so
|
||||
// #root's childList never changes and the watcher above never fires).
|
||||
// Watching <html>'s style attribute catches that re-sync: the push
|
||||
// rewrite re-locates and re-measures, so the bottom panel recovers
|
||||
// instead of staying hidden on a stale {0,0} center rect.
|
||||
const htmlStyleWatcher = new MutationObserver(scheduleLocate)
|
||||
htmlStyleWatcher.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] })
|
||||
// Last-resort safety net (issue #248): no watcher is guaranteed to fire
|
||||
// for every HMR teardown/setup interleaving (e.g. the style attribute
|
||||
// may end up byte-identical, and the col may be swapped before the
|
||||
// subtree watcher attaches). A slow unconditional re-locate makes the
|
||||
// panel converge on the real column within a couple of seconds no
|
||||
// matter what sequence the shell used. locate() is query-free while the
|
||||
// cached column stays connected; only a detached/missing cache falls
|
||||
// back to the document selector.
|
||||
const retry = window.setInterval(locate, 1500)
|
||||
return () => {
|
||||
disposed = true
|
||||
if (locateFrame !== null) cancelAnimationFrame(locateFrame)
|
||||
window.clearInterval(retry)
|
||||
observer?.disconnect()
|
||||
watcher.disconnect()
|
||||
htmlStyleWatcher.disconnect()
|
||||
centerColRef.current?.removeAttribute('data-dsh-center-col')
|
||||
centerColRef.current = null
|
||||
}
|
||||
// Opening the bottom panel re-runs the whole locate/measure chain: a
|
||||
// panel opened before the center column was ever found must not stay
|
||||
// invisible forever (the HMR recovery path depends on the observers
|
||||
// above, this is the belt-and-braces retry for the open moment itself).
|
||||
}, [measureCenter, state?.bottomOpen])
|
||||
|
||||
/**
|
||||
* Free windows — drag-out detection. The tab strips already drive HTML5
|
||||
* DnD (payload application/x-dsh-tab) with drops owned by the panes
|
||||
* (split/merge); this shell watches the DOCUMENT (capture) for the same
|
||||
* drag hovering OUTSIDE the panel host: while the pointer is over the
|
||||
* conversation column it arms the drop (preventDefault) and shows a hint
|
||||
* overlay there, and the drop floats the tab at the release point. Targets
|
||||
* inside the host are ignored here, so pane drops keep their behavior
|
||||
* untouched. Only OUR tab drags count (the body flag is the tab strip's;
|
||||
* OS file drags and any DSH drags pass through). Narrow viewports skip
|
||||
* the gesture — the merged drawer covers the conversation, leaving
|
||||
* nothing to drop onto (the tab context menu entry still floats tabs).
|
||||
*/
|
||||
const [floatHint, setFloatHint] = useState<{ left: number; top: number; width: number; height: number } | null>(null)
|
||||
const floatHintRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (narrow || sessionId === undefined) return
|
||||
const inPanelHost = (target: EventTarget | null): boolean =>
|
||||
target instanceof Element && target.closest('[data-dsh-panel-host]') !== null
|
||||
/** The conversation column's rect when the pointer is over it (and not
|
||||
* over our own surfaces); null otherwise. */
|
||||
const overConversation = (event: DragEvent): DOMRect | null => {
|
||||
if (inPanelHost(event.target)) return null
|
||||
const col = centerColRef.current
|
||||
if (col === null || !col.isConnected) return null
|
||||
const rect = col.getBoundingClientRect()
|
||||
if (rect.width === 0 || rect.height === 0) return null
|
||||
const { clientX: x, clientY: y } = event
|
||||
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return null
|
||||
return rect
|
||||
}
|
||||
const onDragOver = (event: DragEvent): void => {
|
||||
if (!document.body.hasAttribute('data-dsh-tab-dragging')) return
|
||||
const rect = overConversation(event)
|
||||
if (rect !== null) {
|
||||
// preventDefault on dragover is what makes the browser deliver the
|
||||
// drop (and drop the "no" cursor) over the conversation area.
|
||||
event.preventDefault()
|
||||
setFloatHint((prev) => {
|
||||
const next = { left: rect.left, top: rect.top, width: rect.width, height: rect.height }
|
||||
if (prev !== null && prev.left === next.left && prev.top === next.top
|
||||
&& prev.width === next.width && prev.height === next.height) return prev
|
||||
return next
|
||||
})
|
||||
floatHintRef.current = true
|
||||
} else if (floatHintRef.current) {
|
||||
floatHintRef.current = false
|
||||
setFloatHint(null)
|
||||
}
|
||||
}
|
||||
const onDrop = (event: DragEvent): void => {
|
||||
if (!floatHintRef.current) return
|
||||
floatHintRef.current = false
|
||||
setFloatHint(null)
|
||||
const rect = overConversation(event)
|
||||
if (rect === null) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const payload = parseDrag(event.dataTransfer?.getData(TAB_DRAG_TYPE) ?? '')
|
||||
if (payload === null) return
|
||||
store.reduce(s => floatTab(s, payload.tabId, event.clientX, event.clientY))
|
||||
}
|
||||
const clear = (): void => {
|
||||
if (!floatHintRef.current) return
|
||||
floatHintRef.current = false
|
||||
setFloatHint(null)
|
||||
}
|
||||
document.addEventListener('dragover', onDragOver, true)
|
||||
document.addEventListener('drop', onDrop, true)
|
||||
window.addEventListener('dragend', clear, true)
|
||||
window.addEventListener('blur', clear)
|
||||
return () => {
|
||||
document.removeEventListener('dragover', onDragOver, true)
|
||||
document.removeEventListener('drop', onDrop, true)
|
||||
window.removeEventListener('dragend', clear, true)
|
||||
window.removeEventListener('blur', clear)
|
||||
}
|
||||
}, [narrow, sessionId, store])
|
||||
// Free-window drag-out gesture (sidebar/free-windows.tsx): watches the
|
||||
// document for OUR tab drags hovering the conversation column and floats
|
||||
// the tab on release. Returns the drop-zone hint geometry for the layer.
|
||||
const { floatHint } = useFloatDragout({ narrow, sessionId, store, centerColRef })
|
||||
|
||||
/**
|
||||
* Bottom-panel first-expansion auto terminal: the FIRST time the user
|
||||
@@ -969,7 +402,6 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
// move, which is the visible drag lag. The store is committed once on
|
||||
// pointer up (clamping + persistence).
|
||||
const panelRef = useRef<HTMLDivElement | null>(null)
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null)
|
||||
const widthDrag = useRef({ startX: 0, startWidth: 0 })
|
||||
const [draggingWidth, setDraggingWidth] = useState(false)
|
||||
const bottomDrag = useRef({ startY: 0, startHeight: 0 })
|
||||
@@ -985,7 +417,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
useEffect(() => {
|
||||
draggingRef.current = anyDragging
|
||||
if (!anyDragging) measureCenter()
|
||||
}, [anyDragging, measureCenter])
|
||||
// draggingRef is a stable ref object from useCenterColumn (same
|
||||
// provenance note as the float effect above).
|
||||
}, [anyDragging, measureCenter, draggingRef])
|
||||
|
||||
// Clamp mirrors of setWidth/setBottomHeight for mid-drag values (the store
|
||||
// re-clamps on commit; these keep the panels from overshooting mid-drag).
|
||||
@@ -1297,83 +731,13 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
pinTab: (tabId, scope) => {
|
||||
store.reduce(s => setTabPin(s, tabId, scope === null ? null : { scope, homeCwd: cwd }))
|
||||
},
|
||||
}), [store, sessionId, cwd])
|
||||
}), [store, sessionId, cwd, ctx, centerColRef])
|
||||
|
||||
/**
|
||||
* Wrap the base actions to intercept pinned VIRTUAL tab ids (injected from
|
||||
* other sessions). Regular tab ids pass through unchanged. Virtual ids are
|
||||
* detected by the `pinned:` prefix and routed to the HOME session via
|
||||
* reduceFor (which doesn't notify — the revision bump is the local signal).
|
||||
*/
|
||||
const wrappedActions = useMemo<WorkbenchActions>(() => {
|
||||
if (pinnedVirtualTabs.length === 0) return actions
|
||||
const closePinnedInHome = (virtualId: string): void => {
|
||||
const { homeSessionId, tabId: originalId } = parsePinnedVirtualId(virtualId)
|
||||
// The home cwd lives in the virtual tab's meta (snapshotted at pin
|
||||
// time) — pass it to ptyClose so the host resolves the PTY in the
|
||||
// correct workspace container (same scope the WS open used).
|
||||
const vtab = pinnedVirtualTabs.find(t => t.id === virtualId)
|
||||
const homeCwd = vtab !== undefined ? getPinnedHomeScope(vtab)?.cwd : undefined
|
||||
store.reduceFor(homeSessionId, s => {
|
||||
const leaf = leafWithTab(s.splits, originalId) ?? leafWithTab(s.bottomSplits, originalId)
|
||||
if (leaf !== undefined) return closeTab(s, leaf.id, originalId)
|
||||
if (s.floats.some(f => f.tab.id === originalId)) return closeFloatByTab(s, originalId)
|
||||
return s
|
||||
})
|
||||
if (isAgentTabId(originalId)) {
|
||||
void api.agentPtyClose(agentUuidOf(originalId)).catch(() => { /* already released */ })
|
||||
} else {
|
||||
void api.ptyClose({ sessionId: homeSessionId, ...(homeCwd !== undefined ? { cwd: homeCwd } : {}) }, originalId).catch(() => { /* already released */ })
|
||||
}
|
||||
if (activePinnedTabId === virtualId) setActivePinnedTabId(null)
|
||||
setPinnedRevision(v => v + 1)
|
||||
}
|
||||
return {
|
||||
...actions,
|
||||
activateTab: (paneId, tabId) => {
|
||||
if (isPinnedVirtualId(tabId)) {
|
||||
setActivePinnedTabId(tabId)
|
||||
} else {
|
||||
setActivePinnedTabId(null)
|
||||
actions.activateTab(paneId, tabId)
|
||||
}
|
||||
},
|
||||
closeTab: (paneId, tabId) => {
|
||||
if (isPinnedVirtualId(tabId)) {
|
||||
closePinnedInHome(tabId)
|
||||
} else {
|
||||
actions.closeTab(paneId, tabId)
|
||||
}
|
||||
},
|
||||
moveTabBefore: (payload, toPane, beforeTabId) => {
|
||||
if (isPinnedVirtualId(payload.tabId)) return
|
||||
if (isPinnedVirtualId(beforeTabId)) {
|
||||
actions.moveTabToEdge(payload, toPane, 'center')
|
||||
} else {
|
||||
actions.moveTabBefore(payload, toPane, beforeTabId)
|
||||
}
|
||||
},
|
||||
moveTabToEdge: (payload, toPane, zone) => {
|
||||
if (isPinnedVirtualId(payload.tabId)) return
|
||||
actions.moveTabToEdge(payload, toPane, zone)
|
||||
},
|
||||
floatTab: (tabId) => {
|
||||
if (isPinnedVirtualId(tabId)) return
|
||||
actions.floatTab(tabId)
|
||||
},
|
||||
pinTab: (tabId, scope) => {
|
||||
if (isPinnedVirtualId(tabId)) {
|
||||
if (scope !== null) return
|
||||
const { homeSessionId, tabId: originalId } = parsePinnedVirtualId(tabId)
|
||||
store.reduceFor(homeSessionId, s => setTabPin(s, originalId, null))
|
||||
if (activePinnedTabId === tabId) setActivePinnedTabId(null)
|
||||
setPinnedRevision(v => v + 1)
|
||||
} else {
|
||||
actions.pinTab?.(tabId, scope)
|
||||
}
|
||||
},
|
||||
}
|
||||
}, [actions, pinnedVirtualTabs, activePinnedTabId, store])
|
||||
// Pinned virtual tabs (sidebar/use-pinned-tabs.ts): cross-session pinned
|
||||
// tabs inject into the right panel's first leaf, and the actions are
|
||||
// wrapped so pinned virtual ids route to the HOME session (reduceFor +
|
||||
// revision bump).
|
||||
const { augmentedTree, wrappedActions } = usePinnedTabs({ store, sessionId, cwd, snapshot, actions })
|
||||
|
||||
/**
|
||||
* The explorer's @-reference button. Directories append the folder mention
|
||||
@@ -1792,39 +1156,21 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
</div>
|
||||
)}
|
||||
{/*
|
||||
Free windows: tabs dragged out onto the conversation area (or floated
|
||||
from the tab context menu). They live in the panel host like the
|
||||
panels (viewport coordinates, immune to desktop-shell transforms) but
|
||||
are independent of panel state — a window stays up while panels
|
||||
collapse. The floats array's order is the stacking order; the content
|
||||
reuses the regular tab renderer, so every tab type floats unchanged.
|
||||
Free windows + the drag-out hint: the layer renders the floats (the
|
||||
array's order is the stacking order) reusing the regular tab
|
||||
renderer, followed by the dashed drop-zone overlay (sidebar/
|
||||
free-windows.tsx renders both as one fragment — DOM unchanged).
|
||||
*/}
|
||||
{state.floats.map(float => (
|
||||
<FreeWindow
|
||||
key={float.id}
|
||||
float={float}
|
||||
renderTab={(tab, active, paneId) => renderTab(tab, active, paneId, 'float')}
|
||||
getTabIcon={tabIconOf}
|
||||
onRaise={() => { store.reduce(s => raiseFloat(s, float.id)) }}
|
||||
onMove={(x, y) => { store.reduce(s => moveFloat(s, float.id, x, y)) }}
|
||||
onResize={(w, h) => { store.reduce(s => resizeFloat(s, float.id, w, h)) }}
|
||||
onDock={(paneId) => { store.reduce(s => dockFloat(s, float.id, paneId ?? undefined)) }}
|
||||
onClose={() => { ctx.get('betterSidebar')?.closeTab(float.tab.id, sessionId === undefined ? undefined : { sessionId, cwd }) }}
|
||||
/>
|
||||
))}
|
||||
{/*
|
||||
The drag-out hint: while a tab drag hovers the conversation column,
|
||||
a dashed overlay marks the drop zone there (pointer-transparent — it
|
||||
must not disturb the drag it describes).
|
||||
*/}
|
||||
{floatHint !== null && (
|
||||
<div
|
||||
className={css.floatDropHint}
|
||||
style={{ left: floatHint.left, top: floatHint.top, width: floatHint.width, height: floatHint.height }}
|
||||
>
|
||||
<span className={css.floatDropHintLabel}>{t('floatDropHint')}</span>
|
||||
</div>
|
||||
)}
|
||||
<FreeWindowLayer
|
||||
floats={state.floats}
|
||||
hint={floatHint}
|
||||
renderTab={renderTab}
|
||||
getTabIcon={tabIconOf}
|
||||
store={store}
|
||||
ctx={ctx}
|
||||
sessionId={sessionId}
|
||||
cwd={cwd}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+26
-37
@@ -53,6 +53,7 @@ import {
|
||||
type TreeJob,
|
||||
} from './subagent-jobs.ts'
|
||||
import { api, type JobOutputResult } from './api.ts'
|
||||
import { usePolling } from './use-polling.ts'
|
||||
import { IconStopOutline16 } from './icons.tsx'
|
||||
import { t } from './locales.ts'
|
||||
import css from './SubagentView.module.css'
|
||||
@@ -180,52 +181,30 @@ function SubagentLiveLines(props: { live: LastActivity | undefined }) {
|
||||
/**
|
||||
* One shared live-preview poller for the whole Subagent tree. Unlike the old
|
||||
* per-card `subagents.history` timers, this sends at most ONE `subagents.live`
|
||||
* request at a time: a recursive timeout starts only after the previous
|
||||
* request settles, so a slow host never sees abort/restart storms.
|
||||
* request at a time (the shared poller's self-scheduling mode arms the next
|
||||
* tick only after the previous request settles, so a slow host never sees
|
||||
* abort/restart storms); a response settling after the poller stopped (page
|
||||
* hidden, tree re-rooted) is dropped via the aborted signal.
|
||||
*/
|
||||
function useSubagentLive(
|
||||
rootId: string | undefined,
|
||||
active: boolean,
|
||||
): Readonly<Record<string, LastActivity>> {
|
||||
const [live, setLive] = useState<Record<string, LastActivity>>({})
|
||||
const controllerRef = useRef<AbortController | undefined>(undefined)
|
||||
|
||||
// A new tree must never inherit another root's live previews.
|
||||
useEffect(() => { setLive({}) }, [rootId])
|
||||
|
||||
useEffect(() => {
|
||||
if (rootId === undefined || !active) return
|
||||
const targetRootId = rootId
|
||||
let disposed = false
|
||||
let timer: number | undefined
|
||||
|
||||
const schedule = (): void => {
|
||||
if (disposed) return
|
||||
timer = window.setTimeout(() => { void load() }, POLL_MS)
|
||||
}
|
||||
async function load(): Promise<void> {
|
||||
if (disposed) return
|
||||
const controller = new AbortController()
|
||||
controllerRef.current = controller
|
||||
try {
|
||||
const result = await api.subagentsLive(targetRootId, controller.signal)
|
||||
if (!disposed) setLive(result.live)
|
||||
} catch {
|
||||
// Keep the last known live map; the next scheduled poll retries.
|
||||
} finally {
|
||||
if (controllerRef.current === controller) controllerRef.current = undefined
|
||||
if (!disposed) schedule()
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
return () => {
|
||||
disposed = true
|
||||
if (timer !== undefined) window.clearTimeout(timer)
|
||||
controllerRef.current?.abort()
|
||||
controllerRef.current = undefined
|
||||
}
|
||||
}, [rootId, active])
|
||||
const poll = useCallback(async (signal: AbortSignal): Promise<void> => {
|
||||
if (rootId === undefined) return
|
||||
const result = await api.subagentsLive(rootId, signal)
|
||||
if (!signal.aborted) setLive(result.live)
|
||||
}, [rootId])
|
||||
usePolling(rootId !== undefined && active, poll, {
|
||||
intervalMs: POLL_MS,
|
||||
mode: 'self-scheduling',
|
||||
immediate: true,
|
||||
})
|
||||
|
||||
return live
|
||||
}
|
||||
@@ -415,6 +394,9 @@ function JobOutputPane(props: {
|
||||
if (!active || !isJobLive(job)) return
|
||||
const timer = window.setInterval(() => { void load() }, JOB_POLL_MS)
|
||||
return () => { window.clearInterval(timer) }
|
||||
// isJobLive reads only job.status; whole-job identity churns on every
|
||||
// catalog refresh and must not restart the poll interval.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [load, active, job.status])
|
||||
|
||||
useEffect(() => () => { controllerRef.current?.abort() }, [])
|
||||
@@ -425,6 +407,8 @@ function JobOutputPane(props: {
|
||||
if (!isJobLive(job) || typeof state !== 'object' || state.text.length === 0) return
|
||||
const pre = preRef.current
|
||||
if (pre !== null) pre.scrollTop = pre.scrollHeight
|
||||
// Same as the poll effect above: only the status transition matters.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state, job.status])
|
||||
|
||||
return (
|
||||
@@ -656,7 +640,9 @@ export function SubagentView(props: {
|
||||
useCallback(() => sessions.list.getSnapshot(), [sessions]),
|
||||
)
|
||||
const byId = list.byId
|
||||
const catalogs = list.subagentsByParent ?? {}
|
||||
// Memoized so the empty-catalog fallback keeps a stable identity — a fresh
|
||||
// `{}` per render would invalidate every catalog-dependent memo/effect.
|
||||
const catalogs = useMemo(() => list.subagentsByParent ?? {}, [list.subagentsByParent])
|
||||
|
||||
// The topology root: the main agent of the current session's tree.
|
||||
const rootId = useMemo(() => rootAncestor(byId, sessionId), [byId, sessionId])
|
||||
@@ -680,6 +666,9 @@ export function SubagentView(props: {
|
||||
if (rootId === undefined || !active) return
|
||||
observe(rootId, true)
|
||||
return () => {
|
||||
// The cleanup must release everything observed AT cleanup time (the set
|
||||
// mutates as branches open), so reading the ref here is the point.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
for (const parentSessionId of observedRef.current) {
|
||||
sessions.setSubagentCatalogOpen?.(parentSessionId, false)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import type { SidebarTab } from './state.ts'
|
||||
import { isAgentTabId } from './state.ts'
|
||||
import { isPinnedVirtualTab } from './pinned.ts'
|
||||
import { useSubmenuFlip } from './menu-flip.ts'
|
||||
import { IconPinOutline16 } from './icons.tsx'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
@@ -90,6 +91,9 @@ export function TabBar(props: {
|
||||
// The tab right-click context menu: the target tab plus the cursor
|
||||
// position (the portaled Menu anchors there, following the git lens/FileTree).
|
||||
const [tabMenu, setTabMenu] = useState<{ tabId: string; x: number; y: number } | null>(null)
|
||||
// The strip sits at the panel's top, so the pin submenu must grow downward;
|
||||
// the flip hook derives that from the cursor y (upper half → "down").
|
||||
useSubmenuFlip(tabMenu)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
// The context target's index in the render-time tab snapshot; -1 when the
|
||||
@@ -265,6 +269,7 @@ export function TabBar(props: {
|
||||
setMenuOpen(false)
|
||||
}}
|
||||
portal
|
||||
compact
|
||||
align="end"
|
||||
anchor={(
|
||||
<button
|
||||
@@ -355,6 +360,7 @@ export function TabBar(props: {
|
||||
}
|
||||
}}
|
||||
portal
|
||||
compact
|
||||
align="start"
|
||||
getAnchorRect={() => (tabMenu === null ? null : new DOMRect(tabMenu.x, tabMenu.y, 0, 0))}
|
||||
anchor={<span />}
|
||||
|
||||
+14
-13
@@ -37,6 +37,7 @@ import { FitAddon } from '@xterm/addon-fit'
|
||||
import { writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import { t } from './locales.ts'
|
||||
import { ONE_DARK, ONE_LIGHT } from './one-dark-palette.ts'
|
||||
import { openWhenSized } from './open-when-sized.ts'
|
||||
import { api, type SessionScope, type TerminalDepsStatus } from './api.ts'
|
||||
import { agentUuidOf, isAgentTabId, type SidebarStore } from './state.ts'
|
||||
@@ -67,24 +68,24 @@ type TerminalDepsInfo = Extract<TerminalDepsStatus, { ok: false }>
|
||||
* Curated ANSI palettes for the terminal. The surface colors (background,
|
||||
* foreground, cursor, selection) ride the theme tokens so the terminal
|
||||
* blends with the panel in both schemes; the 16 ANSI colors are the same
|
||||
* designed palettes the app's code surfaces use (one-dark family for dark,
|
||||
* one-light family for light), read live so a scheme flip re-themes in
|
||||
* place.
|
||||
* designed syntax families the app's code surfaces use — one-dark for
|
||||
* dark, one-light for light (one-dark-palette.ts, shared with the
|
||||
* CodeMirror themes) — read live so a scheme flip re-themes in place.
|
||||
*/
|
||||
const ANSI_DARK: Record<string, string> = {
|
||||
black: '#282c34', red: '#e06c75', green: '#98c379', yellow: '#e5c07b',
|
||||
blue: '#61afef', magenta: '#c678dd', cyan: '#56b6c2', white: '#abb2bf',
|
||||
brightBlack: '#5c6370', brightRed: '#e06c75', brightGreen: '#98c379',
|
||||
brightYellow: '#e5c07b', brightBlue: '#61afef', brightMagenta: '#c678dd',
|
||||
brightCyan: '#56b6c2', brightWhite: '#ffffff',
|
||||
black: ONE_DARK.black, red: ONE_DARK.red, green: ONE_DARK.green, yellow: ONE_DARK.yellow,
|
||||
blue: ONE_DARK.blue, magenta: ONE_DARK.magenta, cyan: ONE_DARK.cyan, white: ONE_DARK.gray,
|
||||
brightBlack: ONE_DARK.faintGray, brightRed: ONE_DARK.red, brightGreen: ONE_DARK.green,
|
||||
brightYellow: ONE_DARK.yellow, brightBlue: ONE_DARK.blue, brightMagenta: ONE_DARK.magenta,
|
||||
brightCyan: ONE_DARK.cyan, brightWhite: ONE_DARK.white,
|
||||
}
|
||||
|
||||
const ANSI_LIGHT: Record<string, string> = {
|
||||
black: '#383a42', red: '#e45649', green: '#50a14f', yellow: '#c18401',
|
||||
blue: '#0184bc', magenta: '#a626a4', cyan: '#0997b3', white: '#a0a1a7',
|
||||
brightBlack: '#4f525e', brightRed: '#e45649', brightGreen: '#50a14f',
|
||||
brightYellow: '#c18401', brightBlue: '#0184bc', brightMagenta: '#a626a4',
|
||||
brightCyan: '#0997b3', brightWhite: '#fafafa',
|
||||
black: ONE_LIGHT.black, red: ONE_LIGHT.red, green: ONE_LIGHT.green, yellow: ONE_LIGHT.yellow,
|
||||
blue: ONE_LIGHT.blue, magenta: ONE_LIGHT.magenta, cyan: ONE_LIGHT.cyan, white: ONE_LIGHT.gray,
|
||||
brightBlack: ONE_LIGHT.faintGray, brightRed: ONE_LIGHT.red, brightGreen: ONE_LIGHT.green,
|
||||
brightYellow: ONE_LIGHT.yellow, brightBlue: ONE_LIGHT.blue, brightMagenta: ONE_LIGHT.magenta,
|
||||
brightCyan: ONE_LIGHT.cyan, brightWhite: ONE_LIGHT.offWhite,
|
||||
}
|
||||
|
||||
/** The xterm theme for the current scheme (surface from tokens, ANSI curated). */
|
||||
|
||||
+14
-14
@@ -31,27 +31,18 @@ import { SandboxStatusBar } from './SandboxStatusBar.tsx'
|
||||
import { appendToDraft } from './conversation-draft.ts'
|
||||
import { useSelectionPopup } from './selection-popup.ts'
|
||||
import { buildSelectionInsert, linesOfSelection } from './selection-payload.ts'
|
||||
import { lazyChunkComponent } from './lazy-chunk.tsx'
|
||||
import { analyzeMarkdownHtml } from './markdown-html.ts'
|
||||
import { LazyMermaidMarkdown, MarkdownDocument, type MarkdownHtmlMedia } from './MarkdownHtml.tsx'
|
||||
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
|
||||
@@ -117,13 +108,16 @@ export function TextEditor(props: FileViewerProps) {
|
||||
setDirty(false)
|
||||
setSaveState('idle')
|
||||
selectionPopup.hide()
|
||||
// hide() reads a live ref; the reset must fire only on a content (file)
|
||||
// swap, and the hook object's identity churns on every render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [content])
|
||||
|
||||
// A different file switches the remembered preview scroll position to that
|
||||
// file's own entry (first open: none, so the preview starts at the top).
|
||||
useEffect(() => {
|
||||
previewScrollRef.current = previewScrollMemory.get(previewScrollKey(scope, path)) ?? 0
|
||||
}, [path])
|
||||
}, [scope, path])
|
||||
|
||||
// Create the CodeMirror editor once the content is loaded. The view owns
|
||||
// the document; React only tracks dirty state through the update listener
|
||||
@@ -220,6 +214,7 @@ export function TextEditor(props: FileViewerProps) {
|
||||
// The keymap's save() reads live refs; scope/path are stable for a
|
||||
// tab's lifetime, and the dark flip is handled by the reconfigure
|
||||
// effect below (recreating the view here would drop the draft).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [content, path])
|
||||
|
||||
// Scheme flip: re-theme in place (the compartment holds only the
|
||||
@@ -276,6 +271,9 @@ export function TextEditor(props: FileViewerProps) {
|
||||
view.requestMeasure()
|
||||
})
|
||||
})
|
||||
// The reveal reads the live document/view refs; only the flip into
|
||||
// preview triggers it.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mode])
|
||||
|
||||
// Snapshot the live document into the draft whenever the preview needs
|
||||
@@ -340,9 +338,11 @@ export function TextEditor(props: FileViewerProps) {
|
||||
() => (markdown && mode === 'preview' ? splitMermaidBlocks(previewMdText) : []),
|
||||
[markdown, mode, previewMdText],
|
||||
)
|
||||
/** Raw-HTML analysis (block runs lifted out + inline gate). Non-null only
|
||||
* for documents that actually contain HTML — plain markdown keeps the
|
||||
* legacy single-pass render path below, byte-for-byte. */
|
||||
/** Raw-HTML analysis (block runs lifted out + inline gate). Non-null for
|
||||
* every markdown preview, so the render below always takes the split
|
||||
* renderer — its markdown runs rewrite local image destinations internally
|
||||
* (see MarkdownHtml.tsx). The legacy single-pass branches (fed the
|
||||
* pre-rewritten `previewText`) are dead in the current wiring. */
|
||||
const htmlInfo = useMemo(
|
||||
() => (markdown && mode === 'preview' ? analyzeMarkdownHtml(previewMdText) : null),
|
||||
[markdown, mode, previewMdText],
|
||||
|
||||
@@ -63,11 +63,15 @@ export function TreePanel(props: {
|
||||
onOpenWith?: (targetId: string, path: string) => void
|
||||
onToggleOpenWithPin?: (targetId: string) => void
|
||||
onReferenceFile: (path: string, isDir: boolean) => void
|
||||
/** A tree rename landed (passed through to FileTree for tab retargeting). */
|
||||
onPathRenamed?: (oldPath: string, newPath: string) => void
|
||||
/** A tree delete landed (passed through to FileTree for tab closing). */
|
||||
onPathDeleted?: (path: string, isDir: boolean) => void
|
||||
/** Full-window presentation: the panel fills its host instead of docking
|
||||
* at a fixed width. */
|
||||
full?: boolean
|
||||
}) {
|
||||
const { sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, full } = props
|
||||
const { sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, onPathRenamed, onPathDeleted, full } = props
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<{ matches: string[]; truncated: boolean } | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -252,6 +256,8 @@ export function TreePanel(props: {
|
||||
onOpenWith={onOpenWith}
|
||||
onToggleOpenWithPin={onToggleOpenWithPin}
|
||||
onReferenceFile={onReferenceFile}
|
||||
onPathRenamed={onPathRenamed}
|
||||
onPathDeleted={onPathDeleted}
|
||||
refreshTick={refreshTick}
|
||||
onUploadRequest={startUpload}
|
||||
busy={busy}
|
||||
|
||||
@@ -43,7 +43,7 @@ const COPIED_FEEDBACK_MS = 1500
|
||||
/** The modal body: the GitHub topic button + the recommended plugin list
|
||||
* with per-entry jump/copy buttons (extracted for direct testing). */
|
||||
export function PluginListBody(props: { service: BetterSidebarService; kind: PluginKind }) {
|
||||
const { service, kind } = props
|
||||
const { kind } = props
|
||||
// Which entry's copy button currently shows the "已复制" feedback.
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null)
|
||||
// Live catalog filter (name / id / description). A free-text search keeps
|
||||
@@ -56,8 +56,9 @@ export function PluginListBody(props: { service: BetterSidebarService; kind: Plu
|
||||
const needle = query.trim().toLowerCase()
|
||||
const matches = (entry: PluginEntry): boolean => {
|
||||
if (needle === '') return true
|
||||
const name = typeof entry.name === 'function' ? entry.name() : entry.name
|
||||
const description = typeof entry.description === 'function' ? entry.description() : entry.description
|
||||
return entry.name.toLowerCase().includes(needle)
|
||||
return name.toLowerCase().includes(needle)
|
||||
|| entry.id.toLowerCase().includes(needle)
|
||||
|| description.toLowerCase().includes(needle)
|
||||
}
|
||||
@@ -93,8 +94,12 @@ export function PluginListBody(props: { service: BetterSidebarService; kind: Plu
|
||||
window.open(entry.url, '_blank', 'noopener')
|
||||
}
|
||||
|
||||
/** One catalog row (extracted so the group render stays flat). */
|
||||
const renderEntry = (entry: PluginEntry): ReactNode => (
|
||||
/** One catalog row (extracted so the group render stays flat). The name
|
||||
* resolves like the description (string or () => string) so it follows
|
||||
* the active locale; a plain-string entry keeps its raw name. */
|
||||
const renderEntry = (entry: PluginEntry): ReactNode => {
|
||||
const name = typeof entry.name === 'function' ? entry.name() : entry.name
|
||||
return (
|
||||
<div key={entry.id} className={css.pluginEntry}>
|
||||
<div className={css.pluginEntryHead}>
|
||||
{/* The name is a BUTTON on the same window.open path as the
|
||||
@@ -104,16 +109,16 @@ export function PluginListBody(props: { service: BetterSidebarService; kind: Plu
|
||||
<button
|
||||
type="button"
|
||||
className={css.pluginName}
|
||||
aria-label={`${t('openPlugin')}: ${entry.name}`}
|
||||
aria-label={`${t('openPlugin')}: ${name}`}
|
||||
onClick={() => { jump(entry) }}
|
||||
>
|
||||
{entry.name}
|
||||
{name}
|
||||
</button>
|
||||
<span className={css.pluginEntryActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.pluginJumpBtn}
|
||||
aria-label={`${t('openPlugin')}: ${entry.name}`}
|
||||
aria-label={`${t('openPlugin')}: ${name}`}
|
||||
onClick={() => { jump(entry) }}
|
||||
>
|
||||
{t('openPlugin')}
|
||||
@@ -121,7 +126,7 @@ export function PluginListBody(props: { service: BetterSidebarService; kind: Plu
|
||||
<button
|
||||
type="button"
|
||||
className={css.pluginCopyBtn}
|
||||
aria-label={`${t('copyInstall')}: ${entry.name}`}
|
||||
aria-label={`${t('copyInstall')}: ${name}`}
|
||||
onClick={() => { copy(entry) }}
|
||||
>
|
||||
{copiedId === entry.id ? t('copied') : t('copy')}
|
||||
@@ -133,7 +138,8 @@ export function PluginListBody(props: { service: BetterSidebarService; kind: Plu
|
||||
</div>
|
||||
<code className={css.pluginInstall}>{entry.install}</code>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.pluginList}>
|
||||
|
||||
@@ -278,6 +278,15 @@ export const api = {
|
||||
call<FsTextResult | FsBinaryResult>('fs.read', scopePayload(scope, { path }), signal),
|
||||
fsWrite: (scope: SessionScope, path: string, content: string) =>
|
||||
call<{ ok: true }>('fs.write', scopePayload(scope, { path, content })),
|
||||
/** Rename one tree row within its directory (single-segment name; the
|
||||
* server refuses existing destinations, the workspace root, and — while
|
||||
* the fence is armed — anything resolving outside the workspace). */
|
||||
fsRename: (scope: SessionScope, path: string, name: string) =>
|
||||
call<{ path: string }>('fs.rename', scopePayload(scope, { path, name })),
|
||||
/** Permanently delete one tree row (recursive for directories; a symlink
|
||||
* row unlinks the link only). The UI confirms before calling this. */
|
||||
fsRemove: (scope: SessionScope, path: string) =>
|
||||
call<{ path: string }>('fs.remove', scopePayload(scope, { path })),
|
||||
/** Upload one file's raw bytes into `dir` (keeps the folder tree via
|
||||
* `relativePath`); the host streams it under the session workspace. */
|
||||
uploadFile: (scope: SessionScope, dir: string, relativePath: string, body: Blob, signal?: AbortSignal) =>
|
||||
@@ -307,6 +316,11 @@ export const api = {
|
||||
/** Full patch text of one commit (diff display for the history rows). */
|
||||
gitCommitDiff: (scope: SessionScope, hash: string, worktree?: string, signal?: AbortSignal) =>
|
||||
call<{ diff: string }>('git.commit-diff', gitPayload(scope, worktree, { hash }), signal),
|
||||
/** One file's content at a revision (`git show <rev>:<path>`); null when the
|
||||
* revision has no such path. The diff views' on-demand hunk-fold expansion
|
||||
* reads both sides' full contents through this. */
|
||||
gitShow: (scope: SessionScope, rev: string, path: string, worktree?: string, signal?: AbortSignal) =>
|
||||
call<{ content: string | null }>('git.show', gitPayload(scope, worktree, { rev, path }), signal),
|
||||
/** The session's file-tool events for the changes tab's session lens: the
|
||||
* `tool/call` + `tool/result` rows past `afterSeq` (0 = whole window),
|
||||
* capped to the recent window host-side. The client runtime exposes no
|
||||
|
||||
@@ -180,7 +180,7 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
|
||||
],
|
||||
}],
|
||||
},
|
||||
component: ({ ctx, store, scope, tab, visible, onOpenFile, onOpenDiff }) => (
|
||||
component: ({ ctx, store, scope, tab, visible, onOpenDiff }) => (
|
||||
<ChangesTab
|
||||
ctx={ctx}
|
||||
store={store}
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { SidebarSessionEvent } from '../../context-types.ts'
|
||||
import type { TabComponentProps } from '../service.ts'
|
||||
import { t } from '../locales.ts'
|
||||
import { api } from '../api.ts'
|
||||
import { usePolling } from '../use-polling.ts'
|
||||
import { floatTab, type SidebarDiffRef } from '../state.ts'
|
||||
import { GitLens } from './GitLens.tsx'
|
||||
import { SessionLens } from './SessionLens.tsx'
|
||||
@@ -93,6 +94,9 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
|
||||
// only while nothing has ever loaded.
|
||||
if (generation === pollGen.current) setOpsError(true)
|
||||
}
|
||||
// Granular scope fields: the scope object's identity churns, only its
|
||||
// sessionId / cwd fields gate the poll target.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [scope.sessionId, scope.cwd])
|
||||
useEffect(() => {
|
||||
pollGen.current += 1
|
||||
@@ -101,12 +105,11 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
|
||||
seqRef.current = 0
|
||||
setOpsError(false)
|
||||
}, [scope.sessionId])
|
||||
useEffect(() => {
|
||||
void pull()
|
||||
if (!visible) return
|
||||
const timer = window.setInterval(() => { void pull() }, 2_500)
|
||||
return () => { window.clearInterval(timer) }
|
||||
}, [visible, pull])
|
||||
// One pull on every input change — mount, scope change, and visibility
|
||||
// flip all re-pull (a hidden tab still catches up on the flip); only the
|
||||
// poll CADENCE below is gated by visibility.
|
||||
useEffect(() => { void pull() }, [visible, pull])
|
||||
usePolling(visible, pull, { intervalMs: 2_500 })
|
||||
// tick only forces the re-render; the fold reads the ref directly.
|
||||
void tick
|
||||
const ops = opsRef.current
|
||||
|
||||
@@ -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 { buildDiffSegments, diffLines, diffStats, displayPath, foldRowsFromContents, parseUnifiedDiff, unifiedSegments, type DiffFile, type DiffRow, type FoldSegment } from '../diff/rows.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') {
|
||||
@@ -92,7 +158,19 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [diffText, setDiffText] = useState<string | null>(null)
|
||||
const [untracked, setUntracked] = useState<string | undefined>(undefined)
|
||||
// The staged flag of the side ACTUALLY rendered: when the requested side's
|
||||
// diff came back empty the load falls back to the other side, and the fold
|
||||
// expansion must read that side's revisions (else the sliced line numbers
|
||||
// land on the wrong contents).
|
||||
const [effectiveStaged, setEffectiveStaged] = useState<boolean | null>(null)
|
||||
const gitRef = target.kind === 'git' ? target.ref : null
|
||||
// The scope every git call of this target shares (repoRoot folded in when
|
||||
// the ref carries one, exactly like the load effect's paneScope).
|
||||
const gitScope = useMemo<SessionScope>(() => ({
|
||||
sessionId: scope.sessionId,
|
||||
cwd: scope.cwd,
|
||||
...(gitRef?.repoRoot !== undefined ? { repoRoot: gitRef.repoRoot } : {}),
|
||||
}), [scope.sessionId, scope.cwd, gitRef?.repoRoot])
|
||||
|
||||
useEffect(() => {
|
||||
if (gitRef === null) return
|
||||
@@ -106,6 +184,7 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
|
||||
setError(null)
|
||||
setDiffText(null)
|
||||
setUntracked(undefined)
|
||||
setEffectiveStaged(null)
|
||||
const load = async (): Promise<void> => {
|
||||
try {
|
||||
if (gitRef.kind === 'commit') {
|
||||
@@ -118,7 +197,10 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
|
||||
// The requested side is empty — try the OTHER side once (the change
|
||||
// may have moved sides after the preview target was minted).
|
||||
const other = await api.gitDiff(paneScope, gitRef.path, !gitRef.staged, gitRef.worktree)
|
||||
if (other.diff !== '') result = other
|
||||
if (other.diff !== '') {
|
||||
result = other
|
||||
if (!cancelled) setEffectiveStaged(!gitRef.staged)
|
||||
}
|
||||
}
|
||||
if (result.diff !== '') {
|
||||
if (!cancelled) setDiffText(result.diff)
|
||||
@@ -145,10 +227,119 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
|
||||
return () => { cancelled = true }
|
||||
}, [gitRef, scope.sessionId, scope.cwd, tick])
|
||||
|
||||
// ── On-demand git fold expansion: a fold's hidden rows come from both
|
||||
// sides' full contents (git.show / fsRead), fetched ONCE per file so
|
||||
// sibling folds share the request, then sliced by each fold's line
|
||||
// ranges. The cache dies with the target or a refresh tick. ───────────
|
||||
const foldContents = useRef(new Map<string, Promise<{ old: string; new: string }>>())
|
||||
useEffect(() => { foldContents.current = new Map() }, [gitRef, tick])
|
||||
const foldLoader = useMemo(() => {
|
||||
if (gitRef === null) return undefined
|
||||
const sidesOf = (file: DiffFile): Promise<{ old: string; new: string }> => {
|
||||
// Both sides empty cannot cover a non-empty fold — treat it as a failed
|
||||
// fetch so the fold degrades to the unavailable marker instead of
|
||||
// silently expanding to nothing (the symptom of a bad rev or path
|
||||
// reading null on both sides).
|
||||
const ofSides = (oldContent: string | null, newContent: string | null): { old: string; new: string } => {
|
||||
if ((oldContent ?? '') === '' && (newContent ?? '') === '') throw new Error('no content on either side')
|
||||
return { old: oldContent ?? '', new: newContent ?? '' }
|
||||
}
|
||||
const fetchSides = async (): Promise<{ old: string; new: string }> => {
|
||||
if (gitRef.kind === 'commit') {
|
||||
// The patch's -m --first-parent shape: old side from the parent,
|
||||
// new side from the commit (a root commit's parent read fails → '').
|
||||
const [oldSide, newSide] = await Promise.all([
|
||||
file.oldPath === '/dev/null'
|
||||
? Promise.resolve({ content: null })
|
||||
: api.gitShow(gitScope, `${gitRef.hashFull}^`, displayPath(file.oldPath), gitRef.worktree),
|
||||
file.newPath === '/dev/null'
|
||||
? Promise.resolve({ content: null })
|
||||
: api.gitShow(gitScope, gitRef.hashFull, displayPath(file.newPath), gitRef.worktree),
|
||||
])
|
||||
return ofSides(oldSide.content, newSide.content)
|
||||
}
|
||||
// Worktree change: staged is HEAD vs index, unstaged is index vs
|
||||
// worktree (the worktree side reads the live file).
|
||||
const staged = effectiveStaged ?? gitRef.staged
|
||||
if (staged) {
|
||||
const [oldSide, newSide] = await Promise.all([
|
||||
file.oldPath === '/dev/null'
|
||||
? Promise.resolve({ content: null })
|
||||
: api.gitShow(gitScope, 'HEAD', displayPath(file.oldPath), gitRef.worktree),
|
||||
file.newPath === '/dev/null'
|
||||
? Promise.resolve({ content: null })
|
||||
: api.gitShow(gitScope, ':0', displayPath(file.newPath), gitRef.worktree),
|
||||
])
|
||||
return ofSides(oldSide.content, newSide.content)
|
||||
}
|
||||
const [oldSide, worktree] = await Promise.all([
|
||||
file.oldPath === '/dev/null'
|
||||
? Promise.resolve({ content: null })
|
||||
: api.gitShow(gitScope, ':0', displayPath(file.oldPath), gitRef.worktree),
|
||||
api.fsRead(gitScope, resolveSidebarPath(gitRef.repoRoot ?? gitRef.worktree ?? scope.cwd, displayPath(file.newPath))).catch(() => null),
|
||||
])
|
||||
return ofSides(oldSide.content, worktree !== null && worktree.kind === 'text' ? worktree.content : null)
|
||||
}
|
||||
const path = displayPath(file.newPath === '/dev/null' ? file.oldPath : file.newPath)
|
||||
let promise = foldContents.current.get(path)
|
||||
if (promise === undefined) {
|
||||
promise = fetchSides()
|
||||
foldContents.current.set(path, promise)
|
||||
}
|
||||
return promise
|
||||
}
|
||||
return (file: DiffFile, segment: FoldSegment): Promise<readonly DiffRow[]> =>
|
||||
sidesOf(file).then(sides => foldRowsFromContents(segment, sides.old, sides.new))
|
||||
}, [gitRef, gitScope, effectiveStaged, scope])
|
||||
|
||||
// ── 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 +349,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 +505,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 +547,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">
|
||||
@@ -306,6 +589,7 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
|
||||
{diffText !== null && diffText !== '' && (
|
||||
<DiffFiles
|
||||
diff={diffText}
|
||||
resolveFold={foldLoader}
|
||||
untrackedPath={untracked !== undefined && target.ref.kind === 'worktree' ? target.ref.path : undefined}
|
||||
untrackedContent={untracked}
|
||||
/>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { GitLogEntry, GitStatusEntry, GitStatusResult, GitWorktree, SessionScope } from '../api.ts'
|
||||
import { api } from '../api.ts'
|
||||
import { usePolling } from '../use-polling.ts'
|
||||
import { baseName, isWithinWorkspace, relativeTo } from '../paths.ts'
|
||||
import { resolveSidebarPath } from '../produced-files.ts'
|
||||
import { relativeTime, t } from '../locales.ts'
|
||||
@@ -168,6 +169,9 @@ export function GitLens(props: GitLensProps) {
|
||||
} finally {
|
||||
if (options.loading && options.generation === refreshGeneration.current) setLoading(false)
|
||||
}
|
||||
// Granular scope fields: the scope object's identity churns, only its
|
||||
// sessionId / cwd fields gate the git target.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [scope.sessionId, scope.cwd, repoRoot])
|
||||
|
||||
const refresh = useCallback(async (silent = false): Promise<void> => {
|
||||
@@ -231,6 +235,9 @@ export function GitLens(props: GitLensProps) {
|
||||
} finally {
|
||||
refreshInFlight.current = false
|
||||
}
|
||||
// Granular scope fields: the scope object's identity churns, only its
|
||||
// sessionId / cwd fields gate the refresh target.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [scope.sessionId, scope.cwd, refreshTarget])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -274,11 +281,11 @@ export function GitLens(props: GitLensProps) {
|
||||
const generation = refreshGeneration.current += 1
|
||||
void refreshTarget(chosenPathRef.current ?? '', { loading: true, generation })
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!visible) return
|
||||
const timer = window.setInterval(() => { void refresh(true) }, 2_000)
|
||||
return () => { window.clearInterval(timer) }
|
||||
}, [visible, refresh])
|
||||
/** The silent poll tick (the status-only fast path between worktree
|
||||
* re-lists, see refresh) — fixed 2s cadence while visible, no initial
|
||||
* burst (mount and scope changes already refresh above). */
|
||||
const pollTick = useCallback((): Promise<void> => refresh(true), [refresh])
|
||||
usePolling(visible, pollTick, { intervalMs: 2_000 })
|
||||
|
||||
/** Append the next history page (lazy: only when the user asks for more). */
|
||||
const loadMoreLog = async (): Promise<void> => {
|
||||
@@ -664,6 +671,7 @@ export function GitLens(props: GitLensProps) {
|
||||
if (id === 'absolute') copy(resolveSidebarPath(repoRoot ?? selectedWorktree ?? scope.cwd, target.entry.path))
|
||||
}}
|
||||
portal
|
||||
compact
|
||||
align="start"
|
||||
getAnchorRect={() => (fileMenu === null ? null : new DOMRect(fileMenu.x, fileMenu.y, 0, 0))}
|
||||
anchor={<span />}
|
||||
@@ -721,6 +729,7 @@ export function GitLens(props: GitLensProps) {
|
||||
}
|
||||
}}
|
||||
portal
|
||||
compact
|
||||
align="start"
|
||||
getAnchorRect={() => (historyMenu === null ? null : new DOMRect(historyMenu.x, historyMenu.y, 0, 0))}
|
||||
anchor={<span />}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -23,8 +23,9 @@
|
||||
* arbitrary file names, so the plugin's own host route serves the chunks),
|
||||
* 2. read the factory from the global registry,
|
||||
* 3. call it with a require that resolves the platform externals through
|
||||
* `__DSH_MODULES__.import(spec)` — the seed-word branch, the one part of
|
||||
* the module system that is stable across versions.
|
||||
* the injected module system's `import(spec)` (the `ctx.modules` service)
|
||||
* — the seed-word branch, the one part of the module system that is
|
||||
* stable across versions.
|
||||
*
|
||||
* Caching contract (three layers, each with a failure path):
|
||||
* - In-memory: one in-flight promise per chunk, memoized until
|
||||
@@ -90,8 +91,7 @@ const CHUNK_REVALIDATE_TIMEOUT_MS = 5_000
|
||||
* The client module system surface this loader needs to resolve externals.
|
||||
* DSH 0.1.0-rc.8 provides it as the `ctx.modules` service (no page global
|
||||
* anymore); the plugin injects it at activation via
|
||||
* {@link setChunkModuleSystem}. The rc.7-era `window.__DSH_MODULES__` global
|
||||
* remains as a fallback so older hosts and the test harness keep working.
|
||||
* {@link setChunkModuleSystem}.
|
||||
*/
|
||||
export interface ChunkModuleSystem {
|
||||
import(specifier: string): Promise<unknown>
|
||||
@@ -123,12 +123,11 @@ export function setChunkModuleSystem(system: ChunkModuleSystem | undefined): voi
|
||||
}
|
||||
|
||||
/** Resolve the shell-installed module system (injected, then the plugin
|
||||
* global shared with chunk-bundle copies, then the rc.7 page global). */
|
||||
* global shared with chunk-bundle copies). */
|
||||
function moduleSystem(): ChunkModuleSystem | undefined {
|
||||
const g = globalThis as Record<string, unknown>
|
||||
return injectedModuleSystem
|
||||
?? g[MODULE_SYSTEM_GLOBAL] as ChunkModuleSystem | undefined
|
||||
?? (g as { __DSH_MODULES__?: ChunkModuleSystem }).__DSH_MODULES__
|
||||
}
|
||||
|
||||
/** The plugin-owned chunk factory registry the chunk scripts populate. */
|
||||
@@ -244,8 +243,7 @@ export async function loadChunk(name: ChunkName): Promise<ChunkExports> {
|
||||
if (revalidation !== null) await revalidation
|
||||
const cached = cache.get(name)
|
||||
if (cached !== undefined) return cached
|
||||
let task: Promise<ChunkExports>
|
||||
task = (async (): Promise<ChunkExports> => {
|
||||
const task: Promise<ChunkExports> = (async (): Promise<ChunkExports> => {
|
||||
const test = testLoaders.get(name)
|
||||
if (test !== undefined) return test()
|
||||
const modules = moduleSystem()
|
||||
|
||||
+44
-42
@@ -2,15 +2,17 @@
|
||||
* CodeMirror 6 theme pieces for the sidebar editor. The editor surface
|
||||
* (background, caret, gutter) rides the DSH theme tokens so it blends with
|
||||
* the panel in both schemes; only the syntax token colors need concrete
|
||||
* values, and those come from the same designed palettes the app's code
|
||||
* surfaces use — the one-dark family for dark, the one-light family for
|
||||
* light. The scheme flip reconfigures these via a compartment (see
|
||||
* TextEditor), so the document, undo history and scroll survive re-theming.
|
||||
* values, and those come from the same designed syntax families the app's
|
||||
* code surfaces use — one-dark for dark, one-light for light
|
||||
* (one-dark-palette.ts, shared with the terminal's ANSI palette). The
|
||||
* scheme flip reconfigures these via a compartment (see TextEditor), so
|
||||
* the document, undo history and scroll survive re-theming.
|
||||
*/
|
||||
import { Compartment } from '@codemirror/state'
|
||||
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
|
||||
import { tags, type Tag } from '@lezer/highlight'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { ONE_DARK, ONE_LIGHT } from './one-dark-palette.ts'
|
||||
|
||||
/** Token-driven surface shared by both schemes (pure CSS values). */
|
||||
export const cmSurfaceTheme = EditorView.theme({
|
||||
@@ -53,52 +55,52 @@ interface HighlightRule {
|
||||
fontStyle?: string
|
||||
}
|
||||
|
||||
/** one-dark syntax palette (mirrors @codemirror/theme-one-dark). */
|
||||
/** one-dark syntax rules (mirrors @codemirror/theme-one-dark; hues from one-dark-palette.ts). */
|
||||
const HIGHLIGHTS_DARK: HighlightRule[] = [
|
||||
{ tag: tags.comment, color: '#5c6370', fontStyle: 'italic' },
|
||||
{ tag: tags.keyword, color: '#c678dd' },
|
||||
{ tag: tags.string, color: '#98c379' },
|
||||
{ tag: tags.number, color: '#d19a66' },
|
||||
{ tag: tags.bool, color: '#d19a66' },
|
||||
{ tag: tags.atom, color: '#d19a66' },
|
||||
{ tag: tags.typeName, color: '#e5c07b' },
|
||||
{ tag: tags.className, color: '#e5c07b' },
|
||||
{ tag: tags.propertyName, color: '#e06c75' },
|
||||
{ tag: tags.function(tags.variableName), color: '#61afef' },
|
||||
{ tag: tags.variableName, color: '#e06c75' },
|
||||
{ tag: tags.operator, color: '#56b6c2' },
|
||||
{ tag: tags.tagName, color: '#e06c75' },
|
||||
{ tag: tags.attributeName, color: '#d19a66' },
|
||||
{ tag: tags.heading, color: '#e06c75', fontStyle: 'bold' },
|
||||
{ tag: tags.comment, color: ONE_DARK.faintGray, fontStyle: 'italic' },
|
||||
{ tag: tags.keyword, color: ONE_DARK.magenta },
|
||||
{ tag: tags.string, color: ONE_DARK.green },
|
||||
{ tag: tags.number, color: ONE_DARK.orange },
|
||||
{ tag: tags.bool, color: ONE_DARK.orange },
|
||||
{ tag: tags.atom, color: ONE_DARK.orange },
|
||||
{ tag: tags.typeName, color: ONE_DARK.yellow },
|
||||
{ tag: tags.className, color: ONE_DARK.yellow },
|
||||
{ tag: tags.propertyName, color: ONE_DARK.red },
|
||||
{ tag: tags.function(tags.variableName), color: ONE_DARK.blue },
|
||||
{ tag: tags.variableName, color: ONE_DARK.red },
|
||||
{ tag: tags.operator, color: ONE_DARK.cyan },
|
||||
{ tag: tags.tagName, color: ONE_DARK.red },
|
||||
{ tag: tags.attributeName, color: ONE_DARK.orange },
|
||||
{ tag: tags.heading, color: ONE_DARK.red, fontStyle: 'bold' },
|
||||
{ tag: tags.emphasis, fontStyle: 'italic' },
|
||||
{ tag: tags.strong, fontStyle: 'bold' },
|
||||
{ tag: tags.link, color: '#61afef', fontStyle: 'underline' },
|
||||
{ tag: tags.meta, color: '#e5c07b' },
|
||||
{ tag: tags.invalid, color: '#ffffff', fontStyle: 'bold' },
|
||||
{ tag: tags.link, color: ONE_DARK.blue, fontStyle: 'underline' },
|
||||
{ tag: tags.meta, color: ONE_DARK.yellow },
|
||||
{ tag: tags.invalid, color: ONE_DARK.white, fontStyle: 'bold' },
|
||||
]
|
||||
|
||||
/** one-light syntax palette (the light counterpart of one-dark). */
|
||||
/** one-light syntax rules (the light counterpart; hues from one-dark-palette.ts). */
|
||||
const HIGHLIGHTS_LIGHT: HighlightRule[] = [
|
||||
{ tag: tags.comment, color: '#a0a1a7', fontStyle: 'italic' },
|
||||
{ tag: tags.keyword, color: '#a626a4' },
|
||||
{ tag: tags.string, color: '#50a14f' },
|
||||
{ tag: tags.number, color: '#986801' },
|
||||
{ tag: tags.bool, color: '#0184bc' },
|
||||
{ tag: tags.atom, color: '#0184bc' },
|
||||
{ tag: tags.typeName, color: '#c18401' },
|
||||
{ tag: tags.className, color: '#c18401' },
|
||||
{ tag: tags.propertyName, color: '#e45649' },
|
||||
{ tag: tags.function(tags.variableName), color: '#c18401' },
|
||||
{ tag: tags.variableName, color: '#e45649' },
|
||||
{ tag: tags.operator, color: '#383a42' },
|
||||
{ tag: tags.tagName, color: '#e45649' },
|
||||
{ tag: tags.attributeName, color: '#986801' },
|
||||
{ tag: tags.heading, color: '#e45649', fontStyle: 'bold' },
|
||||
{ tag: tags.comment, color: ONE_LIGHT.gray, fontStyle: 'italic' },
|
||||
{ tag: tags.keyword, color: ONE_LIGHT.magenta },
|
||||
{ tag: tags.string, color: ONE_LIGHT.green },
|
||||
{ tag: tags.number, color: ONE_LIGHT.orange },
|
||||
{ tag: tags.bool, color: ONE_LIGHT.blue },
|
||||
{ tag: tags.atom, color: ONE_LIGHT.blue },
|
||||
{ tag: tags.typeName, color: ONE_LIGHT.yellow },
|
||||
{ tag: tags.className, color: ONE_LIGHT.yellow },
|
||||
{ tag: tags.propertyName, color: ONE_LIGHT.red },
|
||||
{ tag: tags.function(tags.variableName), color: ONE_LIGHT.yellow },
|
||||
{ tag: tags.variableName, color: ONE_LIGHT.red },
|
||||
{ tag: tags.operator, color: ONE_LIGHT.black },
|
||||
{ tag: tags.tagName, color: ONE_LIGHT.red },
|
||||
{ tag: tags.attributeName, color: ONE_LIGHT.orange },
|
||||
{ tag: tags.heading, color: ONE_LIGHT.red, fontStyle: 'bold' },
|
||||
{ tag: tags.emphasis, fontStyle: 'italic' },
|
||||
{ tag: tags.strong, fontStyle: 'bold' },
|
||||
{ tag: tags.link, color: '#4078f2', fontStyle: 'underline' },
|
||||
{ tag: tags.meta, color: '#c18401' },
|
||||
{ tag: tags.invalid, color: '#ffffff', fontStyle: 'bold' },
|
||||
{ tag: tags.link, color: ONE_LIGHT.link, fontStyle: 'underline' },
|
||||
{ tag: tags.meta, color: ONE_LIGHT.yellow },
|
||||
{ tag: tags.invalid, color: ONE_LIGHT.white, fontStyle: 'bold' },
|
||||
]
|
||||
|
||||
/** The scheme-dependent extension pair (surface tint + syntax highlight). */
|
||||
|
||||
@@ -203,6 +203,7 @@ export function appendToDraft(ctx: Context, sessionId: string, text: string): bo
|
||||
*/
|
||||
export function fileMention(relativePath: string): { mention: string; label: string } | undefined {
|
||||
const path = relativePath.replace(/[\\/]+$/, '')
|
||||
// eslint-disable-next-line no-control-regex -- rejecting control characters is the point of this guard
|
||||
if (/[\u0000-\u001f\u007f-\u009f"]/u.test(path)) return undefined
|
||||
const mention = /\s/u.test(path) ? `@"${path}"` : `@${path}`
|
||||
const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
* Untracked files produce no `git diff` output; the caller passes their
|
||||
* content to render as a full-file addition instead.
|
||||
*/
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { t } from '../locales.ts'
|
||||
import { diffStats, displayPath, parseUnifiedDiff, unifiedSegments, untrackedFile, type DiffFile } from './rows.ts'
|
||||
import { diffStats, displayPath, parseUnifiedDiff, unifiedSegments, untrackedFile, type DiffFile, type DiffRow, type FoldSegment } from './rows.ts'
|
||||
import { langOfPath } from './highlight.ts'
|
||||
import { DiffRows } from './DiffRows.tsx'
|
||||
import css from './diff.module.css'
|
||||
@@ -51,9 +51,14 @@ export interface DiffFilesProps {
|
||||
/** Untracked-file content: when present, renders as a full-file addition instead of parsing. */
|
||||
untrackedPath?: string
|
||||
untrackedContent?: string
|
||||
/** Fetch a git gap fold's hidden rows on demand (both sides' contents by
|
||||
* the fold's line ranges); forwarded to every file's DiffRows. Absent
|
||||
* folds without `rows` stay non-expandable (session-op diffs and
|
||||
* untracked additions always carry theirs). */
|
||||
resolveFold?: (file: DiffFile, segment: FoldSegment) => Promise<readonly DiffRow[]>
|
||||
}
|
||||
|
||||
export function DiffFiles({ diff, untrackedPath, untrackedContent }: DiffFilesProps) {
|
||||
export function DiffFiles({ diff, untrackedPath, untrackedContent, resolveFold }: DiffFilesProps) {
|
||||
const parsed = useMemo(() => {
|
||||
if (untrackedPath !== undefined) {
|
||||
return { files: [untrackedFile(untrackedPath, untrackedContent ?? '')] }
|
||||
@@ -63,6 +68,26 @@ export function DiffFiles({ diff, untrackedPath, untrackedContent }: DiffFilesPr
|
||||
const [expandedFiles, setExpandedFiles] = useState<Set<number>>(() => defaultExpandedFiles(parsed.files))
|
||||
useEffect(() => { setExpandedFiles(defaultExpandedFiles(parsed.files)) }, [parsed])
|
||||
|
||||
// One in-flight/resolved promise per file+fold key, so a fold re-clicked
|
||||
// after a remount (file header collapsed and re-expanded) resolves without
|
||||
// a second fetch and concurrent clicks join the same request. The cache
|
||||
// dies with `parsed` (a new diff text); rejected promises are dropped so a
|
||||
// later retry can go through.
|
||||
const foldCache = useRef(new Map<string, Promise<readonly DiffRow[]>>())
|
||||
useEffect(() => { foldCache.current = new Map() }, [parsed])
|
||||
const loadFold = (file: DiffFile, segment: FoldSegment): Promise<readonly DiffRow[]> => {
|
||||
if (resolveFold === undefined) return Promise.resolve([])
|
||||
const path = displayPath(file.newPath === '/dev/null' ? file.oldPath : file.newPath)
|
||||
const key = `${path}|${String(segment.oldStart)}|${String(segment.newStart)}`
|
||||
let promise = foldCache.current.get(key)
|
||||
if (promise === undefined) {
|
||||
promise = resolveFold(file, segment)
|
||||
foldCache.current.set(key, promise)
|
||||
promise.catch(() => { foldCache.current.delete(key) })
|
||||
}
|
||||
return promise
|
||||
}
|
||||
|
||||
// Segments and header stats computed once per file.
|
||||
const files = useMemo(
|
||||
() => parsed.files.map((file) => {
|
||||
@@ -107,7 +132,7 @@ export function DiffFiles({ diff, untrackedPath, untrackedContent }: DiffFilesPr
|
||||
)}
|
||||
</button>
|
||||
{expandable && fileExpanded && (
|
||||
<DiffRows segments={segments} lang={langOfPath(to)} />
|
||||
<DiffRows segments={segments} lang={langOfPath(to)} resolveFold={resolveFold !== undefined ? (segment) => loadFold(file, segment) : undefined} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,11 +4,14 @@
|
||||
* line gutters, rewrite (mod) tinting with intra-line character highlights,
|
||||
* lightweight syntax coloring, and long-line folding. Presentational — the
|
||||
* segments arrive precomputed (session ops via buildDiffSegments, git diffs
|
||||
* via unifiedSegments) so both producers render identically.
|
||||
* via unifiedSegments) so both producers render identically; the one
|
||||
* exception is git gap folds, whose rows git never emitted and a caller
|
||||
* supplies on demand through resolveFold (first click fetches, then they
|
||||
* toggle like any fold).
|
||||
*/
|
||||
import { useEffect, useMemo, useState, type ReactElement, type ReactNode } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState, type ReactElement, type ReactNode } from 'react'
|
||||
import { t } from '../locales.ts'
|
||||
import { coalesceInline, diffInline, MIN_FOLD, type DiffRow, type DiffSegment, type InlineDiff } from './rows.ts'
|
||||
import { coalesceInline, diffInline, MIN_FOLD, type DiffRow, type DiffSegment, type FoldSegment, type InlineDiff } from './rows.ts'
|
||||
import { hasBlockComment, isColored, scanLine, type CodeToken, type TokenType } from './highlight.ts'
|
||||
import css from './diff.module.css'
|
||||
|
||||
@@ -105,21 +108,36 @@ export interface DiffRowsProps {
|
||||
segments: readonly DiffSegment[]
|
||||
/** Syntax language id (langOfPath); undefined renders plain text. */
|
||||
lang?: string
|
||||
/** Fetch a git gap fold's hidden rows on demand (both sides' contents
|
||||
* sliced by the fold's line ranges). Absent folds without `rows` — git's
|
||||
* unemitted gaps without a resolver — stay non-expandable markers. */
|
||||
resolveFold?: (segment: FoldSegment) => Promise<readonly DiffRow[]>
|
||||
}
|
||||
|
||||
/** One file's diff rows: fold chips between hunks, highlighted code rows. */
|
||||
export function DiffRows({ segments, lang }: DiffRowsProps) {
|
||||
export function DiffRows({ segments, lang, resolveFold }: DiffRowsProps) {
|
||||
// Long diff lines fold to one ellipsized row; the set holds expanded row keys.
|
||||
const [expandedLines, setExpandedLines] = useState<ReadonlySet<string>>(new Set())
|
||||
// Hunk-fold segments expanded by index; default collapsed.
|
||||
const [expandedFolds, setExpandedFolds] = useState<ReadonlySet<number>>(new Set())
|
||||
// Row-count cap expanded: a huge file renders head rows plus this button.
|
||||
const [expandedAll, setExpandedAll] = useState(false)
|
||||
// On-demand fold expansion (git gaps): resolved rows per segment index,
|
||||
// the loading/failed markers, and an epoch that invalidates in-flight
|
||||
// resolves when the segments identity changes.
|
||||
const [foldData, setFoldData] = useState<ReadonlyMap<number, readonly DiffRow[]>>(new Map())
|
||||
const [foldLoading, setFoldLoading] = useState<ReadonlySet<number>>(new Set())
|
||||
const [foldFailed, setFoldFailed] = useState<ReadonlySet<number>>(new Set())
|
||||
const foldEpoch = useRef(0)
|
||||
// Reset all folding when the segments identity changes (new target).
|
||||
useEffect(() => {
|
||||
setExpandedLines(new Set())
|
||||
setExpandedFolds(new Set())
|
||||
setExpandedAll(false)
|
||||
setFoldData(new Map())
|
||||
setFoldLoading(new Set())
|
||||
setFoldFailed(new Set())
|
||||
foldEpoch.current += 1
|
||||
}, [segments])
|
||||
|
||||
const inlineMap = useMemo(() => buildInlineMap(segments), [segments])
|
||||
@@ -190,7 +208,19 @@ export function DiffRows({ segments, lang }: DiffRowsProps) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const expandable = segment.rows !== undefined && segment.count >= MIN_FOLD
|
||||
if (foldFailed.has(segIndex)) {
|
||||
// The on-demand fetch failed: a quiet, non-clickable marker — the
|
||||
// expansion promise is gone, so no interaction is promised either.
|
||||
return (
|
||||
<div key={`fold-${String(segIndex)}`} className={css.foldRow}>
|
||||
<span className={css.foldMarker} title={t('changesContext')}>
|
||||
{t('changesFoldUnavailable')}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const resolvedRows = foldData.get(segIndex)
|
||||
const expandable = (segment.rows !== undefined || resolvedRows !== undefined || resolveFold !== undefined) && segment.count >= MIN_FOLD
|
||||
if (!expandable) {
|
||||
// A tiny fold (or a git gap with no rows to reveal): a quiet marker.
|
||||
return (
|
||||
@@ -201,27 +231,49 @@ export function DiffRows({ segments, lang }: DiffRowsProps) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const loading = foldLoading.has(segIndex)
|
||||
const isExpanded = expandedFolds.has(segIndex)
|
||||
const revealed = segment.rows ?? resolvedRows
|
||||
return (
|
||||
<div
|
||||
key={`fold-${String(segIndex)}`}
|
||||
className={css.foldRow}
|
||||
data-expandable="true"
|
||||
data-expandable={loading ? undefined : 'true'}
|
||||
data-expanded={isExpanded ? 'true' : undefined}
|
||||
onClick={() => {
|
||||
setExpandedFolds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(segIndex)) next.delete(segIndex)
|
||||
else next.add(segIndex)
|
||||
return next
|
||||
})
|
||||
onClick={loading ? undefined : () => {
|
||||
if (revealed !== undefined) {
|
||||
setExpandedFolds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(segIndex)) next.delete(segIndex)
|
||||
else next.add(segIndex)
|
||||
return next
|
||||
})
|
||||
return
|
||||
}
|
||||
// A git gap fold's first click: fetch its rows, then expand.
|
||||
if (resolveFold === undefined) return
|
||||
const epoch = foldEpoch.current
|
||||
setFoldLoading(prev => new Set(prev).add(segIndex))
|
||||
resolveFold(segment).then(
|
||||
(rows) => {
|
||||
if (foldEpoch.current !== epoch) return
|
||||
setFoldData(prev => new Map(prev).set(segIndex, rows))
|
||||
setFoldLoading(prev => { const next = new Set(prev); next.delete(segIndex); return next })
|
||||
setExpandedFolds(prev => new Set(prev).add(segIndex))
|
||||
},
|
||||
() => {
|
||||
if (foldEpoch.current !== epoch) return
|
||||
setFoldLoading(prev => { const next = new Set(prev); next.delete(segIndex); return next })
|
||||
setFoldFailed(prev => new Set(prev).add(segIndex))
|
||||
},
|
||||
)
|
||||
}}
|
||||
>
|
||||
{isExpanded
|
||||
? segment.rows!.map((row, index) => renderDiffRow(row, `${segIndex}-${String(index)}`))
|
||||
{isExpanded && revealed !== undefined
|
||||
? revealed.map((row, index) => renderDiffRow(row, `${segIndex}-${String(index)}`))
|
||||
: (
|
||||
<span className={css.foldMarker} title={t('changesContext')}>
|
||||
{t('changesFold', { count: segment.count })}
|
||||
{loading ? t('changesFoldLoading') : t('changesFold', { count: segment.count })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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`),
|
||||
}
|
||||
|
||||
@@ -451,6 +451,41 @@ export function untrackedFile(path: string, content: string): DiffFile {
|
||||
return { oldPath: '/dev/null', newPath: `b/${path}`, binary: false, hunks: [{ oldStart: 0, newStart: 1, header: '', lines }] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize a git gap fold's hidden rows from the two sides' full file
|
||||
* contents, by the fold's known line ranges: the old side drives context
|
||||
* rows (each mapped onto the new side through the fold's offset — a gap is
|
||||
* an unchanged run, so the sides align), and new-side lines the old range
|
||||
* never reaches become pure additions. Line numbers clip to the actual
|
||||
* content (a no-newline file's ranges can overrun by one); `\r` endings
|
||||
* survive verbatim, like git's own context lines.
|
||||
*/
|
||||
export function foldRowsFromContents(fold: FoldSegment, oldContent: string, newContent: string): DiffRow[] {
|
||||
const oldLines = oldContent.length === 0 ? [] : oldContent.split('\n')
|
||||
const newLines = newContent.length === 0 ? [] : newContent.split('\n')
|
||||
const offset = fold.newStart - fold.oldStart
|
||||
const rows: DiffRow[] = []
|
||||
const oldFrom = Math.max(fold.oldStart, 1)
|
||||
const oldTo = Math.min(fold.oldEnd, oldLines.length)
|
||||
for (let oldLine = oldFrom; oldLine <= oldTo; oldLine += 1) {
|
||||
const text = oldLines[oldLine - 1]!
|
||||
const newLine = oldLine + offset
|
||||
rows.push(
|
||||
newLine >= fold.newStart && newLine <= fold.newEnd && newLine <= newLines.length
|
||||
? { kind: 'context', oldLine, newLine, text }
|
||||
: { kind: 'context', oldLine, text },
|
||||
)
|
||||
}
|
||||
// New-side lines beyond what the old range reached (a pure-addition gap):
|
||||
// rows carrying only the new-side number, exactly like added lines.
|
||||
const newFrom = Math.max(fold.newStart, 1)
|
||||
const newTo = Math.min(fold.newEnd, newLines.length)
|
||||
for (let newLine = Math.max(newFrom, oldTo + offset + 1); newLine <= newTo; newLine += 1) {
|
||||
rows.push({ kind: 'add', newLine, text: newLines[newLine - 1]! })
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/** Strip the `a/` / `b/` prefix git puts on diff paths (not on /dev/null). */
|
||||
export function displayPath(path: string): string {
|
||||
if (path === '/dev/null') return path
|
||||
|
||||
@@ -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'
|
||||
@@ -25,9 +25,12 @@
|
||||
* legacy signal engines emit without isComposing.
|
||||
*/
|
||||
|
||||
/** The pure decision: is this keyboard event part of an IME composition? */
|
||||
export function isImeComposition(event: { isComposing: boolean; keyCode: number }): boolean {
|
||||
return event.isComposing || event.keyCode === 229
|
||||
/** The pure decision: is this keyboard event part of an IME composition?
|
||||
* `isComposing` is optional on the input: React's synthetic KeyboardEvent
|
||||
* type does not declare it (the DOM event always carries it), and the
|
||||
* keyCode 229 fallback covers exactly those callers. */
|
||||
export function isImeComposition(event: { isComposing?: boolean; keyCode: number }): boolean {
|
||||
return event.isComposing === true || event.keyCode === 229
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+14
-8
@@ -166,14 +166,21 @@ export function apply(ctx: Context): void {
|
||||
)
|
||||
// A failure anywhere in the client lifecycle must never take the app down
|
||||
// silently: log with the plugin prefix and pin a visible diagnostic strip
|
||||
// to the page so a blank panel is never the only symptom.
|
||||
// to the page so a blank panel is never the only symptom. This strip is
|
||||
// the last-resort reporter (no CSS module is reachable from here), so its
|
||||
// colors go through skin token chains with the previous hexes as the
|
||||
// chain tails — worst case (no skin tokens on the page) it renders
|
||||
// byte-identical to the old hardcoded bar, and any `--dsw-alias-*` skin
|
||||
// re-themes it (guide §12: no hardcoded colors).
|
||||
const fail = (phase: string, error: unknown): void => {
|
||||
console.error(`[dsh-better-sidebar] ${phase} error:`, error)
|
||||
try {
|
||||
const bar = document.createElement('div')
|
||||
bar.style.cssText = 'position:fixed;left:8px;bottom:8px;z-index:2147483000;max-width:70vw;padding:8px 12px;'
|
||||
+ 'font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;color:#f2a1a1;background:#1b1b22;'
|
||||
+ 'border:1px solid #f2a1a1;border-radius:8px;white-space:pre-wrap'
|
||||
+ 'font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;'
|
||||
+ 'color:var(--dsw-alias-state-error-primary,#f2a1a1);'
|
||||
+ 'background:var(--dsw-alias-bg-layer-3,var(--dsw-alias-bg-base,#1b1b22));'
|
||||
+ 'border:1px solid var(--dsw-alias-state-error-primary,#f2a1a1);border-radius:8px;white-space:pre-wrap'
|
||||
bar.textContent = `[dsh-better-sidebar] ${phase} error: ${error instanceof Error ? error.message : String(error)}`
|
||||
document.body.appendChild(bar)
|
||||
} catch {
|
||||
@@ -181,10 +188,9 @@ export function apply(ctx: Context): void {
|
||||
}
|
||||
}
|
||||
try {
|
||||
// rc.8+ exposes the client module system as the `ctx.modules` service
|
||||
// (no window.__DSH_MODULES__ page global anymore); the chunk loader needs
|
||||
// it to resolve its externals, so inject it before anything can load a
|
||||
// lazy chunk. The loader falls back to the rc.7 global when absent.
|
||||
// rc.8+ exposes the client module system as the `ctx.modules` service;
|
||||
// the chunk loader needs it to resolve its externals, so inject it
|
||||
// before anything can load a lazy chunk.
|
||||
setChunkModuleSystem(ctx.modules)
|
||||
// Fresh chunk state for this activation: drop per-test fixtures and
|
||||
// revalidate loaded chunk scripts against the bundle route's ETags —
|
||||
@@ -297,7 +303,7 @@ export function apply(ctx: Context): void {
|
||||
// rides the same fetch, so one round trip covers both decisions).
|
||||
const decision = await Promise.race([
|
||||
loadBootDecision(api),
|
||||
new Promise<null>(resolve => { const timer = window.setTimeout(() => resolve(null), 2000) }),
|
||||
new Promise<null>(resolve => { window.setTimeout(() => resolve(null), 2000) }),
|
||||
])
|
||||
if (disposed) return
|
||||
if (decision !== null) {
|
||||
|
||||
@@ -111,3 +111,63 @@ body[data-dsh-sidebar-dragging] #root [data-dsh-center-col],
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Context-menu submenu flip (viewport clamping for the portaled menus).
|
||||
*
|
||||
* The ui-primitives Menu clamps its main portal list into the viewport but
|
||||
* positions nested submenus with static CSS only — anchored at the parent
|
||||
* row's bottom, growing UP and to the RIGHT — with no clamp whatsoever.
|
||||
* Docked on the window's right edge, both defaults are the worst case for
|
||||
* this panel: a tall "open with" submenu escapes past the viewport top when
|
||||
* the row menu opens near the top, and past the right edge on narrow panels
|
||||
* (PANEL_MIN 280 < card 218 + gap + submenu 165).
|
||||
*
|
||||
* menu-flip.ts publishes `data-dsh-sidebar-submenu` on <body> — with the
|
||||
* words "down" and/or "left" — for exactly the lifetime of one of OUR
|
||||
* submenu-bearing menus (FileTree row menu, TabBar tab menu). The tokens
|
||||
* flip the growth direction; the attribute's absence restores the
|
||||
* primitive's own geometry untouched, and host-owned menus are never
|
||||
* affected because they never open while the attribute exists (each side
|
||||
* closes on the other's outside pointerdown).
|
||||
*
|
||||
* No max-height/overflow scroll cap here on purpose: overflow clipping
|
||||
* would cut the submenu's ::before hover bridge (the corridor that keeps
|
||||
* itemWrap's mouseleave from closing the card mid-crossing). */
|
||||
|
||||
/* Visual tether, part 1 — the parent row stays lit while its submenu is
|
||||
* open. The primitive only styles :hover, so once the pointer crossed into
|
||||
* the side card the parent row's fill cleared and the floating card read as
|
||||
* unrelated. The Menu marks the open parent row with aria-expanded="true"
|
||||
* (host-rendered, stable), so the same hover token re-applies for the
|
||||
* submenu's whole lifetime. */
|
||||
body[data-dsh-sidebar-submenu] div[role="menu"] button[aria-expanded="true"] {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* Visual tether, part 2 — the side card hugs its parent (10px → 4px gap),
|
||||
* with the ::before hover corridor narrowed to match. Ordered BEFORE the
|
||||
* flip overrides below so their left/right flips still win. */
|
||||
body[data-dsh-sidebar-submenu] div[role="menu"] div[role="menu"] {
|
||||
left: calc(100% + 4px);
|
||||
}
|
||||
|
||||
body[data-dsh-sidebar-submenu] div[role="menu"] div[role="menu"]::before {
|
||||
left: -4px;
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
body[data-dsh-sidebar-submenu~="down"] div[role="menu"] div[role="menu"] {
|
||||
top: -4px;
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
body[data-dsh-sidebar-submenu~="left"] div[role="menu"] div[role="menu"] {
|
||||
left: auto;
|
||||
right: calc(100% + 4px);
|
||||
}
|
||||
|
||||
/* The hover bridge follows the card to the left side of the parent row. */
|
||||
body[data-dsh-sidebar-submenu~="left"] div[role="menu"] div[role="menu"]::before {
|
||||
left: auto;
|
||||
right: -4px;
|
||||
}
|
||||
|
||||
@@ -22,12 +22,12 @@ interface LazyChunkViewProps<P> {
|
||||
props: P
|
||||
}
|
||||
|
||||
function LazyChunkView<P>({ chunk, pick, props }: LazyChunkViewProps<P>): ReactNode {
|
||||
function LazyChunkView<P extends object>({ chunk, pick, props }: LazyChunkViewProps<P>): ReactNode {
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
const [state, setState] = useState<
|
||||
| { status: 'loading' }
|
||||
| { status: 'error'; message: string }
|
||||
| { status: 'ready'; Comp: ComponentType<any> }
|
||||
| { status: 'ready'; Comp: ComponentType<P> }
|
||||
>({ status: 'loading' })
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -20,11 +20,19 @@ export const ar: Record<string, string> = {
|
||||
files: 'الملفات',
|
||||
changesSessionEmpty: 'لا توجد عمليات ملفات في هذه الجلسة بعد',
|
||||
changesRead: 'قراءة',
|
||||
changesMdReading: 'قراءة',
|
||||
changesHtmlRender: "عرض",
|
||||
changesHtmlRaw: "المصدر",
|
||||
changesPdfRender: "عرض",
|
||||
changesPdfRaw: "المصدر",
|
||||
changesMdRaw: 'الخام',
|
||||
changesWrite: 'كتابة',
|
||||
changesEdit: 'تحرير',
|
||||
changesRunning: 'قيد التنفيذ',
|
||||
changesError: 'خطأ',
|
||||
changesFold: '{count} أسطر… انقر للتوسيع',
|
||||
changesFoldLoading: 'جارٍ التوسيع…',
|
||||
changesFoldUnavailable: 'السياق غير متوفر',
|
||||
changesContext: 'سياق',
|
||||
changesPriorUnknown: 'المحتوى السابق خارج النافذة؛ يُعرض كما لو أُضيف بالكامل',
|
||||
explorer: 'المستكشف',
|
||||
@@ -199,6 +207,13 @@ export const ar: Record<string, string> = {
|
||||
addToConversation: 'إضافة إلى المحادثة',
|
||||
copyRelative: 'نسخ المسار النسبي',
|
||||
copyAbsolute: 'نسخ المسار المطلق',
|
||||
rename: 'إعادة تسمية',
|
||||
renameInvalid: 'لا يمكن أن يكون الاسم فارغًا أو يحتوي على فواصل مسار.',
|
||||
delete: 'حذف',
|
||||
deleteTitle: 'حذف «{name}»؟',
|
||||
deleteDescFile: 'سيتم حذف هذا الملف نهائيًا. لا يمكن التراجع عن هذا الإجراء.',
|
||||
deleteDescDir: 'سيتم حذف هذا الدليل وكل محتوياته نهائيًا. لا يمكن التراجع عن هذا الإجراء.',
|
||||
dismiss: 'إغلاق',
|
||||
download: 'تنزيل',
|
||||
uploadFiles: 'رفع ملفات',
|
||||
uploadFolder: 'رفع مجلد',
|
||||
@@ -422,4 +437,29 @@ 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',
|
||||
pluginDocsPanelName: 'dsh-docs-panel مستندات عامة',
|
||||
pluginFlowglassName: 'dsh-flowglass Flowglass',
|
||||
pluginGitForgeName: 'dsh-git-forge بيانات اعتماد Git',
|
||||
pluginGitRemotesName: 'dsh-git-remotes أجهزة Git البعيدة',
|
||||
pluginGithubWorkbenchName: 'dsh-github-workbench منصة GitHub',
|
||||
pluginSidebarQaName: 'dsh-sidebar-qa تحديد وسؤال',
|
||||
pluginSidenoteName: 'dsh-sidenote محادثة جانبية',
|
||||
pluginServerDeckName: 'dsh-server-deck لوحة الخوادم',
|
||||
pluginSuhuangScrollName: 'dsh-suhuang-scroll Suhuang Scroll',
|
||||
pluginSshTunnelName: 'dsh-ssh-tunnel نفق SSH',
|
||||
pluginTurnReviewName: 'dsh-turn-review مراجعة الجولة',
|
||||
pluginBilingualReaderName: 'dsh-bilingual-reader قارئ ثنائي اللغة',
|
||||
pluginOfficeName: 'معاينة Office',
|
||||
pluginMdExportName: 'تصدير Markdown',
|
||||
pluginCodeNavName: 'مستكشف معاينة الكود',
|
||||
pluginVideoPreviewName: 'معاينة الفيديو',
|
||||
presetDshDesktopDesc: 'وضع Electron المتقدم (بلا إطار): يحجز macOS شريطًا علويًا بمقدار 20px، ويحجز Windows بمقدار 32px لشريط العنوان عند غياب WCO',
|
||||
}
|
||||
|
||||
@@ -5,11 +5,19 @@ 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',
|
||||
changesError: 'Fehler',
|
||||
changesFold: '{count} Zeilen… zum Aufklappen klicken',
|
||||
changesFoldLoading: 'Wird aufgeklappt…',
|
||||
changesFoldUnavailable: 'Kontext nicht verfügbar',
|
||||
changesContext: 'Kontext',
|
||||
changesPriorUnknown: 'Inhalt vor der Änderung liegt außerhalb des Fensters; als vollständig neu angezeigt',
|
||||
explorer: 'Explorer',
|
||||
@@ -184,6 +192,13 @@ export const de: Record<string, string> = {
|
||||
addToConversation: 'Zur Unterhaltung hinzufügen',
|
||||
copyRelative: 'Relativen Pfad kopieren',
|
||||
copyAbsolute: 'Absoluten Pfad kopieren',
|
||||
rename: 'Umbenennen',
|
||||
renameInvalid: 'Der Name darf nicht leer sein und keine Pfadtrenner enthalten.',
|
||||
delete: 'Löschen',
|
||||
deleteTitle: '„{name}“ löschen?',
|
||||
deleteDescFile: 'Dadurch wird die Datei dauerhaft gelöscht. Dies kann nicht rückgängig gemacht werden.',
|
||||
deleteDescDir: 'Dadurch werden das Verzeichnis und dessen gesamter Inhalt dauerhaft gelöscht. Dies kann nicht rückgängig gemacht werden.',
|
||||
dismiss: 'Schließen',
|
||||
download: 'Herunterladen',
|
||||
uploadFiles: 'Dateien hochladen',
|
||||
uploadFolder: 'Ordner hochladen',
|
||||
@@ -407,4 +422,29 @@ 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',
|
||||
pluginDocsPanelName: 'dsh-docs-panel Globale Dokumente',
|
||||
pluginFlowglassName: 'dsh-flowglass Flowglass',
|
||||
pluginGitForgeName: 'dsh-git-forge Git-Zugangsdaten',
|
||||
pluginGitRemotesName: 'dsh-git-remotes Git-Remotes',
|
||||
pluginGithubWorkbenchName: 'dsh-github-workbench GitHub-Workbench',
|
||||
pluginSidebarQaName: 'dsh-sidebar-qa Auswählen & Fragen',
|
||||
pluginSidenoteName: 'dsh-sidenote Seiten-Chat',
|
||||
pluginServerDeckName: 'dsh-server-deck Server-Deck',
|
||||
pluginSuhuangScrollName: 'dsh-suhuang-scroll Suhuang Scroll',
|
||||
pluginSshTunnelName: 'dsh-ssh-tunnel SSH-Tunnel',
|
||||
pluginTurnReviewName: 'dsh-turn-review Runden-Review',
|
||||
pluginBilingualReaderName: 'dsh-bilingual-reader Zweisprachiger Reader',
|
||||
pluginOfficeName: 'Office-Vorschau',
|
||||
pluginMdExportName: 'Markdown-Export',
|
||||
pluginCodeNavName: 'Code-Vorschau-Navigation',
|
||||
pluginVideoPreviewName: 'Video-Vorschau',
|
||||
presetDshDesktopDesc: 'Elektronischer Erweiterte-Modus (rahmenlos): macOS reserviert oben 20px; Windows reserviert ohne WCO 32px für die Titelleiste',
|
||||
}
|
||||
|
||||
@@ -12,11 +12,19 @@ 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',
|
||||
changesError: 'erreur',
|
||||
changesFold: '{count} lignes… cliquer pour déplier',
|
||||
changesFoldLoading: 'Dépliage…',
|
||||
changesFoldUnavailable: 'Contexte indisponible',
|
||||
changesContext: 'contexte',
|
||||
changesPriorUnknown: 'Le contenu antérieur est hors de la fenêtre ; affiché comme entièrement ajouté',
|
||||
explorer: 'Explorateur',
|
||||
@@ -191,6 +199,13 @@ export const fr: Record<string, string> = {
|
||||
addToConversation: 'Ajouter à la discussion',
|
||||
copyRelative: 'Copier le chemin relatif',
|
||||
copyAbsolute: 'Copier le chemin absolu',
|
||||
rename: 'Renommer',
|
||||
renameInvalid: 'Le nom ne peut pas être vide ni contenir de séparateurs de chemin.',
|
||||
delete: 'Supprimer',
|
||||
deleteTitle: 'Supprimer « {name} » ?',
|
||||
deleteDescFile: 'Ce fichier sera définitivement supprimé. Cette action est irréversible.',
|
||||
deleteDescDir: 'Ce répertoire et tout son contenu seront définitivement supprimés. Cette action est irréversible.',
|
||||
dismiss: 'Fermer',
|
||||
download: 'Télécharger',
|
||||
uploadFiles: 'Importer des fichiers',
|
||||
uploadFolder: 'Importer un dossier',
|
||||
@@ -414,4 +429,29 @@ 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',
|
||||
pluginDocsPanelName: 'dsh-docs-panel Docs globales',
|
||||
pluginFlowglassName: 'dsh-flowglass Flowglass',
|
||||
pluginGitForgeName: 'dsh-git-forge Identifiants Git',
|
||||
pluginGitRemotesName: 'dsh-git-remotes Dépôts distants Git',
|
||||
pluginGithubWorkbenchName: 'dsh-github-workbench Atelier GitHub',
|
||||
pluginSidebarQaName: 'dsh-sidebar-qa Sélectionner & demander',
|
||||
pluginSidenoteName: 'dsh-sidenote Chat latéral',
|
||||
pluginServerDeckName: 'dsh-server-deck Tableau de bord serveurs',
|
||||
pluginSuhuangScrollName: 'dsh-suhuang-scroll Suhuang Scroll',
|
||||
pluginSshTunnelName: 'dsh-ssh-tunnel Tunnel SSH',
|
||||
pluginTurnReviewName: 'dsh-turn-review Revue du tour',
|
||||
pluginBilingualReaderName: 'dsh-bilingual-reader Lecteur bilingue',
|
||||
pluginOfficeName: 'Aperçu Office',
|
||||
pluginMdExportName: 'Exportation Markdown',
|
||||
pluginCodeNavName: 'Navigateur d’aperçu de code',
|
||||
pluginVideoPreviewName: 'Aperçu vidéo',
|
||||
presetDshDesktopDesc: 'Mode avancé Electron (sans bordure) : macOS réserve 20px en haut ; Windows réserve 32px pour la barre de titre sans WCO',
|
||||
}
|
||||
|
||||
@@ -19,11 +19,19 @@ export const hi: Record<string, string> = {
|
||||
files: 'फ़ाइलें',
|
||||
changesSessionEmpty: 'इस सत्र में अभी कोई फ़ाइल ऑपरेशन नहीं',
|
||||
changesRead: 'पढ़ना',
|
||||
changesMdReading: 'पठन',
|
||||
changesHtmlRender: "रेंडर",
|
||||
changesHtmlRaw: "मूल",
|
||||
changesPdfRender: "रेंडर",
|
||||
changesPdfRaw: "मूल",
|
||||
changesMdRaw: 'मूल पाठ',
|
||||
changesWrite: 'लिखना',
|
||||
changesEdit: 'संपादन',
|
||||
changesRunning: 'चल रहा है',
|
||||
changesError: 'त्रुटि',
|
||||
changesFold: '{count} पंक्तियाँ… विस्तार के लिए क्लिक करें',
|
||||
changesFoldLoading: 'विस्तार हो रहा है…',
|
||||
changesFoldUnavailable: 'संदर्भ अनुपलब्ध',
|
||||
changesContext: 'संदर्भ',
|
||||
changesPriorUnknown: 'बदलाव से पहले की सामग्री विंडो में नहीं है; पूरी नई जोड़ी गई दिखाई जाती है',
|
||||
explorer: 'एक्सप्लोरर',
|
||||
@@ -198,6 +206,13 @@ export const hi: Record<string, string> = {
|
||||
addToConversation: 'वार्तालाप में जोड़ें',
|
||||
copyRelative: 'सापेक्ष पथ कॉपी',
|
||||
copyAbsolute: 'निरपेक्ष पथ कॉपी',
|
||||
rename: 'नाम बदलें',
|
||||
renameInvalid: 'नाम खाली नहीं हो सकता और उसमें पथ विभाजक नहीं हो सकते।',
|
||||
delete: 'हटाएँ',
|
||||
deleteTitle: '“{name}” हटाएँ?',
|
||||
deleteDescFile: 'यह फ़ाइल स्थायी रूप से हटा दी जाएगी। इसे पूर्ववत नहीं किया जा सकता।',
|
||||
deleteDescDir: 'यह निर्देशिका और उसकी सारी सामग्री स्थायी रूप से हटा दी जाएगी। इसे पूर्ववत नहीं किया जा सकता।',
|
||||
dismiss: 'बंद करें',
|
||||
download: 'डाउनलोड',
|
||||
uploadFiles: 'फ़ाइलें अपलोड करें',
|
||||
uploadFolder: 'फ़ोल्डर अपलोड करें',
|
||||
@@ -421,4 +436,29 @@ 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 टैब',
|
||||
pluginDocsPanelName: 'dsh-docs-panel ग्लोबल डॉक्स',
|
||||
pluginFlowglassName: 'dsh-flowglass Flowglass',
|
||||
pluginGitForgeName: 'dsh-git-forge Git क्रेडेंशियल',
|
||||
pluginGitRemotesName: 'dsh-git-remotes Git रिमोट',
|
||||
pluginGithubWorkbenchName: 'dsh-github-workbench GitHub वर्कबेंच',
|
||||
pluginSidebarQaName: 'dsh-sidebar-qa चुनें और पूछें',
|
||||
pluginSidenoteName: 'dsh-sidenote साइड चैट',
|
||||
pluginServerDeckName: 'dsh-server-deck सर्वर डेक',
|
||||
pluginSuhuangScrollName: 'dsh-suhuang-scroll Suhuang Scroll',
|
||||
pluginSshTunnelName: 'dsh-ssh-tunnel SSH टनल',
|
||||
pluginTurnReviewName: 'dsh-turn-review टर्न समीक्षा',
|
||||
pluginBilingualReaderName: 'dsh-bilingual-reader द्विभाषी रीडर',
|
||||
pluginOfficeName: 'Office प्रीव्यू',
|
||||
pluginMdExportName: 'Markdown एक्सपोर्ट',
|
||||
pluginCodeNavName: 'कोड प्रीव्यू नेविगेटर',
|
||||
pluginVideoPreviewName: 'वीडियो प्रीव्यू',
|
||||
presetDshDesktopDesc: 'Electron एडवांस्ड (फ्रेमलेस) मोड: macOS ऊपर 20px सुरक्षित रखता है; Windows WCO अनुपलब्ध होने पर टाइटल बार के लिए 32px रखता है',
|
||||
}
|
||||
|
||||
@@ -17,11 +17,19 @@ 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',
|
||||
changesError: 'error',
|
||||
changesFold: '{count} baris… klik untuk meluaskan',
|
||||
changesFoldLoading: 'Meluaskan…',
|
||||
changesFoldUnavailable: 'Konteks tidak tersedia',
|
||||
changesContext: 'konteks',
|
||||
changesPriorUnknown: 'Konten sebelum perubahan berada di luar jendela; ditampilkan sebagai sepenuhnya baru',
|
||||
explorer: 'Explorer',
|
||||
@@ -196,6 +204,13 @@ export const id: Record<string, string> = {
|
||||
addToConversation: 'Tambahkan ke obrolan',
|
||||
copyRelative: 'Salin path relatif',
|
||||
copyAbsolute: 'Salin path absolut',
|
||||
rename: 'Ganti nama',
|
||||
renameInvalid: 'Nama tidak boleh kosong atau mengandung pemisah jalur.',
|
||||
delete: 'Hapus',
|
||||
deleteTitle: 'Hapus "{name}"?',
|
||||
deleteDescFile: 'Ini menghapus file secara permanen. Tidak dapat dibatalkan.',
|
||||
deleteDescDir: 'Ini menghapus direktori dan seluruh isinya secara permanen. Tidak dapat dibatalkan.',
|
||||
dismiss: 'Tutup',
|
||||
download: 'Unduh',
|
||||
uploadFiles: 'Unggah berkas',
|
||||
uploadFolder: 'Unggah folder',
|
||||
@@ -419,4 +434,29 @@ 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',
|
||||
pluginDocsPanelName: 'dsh-docs-panel Dokumen global',
|
||||
pluginFlowglassName: 'dsh-flowglass Flowglass',
|
||||
pluginGitForgeName: 'dsh-git-forge Kredensial Git',
|
||||
pluginGitRemotesName: 'dsh-git-remotes Remote Git',
|
||||
pluginGithubWorkbenchName: 'dsh-github-workbench Meja kerja GitHub',
|
||||
pluginSidebarQaName: 'dsh-sidebar-qa Pilih & tanya',
|
||||
pluginSidenoteName: 'dsh-sidenote Obrolan samping',
|
||||
pluginServerDeckName: 'dsh-server-deck Dek server',
|
||||
pluginSuhuangScrollName: 'dsh-suhuang-scroll Suhuang Scroll',
|
||||
pluginSshTunnelName: 'dsh-ssh-tunnel Terowongan SSH',
|
||||
pluginTurnReviewName: 'dsh-turn-review Tinjauan giliran',
|
||||
pluginBilingualReaderName: 'dsh-bilingual-reader Pembaca dwibahasa',
|
||||
pluginOfficeName: 'Pratinjau Office',
|
||||
pluginMdExportName: 'Ekspor Markdown',
|
||||
pluginCodeNavName: 'Navigator pratinjau kode',
|
||||
pluginVideoPreviewName: 'Pratinjau video',
|
||||
presetDshDesktopDesc: 'Mode Electron lanjutan (tanpa bingkai): macOS mencadangkan 20px di atas; Windows mencadangkan 32px untuk bilah judul saat WCO tidak tersedia',
|
||||
}
|
||||
|
||||
@@ -10,11 +10,19 @@ 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',
|
||||
changesError: 'errore',
|
||||
changesFold: '{count} righe… clicca per espandere',
|
||||
changesFoldLoading: 'Espansione…',
|
||||
changesFoldUnavailable: 'Contesto non disponibile',
|
||||
changesContext: 'contesto',
|
||||
changesPriorUnknown: 'Il contenuto precedente è fuori dalla finestra; mostrato come interamente aggiunto',
|
||||
explorer: 'Esplora risorse',
|
||||
@@ -189,6 +197,13 @@ export const it: Record<string, string> = {
|
||||
addToConversation: 'Aggiungi alla conversazione',
|
||||
copyRelative: 'Copia percorso relativo',
|
||||
copyAbsolute: 'Copia percorso assoluto',
|
||||
rename: 'Rinomina',
|
||||
renameInvalid: 'Il nome non può essere vuoto o contenere separatori di percorso.',
|
||||
delete: 'Elimina',
|
||||
deleteTitle: 'Eliminare "{name}"?',
|
||||
deleteDescFile: 'Il file verrà eliminato definitivamente. Questa azione non può essere annullata.',
|
||||
deleteDescDir: 'La directory e tutto il suo contenuto verranno eliminati definitivamente. Questa azione non può essere annullata.',
|
||||
dismiss: 'Chiudi',
|
||||
download: 'Scarica',
|
||||
uploadFiles: 'Carica file',
|
||||
uploadFolder: 'Carica cartella',
|
||||
@@ -412,4 +427,29 @@ 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',
|
||||
pluginDocsPanelName: 'dsh-docs-panel Documenti globali',
|
||||
pluginFlowglassName: 'dsh-flowglass Flowglass',
|
||||
pluginGitForgeName: 'dsh-git-forge Credenziali Git',
|
||||
pluginGitRemotesName: 'dsh-git-remotes Remote Git',
|
||||
pluginGithubWorkbenchName: 'dsh-github-workbench Banco di lavoro GitHub',
|
||||
pluginSidebarQaName: 'dsh-sidebar-qa Seleziona e chiedi',
|
||||
pluginSidenoteName: 'dsh-sidenote Chat laterale',
|
||||
pluginServerDeckName: 'dsh-server-deck Ponte server',
|
||||
pluginSuhuangScrollName: 'dsh-suhuang-scroll Suhuang Scroll',
|
||||
pluginSshTunnelName: 'dsh-ssh-tunnel Tunnel SSH',
|
||||
pluginTurnReviewName: 'dsh-turn-review Revisione del turno',
|
||||
pluginBilingualReaderName: 'dsh-bilingual-reader Lettore bilingue',
|
||||
pluginOfficeName: 'Anteprima Office',
|
||||
pluginMdExportName: 'Esportazione Markdown',
|
||||
pluginCodeNavName: 'Navigatore anteprima codice',
|
||||
pluginVideoPreviewName: 'Anteprima video',
|
||||
presetDshDesktopDesc: 'Modalità avanzata Electron (senza bordi): macOS riserva 20px in alto; Windows riserva 32px per la barra del titolo quando WCO non è disponibile',
|
||||
}
|
||||
|
||||
@@ -19,11 +19,19 @@ export const ja: Record<string, string> = {
|
||||
files: 'ファイル',
|
||||
changesSessionEmpty: 'このセッションにはファイル操作がまだありません',
|
||||
changesRead: '読み取り',
|
||||
changesMdReading: '閲覧',
|
||||
changesHtmlRender: "レンダリング",
|
||||
changesHtmlRaw: "原文",
|
||||
changesPdfRender: "レンダリング",
|
||||
changesPdfRaw: "原文",
|
||||
changesMdRaw: '原文',
|
||||
changesWrite: '書き込み',
|
||||
changesEdit: '編集',
|
||||
changesRunning: '実行中',
|
||||
changesError: 'エラー',
|
||||
changesFold: '{count} 行…クリックで展開',
|
||||
changesFoldLoading: '展開中…',
|
||||
changesFoldUnavailable: 'コンテキスト未読み込み',
|
||||
changesContext: 'コンテキスト',
|
||||
changesPriorUnknown: '変更前の内容がウィンドウ内になく、すべて新規追加として表示されます',
|
||||
explorer: 'エクスプローラー',
|
||||
@@ -198,6 +206,13 @@ export const ja: Record<string, string> = {
|
||||
addToConversation: '会話に追加',
|
||||
copyRelative: '相対パスをコピー',
|
||||
copyAbsolute: '絶対パスをコピー',
|
||||
rename: '名前の変更',
|
||||
renameInvalid: '名前は空にできず、パス区切り文字を含められません。',
|
||||
delete: '削除',
|
||||
deleteTitle: '「{name}」を削除しますか?',
|
||||
deleteDescFile: 'このファイルを完全に削除します。元に戻せません。',
|
||||
deleteDescDir: 'このディレクトリと内容物をすべて完全に削除します。元に戻せません。',
|
||||
dismiss: '閉じる',
|
||||
download: 'ダウンロード',
|
||||
uploadFiles: 'ファイルをアップロード',
|
||||
uploadFolder: 'フォルダーをアップロード',
|
||||
@@ -421,4 +436,29 @@ 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 タブ',
|
||||
pluginDocsPanelName: 'dsh-docs-panel グローバルドキュメント',
|
||||
pluginFlowglassName: 'dsh-flowglass Flowglass',
|
||||
pluginGitForgeName: 'dsh-git-forge Git 認証情報',
|
||||
pluginGitRemotesName: 'dsh-git-remotes Git リモート',
|
||||
pluginGithubWorkbenchName: 'dsh-github-workbench GitHub ワークベンチ',
|
||||
pluginSidebarQaName: 'dsh-sidebar-qa 選択して質問',
|
||||
pluginSidenoteName: 'dsh-sidenote サイドチャット',
|
||||
pluginServerDeckName: 'dsh-server-deck サーバーデッキ',
|
||||
pluginSuhuangScrollName: 'dsh-suhuang-scroll Suhuang Scroll',
|
||||
pluginSshTunnelName: 'dsh-ssh-tunnel SSH トンネル',
|
||||
pluginTurnReviewName: 'dsh-turn-review ターンレビュー',
|
||||
pluginBilingualReaderName: 'dsh-bilingual-reader バイリンガルリーダー',
|
||||
pluginOfficeName: 'Office プレビュー',
|
||||
pluginMdExportName: 'Markdown エクスポート',
|
||||
pluginCodeNavName: 'コードプレビューナビゲーター',
|
||||
pluginVideoPreviewName: '動画プレビュー',
|
||||
presetDshDesktopDesc: 'Electron 高度モード(枠なし):macOS は上部 20px、Windows は WCO 未提供時に 32px のタイトルバー分を確保',
|
||||
}
|
||||
|
||||
@@ -11,11 +11,19 @@ export const ko: Record<string, string> = {
|
||||
files: '파일',
|
||||
changesSessionEmpty: '이 세션에 아직 파일 작업이 없습니다',
|
||||
changesRead: '읽기',
|
||||
changesMdReading: '읽기',
|
||||
changesHtmlRender: "렌더링",
|
||||
changesHtmlRaw: "원문",
|
||||
changesPdfRender: "렌더링",
|
||||
changesPdfRaw: "원문",
|
||||
changesMdRaw: '원문',
|
||||
changesWrite: '쓰기',
|
||||
changesEdit: '편집',
|
||||
changesRunning: '실행 중',
|
||||
changesError: '오류',
|
||||
changesFold: '{count}줄… 클릭하여 펼치기',
|
||||
changesFoldLoading: '펼치는 중…',
|
||||
changesFoldUnavailable: '컨텍스트를 불러올 수 없음',
|
||||
changesContext: '컨텍스트',
|
||||
changesPriorUnknown: '변경 전 내용이 창에 없어 모두 새로 추가된 것으로 표시됩니다',
|
||||
explorer: '탐색기',
|
||||
@@ -190,6 +198,13 @@ export const ko: Record<string, string> = {
|
||||
addToConversation: '대화에 추가',
|
||||
copyRelative: '상대 경로 복사',
|
||||
copyAbsolute: '절대 경로 복사',
|
||||
rename: '이름 바꾸기',
|
||||
renameInvalid: '이름은 비워 둘 수 없으며 경로 구분 기호를 포함할 수 없습니다.',
|
||||
delete: '삭제',
|
||||
deleteTitle: '"{name}"을(를) 삭제할까요?',
|
||||
deleteDescFile: '이 파일이 영구적으로 삭제됩니다. 되돌릴 수 없습니다.',
|
||||
deleteDescDir: '이 디렉터리와 모든 내용이 영구적으로 삭제됩니다. 되돌릴 수 없습니다.',
|
||||
dismiss: '닫기',
|
||||
download: '다운로드',
|
||||
uploadFiles: '파일 업로드',
|
||||
uploadFolder: '폴더 업로드',
|
||||
@@ -413,4 +428,29 @@ 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 탭',
|
||||
pluginDocsPanelName: 'dsh-docs-panel 전역 문서',
|
||||
pluginFlowglassName: 'dsh-flowglass Flowglass',
|
||||
pluginGitForgeName: 'dsh-git-forge Git 자격 증명',
|
||||
pluginGitRemotesName: 'dsh-git-remotes Git 리모트',
|
||||
pluginGithubWorkbenchName: 'dsh-github-workbench GitHub 워크벤치',
|
||||
pluginSidebarQaName: 'dsh-sidebar-qa 선택 후 질문',
|
||||
pluginSidenoteName: 'dsh-sidenote 사이드 채팅',
|
||||
pluginServerDeckName: 'dsh-server-deck 서버 데크',
|
||||
pluginSuhuangScrollName: 'dsh-suhuang-scroll Suhuang Scroll',
|
||||
pluginSshTunnelName: 'dsh-ssh-tunnel SSH 터널',
|
||||
pluginTurnReviewName: 'dsh-turn-review 턴 리뷰',
|
||||
pluginBilingualReaderName: 'dsh-bilingual-reader 이중언어 리더',
|
||||
pluginOfficeName: 'Office 미리보기',
|
||||
pluginMdExportName: 'Markdown 내보내기',
|
||||
pluginCodeNavName: '코드 미리보기 내비게이터',
|
||||
pluginVideoPreviewName: '동영상 미리보기',
|
||||
presetDshDesktopDesc: 'Electron 고급 모드(테두리 없음): macOS는 상단에 20px, Windows는 WCO가 없을 때 타이틀 바에 32px를 확보',
|
||||
}
|
||||
|
||||
@@ -17,11 +17,19 @@ 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',
|
||||
changesError: 'fout',
|
||||
changesFold: '{count} regels… klik om uit te klappen',
|
||||
changesFoldLoading: 'Uitklappen…',
|
||||
changesFoldUnavailable: 'Context niet beschikbaar',
|
||||
changesContext: 'context',
|
||||
changesPriorUnknown: 'Eerdere inhoud valt buiten het venster; getoond als volledig nieuw',
|
||||
explorer: 'Verkenner',
|
||||
@@ -196,6 +204,13 @@ export const nl: Record<string, string> = {
|
||||
addToConversation: 'Aan conversatie toevoegen',
|
||||
copyRelative: 'Relatief pad kopiëren',
|
||||
copyAbsolute: 'Absoluut pad kopiëren',
|
||||
rename: 'Naam wijzigen',
|
||||
renameInvalid: 'De naam mag niet leeg zijn of padscheidingstekens bevatten.',
|
||||
delete: 'Verwijderen',
|
||||
deleteTitle: '"{name}" verwijderen?',
|
||||
deleteDescFile: 'Dit verwijdert het bestand permanent. Dit kan niet ongedaan worden gemaakt.',
|
||||
deleteDescDir: 'Dit verwijdert de map en alles erin permanent. Dit kan niet ongedaan worden gemaakt.',
|
||||
dismiss: 'Sluiten',
|
||||
download: 'Downloaden',
|
||||
uploadFiles: 'Bestanden uploaden',
|
||||
uploadFolder: 'Map uploaden',
|
||||
@@ -419,4 +434,29 @@ 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',
|
||||
pluginDocsPanelName: 'dsh-docs-panel Globale documenten',
|
||||
pluginFlowglassName: 'dsh-flowglass Flowglass',
|
||||
pluginGitForgeName: 'dsh-git-forge Git-inloggegevens',
|
||||
pluginGitRemotesName: 'dsh-git-remotes Git-remotes',
|
||||
pluginGithubWorkbenchName: 'dsh-github-workbench GitHub-workbench',
|
||||
pluginSidebarQaName: 'dsh-sidebar-qa Selecteren & vragen',
|
||||
pluginSidenoteName: 'dsh-sidenote Zijchat',
|
||||
pluginServerDeckName: 'dsh-server-deck Serverdeck',
|
||||
pluginSuhuangScrollName: 'dsh-suhuang-scroll Suhuang Scroll',
|
||||
pluginSshTunnelName: 'dsh-ssh-tunnel SSH-tunnel',
|
||||
pluginTurnReviewName: 'dsh-turn-review Beurtreview',
|
||||
pluginBilingualReaderName: 'dsh-bilingual-reader Tweetalige lezer',
|
||||
pluginOfficeName: 'Office-voorvertoning',
|
||||
pluginMdExportName: 'Markdown-export',
|
||||
pluginCodeNavName: 'Codevoorvertoning-navigator',
|
||||
pluginVideoPreviewName: 'Videovoorvertoning',
|
||||
presetDshDesktopDesc: 'Geavanceerde Electronmodus (zonder rand): macOS reserveert 20px bovenaan; Windows reserveert 32px voor de titelbalk wanneer WCO niet beschikbaar is',
|
||||
}
|
||||
|
||||
@@ -21,11 +21,19 @@ 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',
|
||||
changesError: 'błąd',
|
||||
changesFold: '{count} wierszy… kliknij, aby rozwinąć',
|
||||
changesFoldLoading: 'Rozwijanie…',
|
||||
changesFoldUnavailable: 'Kontekst niedostępny',
|
||||
changesContext: 'kontekst',
|
||||
changesPriorUnknown: 'Zawartość sprzed zmiany jest poza oknem; pokazana jako w całości nowa',
|
||||
explorer: 'Eksplorator',
|
||||
@@ -200,6 +208,13 @@ export const pl: Record<string, string> = {
|
||||
addToConversation: 'Dodaj do rozmowy',
|
||||
copyRelative: 'Kopiuj ścieżkę względną',
|
||||
copyAbsolute: 'Kopiuj ścieżkę bezwzględną',
|
||||
rename: 'Zmień nazwę',
|
||||
renameInvalid: 'Nazwa nie może być pusta ani zawierać separatorów ścieżki.',
|
||||
delete: 'Usuń',
|
||||
deleteTitle: 'Usunąć „{name}”?',
|
||||
deleteDescFile: 'Spowoduje to trwałe usunięcie pliku. Tej operacji nie można cofnąć.',
|
||||
deleteDescDir: 'Spowoduje to trwałe usunięcie katalogu i całej jego zawartości. Tej operacji nie można cofnąć.',
|
||||
dismiss: 'Zamknij',
|
||||
download: 'Pobierz',
|
||||
uploadFiles: 'Wgraj pliki',
|
||||
uploadFolder: 'Wgraj folder',
|
||||
@@ -423,4 +438,29 @@ 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',
|
||||
pluginDocsPanelName: 'dsh-docs-panel Dokumenty globalne',
|
||||
pluginFlowglassName: 'dsh-flowglass Flowglass',
|
||||
pluginGitForgeName: 'dsh-git-forge Dane uwierzytelniające Git',
|
||||
pluginGitRemotesName: 'dsh-git-remotes Zdalne repozytoria Git',
|
||||
pluginGithubWorkbenchName: 'dsh-github-workbench Warsztat GitHub',
|
||||
pluginSidebarQaName: 'dsh-sidebar-qa Zaznacz i zapytaj',
|
||||
pluginSidenoteName: 'dsh-sidenote Czat boczny',
|
||||
pluginServerDeckName: 'dsh-server-deck Panel serwerów',
|
||||
pluginSuhuangScrollName: 'dsh-suhuang-scroll Suhuang Scroll',
|
||||
pluginSshTunnelName: 'dsh-ssh-tunnel Tunel SSH',
|
||||
pluginTurnReviewName: 'dsh-turn-review Rewizja tury',
|
||||
pluginBilingualReaderName: 'dsh-bilingual-reader Czytnik dwujęzyczny',
|
||||
pluginOfficeName: 'Podgląd Office',
|
||||
pluginMdExportName: 'Eksport Markdown',
|
||||
pluginCodeNavName: 'Nawigator podglądu kodu',
|
||||
pluginVideoPreviewName: 'Podgląd wideo',
|
||||
presetDshDesktopDesc: 'Tryb zaawansowany Electron (bez ramki): macOS rezerwuje 20px u góry; Windows bez WCO rezerwuje 32px na pasek tytułu',
|
||||
}
|
||||
|
||||
@@ -2,11 +2,19 @@ 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',
|
||||
changesError: 'erro',
|
||||
changesFold: '{count} linhas… clique para expandir',
|
||||
changesFoldLoading: 'Expandindo…',
|
||||
changesFoldUnavailable: 'Contexto indisponível',
|
||||
changesContext: 'contexto',
|
||||
changesPriorUnknown: 'O conteúdo anterior está fora da janela; mostrado como totalmente adicionado',
|
||||
explorer: 'Explorador',
|
||||
@@ -181,6 +189,13 @@ export const pt: Record<string, string> = {
|
||||
addToConversation: 'Adicionar à conversa',
|
||||
copyRelative: 'Copiar caminho relativo',
|
||||
copyAbsolute: 'Copiar caminho absoluto',
|
||||
rename: 'Renomear',
|
||||
renameInvalid: 'O nome não pode ser vazio nem conter separadores de caminho.',
|
||||
delete: 'Excluir',
|
||||
deleteTitle: 'Excluir "{name}"?',
|
||||
deleteDescFile: 'Isso exclui o arquivo permanentemente. Esta ação não pode ser desfeita.',
|
||||
deleteDescDir: 'Isso exclui o diretório e todo o seu conteúdo permanentemente. Esta ação não pode ser desfeita.',
|
||||
dismiss: 'Fechar',
|
||||
download: 'Baixar',
|
||||
uploadFiles: 'Enviar arquivos',
|
||||
uploadFolder: 'Enviar pasta',
|
||||
@@ -404,4 +419,29 @@ 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',
|
||||
pluginDocsPanelName: 'dsh-docs-panel Documentos globais',
|
||||
pluginFlowglassName: 'dsh-flowglass Flowglass',
|
||||
pluginGitForgeName: 'dsh-git-forge Credenciais Git',
|
||||
pluginGitRemotesName: 'dsh-git-remotes Remotos Git',
|
||||
pluginGithubWorkbenchName: 'dsh-github-workbench Bancada GitHub',
|
||||
pluginSidebarQaName: 'dsh-sidebar-qa Selecionar e perguntar',
|
||||
pluginSidenoteName: 'dsh-sidenote Chat lateral',
|
||||
pluginServerDeckName: 'dsh-server-deck Deck de servidores',
|
||||
pluginSuhuangScrollName: 'dsh-suhuang-scroll Suhuang Scroll',
|
||||
pluginSshTunnelName: 'dsh-ssh-tunnel Túnel SSH',
|
||||
pluginTurnReviewName: 'dsh-turn-review Revisão do turno',
|
||||
pluginBilingualReaderName: 'dsh-bilingual-reader Leitor bilíngue',
|
||||
pluginOfficeName: 'Pré-visualização Office',
|
||||
pluginMdExportName: 'Exportação Markdown',
|
||||
pluginCodeNavName: 'Navegador de pré-visualização de código',
|
||||
pluginVideoPreviewName: 'Pré-visualização de vídeo',
|
||||
presetDshDesktopDesc: 'Modo avançado Electron (sem moldura): o macOS reserva 20px no topo; o Windows reserva 32px para a barra de título sem WCO',
|
||||
}
|
||||
|
||||
@@ -15,11 +15,19 @@ export const ru: Record<string, string> = {
|
||||
files: 'Файлы',
|
||||
changesSessionEmpty: 'В этой сессии пока нет файловых операций',
|
||||
changesRead: 'Чтение',
|
||||
changesMdReading: 'Чтение',
|
||||
changesHtmlRender: "Рендер",
|
||||
changesHtmlRaw: "Исходник",
|
||||
changesPdfRender: "Рендер",
|
||||
changesPdfRaw: "Исходник",
|
||||
changesMdRaw: 'Исходный текст',
|
||||
changesWrite: 'Запись',
|
||||
changesEdit: 'Изменение',
|
||||
changesRunning: 'выполняется',
|
||||
changesError: 'ошибка',
|
||||
changesFold: '{count} строк… нажмите, чтобы развернуть',
|
||||
changesFoldLoading: 'Разворачивание…',
|
||||
changesFoldUnavailable: 'Контекст недоступен',
|
||||
changesContext: 'контекст',
|
||||
changesPriorUnknown: 'Содержимое до изменения вне окна; показано как полностью добавленное',
|
||||
explorer: 'Проводник',
|
||||
@@ -194,6 +202,13 @@ export const ru: Record<string, string> = {
|
||||
addToConversation: 'Добавить в диалог',
|
||||
copyRelative: 'Копировать относительный путь',
|
||||
copyAbsolute: 'Копировать абсолютный путь',
|
||||
rename: 'Переименовать',
|
||||
renameInvalid: 'Имя не может быть пустым или содержать разделители пути.',
|
||||
delete: 'Удалить',
|
||||
deleteTitle: 'Удалить «{name}»?',
|
||||
deleteDescFile: 'Файл будет удалён безвозвратно. Это действие нельзя отменить.',
|
||||
deleteDescDir: 'Каталог и всё его содержимое будут удалены безвозвратно. Это действие нельзя отменить.',
|
||||
dismiss: 'Закрыть',
|
||||
download: 'Скачать',
|
||||
uploadFiles: 'Загрузить файлы',
|
||||
uploadFolder: 'Загрузить папку',
|
||||
@@ -419,4 +434,29 @@ 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',
|
||||
pluginDocsPanelName: 'dsh-docs-panel Глобальные документы',
|
||||
pluginFlowglassName: 'dsh-flowglass Flowglass',
|
||||
pluginGitForgeName: 'dsh-git-forge Учётные данные Git',
|
||||
pluginGitRemotesName: 'dsh-git-remotes Удалённые репозитории Git',
|
||||
pluginGithubWorkbenchName: 'dsh-github-workbench Рабочая среда GitHub',
|
||||
pluginSidebarQaName: 'dsh-sidebar-qa Выделить и спросить',
|
||||
pluginSidenoteName: 'dsh-sidenote Боковой чат',
|
||||
pluginServerDeckName: 'dsh-server-deck Панель серверов',
|
||||
pluginSuhuangScrollName: 'dsh-suhuang-scroll Suhuang Scroll',
|
||||
pluginSshTunnelName: 'dsh-ssh-tunnel SSH-туннель',
|
||||
pluginTurnReviewName: 'dsh-turn-review Ревью раунда',
|
||||
pluginBilingualReaderName: 'dsh-bilingual-reader Двуязычный ридер',
|
||||
pluginOfficeName: 'Предпросмотр Office',
|
||||
pluginMdExportName: 'Экспорт Markdown',
|
||||
pluginCodeNavName: 'Навигатор предпросмотра кода',
|
||||
pluginVideoPreviewName: 'Предпросмотр видео',
|
||||
presetDshDesktopDesc: 'Расширенный режим Electron (без рамки): macOS резервирует 20px сверху; Windows без WCO резервирует 32px под строку заголовка',
|
||||
}
|
||||
|
||||
@@ -2,11 +2,19 @@ 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',
|
||||
changesError: 'fel',
|
||||
changesFold: '{count} rader… klicka för att expandera',
|
||||
changesFoldLoading: 'Expanderar…',
|
||||
changesFoldUnavailable: 'Kontext ej tillgänglig',
|
||||
changesContext: 'kontext',
|
||||
changesPriorUnknown: 'Innehållet före ändringen ligger utanför fönstret; visas som helt nytt',
|
||||
explorer: 'Utforskare',
|
||||
@@ -181,6 +189,13 @@ export const sv: Record<string, string> = {
|
||||
addToConversation: 'Lägg till i konversation',
|
||||
copyRelative: 'Kopiera relativ sökväg',
|
||||
copyAbsolute: 'Kopiera absolut sökväg',
|
||||
rename: 'Byt namn',
|
||||
renameInvalid: 'Namnet får inte vara tomt eller innehålla sökvägsavgränsare.',
|
||||
delete: 'Ta bort',
|
||||
deleteTitle: 'Ta bort "{name}"?',
|
||||
deleteDescFile: 'Detta tar bort filen permanent. Det går inte att ångra.',
|
||||
deleteDescDir: 'Detta tar bort katalogen och allt i den permanent. Det går inte att ångra.',
|
||||
dismiss: 'Stäng',
|
||||
download: 'Ladda ner',
|
||||
uploadFiles: 'Ladda upp filer',
|
||||
uploadFolder: 'Ladda upp mapp',
|
||||
@@ -404,4 +419,29 @@ 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',
|
||||
pluginDocsPanelName: 'dsh-docs-panel Globala dokument',
|
||||
pluginFlowglassName: 'dsh-flowglass Flowglass',
|
||||
pluginGitForgeName: 'dsh-git-forge Git-autentiseringsuppgifter',
|
||||
pluginGitRemotesName: 'dsh-git-remotes Git-fjärrarkiv',
|
||||
pluginGithubWorkbenchName: 'dsh-github-workbench GitHub-arbetsbänk',
|
||||
pluginSidebarQaName: 'dsh-sidebar-qa Markera och fråga',
|
||||
pluginSidenoteName: 'dsh-sidenote Sidchatt',
|
||||
pluginServerDeckName: 'dsh-server-deck Serverdäck',
|
||||
pluginSuhuangScrollName: 'dsh-suhuang-scroll Suhuang Scroll',
|
||||
pluginSshTunnelName: 'dsh-ssh-tunnel SSH-tunnel',
|
||||
pluginTurnReviewName: 'dsh-turn-review Varvgranskning',
|
||||
pluginBilingualReaderName: 'dsh-bilingual-reader Tvåspråkig läsare',
|
||||
pluginOfficeName: 'Office-förhandsvisning',
|
||||
pluginMdExportName: 'Markdown-export',
|
||||
pluginCodeNavName: 'Kodförhandsvisningsnavigatör',
|
||||
pluginVideoPreviewName: 'Videoförhandsvisning',
|
||||
presetDshDesktopDesc: 'Electrons avancerade läge (ramlöst): macOS reserverar 20px högst upp; Windows reserverar 32px för namnlisten när WCO inte är tillgängligt',
|
||||
}
|
||||
|
||||
@@ -19,11 +19,19 @@ export const th: Record<string, string> = {
|
||||
files: 'ไฟล์',
|
||||
changesSessionEmpty: 'ยังไม่มีการดำเนินการไฟล์ในเซสชันนี้',
|
||||
changesRead: 'อ่าน',
|
||||
changesMdReading: 'อ่าน',
|
||||
changesHtmlRender: "เรนเดอร์",
|
||||
changesHtmlRaw: "ต้นฉบับ",
|
||||
changesPdfRender: "เรนเดอร์",
|
||||
changesPdfRaw: "ต้นฉบับ",
|
||||
changesMdRaw: 'ดิบ',
|
||||
changesWrite: 'เขียน',
|
||||
changesEdit: 'แก้ไข',
|
||||
changesRunning: 'กำลังทำงาน',
|
||||
changesError: 'ข้อผิดพลาด',
|
||||
changesFold: '{count} บรรทัด… คลิกเพื่อขยาย',
|
||||
changesFoldLoading: 'กำลังขยาย…',
|
||||
changesFoldUnavailable: 'ไม่สามารถโหลดบริบทได้',
|
||||
changesContext: 'บริบท',
|
||||
changesPriorUnknown: 'เนื้อหาก่อนแก้ไขอยู่นอกหน้าต่าง แสดงเป็นเพิ่มใหม่ทั้งหมด',
|
||||
explorer: 'ตัวสำรวจ',
|
||||
@@ -198,6 +206,13 @@ export const th: Record<string, string> = {
|
||||
addToConversation: 'เพิ่มไปยังแชท',
|
||||
copyRelative: 'คัดลอกพาธสัมพัทธ์',
|
||||
copyAbsolute: 'คัดลอกพาธ absolute',
|
||||
rename: 'เปลี่ยนชื่อ',
|
||||
renameInvalid: 'ชื่อต้องไม่ว่างเปล่าและต้องไม่มีตัวคั่นเส้นทาง',
|
||||
delete: 'ลบ',
|
||||
deleteTitle: 'ลบ "{name}" หรือไม่',
|
||||
deleteDescFile: 'การดำเนินการนี้จะลบไฟล์อย่างถาวร และไม่สามารถย้อนกลับได้',
|
||||
deleteDescDir: 'การดำเนินการนี้จะลบไดเรกทอรีและเนื้อหาทั้งหมดอย่างถาวร และไม่สามารถย้อนกลับได้',
|
||||
dismiss: 'ปิด',
|
||||
download: 'ดาวน์โหลด',
|
||||
uploadFiles: 'อัปโหลดไฟล์',
|
||||
uploadFolder: 'อัปโหลดโฟลเดอร์',
|
||||
@@ -421,4 +436,29 @@ 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',
|
||||
pluginDocsPanelName: 'dsh-docs-panel เอกสารส่วนกลาง',
|
||||
pluginFlowglassName: 'dsh-flowglass Flowglass',
|
||||
pluginGitForgeName: 'dsh-git-forge ข้อมูลรับรอง Git',
|
||||
pluginGitRemotesName: 'dsh-git-remotes Remote ของ Git',
|
||||
pluginGithubWorkbenchName: 'dsh-github-workbench เวิร์กเบนช์ GitHub',
|
||||
pluginSidebarQaName: 'dsh-sidebar-qa เลือกแล้วถาม',
|
||||
pluginSidenoteName: 'dsh-sidenote แชทข้างเคียง',
|
||||
pluginServerDeckName: 'dsh-server-deck เด็คเซิร์ฟเวอร์',
|
||||
pluginSuhuangScrollName: 'dsh-suhuang-scroll Suhuang Scroll',
|
||||
pluginSshTunnelName: 'dsh-ssh-tunnel อุโมงค์ SSH',
|
||||
pluginTurnReviewName: 'dsh-turn-review รีวิวรอบ',
|
||||
pluginBilingualReaderName: 'dsh-bilingual-reader ตัวอ่านสองภาษา',
|
||||
pluginOfficeName: 'พรีวิว Office',
|
||||
pluginMdExportName: 'ส่งออก Markdown',
|
||||
pluginCodeNavName: 'ตัวนำทางพรีวิวโค้ด',
|
||||
pluginVideoPreviewName: 'พรีวิววิดีโอ',
|
||||
presetDshDesktopDesc: 'โหมดขั้นสูงของ Electron (ไร้กรอบ): macOS จองแถบด้านบน 20px; Windows จอง 32px ให้แถบหัวเรื่องเมื่อไม่มี WCO',
|
||||
}
|
||||
|
||||
@@ -19,11 +19,19 @@ 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',
|
||||
changesError: 'hata',
|
||||
changesFold: '{count} satır… genişletmek için tıklayın',
|
||||
changesFoldLoading: 'Genişletiliyor…',
|
||||
changesFoldUnavailable: 'Bağlam yüklenemedi',
|
||||
changesContext: 'bağlam',
|
||||
changesPriorUnknown: 'Değişiklik öncesi içerik pencere dışında; tamamen yeni eklenmiş olarak gösteriliyor',
|
||||
explorer: 'Gezgin',
|
||||
@@ -198,6 +206,13 @@ export const tr: Record<string, string> = {
|
||||
addToConversation: 'Sohbete ekle',
|
||||
copyRelative: 'Göreli yolu kopyala',
|
||||
copyAbsolute: 'Mutlak yolu kopyala',
|
||||
rename: 'Yeniden adlandır',
|
||||
renameInvalid: 'Ad boş olamaz veya yol ayırıcıları içeremez.',
|
||||
delete: 'Sil',
|
||||
deleteTitle: '"{name}" silinsin mi?',
|
||||
deleteDescFile: 'Bu, dosyayı kalıcı olarak siler. Bu işlem geri alınamaz.',
|
||||
deleteDescDir: 'Bu, dizini ve tüm içeriğini kalıcı olarak siler. Bu işlem geri alınamaz.',
|
||||
dismiss: 'Kapat',
|
||||
download: 'İndir',
|
||||
uploadFiles: 'Dosyaları yükle',
|
||||
uploadFolder: 'Klasör yükle',
|
||||
@@ -421,4 +436,29 @@ 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',
|
||||
pluginDocsPanelName: 'dsh-docs-panel Genel belgeler',
|
||||
pluginFlowglassName: 'dsh-flowglass Flowglass',
|
||||
pluginGitForgeName: 'dsh-git-forge Git kimlik bilgileri',
|
||||
pluginGitRemotesName: 'dsh-git-remotes Git uzak depoları',
|
||||
pluginGithubWorkbenchName: 'dsh-github-workbench GitHub çalışma tezgâhı',
|
||||
pluginSidebarQaName: 'dsh-sidebar-qa Seç ve sor',
|
||||
pluginSidenoteName: 'dsh-sidenote Yan sohbet',
|
||||
pluginServerDeckName: 'dsh-server-deck Sunucu paneli',
|
||||
pluginSuhuangScrollName: 'dsh-suhuang-scroll Suhuang Scroll',
|
||||
pluginSshTunnelName: 'dsh-ssh-tunnel SSH tüneli',
|
||||
pluginTurnReviewName: 'dsh-turn-review Tur incelemesi',
|
||||
pluginBilingualReaderName: 'dsh-bilingual-reader İki dilli okuyucu',
|
||||
pluginOfficeName: 'Office önizlemesi',
|
||||
pluginMdExportName: 'Markdown dışa aktarma',
|
||||
pluginCodeNavName: 'Kod önizleme gezgini',
|
||||
pluginVideoPreviewName: 'Video önizlemesi',
|
||||
presetDshDesktopDesc: 'Electron gelişmiş (çerçevesiz) mod: macOS üstte 20px ayırır; Windows, WCO yokken başlık çubuğu için 32px ayırır',
|
||||
}
|
||||
|
||||
@@ -19,11 +19,19 @@ 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',
|
||||
changesError: 'lỗi',
|
||||
changesFold: '{count} dòng… bấm để mở rộng',
|
||||
changesFoldLoading: 'Đang mở rộng…',
|
||||
changesFoldUnavailable: 'Không tải được ngữ cảnh',
|
||||
changesContext: 'ngữ cảnh',
|
||||
changesPriorUnknown: 'Nội dung trước thay đổi nằm ngoài cửa sổ; hiển thị như toàn bộ mới thêm',
|
||||
explorer: 'Trình khám phá',
|
||||
@@ -198,6 +206,13 @@ export const vi: Record<string, string> = {
|
||||
addToConversation: 'Thêm vào cuộc trò chuyện',
|
||||
copyRelative: 'Sao chép đường dẫn tương đối',
|
||||
copyAbsolute: 'Sao chép đường dẫn tuyệt đối',
|
||||
rename: 'Đổi tên',
|
||||
renameInvalid: 'Tên không được để trống hoặc chứa dấu phân tách đường dẫn.',
|
||||
delete: 'Xóa',
|
||||
deleteTitle: 'Xóa "{name}"?',
|
||||
deleteDescFile: 'Thao tác này sẽ xóa vĩnh viễn tệp. Không thể hoàn tác.',
|
||||
deleteDescDir: 'Thao tác này sẽ xóa vĩnh viễn thư mục và toàn bộ nội dung của nó. Không thể hoàn tác.',
|
||||
dismiss: 'Đóng',
|
||||
download: 'Tải xuống',
|
||||
uploadFiles: 'Tải tệp lên',
|
||||
uploadFolder: 'Tải thư mục lên',
|
||||
@@ -421,4 +436,29 @@ 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',
|
||||
pluginDocsPanelName: 'dsh-docs-panel Tài liệu toàn cục',
|
||||
pluginFlowglassName: 'dsh-flowglass Flowglass',
|
||||
pluginGitForgeName: 'dsh-git-forge Thông tin đăng nhập Git',
|
||||
pluginGitRemotesName: 'dsh-git-remotes Remote Git',
|
||||
pluginGithubWorkbenchName: 'dsh-github-workbench Bàn làm việc GitHub',
|
||||
pluginSidebarQaName: 'dsh-sidebar-qa Chọn & hỏi',
|
||||
pluginSidenoteName: 'dsh-sidenote Trò chuyện bên',
|
||||
pluginServerDeckName: 'dsh-server-deck Bảng máy chủ',
|
||||
pluginSuhuangScrollName: 'dsh-suhuang-scroll Suhuang Scroll',
|
||||
pluginSshTunnelName: 'dsh-ssh-tunnel Đường hầm SSH',
|
||||
pluginTurnReviewName: 'dsh-turn-review Duyệt vòng',
|
||||
pluginBilingualReaderName: 'dsh-bilingual-reader Trình đọc song ngữ',
|
||||
pluginOfficeName: 'Xem trước Office',
|
||||
pluginMdExportName: 'Xuất Markdown',
|
||||
pluginCodeNavName: 'Trình điều hướng xem trước mã',
|
||||
pluginVideoPreviewName: 'Xem trước video',
|
||||
presetDshDesktopDesc: 'Chế độ Electron nâng cao (không viền): macOS dành 20px ở trên cùng; Windows dành 32px cho thanh tiêu đề khi không có WCO',
|
||||
}
|
||||
|
||||
@@ -34,11 +34,19 @@ export const zhHK: Record<string, string> = {
|
||||
files: '檔案',
|
||||
changesSessionEmpty: '本工作階段還沒有檔案操作',
|
||||
changesRead: '讀取',
|
||||
changesMdReading: '閱讀',
|
||||
changesHtmlRender: "渲染",
|
||||
changesHtmlRaw: "原文",
|
||||
changesPdfRender: "渲染",
|
||||
changesPdfRaw: "原文",
|
||||
changesMdRaw: '原文',
|
||||
changesWrite: '寫入',
|
||||
changesEdit: '編輯',
|
||||
changesRunning: '執行中',
|
||||
changesError: '出錯',
|
||||
changesFold: '{count} 行…點擊展開',
|
||||
changesFoldLoading: '展開中…',
|
||||
changesFoldUnavailable: '上下文未載入',
|
||||
changesContext: '上下文',
|
||||
changesPriorUnknown: '變更前的內容不在視窗內,顯示為全部新增',
|
||||
explorer: '檔案總管',
|
||||
@@ -213,6 +221,13 @@ export const zhHK: Record<string, string> = {
|
||||
addToConversation: '新增到對話',
|
||||
copyRelative: '複製相對位址',
|
||||
copyAbsolute: '複製絕對位址',
|
||||
rename: '重新命名',
|
||||
renameInvalid: '名稱不可為空,且不可包含路徑分隔符。',
|
||||
delete: '刪除',
|
||||
deleteTitle: '刪除「{name}」?',
|
||||
deleteDescFile: '將永久刪除該檔案,此操作無法復原。',
|
||||
deleteDescDir: '將永久刪除該目錄及其全部內容,此操作無法復原。',
|
||||
dismiss: '關閉',
|
||||
download: '下載',
|
||||
uploadFiles: '上傳檔案',
|
||||
uploadFolder: '上傳資料夾',
|
||||
@@ -436,4 +451,29 @@ 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 標籤',
|
||||
pluginDocsPanelName: 'dsh-docs-panel 全域文件',
|
||||
pluginFlowglassName: 'dsh-flowglass 流鏡',
|
||||
pluginGitForgeName: 'dsh-git-forge Git 憑證',
|
||||
pluginGitRemotesName: 'dsh-git-remotes Git 遠端',
|
||||
pluginGithubWorkbenchName: 'dsh-github-workbench GitHub 工作台',
|
||||
pluginSidebarQaName: 'dsh-sidebar-qa 劃選追問',
|
||||
pluginSidenoteName: 'dsh-sidenote 側邊對話',
|
||||
pluginServerDeckName: 'dsh-server-deck 伺服器甲板',
|
||||
pluginSuhuangScrollName: 'dsh-suhuang-scroll 蘇黃共閱',
|
||||
pluginSshTunnelName: 'dsh-ssh-tunnel SSH 隧道',
|
||||
pluginTurnReviewName: 'dsh-turn-review 本輪審查',
|
||||
pluginBilingualReaderName: 'dsh-bilingual-reader 雙語閱讀',
|
||||
pluginOfficeName: 'Office 預覽插件',
|
||||
pluginMdExportName: 'Markdown 匯出插件',
|
||||
pluginCodeNavName: '程式碼預覽導航',
|
||||
pluginVideoPreviewName: '影片預覽插件',
|
||||
presetDshDesktopDesc: 'Electron 進階模式(無邊框):macOS 頂欄 20px、Windows 無 WCO 時 32px 標題欄讓位',
|
||||
}
|
||||
|
||||
@@ -34,11 +34,19 @@ export const zhMO: Record<string, string> = {
|
||||
files: '檔案',
|
||||
changesSessionEmpty: '本工作階段還沒有檔案操作',
|
||||
changesRead: '讀取',
|
||||
changesMdReading: '閱讀',
|
||||
changesHtmlRender: "渲染",
|
||||
changesHtmlRaw: "原文",
|
||||
changesPdfRender: "渲染",
|
||||
changesPdfRaw: "原文",
|
||||
changesMdRaw: '原文',
|
||||
changesWrite: '寫入',
|
||||
changesEdit: '編輯',
|
||||
changesRunning: '執行中',
|
||||
changesError: '出錯',
|
||||
changesFold: '{count} 行…點擊展開',
|
||||
changesFoldLoading: '展開中…',
|
||||
changesFoldUnavailable: '上下文未載入',
|
||||
changesContext: '上下文',
|
||||
changesPriorUnknown: '變更前的內容不在視窗內,顯示為全部新增',
|
||||
explorer: '檔案總管',
|
||||
@@ -213,6 +221,13 @@ export const zhMO: Record<string, string> = {
|
||||
addToConversation: '新增到對話',
|
||||
copyRelative: '複製相對位址',
|
||||
copyAbsolute: '複製絕對位址',
|
||||
rename: '重新命名',
|
||||
renameInvalid: '名稱不可為空,且不可包含路徑分隔符。',
|
||||
delete: '刪除',
|
||||
deleteTitle: '刪除「{name}」?',
|
||||
deleteDescFile: '將永久刪除該檔案,此操作無法復原。',
|
||||
deleteDescDir: '將永久刪除該目錄及其全部內容,此操作無法復原。',
|
||||
dismiss: '關閉',
|
||||
download: '下載',
|
||||
uploadFiles: '上傳檔案',
|
||||
uploadFolder: '上傳資料夾',
|
||||
@@ -436,4 +451,29 @@ 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 標籤',
|
||||
pluginDocsPanelName: 'dsh-docs-panel 全域文件',
|
||||
pluginFlowglassName: 'dsh-flowglass 流鏡',
|
||||
pluginGitForgeName: 'dsh-git-forge Git 憑證',
|
||||
pluginGitRemotesName: 'dsh-git-remotes Git 遠端',
|
||||
pluginGithubWorkbenchName: 'dsh-github-workbench GitHub 工作台',
|
||||
pluginSidebarQaName: 'dsh-sidebar-qa 劃選追問',
|
||||
pluginSidenoteName: 'dsh-sidenote 側邊對話',
|
||||
pluginServerDeckName: 'dsh-server-deck 伺服器甲板',
|
||||
pluginSuhuangScrollName: 'dsh-suhuang-scroll 蘇黃共閱',
|
||||
pluginSshTunnelName: 'dsh-ssh-tunnel SSH 隧道',
|
||||
pluginTurnReviewName: 'dsh-turn-review 本輪審查',
|
||||
pluginBilingualReaderName: 'dsh-bilingual-reader 雙語閱讀',
|
||||
pluginOfficeName: 'Office 預覽插件',
|
||||
pluginMdExportName: 'Markdown 匯出插件',
|
||||
pluginCodeNavName: '代碼預覽導航',
|
||||
pluginVideoPreviewName: '影片預覽插件',
|
||||
presetDshDesktopDesc: 'Electron 進階模式(無邊框):macOS 頂欄 20px、Windows 無 WCO 時 32px 標題欄讓位',
|
||||
}
|
||||
|
||||
@@ -34,11 +34,19 @@ export const zhTW: Record<string, string> = {
|
||||
files: '檔案',
|
||||
changesSessionEmpty: '本作業階段還沒有檔案操作',
|
||||
changesRead: '讀取',
|
||||
changesMdReading: '閱讀',
|
||||
changesHtmlRender: "渲染",
|
||||
changesHtmlRaw: "原文",
|
||||
changesPdfRender: "渲染",
|
||||
changesPdfRaw: "原文",
|
||||
changesMdRaw: '原文',
|
||||
changesWrite: '寫入',
|
||||
changesEdit: '編輯',
|
||||
changesRunning: '執行中',
|
||||
changesError: '出錯',
|
||||
changesFold: '{count} 行…點擊展開',
|
||||
changesFoldLoading: '展開中…',
|
||||
changesFoldUnavailable: '上下文未載入',
|
||||
changesContext: '上下文',
|
||||
changesPriorUnknown: '變更前的內容不在視窗內,顯示為全部新增',
|
||||
explorer: '檔案總管',
|
||||
@@ -213,6 +221,13 @@ export const zhTW: Record<string, string> = {
|
||||
addToConversation: '新增到對話',
|
||||
copyRelative: '複製相對位址',
|
||||
copyAbsolute: '複製絕對位址',
|
||||
rename: '重新命名',
|
||||
renameInvalid: '名稱不可為空,且不可包含路徑分隔符。',
|
||||
delete: '刪除',
|
||||
deleteTitle: '刪除「{name}」?',
|
||||
deleteDescFile: '將永久刪除該檔案,此操作無法復原。',
|
||||
deleteDescDir: '將永久刪除該目錄及其全部內容,此操作無法復原。',
|
||||
dismiss: '關閉',
|
||||
download: '下載',
|
||||
uploadFiles: '上傳檔案',
|
||||
uploadFolder: '上傳資料夾',
|
||||
@@ -436,4 +451,29 @@ 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 標籤',
|
||||
pluginDocsPanelName: 'dsh-docs-panel 全域文件',
|
||||
pluginFlowglassName: 'dsh-flowglass 流鏡',
|
||||
pluginGitForgeName: 'dsh-git-forge Git 憑證',
|
||||
pluginGitRemotesName: 'dsh-git-remotes Git 遠端',
|
||||
pluginGithubWorkbenchName: 'dsh-github-workbench GitHub 工作台',
|
||||
pluginSidebarQaName: 'dsh-sidebar-qa 劃選追問',
|
||||
pluginSidenoteName: 'dsh-sidenote 側邊對話',
|
||||
pluginServerDeckName: 'dsh-server-deck 伺服器甲板',
|
||||
pluginSuhuangScrollName: 'dsh-suhuang-scroll 蘇黃共閱',
|
||||
pluginSshTunnelName: 'dsh-ssh-tunnel SSH 隧道',
|
||||
pluginTurnReviewName: 'dsh-turn-review 本輪審查',
|
||||
pluginBilingualReaderName: 'dsh-bilingual-reader 雙語閱讀',
|
||||
pluginOfficeName: 'Office 預覽插件',
|
||||
pluginMdExportName: 'Markdown 匯出插件',
|
||||
pluginCodeNavName: '程式碼預覽導覽',
|
||||
pluginVideoPreviewName: '影片預覽插件',
|
||||
presetDshDesktopDesc: 'Electron 進階模式(無邊框):macOS 頂欄 20px、Windows 無 WCO 時 32px 標題列讓位',
|
||||
}
|
||||
|
||||
@@ -26,11 +26,24 @@ export const zh = {
|
||||
files: '文件',
|
||||
changesSessionEmpty: '本会话还没有文件操作',
|
||||
changesRead: '读取',
|
||||
changesMdReading: '阅读',
|
||||
changesMdRaw: '原文',
|
||||
changesHtmlRender: '渲染',
|
||||
changesHtmlRaw: '原文',
|
||||
changesPdfRender: '渲染',
|
||||
changesPdfRaw: '原文',
|
||||
changesWrite: '写入',
|
||||
changesEdit: '编辑',
|
||||
changesRunning: '执行中',
|
||||
changesError: '出错',
|
||||
changesRedactOn: '已开启脱敏:敏感内容以 [REDACTED] 显示;点击关闭(自担风险)',
|
||||
changesRedactOff: '脱敏已关闭:将原样显示文件内容;点击开启',
|
||||
changesRedactOnLabel: '脱敏中',
|
||||
changesRedactOffLabel: '脱敏关',
|
||||
changesRedactBanner: '已脱敏',
|
||||
changesFold: '{count} 行…点击展开',
|
||||
changesFoldLoading: '展开中…',
|
||||
changesFoldUnavailable: '上下文未加载',
|
||||
changesContext: '上下文',
|
||||
changesPriorUnknown: '变更前的内容不在窗口内,显示为全新增',
|
||||
explorer: '资源管理器',
|
||||
@@ -205,6 +218,13 @@ export const zh = {
|
||||
addToConversation: '添加到对话',
|
||||
copyRelative: '复制相对地址',
|
||||
copyAbsolute: '复制绝对地址',
|
||||
rename: '重命名',
|
||||
renameInvalid: '名称不能为空,且不能包含路径分隔符。',
|
||||
delete: '删除',
|
||||
deleteTitle: '删除「{name}」?',
|
||||
deleteDescFile: '将永久删除该文件,此操作不可撤销。',
|
||||
deleteDescDir: '将永久删除该目录及其全部内容,此操作不可撤销。',
|
||||
dismiss: '关闭',
|
||||
download: '下载',
|
||||
uploadFiles: '上传文件',
|
||||
uploadFolder: '上传文件夹',
|
||||
@@ -428,6 +448,26 @@ export const zh = {
|
||||
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 显示,选中一段文字即用大模型划词翻译,结合上下文、完全隔离主对话,只作阅读辅助',
|
||||
pluginSentinelName: 'dsh-sentinel 唤醒系统',
|
||||
pluginEgoBrowserName: 'ego-browser Agent 浏览器',
|
||||
pluginBetterOverleafName: 'dsh-better-overleaf Overleaf 标签页',
|
||||
pluginDocsPanelName: 'dsh-docs-panel 全局文档',
|
||||
pluginFlowglassName: 'dsh-flowglass 流镜',
|
||||
pluginGitForgeName: 'dsh-git-forge Git 凭据',
|
||||
pluginGitRemotesName: 'dsh-git-remotes Git 远程',
|
||||
pluginGithubWorkbenchName: 'dsh-github-workbench GitHub 工作台',
|
||||
pluginSidebarQaName: 'dsh-sidebar-qa 划选追问',
|
||||
pluginSidenoteName: 'dsh-sidenote 侧边聊天',
|
||||
pluginServerDeckName: 'dsh-server-deck 服务器甲板',
|
||||
pluginSuhuangScrollName: 'dsh-suhuang-scroll 苏黄共阅',
|
||||
pluginSshTunnelName: 'dsh-ssh-tunnel SSH 隧道',
|
||||
pluginTurnReviewName: 'dsh-turn-review 本轮审查',
|
||||
pluginBilingualReaderName: 'dsh-bilingual-reader 双语阅读',
|
||||
pluginOfficeName: 'Office 预览插件',
|
||||
pluginMdExportName: 'Markdown 导出插件',
|
||||
pluginCodeNavName: '代码预览导航',
|
||||
pluginVideoPreviewName: '视频预览插件',
|
||||
presetDshDesktopDesc: 'Electron 高级模式(无边框):macOS 顶栏 20px、Windows 无 WCO 时 32px 标题栏让位',
|
||||
}
|
||||
|
||||
/** The en dictionary (key-set-equal to zh, enforced by the type annotation). */
|
||||
@@ -435,11 +475,24 @@ 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',
|
||||
changesFoldLoading: 'Expanding…',
|
||||
changesFoldUnavailable: 'Context unavailable',
|
||||
changesContext: 'context',
|
||||
changesPriorUnknown: 'Prior content is outside the loaded window; shown as all-added',
|
||||
explorer: 'Explorer',
|
||||
@@ -614,6 +667,13 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
addToConversation: 'Add to conversation',
|
||||
copyRelative: 'Copy relative path',
|
||||
copyAbsolute: 'Copy absolute path',
|
||||
rename: 'Rename',
|
||||
renameInvalid: 'The name cannot be empty or contain path separators.',
|
||||
delete: 'Delete',
|
||||
deleteTitle: 'Delete "{name}"?',
|
||||
deleteDescFile: 'This permanently deletes the file. This cannot be undone.',
|
||||
deleteDescDir: 'This permanently deletes the directory and everything inside it. This cannot be undone.',
|
||||
dismiss: 'Dismiss',
|
||||
download: 'Download',
|
||||
uploadFiles: 'Upload files',
|
||||
uploadFolder: 'Upload folder',
|
||||
@@ -837,6 +897,26 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
pluginEgoBrowserDesc: 'The agent browser for DeepSeek Harness: 32 ego_* tools drive a real Chromium, with a native sidebar "ego browser" tab giving a live view of every page the agent visits — you can click, drag, and type to take over. Registers the tab automatically when better-sidebar is present, otherwise falls back to a floating bubble',
|
||||
pluginBetterOverleafDesc: 'Overleaf tab for better-sidebar: direct-CDP browser login (third-party Chromium supported), project list/switch, local git mirrors under <workspace>/overleaf/, two-way git sync with read-only API fallback, and file preview through the sidebar workbench',
|
||||
pluginBilingualReaderDesc: 'Read paper PDFs in the DSH sidebar: native PDF rendering, select text to translate it with the LLM, using context while staying fully isolated from the main conversation — a reading aid only',
|
||||
pluginSentinelName: 'dsh-sentinel Wake-up System',
|
||||
pluginEgoBrowserName: 'ego-browser Agent Browser',
|
||||
pluginBetterOverleafName: 'dsh-better-overleaf Overleaf Tab',
|
||||
pluginDocsPanelName: 'dsh-docs-panel Global Docs',
|
||||
pluginFlowglassName: 'dsh-flowglass Flowglass',
|
||||
pluginGitForgeName: 'dsh-git-forge Git Credentials',
|
||||
pluginGitRemotesName: 'dsh-git-remotes Git Remotes',
|
||||
pluginGithubWorkbenchName: 'dsh-github-workbench GitHub Workbench',
|
||||
pluginSidebarQaName: 'dsh-sidebar-qa Select & Ask',
|
||||
pluginSidenoteName: 'dsh-sidenote Side Chat',
|
||||
pluginServerDeckName: 'dsh-server-deck Server Deck',
|
||||
pluginSuhuangScrollName: 'dsh-suhuang-scroll Suhuang Scroll',
|
||||
pluginSshTunnelName: 'dsh-ssh-tunnel SSH Tunnel',
|
||||
pluginTurnReviewName: 'dsh-turn-review Turn Review',
|
||||
pluginBilingualReaderName: 'dsh-bilingual-reader Bilingual Reader',
|
||||
pluginOfficeName: 'Office Preview',
|
||||
pluginMdExportName: 'Markdown Export',
|
||||
pluginCodeNavName: 'Code Preview Navigator',
|
||||
pluginVideoPreviewName: 'Video Preview',
|
||||
presetDshDesktopDesc: 'Electron advanced (frameless) mode: macOS reserves a 20px top strip; Windows reserves a 32px title bar when WCO is unavailable',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -133,5 +133,6 @@ export function rewriteLocalImageUrls(
|
||||
return `${head}${resolve(dest.replace(/^<|>$/g, ''))}`
|
||||
})
|
||||
|
||||
// eslint-disable-next-line no-control-regex -- NUL is the deliberate mask sentinel (cannot appear in source markdown)
|
||||
return refsRewritten.replace(/\u0000(\d+)\u0000/g, (_m, index: string) => masks[Number(index)] ?? '')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Submenu flip geometry for the portaled context menus.
|
||||
*
|
||||
* The primitives `Menu` clamps its main list into the viewport (portal mode,
|
||||
* 12px margin) but positions nested submenus with pure CSS — `bottom: -4px`
|
||||
* (grow UP from the parent row) and `left: calc(100% + 10px)` (open RIGHT of
|
||||
* the card) — with no viewport clamp at all. This sidebar lives docked on the
|
||||
* RIGHT edge of the window, so both defaults are the worst case here: a tall
|
||||
* "open with" submenu pokes past the viewport top when the row menu opens
|
||||
* near the top, and past the right edge whenever the panel is narrow.
|
||||
*
|
||||
* The host primitive is not ours to patch, so the fix is a body attribute
|
||||
* written while one of OUR submenu-bearing menus is open: layout.css flips
|
||||
* the submenu's growth direction per token. The attribute's lifetime IS the
|
||||
* scope — only our menus set it, and layout.css rules for
|
||||
* `div[role="menu"] div[role="menu"]` only apply while it exists, so
|
||||
* host-owned menus are never touched.
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react'
|
||||
|
||||
/** body attribute carrying the flip tokens (whitespace-separated words). */
|
||||
export const SUBMENU_FLIP_ATTR = 'data-dsh-sidebar-submenu'
|
||||
|
||||
/**
|
||||
* Mirror of the primitive's submenu geometry (Menu.module.css): card
|
||||
* min-width 218 + card-to-submenu gap 10 + submenu min-width 163..165 +
|
||||
* viewport margin 12 ≈ 400px of right-hand room a right-growing submenu
|
||||
* needs. If the cursor is within that of the right edge, open left instead.
|
||||
* The estimate is conservative (the primitive's own clamp can only move the
|
||||
* card further left), so a wrong guess flips left unnecessarily — never into
|
||||
* a new overflow — and drifting from future host metrics degrades the same
|
||||
* way, back to today's behavior at worst.
|
||||
*/
|
||||
const SUBMENU_RIGHT_ROOM = 400
|
||||
|
||||
/** Below the vertical midpoint the parent row usually has more room above. */
|
||||
function isUpperHalf(y: number, vh: number): boolean {
|
||||
return y < vh / 2
|
||||
}
|
||||
|
||||
/**
|
||||
* The flip tokens for a menu opened at viewport coordinates (x, y):
|
||||
* `down` — open the submenu downward from the parent row (cursor in the
|
||||
* upper half; the sidebar's tab strip is always up there);
|
||||
* `left` — open the submenu leftward of the card (not enough right-hand
|
||||
* room). Empty string when the primitive's defaults already fit.
|
||||
*/
|
||||
export function submenuFlipTokens(x: number, y: number, vw: number, vh: number): string {
|
||||
const tokens: string[] = []
|
||||
if (isUpperHalf(y, vh)) tokens.push('down')
|
||||
if (x + SUBMENU_RIGHT_ROOM > vw) tokens.push('left')
|
||||
return tokens.join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish the flip tokens on <body> while the given cursor-anchored menu
|
||||
* state is open; clear them on close and on unmount (a menu left open by an
|
||||
* unmounting panel must not leave stale geometry on the shared body).
|
||||
* `menu` only needs the viewport coordinates the Menu anchored to.
|
||||
*/
|
||||
export function useSubmenuFlip(menu: { x: number; y: number } | null): void {
|
||||
useEffect(() => {
|
||||
if (menu === null) return
|
||||
document.body.setAttribute(SUBMENU_FLIP_ATTR, submenuFlipTokens(menu.x, menu.y, window.innerWidth, window.innerHeight))
|
||||
return () => { document.body.removeAttribute(SUBMENU_FLIP_ATTR) }
|
||||
}, [menu])
|
||||
}
|
||||
@@ -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,62 @@
|
||||
/**
|
||||
* The single source for the syntax color families the app's code surfaces
|
||||
* use: one-dark for the dark scheme, one-light for the light scheme. Two
|
||||
* lazy-chunk consumers assemble their theme objects from these constants —
|
||||
* the terminal's curated ANSI 16 (TerminalView.tsx) and CodeMirror's
|
||||
* highlight rules (cm-themes.ts) — so a family color is defined exactly
|
||||
* once and the two views can never drift apart.
|
||||
*
|
||||
* The palette holds only the syntax hues; surface colors (background,
|
||||
* foreground, caret, gutter) are theme-token driven and selection tints /
|
||||
* token-fallback colors stay with their consumers (they are not part of
|
||||
* the designed syntax families).
|
||||
*
|
||||
* Chunk boundary: this module is inlined into BOTH lazy chunks that import
|
||||
* it (terminal and editor) — the constants are plain strings, the per-chunk
|
||||
* copy is a few hundred bytes, and no cross-chunk import is introduced. The
|
||||
* core bundle does not (and must not) import this module.
|
||||
*/
|
||||
|
||||
/** one-dark family (dark scheme) syntax hues. */
|
||||
export const ONE_DARK = {
|
||||
/** Editor-background tone; ANSI black. */
|
||||
black: '#282c34',
|
||||
/** Default foreground gray; ANSI white. */
|
||||
gray: '#abb2bf',
|
||||
/** Comment gray; ANSI bright black. */
|
||||
faintGray: '#5c6370',
|
||||
/** Pure white; ANSI bright white and the invalid-token color. */
|
||||
white: '#ffffff',
|
||||
red: '#e06c75',
|
||||
green: '#98c379',
|
||||
yellow: '#e5c07b',
|
||||
blue: '#61afef',
|
||||
magenta: '#c678dd',
|
||||
cyan: '#56b6c2',
|
||||
/** Numbers, bools, atoms, attribute names. */
|
||||
orange: '#d19a66',
|
||||
} as const
|
||||
|
||||
/** one-light family (light scheme) syntax hues. */
|
||||
export const ONE_LIGHT = {
|
||||
/** Foreground dark; ANSI black and the operator color. */
|
||||
black: '#383a42',
|
||||
/** Comment gray; ANSI white. */
|
||||
gray: '#a0a1a7',
|
||||
/** ANSI bright black. */
|
||||
faintGray: '#4f525e',
|
||||
/** Pure white; the invalid-token color (stays white in light for contrast). */
|
||||
white: '#ffffff',
|
||||
/** Off-white; ANSI bright white. */
|
||||
offWhite: '#fafafa',
|
||||
red: '#e45649',
|
||||
green: '#50a14f',
|
||||
yellow: '#c18401',
|
||||
blue: '#0184bc',
|
||||
magenta: '#a626a4',
|
||||
cyan: '#0997b3',
|
||||
/** Numbers and attribute names. */
|
||||
orange: '#986801',
|
||||
/** Markdown links — the light family's dedicated link blue (dark rides blue). */
|
||||
link: '#4078f2',
|
||||
} as const
|
||||
@@ -14,8 +14,10 @@ export const PLUGIN_TOPIC_URL = 'https://github.com/topics/dsh-better-sidebar'
|
||||
export interface PluginEntry {
|
||||
/** Unique id (the npm package name). */
|
||||
id: string
|
||||
/** Short display name. */
|
||||
name: string
|
||||
/** Short display name (i18n friendly: string or () => string — the plain
|
||||
* string form is the raw-name fallback when no dictionary key covers the
|
||||
* entry, mirroring `description`). */
|
||||
name: string | (() => string)
|
||||
/** GitHub repository URL. */
|
||||
url: string
|
||||
/** One-line description (i18n friendly: string or () => string). */
|
||||
|
||||
+20
-20
@@ -2,11 +2,11 @@
|
||||
* The built-in catalog of TAB-registration plugins (sidebar pages),
|
||||
* shown in the "add tab plugin" modal (Side card settings → 侧边栏内容 grid
|
||||
* → the dashed card). Adding an entry: append one object here (unique
|
||||
* `id` = npm package name, `url` = GitHub repo, `description` =
|
||||
* i18n-friendly (add a `pluginXxxDesc` key in locales.ts), `install` = the
|
||||
* full one-line install script — it starts with `cd ~/.dsh` so the install
|
||||
* runs with the DSH home as the working directory). Data integrity is
|
||||
* guarded by `tests/plugin-list.spec.ts`.
|
||||
* `id` = npm package name, `name` / `description` = i18n-friendly (add a
|
||||
* `pluginXxxName` / `pluginXxxDesc` key in locales.ts), `url` = GitHub repo,
|
||||
* `install` = the full one-line install script — it starts with `cd ~/.dsh`
|
||||
* so the install runs with the DSH home as the working directory). Data
|
||||
* integrity is guarded by `tests/plugin-list.spec.ts`.
|
||||
*/
|
||||
import { t } from './locales.ts'
|
||||
import type { PluginEntry } from './plugins-shared.ts'
|
||||
@@ -15,7 +15,7 @@ import type { PluginEntry } from './plugins-shared.ts'
|
||||
export const builtinTabPlugins: readonly PluginEntry[] = [
|
||||
{
|
||||
id: '@dsh-external/dsh-sentinel',
|
||||
name: 'dsh-sentinel 唤醒系统',
|
||||
name: () => t('pluginSentinelName'),
|
||||
url: 'https://github.com/fuhefei/dsh-sentinel',
|
||||
description: () => t('pluginSentinelDesc'),
|
||||
// The official one-line bundle-channel install (git source, build
|
||||
@@ -26,7 +26,7 @@ export const builtinTabPlugins: readonly PluginEntry[] = [
|
||||
},
|
||||
{
|
||||
id: '@dsh-external/ego-browser',
|
||||
name: 'ego-browser Agent 浏览器',
|
||||
name: () => t('pluginEgoBrowserName'),
|
||||
url: 'https://github.com/Fisfzy/ego-browser',
|
||||
description: () => t('pluginEgoBrowserDesc'),
|
||||
// Registers a sidebar tab for the agent browser; optional peer of
|
||||
@@ -35,7 +35,7 @@ export const builtinTabPlugins: readonly PluginEntry[] = [
|
||||
},
|
||||
{
|
||||
id: 'dsh-better-overleaf',
|
||||
name: 'dsh-better-overleaf Overleaf 标签页',
|
||||
name: () => t('pluginBetterOverleafName'),
|
||||
url: 'https://github.com/Hoemr/dsh-better-overleaf',
|
||||
description: () => t('pluginBetterOverleafDesc'),
|
||||
// Published on npm; peer-depends on dsh-better-sidebar (Overleaf tab),
|
||||
@@ -44,7 +44,7 @@ export const builtinTabPlugins: readonly PluginEntry[] = [
|
||||
},
|
||||
{
|
||||
id: 'dsh-docs-panel',
|
||||
name: 'dsh-docs-panel 全局文档',
|
||||
name: () => t('pluginDocsPanelName'),
|
||||
url: 'https://github.com/mlosun/dsh-docs-panel',
|
||||
description: () => t('pluginDocsPanelDesc'),
|
||||
// dsh-docs-panel hard-depends on dsh-better-sidebar (required peer), so
|
||||
@@ -53,7 +53,7 @@ export const builtinTabPlugins: readonly PluginEntry[] = [
|
||||
},
|
||||
{
|
||||
id: 'dsh-flowglass',
|
||||
name: 'dsh-flowglass 流镜',
|
||||
name: () => t('pluginFlowglassName'),
|
||||
url: 'https://github.com/Iwctwbh/dsh-flowglass',
|
||||
description: () => t('pluginFlowglassDesc'),
|
||||
// Flowglass keeps its standalone drawer as a fallback and registers the
|
||||
@@ -62,7 +62,7 @@ export const builtinTabPlugins: readonly PluginEntry[] = [
|
||||
},
|
||||
{
|
||||
id: 'dsh-git-forge',
|
||||
name: 'dsh-git-forge Git 凭据',
|
||||
name: () => t('pluginGitForgeName'),
|
||||
url: 'https://github.com/thirsty5034/dsh-git-forge',
|
||||
description: () => t('pluginGitForgeDesc'),
|
||||
// Peer-depends on dsh-better-sidebar (Git Forge tab). Install the
|
||||
@@ -71,14 +71,14 @@ export const builtinTabPlugins: readonly PluginEntry[] = [
|
||||
},
|
||||
{
|
||||
id: 'dsh-git-remotes',
|
||||
name: 'dsh-git-remotes Git 远程',
|
||||
name: () => t('pluginGitRemotesName'),
|
||||
url: 'https://github.com/yq04/dsh-git-remotes',
|
||||
description: () => t('pluginGitRemotesDesc'),
|
||||
install: 'cd ~/.dsh && dsh plugin --profile web add dsh-better-sidebar && dsh plugin --profile web add git+https://github.com/yq04/dsh-git-remotes.git',
|
||||
},
|
||||
{
|
||||
id: 'dsh-github-workbench',
|
||||
name: 'dsh-github-workbench GitHub 工作台',
|
||||
name: () => t('pluginGithubWorkbenchName'),
|
||||
url: 'https://github.com/meyaomiao/dsh-github-workbench',
|
||||
description: () => t('pluginGithubWorkbenchDesc'),
|
||||
// Full GitHub workbench tab: remote repo tree + Issues/PRs/Actions tabs
|
||||
@@ -88,7 +88,7 @@ export const builtinTabPlugins: readonly PluginEntry[] = [
|
||||
},
|
||||
{
|
||||
id: 'dsh-sidebar-qa',
|
||||
name: 'dsh-sidebar-qa 划选追问',
|
||||
name: () => t('pluginSidebarQaName'),
|
||||
url: 'https://github.com/ChenRuoT/dsh-sidebar-qa',
|
||||
description: () => t('pluginSidebarQaDesc'),
|
||||
// dsh-sidebar-qa hard-depends on dsh-better-sidebar (required peer), so
|
||||
@@ -97,7 +97,7 @@ export const builtinTabPlugins: readonly PluginEntry[] = [
|
||||
},
|
||||
{
|
||||
id: 'dsh-sidenote',
|
||||
name: 'dsh-sidenote 侧边聊天',
|
||||
name: () => t('pluginSidenoteName'),
|
||||
url: 'https://github.com/g-yixuan/dsh-sidenote',
|
||||
description: () => t('pluginSidenoteDesc'),
|
||||
// dsh-sidenote hard-depends on dsh-better-sidebar (required peer), so
|
||||
@@ -106,7 +106,7 @@ export const builtinTabPlugins: readonly PluginEntry[] = [
|
||||
},
|
||||
{
|
||||
id: 'dsh-server-deck',
|
||||
name: 'dsh-server-deck 服务器甲板',
|
||||
name: () => t('pluginServerDeckName'),
|
||||
url: 'https://github.com/meyaomiao/DSH-server-deck',
|
||||
description: () => t('pluginServerDeckDesc'),
|
||||
// Published on npm; dual-mount like flowglass — registers the native
|
||||
@@ -116,7 +116,7 @@ export const builtinTabPlugins: readonly PluginEntry[] = [
|
||||
},
|
||||
{
|
||||
id: 'dsh-suhuang-scroll',
|
||||
name: 'dsh-suhuang-scroll 苏黄共阅',
|
||||
name: () => t('pluginSuhuangScrollName'),
|
||||
url: 'https://github.com/YZDame/dsh-suhuang-scroll',
|
||||
description: () => t('pluginSuhuangScrollDesc'),
|
||||
// Suhuang Scroll is a DSH Web plugin whose runtime console registers in
|
||||
@@ -125,7 +125,7 @@ export const builtinTabPlugins: readonly PluginEntry[] = [
|
||||
},
|
||||
{
|
||||
id: 'dsh-ssh-tunnel',
|
||||
name: 'dsh-ssh-tunnel SSH 隧道',
|
||||
name: () => t('pluginSshTunnelName'),
|
||||
url: 'https://github.com/thirsty5034/dsh-ssh-tunnel',
|
||||
description: () => t('pluginSshTunnelDesc'),
|
||||
// Peer-depends on dsh-better-sidebar (SSH Tunnel tab + center terminal/SFTP).
|
||||
@@ -134,7 +134,7 @@ export const builtinTabPlugins: readonly PluginEntry[] = [
|
||||
},
|
||||
{
|
||||
id: 'dsh-turn-review',
|
||||
name: 'dsh-turn-review 本轮审查',
|
||||
name: () => t('pluginTurnReviewName'),
|
||||
url: 'https://github.com/yq04/dsh-turn-review',
|
||||
description: () => t('pluginTurnReviewDesc'),
|
||||
// Needs dsh-better-sidebar (optional peer) for the tab; no model tools.
|
||||
@@ -142,7 +142,7 @@ export const builtinTabPlugins: readonly PluginEntry[] = [
|
||||
},
|
||||
{
|
||||
id: 'dsh-bilingual-reader',
|
||||
name: 'dsh-bilingual-reader 双语阅读',
|
||||
name: () => t('pluginBilingualReaderName'),
|
||||
url: 'https://github.com/Johnblur/dsh-bilingual-reader',
|
||||
description: () => t('pluginBilingualReaderDesc'),
|
||||
// Bilingual paper reading: a native-PDF tab with LLM selection translation,
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
* The built-in catalog of FILE-PREVIEWER plugins (file-type previewers),
|
||||
* shown in the "add preview plugin" modal (Side card settings → 文件预览
|
||||
* grid → the dashed card). Adding an entry: append one object here (unique
|
||||
* `id` = npm package name, `url` = GitHub repo, `description` =
|
||||
* i18n-friendly, `install` = the full shell command pre-filled into the
|
||||
* `id` = npm package name, `name` / `description` = i18n-friendly (add a
|
||||
* `pluginXxxName` / `pluginXxxDesc` key in locales.ts), `url` = GitHub repo,
|
||||
* `install` = the full shell command pre-filled into the
|
||||
* install terminal — it starts with `cd ~/.dsh` so the install runs with
|
||||
* the DSH home as the working directory). Data integrity is guarded by
|
||||
* `tests/plugin-list.spec.ts`.
|
||||
@@ -15,28 +16,28 @@ import type { PluginEntry } from './plugins-shared.ts'
|
||||
export const builtinViewerPlugins: readonly PluginEntry[] = [
|
||||
{
|
||||
id: '@huanlin/dsh-plugin-better-sidebar-plugin-office',
|
||||
name: 'Office 预览插件',
|
||||
name: () => t('pluginOfficeName'),
|
||||
url: 'https://github.com/HuanLinOTO/dsh-plugin-better-sidebar-plugin-office',
|
||||
description: () => t('pluginOfficeDesc'),
|
||||
install: 'cd ~/.dsh && dsh plugin --profile web add @huanlin/dsh-plugin-better-sidebar-plugin-office',
|
||||
},
|
||||
{
|
||||
id: 'dsh-md-export',
|
||||
name: 'Markdown 导出插件',
|
||||
name: () => t('pluginMdExportName'),
|
||||
url: 'https://github.com/AnakinCao/dsh-md-export',
|
||||
description: () => t('pluginMdExportDesc'),
|
||||
install: 'cd ~/.dsh && dsh plugin --profile web add dsh-md-export',
|
||||
},
|
||||
{
|
||||
id: 'dsh-code-nav',
|
||||
name: '代码预览导航',
|
||||
name: () => t('pluginCodeNavName'),
|
||||
url: 'https://github.com/AnakinCao/dsh-code-nav',
|
||||
description: () => t('pluginCodeNavDesc'),
|
||||
install: 'cd ~/.dsh && dsh plugin --profile web add https://github.com/AnakinCao/dsh-code-nav.git',
|
||||
},
|
||||
{
|
||||
id: 'dsh-video-preview',
|
||||
name: '视频预览插件',
|
||||
name: () => t('pluginVideoPreviewName'),
|
||||
url: 'https://github.com/zemul/dsh-video-preview',
|
||||
description: () => t('pluginVideoPreviewDesc'),
|
||||
install: 'cd ~/.dsh && dsh plugin --profile web add dsh-video-preview',
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -133,9 +133,11 @@ export function useSelectionPopup(options: SelectionPopupOptions): SelectionPopu
|
||||
// Re-target the visibility observer whenever a popup opens: the surface
|
||||
// refs may have been null at mount (content loads async, mode flips swap
|
||||
// the preview container for the CodeMirror host), so the surface is only
|
||||
// trustworthy at open time.
|
||||
// trustworthy at open time. The boolean flip is the only thing that must
|
||||
// re-run this; an anchor move while open keeps the same surface.
|
||||
const popupOpen = popup !== null
|
||||
useEffect(() => {
|
||||
if (popup === null) return
|
||||
if (!popupOpen) return
|
||||
observerRef.current?.disconnect()
|
||||
observerRef.current = null
|
||||
if (typeof IntersectionObserver === 'undefined') return
|
||||
@@ -148,10 +150,7 @@ export function useSelectionPopup(options: SelectionPopupOptions): SelectionPopu
|
||||
}, { threshold: 0 })
|
||||
observerRef.current = observer
|
||||
observer.observe(surface)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- the boolean
|
||||
// flip is the only thing that must re-run this; an anchor move while
|
||||
// open keeps the same surface.
|
||||
}, [popup !== null])
|
||||
}, [popupOpen])
|
||||
|
||||
return { popup, buttonRef, show, hide, commit }
|
||||
}
|
||||
@@ -449,7 +449,7 @@ function baseNameOf(path: string): string {
|
||||
export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDescriptor | undefined {
|
||||
for (const tab of tabs) {
|
||||
if (tab.urlTarget === undefined) continue
|
||||
let claimed = false
|
||||
let claimed: boolean
|
||||
try {
|
||||
claimed = tab.urlTarget(url) === true
|
||||
} catch (error) {
|
||||
@@ -465,7 +465,7 @@ export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDes
|
||||
* The plugin version this service instance reports. Keep in lockstep with
|
||||
* `package.json`'s version — `tests/service.spec.ts` asserts the pair.
|
||||
*/
|
||||
export const SIDEBAR_SERVICE_VERSION = '0.18.0'
|
||||
export const SIDEBAR_SERVICE_VERSION = '0.18.1'
|
||||
|
||||
/**
|
||||
* Monotonic capability list consumers use to gate new API usage (features
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
* 100+ GitHub stars (a real user base). The mechanism is opt-in: auto
|
||||
* detection never applies a preset — the settings badge only SUGGESTS it.
|
||||
*/
|
||||
import { t } from './locales.ts'
|
||||
import type { DesktopEnv } from './desktop-env.ts'
|
||||
|
||||
export interface ShellPreset {
|
||||
@@ -17,8 +18,10 @@ export interface ShellPreset {
|
||||
readonly id: string
|
||||
/** User-facing name of the shell. */
|
||||
readonly title: string
|
||||
/** One-line description shown in the settings popup. */
|
||||
readonly desc: string
|
||||
/** One-line description shown in the settings popup (i18n friendly:
|
||||
* string or () => string — the plain string form is the raw-text
|
||||
* fallback when no dictionary key covers the preset). */
|
||||
readonly desc: string | (() => string)
|
||||
/**
|
||||
* The top strip (px) this shell reserves over web content, per
|
||||
* environment — the fallback used when neither the standard WCO API nor
|
||||
@@ -53,7 +56,7 @@ export interface ShellPreset {
|
||||
const DSH_DESKTOP: ShellPreset = {
|
||||
id: 'dsh-desktop',
|
||||
title: 'DeepSeek Harness Desktop',
|
||||
desc: 'Electron 高级模式(无边框):macOS 顶栏 20px、Windows 无 WCO 时 32px 标题栏让位',
|
||||
desc: () => t('presetDshDesktopDesc'),
|
||||
stripFor: (env) => {
|
||||
if (env.mode !== 'advanced') return undefined
|
||||
if (env.platform === 'darwin') return 20
|
||||
|
||||
@@ -1038,6 +1038,83 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Inline rename editor ─────────────────────────────────────────────────
|
||||
The row swaps its name span for a plain input (same row metrics: 34px
|
||||
height, 6px gap, xxs type); only tokens, no hardcoded colors. */
|
||||
.explorerRenaming {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.explorerRenameInput {
|
||||
box-sizing: border-box;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
height: 24px;
|
||||
padding: 0 6px;
|
||||
border: 1px solid var(--dsw-alias-state-business-primary);
|
||||
border-radius: 6px;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font: var(--dsw-font-xxs-12);
|
||||
}
|
||||
|
||||
.explorerRenameInput:focus {
|
||||
outline: none;
|
||||
border-color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
/* The dismissable mutation-failure strip above the root row (rename/delete
|
||||
refusals — raw server text, same policy as the fence notice). */
|
||||
.explorerActionError {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
margin-bottom: 4px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--dsw-alias-state-error-primary);
|
||||
border-radius: 8px;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
}
|
||||
|
||||
.explorerActionErrorText {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.explorerActionErrorClose {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.explorerActionErrorClose:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* The delete-confirmation modal's description paragraph (the git lens's
|
||||
.gitConfirmDesc sibling — same quiet single-question look). */
|
||||
.explorerConfirmDesc {
|
||||
margin: 0;
|
||||
font: var(--dsw-font-s-14);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* The hover-revealed @-reference button at the row's far right (and the
|
||||
transient "copied" label that replaces it after a copy). The only auto
|
||||
margin in the row absorbs the filename's spare width, so a short name
|
||||
@@ -2439,15 +2516,16 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
|
||||
}
|
||||
|
||||
/* The pushpin toggle on an "open with" submenu row: brand ink once the
|
||||
target is pinned to the menu's top level. */
|
||||
target is pinned to the menu's top level. Sized for the compact menu's
|
||||
26px rows (16px box, 12px glyph) — proportionate without overflowing. */
|
||||
.openWithPin {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 6px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 5px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* The tab-content cell + the + menu builder, extracted from Sidebar.tsx
|
||||
* (behavior identical): one memoized cell dispatches a tab to its registry
|
||||
* descriptor's component, and buildNewTabOptions derives the + menu rows
|
||||
* from the same registry.
|
||||
*/
|
||||
import { createElement, memo } from 'react'
|
||||
import type { Context } from '../../context-types.ts'
|
||||
import type { SidebarState, SidebarStore, SidebarTab } from '../state.ts'
|
||||
import type { SessionScope } from '../api.ts'
|
||||
import { OrphanedTab } from '../OrphanedTab.tsx'
|
||||
import { RenderBoundary } from '../RenderBoundary.tsx'
|
||||
import { tabContentCompare, type TabContentMemoKey } from '../tab-content-memo.ts'
|
||||
import type { NewTabOption } from '../TabBar.tsx'
|
||||
import css from '../sidebar.module.css'
|
||||
|
||||
/** Props of one tab's content cell = the memo key (tab-content-memo.ts) plus
|
||||
* the runtime objects/callbacks the cell renders with. The memo comparator
|
||||
* is the pure `tabContentCompare`; anything in the key decides a re-render
|
||||
* must propagate, anything outside it must be a stable object (ctx/store)
|
||||
* or covered by a compared field (paneId covers onOpenDiff's captured
|
||||
* pane; sessionId/cwd cover onReferenceFile). */
|
||||
interface TabContentProps extends TabContentMemoKey {
|
||||
onToggleDir: (path: string) => void
|
||||
onReferenceFile: (path: string, isDir: boolean) => void
|
||||
ctx: Context
|
||||
store: SidebarStore
|
||||
/** Fired before a topology node jumps to its child session (see Sidebar). */
|
||||
onSubagentJump: (childSessionId: string) => void
|
||||
/** Open a diff tab from the git panel (placement handled by the store). */
|
||||
onOpenDiff: (tab: SidebarTab) => void
|
||||
}
|
||||
|
||||
/** Render the content of one tab (dispatched by type). */
|
||||
export const TabContent = memo(function TabContent(props: TabContentProps) {
|
||||
const { tab, effectiveTabId, sessionId, cwd, expanded, revealed, onToggleDir, onReferenceFile, ctx, store, visible, onSubagentJump, onOpenDiff } = props
|
||||
const scope = { sessionId, cwd }
|
||||
const descriptor = ctx.get('betterSidebar')?.getTab(tab.type)
|
||||
if (descriptor === undefined) {
|
||||
return <OrphanedTab ctx={ctx} store={store} scope={scope} tab={tab} visible={visible} />
|
||||
}
|
||||
// For pinned virtual tabs, the tab descriptor's component (e.g. TerminalView)
|
||||
// must receive the ORIGINAL tab id so it connects to the home session's PTY.
|
||||
// The virtual tab's own id is a unique display key (prefixed); effectiveTabId
|
||||
// restores the real id at the component boundary.
|
||||
const componentTab = effectiveTabId !== undefined ? { ...tab, id: effectiveTabId } : tab
|
||||
return createElement(
|
||||
RenderBoundary,
|
||||
{ className: css.tabBoundaryError },
|
||||
createElement(descriptor.component, {
|
||||
ctx, store, scope, tab: componentTab, visible, expanded, revealed,
|
||||
onToggleDir, onReferenceFile, onOpenDiff, onSubagentJump,
|
||||
}),
|
||||
)
|
||||
}, tabContentCompare)
|
||||
|
||||
/** The + menu options for the current state, driven by the tab registry.
|
||||
* Hidden tabs (editor/diff) never show; `available` returning false shows
|
||||
* a disabled row (e.g. terminal at capacity) instead of hiding the option.
|
||||
* Tabs the user disabled in the side card settings are filtered out
|
||||
* entirely — re-enabling them is the settings page's job. */
|
||||
export function buildNewTabOptions(state: SidebarState, ctx: Context, scope: SessionScope): NewTabOption[] {
|
||||
const service = ctx.get('betterSidebar')
|
||||
if (service === undefined) return []
|
||||
return service.getTabs()
|
||||
.filter(d => !d.hidden && service.isTabEnabled(d.id))
|
||||
.sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
|
||||
.map(d => ({
|
||||
id: d.id,
|
||||
label: typeof d.title === 'function' ? d.title() : d.title,
|
||||
disabled: !(d.available?.(ctx, scope, state) ?? true),
|
||||
// 14 matches the compact + menu's icon slot (compactList .itemIcon).
|
||||
icon: typeof d.icon === 'function' ? d.icon(14) : d.icon,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Free windows (extracted from Sidebar.tsx, behavior identical): the
|
||||
* drag-out gesture that floats a tab onto the conversation column, plus the
|
||||
* render layer for the floating windows and its drop-zone hint overlay.
|
||||
*/
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import type { Context } from '../../context-types.ts'
|
||||
import {
|
||||
dockFloat, floatTab, moveFloat, raiseFloat, resizeFloat,
|
||||
type FloatWindow, type SidebarStore, type SidebarTab,
|
||||
} from '../state.ts'
|
||||
import { TAB_DRAG_TYPE, parseDrag } from '../TabBar.tsx'
|
||||
import { FreeWindow } from '../FreeWindow.tsx'
|
||||
import { t } from '../locales.ts'
|
||||
import css from '../sidebar.module.css'
|
||||
|
||||
/** The dashed drop-zone hint's geometry (viewport coordinates). */
|
||||
export interface FloatDropHint {
|
||||
left: number
|
||||
top: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Free windows — drag-out detection. The tab strips already drive HTML5
|
||||
* DnD (payload application/x-dsh-tab) with drops owned by the panes
|
||||
* (split/merge); this hook watches the DOCUMENT (capture) for the same
|
||||
* drag hovering OUTSIDE the panel host: while the pointer is over the
|
||||
* conversation column it arms the drop (preventDefault) and shows a hint
|
||||
* overlay there, and the drop floats the tab at the release point. Targets
|
||||
* inside the host are ignored here, so pane drops keep their behavior
|
||||
* untouched. Only OUR tab drags count (the body flag is the tab strip's;
|
||||
* OS file drags and any DSH drags pass through). Narrow viewports skip
|
||||
* the gesture — the merged drawer covers the conversation, leaving
|
||||
* nothing to drop onto (the tab context menu entry still floats tabs).
|
||||
*/
|
||||
export function useFloatDragout(input: {
|
||||
narrow: boolean
|
||||
sessionId: string | undefined
|
||||
store: SidebarStore
|
||||
/** The AppFrame center column (from useCenterColumn); stable ref object. */
|
||||
centerColRef: { readonly current: HTMLElement | null }
|
||||
}): { floatHint: FloatDropHint | null } {
|
||||
const { narrow, sessionId, store, centerColRef } = input
|
||||
const [floatHint, setFloatHint] = useState<FloatDropHint | null>(null)
|
||||
const floatHintRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (narrow || sessionId === undefined) return
|
||||
const inPanelHost = (target: EventTarget | null): boolean =>
|
||||
target instanceof Element && target.closest('[data-dsh-panel-host]') !== null
|
||||
/** The conversation column's rect when the pointer is over it (and not
|
||||
* over our own surfaces); null otherwise. */
|
||||
const overConversation = (event: DragEvent): DOMRect | null => {
|
||||
if (inPanelHost(event.target)) return null
|
||||
const col = centerColRef.current
|
||||
if (col === null || !col.isConnected) return null
|
||||
const rect = col.getBoundingClientRect()
|
||||
if (rect.width === 0 || rect.height === 0) return null
|
||||
const { clientX: x, clientY: y } = event
|
||||
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return null
|
||||
return rect
|
||||
}
|
||||
const onDragOver = (event: DragEvent): void => {
|
||||
if (!document.body.hasAttribute('data-dsh-tab-dragging')) return
|
||||
const rect = overConversation(event)
|
||||
if (rect !== null) {
|
||||
// preventDefault on dragover is what makes the browser deliver the
|
||||
// drop (and drop the "no" cursor) over the conversation area.
|
||||
event.preventDefault()
|
||||
setFloatHint((prev) => {
|
||||
const next = { left: rect.left, top: rect.top, width: rect.width, height: rect.height }
|
||||
if (prev !== null && prev.left === next.left && prev.top === next.top
|
||||
&& prev.width === next.width && prev.height === next.height) return prev
|
||||
return next
|
||||
})
|
||||
floatHintRef.current = true
|
||||
} else if (floatHintRef.current) {
|
||||
floatHintRef.current = false
|
||||
setFloatHint(null)
|
||||
}
|
||||
}
|
||||
const onDrop = (event: DragEvent): void => {
|
||||
if (!floatHintRef.current) return
|
||||
floatHintRef.current = false
|
||||
setFloatHint(null)
|
||||
const rect = overConversation(event)
|
||||
if (rect === null) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const payload = parseDrag(event.dataTransfer?.getData(TAB_DRAG_TYPE) ?? '')
|
||||
if (payload === null) return
|
||||
store.reduce(s => floatTab(s, payload.tabId, event.clientX, event.clientY))
|
||||
}
|
||||
const clear = (): void => {
|
||||
if (!floatHintRef.current) return
|
||||
floatHintRef.current = false
|
||||
setFloatHint(null)
|
||||
}
|
||||
document.addEventListener('dragover', onDragOver, true)
|
||||
document.addEventListener('drop', onDrop, true)
|
||||
window.addEventListener('dragend', clear, true)
|
||||
window.addEventListener('blur', clear)
|
||||
return () => {
|
||||
document.removeEventListener('dragover', onDragOver, true)
|
||||
document.removeEventListener('drop', onDrop, true)
|
||||
window.removeEventListener('dragend', clear, true)
|
||||
window.removeEventListener('blur', clear)
|
||||
}
|
||||
// centerColRef is a stable ref object from useCenterColumn (it lost its
|
||||
// useRef provenance crossing the hook boundary, so it is listed here).
|
||||
}, [narrow, sessionId, store, centerColRef])
|
||||
|
||||
return { floatHint }
|
||||
}
|
||||
|
||||
/**
|
||||
* The free-window render layer: tabs dragged out onto the conversation area
|
||||
* (or floated from the tab context menu). They live in the panel host like
|
||||
* the panels (viewport coordinates, immune to desktop-shell transforms) but
|
||||
* are independent of panel state — a window stays up while panels collapse.
|
||||
* The floats array's order is the stacking order; the content reuses the
|
||||
* regular tab renderer, so every tab type floats unchanged. Renders a
|
||||
* fragment so the DOM is exactly the floats followed by the drag-out hint.
|
||||
*/
|
||||
export function FreeWindowLayer(props: {
|
||||
floats: readonly FloatWindow[]
|
||||
hint: FloatDropHint | null
|
||||
renderTab: (tab: SidebarTab, active: boolean, paneId: string, placement: 'top' | 'bottom' | 'float') => ReactNode
|
||||
getTabIcon: (tab: SidebarTab) => ReactNode
|
||||
store: SidebarStore
|
||||
ctx: Context
|
||||
sessionId: string
|
||||
cwd: string | undefined
|
||||
}) {
|
||||
const { floats, hint, renderTab, getTabIcon, store, ctx, sessionId, cwd } = props
|
||||
return (
|
||||
<>
|
||||
{floats.map(float => (
|
||||
<FreeWindow
|
||||
key={float.id}
|
||||
float={float}
|
||||
renderTab={(tab, active, paneId) => renderTab(tab, active, paneId, 'float')}
|
||||
getTabIcon={getTabIcon}
|
||||
onRaise={() => { store.reduce(s => raiseFloat(s, float.id)) }}
|
||||
onMove={(x, y) => { store.reduce(s => moveFloat(s, float.id, x, y)) }}
|
||||
onResize={(w, h) => { store.reduce(s => resizeFloat(s, float.id, w, h)) }}
|
||||
onDock={(paneId) => { store.reduce(s => dockFloat(s, float.id, paneId ?? undefined)) }}
|
||||
onClose={() => { ctx.get('betterSidebar')?.closeTab(float.tab.id, sessionId === undefined ? undefined : { sessionId, cwd }) }}
|
||||
/>
|
||||
))}
|
||||
{/*
|
||||
The drag-out hint: while a tab drag hovers the conversation column,
|
||||
a dashed overlay marks the drop zone there (pointer-transparent — it
|
||||
must not disturb the drag it describes).
|
||||
*/}
|
||||
{hint !== null && (
|
||||
<div
|
||||
className={css.floatDropHint}
|
||||
style={{ left: hint.left, top: hint.top, width: hint.width, height: hint.height }}
|
||||
>
|
||||
<span className={css.floatDropHintLabel}>{t('floatDropHint')}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Center-column tracking (extracted from Sidebar.tsx, behavior identical):
|
||||
* the bottom panel spans ONLY the app shell's center column ("squeezes the
|
||||
* agent output area") — it starts at the app sidebar's right edge and ends
|
||||
* at the details column's left edge (the details column sits between the
|
||||
* center and the right panel). Measured directly from the AppFrame's center
|
||||
* column DOM (the parent of the [data-slot="conversation"] wrapper —
|
||||
* layout.css's center column) so the bottom panel tracks the column's real
|
||||
* horizontal edges — including the animated AppFrame padding reservation
|
||||
* while the right panel opens/closes; a frame that never appears keeps the
|
||||
* initial zero-size fallback (the panel renders at 0 width until measured).
|
||||
* The rect lives in a REF (not state): the open/close transition resizes
|
||||
* the center column EVERY frame for its duration, and reacting per frame
|
||||
* with setState re-renders the whole Sidebar (every mounted tab) at
|
||||
* animation cadence — the visible toggle jank (#315). measureCenter
|
||||
* writes the bottom panel's edges directly (same DOM-write pattern as
|
||||
* applyDrag), so the panel still tracks the column per frame with zero
|
||||
* React work; `centerMeasured` flips ONCE to gate the hidden→visible
|
||||
* first-paint fallback.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { resolveCenterColumn } from '../center-column.ts'
|
||||
|
||||
export function useCenterColumn(
|
||||
/** The bottom panel element: measureCenter writes its edges directly. */
|
||||
bottomRef: { readonly current: HTMLDivElement | null },
|
||||
/** Re-runs the whole locate/measure chain on change (opening the bottom
|
||||
* panel re-runs the chain: a panel opened before the center column was
|
||||
* ever found must not stay invisible forever). */
|
||||
bottomOpen: boolean | undefined,
|
||||
) {
|
||||
const centerRectRef = useRef({ left: 0, right: 0 })
|
||||
const [centerMeasured, setCenterMeasured] = useState(false)
|
||||
// Refs keep the measure step stable across renders and let it skip work
|
||||
// mid-drag: during a width/corner drag the layout push resizes the center
|
||||
// column every frame, and reacting (setCenterRect → re-render) would
|
||||
// re-introduce the drag lag this shell deliberately avoids. applyDrag
|
||||
// writes the bottom panel's edges directly, so measurement pauses then.
|
||||
const centerColRef = useRef<HTMLElement | null>(null)
|
||||
const draggingRef = useRef(false)
|
||||
const measureCenter = useCallback((): void => {
|
||||
if (draggingRef.current) return
|
||||
const col = centerColRef.current
|
||||
if (col === null) return
|
||||
if (!col.isConnected) {
|
||||
// The observed column was detached (HMR re-render swapped the node
|
||||
// in place): its rect is stale garbage. Drop the ref — the locate
|
||||
// chain re-runs on the next mutation/interval tick and picks up the
|
||||
// new column node (issue #248).
|
||||
centerColRef.current = null
|
||||
return
|
||||
}
|
||||
const rect = col.getBoundingClientRect()
|
||||
// Ref + direct DOM write (see the centerRectRef comment): the bottom
|
||||
// panel keeps tracking the center column per frame during the right
|
||||
// panel's open/close animation without re-rendering the shell. The
|
||||
// one-shot measured flip renders the panel visible once (a stale
|
||||
// {0,0} fallback would flash full-width).
|
||||
centerRectRef.current = { left: rect.left, right: rect.right }
|
||||
const bottom = bottomRef.current
|
||||
if (bottom !== null) {
|
||||
bottom.style.setProperty('left', `${rect.left}px`)
|
||||
bottom.style.setProperty('right', `${window.innerWidth - rect.right}px`)
|
||||
}
|
||||
setCenterMeasured(prev => (prev ? prev : true))
|
||||
}, [bottomRef])
|
||||
useEffect(() => {
|
||||
let disposed = false
|
||||
let observer: ResizeObserver | undefined
|
||||
// Locate the AppFrame's center column. DSH 0.1.x wraps slot hosts in
|
||||
// [data-slot] containers: the conversation slot wrapper
|
||||
// ([data-slot="conversation"]) sits directly inside the center column,
|
||||
// so its parent IS that column — no hashed-class or positional
|
||||
// dependency (layout.css uses the same anchor). The shell swaps the
|
||||
// boot page for the AppFrame only AFTER boot settles, so the first
|
||||
// query may miss it. Never give up: watch #root's subtree (the swap and
|
||||
// HMR re-renders mutate it) and re-run this locator — querying once and
|
||||
// bailing would strand the panel at the zero-size fallback forever
|
||||
// (observed: a 1px sliver at the viewport's left edge).
|
||||
const locate = (): void => {
|
||||
if (disposed) return
|
||||
// Hot path (#403): streaming output mutates #root at token cadence.
|
||||
// Reuse a still-connected center column and only query after boot/HMR
|
||||
// detached the cached node.
|
||||
const col = resolveCenterColumn(centerColRef.current)
|
||||
if (col === undefined || !col.isConnected) {
|
||||
if (centerColRef.current !== null) {
|
||||
centerColRef.current.removeAttribute('data-dsh-center-col')
|
||||
centerColRef.current = null
|
||||
observer?.disconnect()
|
||||
observer = undefined
|
||||
}
|
||||
return
|
||||
}
|
||||
if (centerColRef.current !== col) {
|
||||
// A NEW column node (boot swap, HMR re-render, or a previous locate
|
||||
// that found nothing): attach the ResizeObserver to THIS node and
|
||||
// measure it once. Same-node size changes are the ResizeObserver's
|
||||
// job — no forced measurement here, because a forced
|
||||
// getBoundingClientRect per mutation would reflow the shell at
|
||||
// mutation cadence. The tag retargets with the ref: layout.css's
|
||||
// bottom-push rule anchors on [data-dsh-center-col], so exactly the
|
||||
// measured node carries it (a stale tag on a swapped-out node would
|
||||
// leave the push rule anchorless or doubled).
|
||||
centerColRef.current?.removeAttribute('data-dsh-center-col')
|
||||
centerColRef.current = col
|
||||
col.setAttribute('data-dsh-center-col', '')
|
||||
observer?.disconnect()
|
||||
observer = new ResizeObserver(measureCenter)
|
||||
observer.observe(col)
|
||||
measureCenter()
|
||||
}
|
||||
}
|
||||
locate()
|
||||
// rAF-debounce the mutation watchers: #root's subtree changes at chat
|
||||
// cadence (streaming turns), and locate() itself must stay cheap.
|
||||
let locateFrame: number | null = null
|
||||
const scheduleLocate = (): void => {
|
||||
if (locateFrame !== null) return
|
||||
// Mid-drag every frame writes --dsh-sidebar-* on <html>'s style
|
||||
// attribute, which is the mutation this watcher observes — relocating
|
||||
// per drag frame is pointless (the center column node cannot change
|
||||
// while the pointer is captured) and adds locator work to every
|
||||
// frame's budget (#315). The 1.5s retry below still covers any node
|
||||
// swap that somehow lands mid-drag.
|
||||
if (draggingRef.current) return
|
||||
locateFrame = requestAnimationFrame(() => {
|
||||
locateFrame = null
|
||||
locate()
|
||||
})
|
||||
}
|
||||
const watcher = new MutationObserver(scheduleLocate)
|
||||
const root = document.getElementById('root')
|
||||
if (root !== null) watcher.observe(root, { childList: true, subtree: true })
|
||||
// The layout push writes --dsh-sidebar-* on <html>. A HMR re-activation
|
||||
// clears those variables on teardown and re-writes them on setup — and
|
||||
// that is also the moment the shell may have re-created the center
|
||||
// column under a REUSED #root child (React swaps nodes in place, so
|
||||
// #root's childList never changes and the watcher above never fires).
|
||||
// Watching <html>'s style attribute catches that re-sync: the push
|
||||
// rewrite re-locates and re-measures, so the bottom panel recovers
|
||||
// instead of staying hidden on a stale {0,0} center rect.
|
||||
const htmlStyleWatcher = new MutationObserver(scheduleLocate)
|
||||
htmlStyleWatcher.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] })
|
||||
// Last-resort safety net (issue #248): no watcher is guaranteed to fire
|
||||
// for every HMR teardown/setup interleaving (e.g. the style attribute
|
||||
// may end up byte-identical, and the col may be swapped before the
|
||||
// subtree watcher attaches). A slow unconditional re-locate makes the
|
||||
// panel converge on the real column within a couple of seconds no
|
||||
// matter what sequence the shell used. locate() is query-free while the
|
||||
// cached column stays connected; only a detached/missing cache falls
|
||||
// back to the document selector.
|
||||
const retry = window.setInterval(locate, 1500)
|
||||
return () => {
|
||||
disposed = true
|
||||
if (locateFrame !== null) cancelAnimationFrame(locateFrame)
|
||||
window.clearInterval(retry)
|
||||
observer?.disconnect()
|
||||
watcher.disconnect()
|
||||
htmlStyleWatcher.disconnect()
|
||||
centerColRef.current?.removeAttribute('data-dsh-center-col')
|
||||
centerColRef.current = null
|
||||
}
|
||||
// Opening the bottom panel re-runs the whole locate/measure chain: a
|
||||
// panel opened before the center column was ever found must not stay
|
||||
// invisible forever (the HMR recovery path depends on the observers
|
||||
// above, this is the belt-and-braces retry for the open moment itself).
|
||||
}, [measureCenter, bottomOpen])
|
||||
|
||||
return { centerColRef, centerRectRef, centerMeasured, measureCenter, draggingRef }
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Host-feed subscriptions (extracted from Sidebar.tsx, behavior identical):
|
||||
* the WebSocket pushes (agent terminals, agent opens) and the session-list
|
||||
* driven auto-activation triggers (subagent, background jobs, topology
|
||||
* jump-back). All of it reacts to the host's live feeds for the CURRENT
|
||||
* session; the sidebar shell only consumes the returned jump-back ref.
|
||||
*/
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { Context, SidebarSessionList } from '../../context-types.ts'
|
||||
import { firstLeaf, reconcileAgentTerminals, togglePanel, type SidebarStore } from '../state.ts'
|
||||
import { isNarrowWidth } from '../breakpoints.ts'
|
||||
import { detectNewDirectSubagent } from '../subagent-detect.ts'
|
||||
import { detectNewJob } from '../subagent-jobs.ts'
|
||||
import { t } from '../locales.ts'
|
||||
|
||||
/** How many consecutive reconnect failures stop the agent-terminals push loop
|
||||
* (mirror of the terminal view's own cap; the loop restarts on session switch). */
|
||||
const FAILURE_LIMIT = 3
|
||||
|
||||
/**
|
||||
* Subagent auto-open debounce (ms). The host delivers a new child's origin
|
||||
* and its title in SEPARATE frames: a Side Chat thread's first visible
|
||||
* frame still shows a fallback title (no 'Side: ' prefix), so an immediate
|
||||
* 0→N decision mistakes it for a genuine subagent and pops the task page.
|
||||
* The trigger therefore re-evaluates against the live snapshot once the
|
||||
* title frame has had time to land.
|
||||
*/
|
||||
const AUTO_OPEN_DEBOUNCE_MS = 500
|
||||
|
||||
export function useHostFeeds(feeds: {
|
||||
ctx: Context
|
||||
store: SidebarStore
|
||||
sessionList: SidebarSessionList
|
||||
sessionId: string | undefined
|
||||
}): { subagentJumpRef: { current: string | undefined } } {
|
||||
const { ctx, store, sessionList, sessionId } = feeds
|
||||
|
||||
/**
|
||||
* Agent terminals push: subscribe to the host's live list of agent-owned
|
||||
* terminals for this session (created by the model through the
|
||||
* `terminal_create` tool). The host pushes a JSON array on every
|
||||
* create / close / exit; the sidebar reconciles the list into tabs
|
||||
* (id `agent:<uuid>`, title from the agent). A disconnected socket
|
||||
* retries with a short backoff so a refresh or transient drop reattaches
|
||||
* the same shell without losing the agent's work — capped like the
|
||||
* terminal view's own reconnect loop, so a refused endpoint never spins
|
||||
* forever (the next session switch restarts the loop).
|
||||
* While the terminal tab type is disabled in settings, pushes are
|
||||
* ignored (no auto-added tabs); re-enabling makes the next push converge.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (sessionId === undefined) return
|
||||
let socket: WebSocket | null = null
|
||||
let retry: number | undefined
|
||||
let closed = false
|
||||
let failures = 0
|
||||
const connect = (): void => {
|
||||
if (closed) return
|
||||
const url = new URL('/sidebar/ws/agent-terminals', location.origin)
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
url.search = new URLSearchParams({ sessionId }).toString()
|
||||
socket = new WebSocket(url.toString())
|
||||
socket.onmessage = (event) => {
|
||||
if (typeof event.data !== 'string') return
|
||||
try {
|
||||
const list = JSON.parse(event.data) as Array<{ uuid: string; title: string; command: string; exited: boolean }>
|
||||
if (!Array.isArray(list)) return
|
||||
store.reduce(s => ctx.get('betterSidebar')?.isTabEnabled('terminal') === false
|
||||
? s
|
||||
: reconcileAgentTerminals(s, list))
|
||||
} catch {
|
||||
// Malformed push: ignore (the next push will reconcile).
|
||||
}
|
||||
}
|
||||
socket.onclose = () => {
|
||||
if (closed) return
|
||||
failures += 1
|
||||
if (failures >= FAILURE_LIMIT) {
|
||||
console.error('[dsh-better-sidebar] agent-terminals connection failed; stopping reconnect loop', sessionId)
|
||||
return
|
||||
}
|
||||
retry = window.setTimeout(connect, 2000)
|
||||
}
|
||||
socket.onerror = () => { socket?.close() }
|
||||
}
|
||||
connect()
|
||||
return () => {
|
||||
closed = true
|
||||
window.clearTimeout(retry)
|
||||
socket?.close()
|
||||
}
|
||||
}, [sessionId, ctx, store])
|
||||
|
||||
/**
|
||||
* Agent opens push: subscribe to the host's `sidebar_open` requests for
|
||||
* this session (the model actively opens a file / folder / HTTP(S) page).
|
||||
* The host pushes one JSON request per open; the sidebar routes it to the
|
||||
* matching built-in tab: a file opens in the editor (per-path dedupe), a
|
||||
* folder opens a file window whose tree is rooted at the folder
|
||||
* (`meta.dir`), and a URL opens in the browser tab. A disconnected socket
|
||||
* retries with a short backoff (mirror of the agent-terminals loop): the
|
||||
* host queue keeps undelivered requests and replays them on the first
|
||||
* attach, so a refresh or a session switch lands the opens the model
|
||||
* queued while no view was connected.
|
||||
* While the side-card setting is off, pushes are ignored as a defensive
|
||||
* gate — the host already unregisters the tool and drains the queue.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (sessionId === undefined) return
|
||||
let socket: WebSocket | null = null
|
||||
let retry: number | undefined
|
||||
let closed = false
|
||||
let failures = 0
|
||||
const connect = (): void => {
|
||||
if (closed) return
|
||||
const url = new URL('/sidebar/ws/agent-opens', location.origin)
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
url.search = new URLSearchParams({ sessionId }).toString()
|
||||
socket = new WebSocket(url.toString())
|
||||
socket.onmessage = (event) => {
|
||||
if (typeof event.data !== 'string') return
|
||||
try {
|
||||
const request = JSON.parse(event.data) as { kind?: unknown; target?: unknown; title?: unknown }
|
||||
if (request === null || typeof request !== 'object') return
|
||||
if (request.kind !== 'file' && request.kind !== 'folder' && request.kind !== 'url') return
|
||||
if (typeof request.target !== 'string' || request.target === '') return
|
||||
if (store.getPrefs().agentOpenTools !== true) return
|
||||
const scope = { sessionId }
|
||||
const title = typeof request.title === 'string' && request.title !== '' ? request.title : undefined
|
||||
if (request.kind === 'url') {
|
||||
ctx.get('betterSidebar')?.openTab({ type: 'browser', url: request.target, title }, scope)
|
||||
} else if (request.kind === 'folder') {
|
||||
ctx.get('betterSidebar')?.openTab({
|
||||
type: 'editor',
|
||||
title,
|
||||
path: request.target,
|
||||
id: `editor:${request.target}`,
|
||||
meta: { dir: true },
|
||||
}, scope)
|
||||
} else {
|
||||
ctx.get('betterSidebar')?.openFile(scope, request.target, title)
|
||||
}
|
||||
} catch {
|
||||
// Malformed push: ignore (the next push carries its own request).
|
||||
}
|
||||
}
|
||||
socket.onclose = () => {
|
||||
if (closed) return
|
||||
failures += 1
|
||||
if (failures >= FAILURE_LIMIT) {
|
||||
console.error('[dsh-better-sidebar] agent-opens connection failed; stopping reconnect loop', sessionId)
|
||||
return
|
||||
}
|
||||
retry = window.setTimeout(connect, 2000)
|
||||
}
|
||||
socket.onerror = () => { socket?.close() }
|
||||
}
|
||||
connect()
|
||||
return () => {
|
||||
closed = true
|
||||
window.clearTimeout(retry)
|
||||
socket?.close()
|
||||
}
|
||||
}, [sessionId, ctx, store])
|
||||
|
||||
/**
|
||||
* Subagent auto-activation: the moment the current conversation spawns its
|
||||
* FIRST direct subagent (a 0 → N transition on the list feed), the "auto
|
||||
* open" pref is on, and the Tasks tab type is enabled in settings, activate
|
||||
* the Tasks page. Single-instance semantics focus an existing pane tab in
|
||||
* place or raise an existing free window; a new tab lands in the right pane
|
||||
* and is never duplicated. On wide viewports the right panel also expands;
|
||||
* on narrow viewports background activity never forces the full-screen
|
||||
* drawer open over the chat.
|
||||
* Switching to a session that already has subagents never triggers — its
|
||||
* baseline starts at the current count — so a deliberate layout is never
|
||||
* fought.
|
||||
*
|
||||
* The decision is DEBOUNCED (AUTO_OPEN_DEBOUNCE_MS): a Side Chat thread
|
||||
* is also a subagent-origin child, and its 'Side: ' title lands one frame
|
||||
* after its origin — an immediate check would misread that first frame as
|
||||
* a new subagent and pop this page on every thread creation. The timer
|
||||
* re-evaluates the ORIGINAL baseline against the live snapshot; by then
|
||||
* the title filter (isSideThreadSummary) sees the settled label.
|
||||
*/
|
||||
const listBaselineRef = useRef<SidebarSessionList | undefined>(undefined)
|
||||
const autoOpenPendingRef = useRef<{ baseline: SidebarSessionList; timer: number } | null>(null)
|
||||
useEffect(() => {
|
||||
const prev = listBaselineRef.current
|
||||
listBaselineRef.current = sessionList
|
||||
if (sessionId === undefined || prev === undefined) return
|
||||
if (autoOpenPendingRef.current !== null) return
|
||||
if (!detectNewDirectSubagent(prev, sessionList, sessionId)) return
|
||||
const baseline = prev
|
||||
const timer = window.setTimeout(() => {
|
||||
autoOpenPendingRef.current = null
|
||||
if (!detectNewDirectSubagent(baseline, ctx.sessions.list.getSnapshot(), sessionId)) return
|
||||
if (!store.getPrefs().autoOpenSubagent) return
|
||||
if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return
|
||||
// Read the viewport when the delayed activation fires: a resize while
|
||||
// the debounce is armed must not let background activity force the
|
||||
// narrow full-screen drawer open over the chat.
|
||||
if (!isNarrowWidth(window.innerWidth)) {
|
||||
store.reduce(s => s.panelOpen ? s : togglePanel(s))
|
||||
}
|
||||
// Choose the right panel as the landing pane for a newly created Tasks
|
||||
// tab. Single-instance dedupe still activates an existing pane tab in
|
||||
// place or raises an existing free window.
|
||||
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
|
||||
ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') })
|
||||
}, AUTO_OPEN_DEBOUNCE_MS)
|
||||
autoOpenPendingRef.current = { baseline, timer }
|
||||
}, [sessionList, sessionId, store, ctx])
|
||||
|
||||
// A session switch (or unmount) voids any armed auto-open recheck.
|
||||
useEffect(() => () => {
|
||||
const pending = autoOpenPendingRef.current
|
||||
if (pending !== null) window.clearTimeout(pending.timer)
|
||||
autoOpenPendingRef.current = null
|
||||
}, [sessionId])
|
||||
|
||||
/**
|
||||
* Job auto-activation: the moment a NEW background job appears for the
|
||||
* current conversation (a job id the previous snapshot lacked), the
|
||||
* auto-open pref is on, and the Tasks tab type is enabled, activate the Tasks
|
||||
* page that contains the background-jobs section. The right panel expands
|
||||
* only on wide viewports. Unlike the subagent trigger (0 → N only), ANY
|
||||
* new job id triggers: the agent may start several jobs in one session, and
|
||||
* each should surface. A fresh page load never triggers — its baseline starts
|
||||
* at the current snapshot.
|
||||
*/
|
||||
const jobBaselineRef = useRef<SidebarSessionList | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
const prev = jobBaselineRef.current
|
||||
jobBaselineRef.current = sessionList
|
||||
if (sessionId === undefined || prev === undefined) return
|
||||
if (!detectNewJob(prev, sessionList, sessionId)) return
|
||||
if (!store.getPrefs().autoOpenJobs) return
|
||||
if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return
|
||||
if (!isNarrowWidth(window.innerWidth)) {
|
||||
store.reduce(s => s.panelOpen ? s : togglePanel(s))
|
||||
}
|
||||
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
|
||||
ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') })
|
||||
}, [sessionList, sessionId, store, ctx])
|
||||
|
||||
/**
|
||||
* Topology jump-back: clicking a subagent node on the Subagent page calls
|
||||
* the official `openSubagent`, which switches the sidebar to that child
|
||||
* session's OWN layout (a fresh child session defaults to the explorer).
|
||||
* The README contract says the Subagent page must stay open with the jumped
|
||||
* node highlighted — so once the current session becomes the recorded jump
|
||||
* target, re-open the Subagent page on top of the child's layout (expanding
|
||||
* the panel first if it is collapsed). Only this explicit node click arms
|
||||
* the flag, so switching to a subagent session by any other means keeps
|
||||
* that session's own layout untouched.
|
||||
*/
|
||||
const subagentJumpRef = useRef<string | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
const pending = subagentJumpRef.current
|
||||
if (pending === undefined || sessionId !== pending) return
|
||||
subagentJumpRef.current = undefined
|
||||
store.reduce(s => s.panelOpen ? s : togglePanel(s))
|
||||
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
|
||||
ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') })
|
||||
}, [sessionId, store, ctx])
|
||||
|
||||
return { subagentJumpRef }
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Inline pinned terminals (extracted from Sidebar.tsx, behavior identical):
|
||||
* pinned tabs from OTHER sessions inject as VIRTUAL tabs into the first
|
||||
* leaf of the right panel's split tree, and the workbench actions are
|
||||
* wrapped so virtual ids route back to the HOME session. The shell only
|
||||
* consumes the augmented tree and the wrapped actions.
|
||||
*/
|
||||
import { useMemo, useState } from 'react'
|
||||
import {
|
||||
agentUuidOf, closeFloatByTab, closeTab, isAgentTabId, leafWithTab, setTabPin,
|
||||
type SidebarSnapshot, type SidebarStore, type SplitNode,
|
||||
} from '../state.ts'
|
||||
import {
|
||||
collectPinnedTabs, createPinnedVirtualTab, getPinnedHomeScope, injectPinnedIntoTree,
|
||||
isPinnedVirtualId, parsePinnedVirtualId, type PinnedTabEntry,
|
||||
} from '../pinned.ts'
|
||||
import type { WorkbenchActions } from '../split-pane.tsx'
|
||||
import { api } from '../api.ts'
|
||||
|
||||
export function usePinnedTabs(input: {
|
||||
store: SidebarStore
|
||||
sessionId: string | undefined
|
||||
cwd: string | undefined
|
||||
snapshot: SidebarSnapshot
|
||||
actions: WorkbenchActions
|
||||
}): { augmentedTree: SplitNode | undefined; wrappedActions: WorkbenchActions } {
|
||||
const { store, sessionId, cwd, snapshot, actions } = input
|
||||
const state = snapshot.state
|
||||
|
||||
/**
|
||||
* Inline pinned terminals (v0.17.0+): pinned tabs from OTHER sessions
|
||||
* inject as VIRTUAL tabs into the first leaf of the right panel's split
|
||||
* tree. The virtual tabs have unique ids (prefixed with the home session)
|
||||
* and carry the home scope in meta. Clicking a virtual tab sets
|
||||
* `activePinnedTabId` — the augmented tree overrides the leaf's `active`
|
||||
* so the pinned tab's content renders in-place (TerminalView connects to
|
||||
* the home session's PTY via WS, no session jump).
|
||||
*
|
||||
* Closing/unpinning a virtual tab targets the HOME session via reduceFor
|
||||
* (which doesn't notify — targeted opens must not re-render the active
|
||||
* session). The `pinnedRevision` state bump forces the pinnedEntries
|
||||
* useMemo to recompute after such an action.
|
||||
*/
|
||||
const [activePinnedTabId, setActivePinnedTabId] = useState<string | null>(null)
|
||||
const [pinnedRevision, setPinnedRevision] = useState(0)
|
||||
|
||||
/**
|
||||
* Cross-session pinned-tab collection. Recomputed on every store notify,
|
||||
* session-list change, and pinned action (the revision bump covers
|
||||
* reduceFor updates that don't notify). Only tabs from OTHER sessions —
|
||||
* the viewer's own pinned tabs are already on its tab strip.
|
||||
*/
|
||||
const pinnedEntries: readonly PinnedTabEntry[] = useMemo(() => {
|
||||
if (sessionId === undefined) return []
|
||||
return collectPinnedTabs(store.getSessionStates(), { sessionId, cwd })
|
||||
// snapshot / pinnedRevision are deliberate cache-busters (see the comment
|
||||
// above): they recompute this memo for changes that carry no dep of their own.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [store, sessionId, cwd, snapshot, pinnedRevision])
|
||||
|
||||
/** Virtual SidebarTab objects for the pinned entries (stable references
|
||||
* via useMemo so TabContent's memo comparator holds). */
|
||||
const pinnedVirtualTabs = useMemo(
|
||||
() => pinnedEntries.map(createPinnedVirtualTab),
|
||||
[pinnedEntries],
|
||||
)
|
||||
|
||||
/** The right panel's split tree with pinned virtual tabs injected into the
|
||||
* first leaf. When `activePinnedTabId` is set, that leaf's `active` is
|
||||
* overridden so the pinned tab's content is visible. */
|
||||
const augmentedTree = useMemo(
|
||||
() => state === undefined ? undefined : injectPinnedIntoTree(state.splits, pinnedVirtualTabs, activePinnedTabId),
|
||||
[state, pinnedVirtualTabs, activePinnedTabId],
|
||||
)
|
||||
|
||||
/**
|
||||
* Wrap the base actions to intercept pinned VIRTUAL tab ids (injected from
|
||||
* other sessions). Regular tab ids pass through unchanged. Virtual ids are
|
||||
* detected by the `pinned:` prefix and routed to the HOME session via
|
||||
* reduceFor (which doesn't notify — the revision bump is the local signal).
|
||||
*/
|
||||
const wrappedActions = useMemo<WorkbenchActions>(() => {
|
||||
if (pinnedVirtualTabs.length === 0) return actions
|
||||
const closePinnedInHome = (virtualId: string): void => {
|
||||
const { homeSessionId, tabId: originalId } = parsePinnedVirtualId(virtualId)
|
||||
// The home cwd lives in the virtual tab's meta (snapshotted at pin
|
||||
// time) — pass it to ptyClose so the host resolves the PTY in the
|
||||
// correct workspace container (same scope the WS open used).
|
||||
const vtab = pinnedVirtualTabs.find(t => t.id === virtualId)
|
||||
const homeCwd = vtab !== undefined ? getPinnedHomeScope(vtab)?.cwd : undefined
|
||||
store.reduceFor(homeSessionId, s => {
|
||||
const leaf = leafWithTab(s.splits, originalId) ?? leafWithTab(s.bottomSplits, originalId)
|
||||
if (leaf !== undefined) return closeTab(s, leaf.id, originalId)
|
||||
if (s.floats.some(f => f.tab.id === originalId)) return closeFloatByTab(s, originalId)
|
||||
return s
|
||||
})
|
||||
if (isAgentTabId(originalId)) {
|
||||
void api.agentPtyClose(agentUuidOf(originalId)).catch(() => { /* already released */ })
|
||||
} else {
|
||||
void api.ptyClose({ sessionId: homeSessionId, ...(homeCwd !== undefined ? { cwd: homeCwd } : {}) }, originalId).catch(() => { /* already released */ })
|
||||
}
|
||||
if (activePinnedTabId === virtualId) setActivePinnedTabId(null)
|
||||
setPinnedRevision(v => v + 1)
|
||||
}
|
||||
return {
|
||||
...actions,
|
||||
activateTab: (paneId, tabId) => {
|
||||
if (isPinnedVirtualId(tabId)) {
|
||||
setActivePinnedTabId(tabId)
|
||||
} else {
|
||||
setActivePinnedTabId(null)
|
||||
actions.activateTab(paneId, tabId)
|
||||
}
|
||||
},
|
||||
closeTab: (paneId, tabId) => {
|
||||
if (isPinnedVirtualId(tabId)) {
|
||||
closePinnedInHome(tabId)
|
||||
} else {
|
||||
actions.closeTab(paneId, tabId)
|
||||
}
|
||||
},
|
||||
moveTabBefore: (payload, toPane, beforeTabId) => {
|
||||
if (isPinnedVirtualId(payload.tabId)) return
|
||||
if (isPinnedVirtualId(beforeTabId)) {
|
||||
actions.moveTabToEdge(payload, toPane, 'center')
|
||||
} else {
|
||||
actions.moveTabBefore(payload, toPane, beforeTabId)
|
||||
}
|
||||
},
|
||||
moveTabToEdge: (payload, toPane, zone) => {
|
||||
if (isPinnedVirtualId(payload.tabId)) return
|
||||
actions.moveTabToEdge(payload, toPane, zone)
|
||||
},
|
||||
floatTab: (tabId) => {
|
||||
if (isPinnedVirtualId(tabId)) return
|
||||
actions.floatTab(tabId)
|
||||
},
|
||||
pinTab: (tabId, scope) => {
|
||||
if (isPinnedVirtualId(tabId)) {
|
||||
if (scope !== null) return
|
||||
const { homeSessionId, tabId: originalId } = parsePinnedVirtualId(tabId)
|
||||
store.reduceFor(homeSessionId, s => setTabPin(s, originalId, null))
|
||||
if (activePinnedTabId === tabId) setActivePinnedTabId(null)
|
||||
setPinnedRevision(v => v + 1)
|
||||
} else {
|
||||
actions.pinTab?.(tabId, scope)
|
||||
}
|
||||
},
|
||||
}
|
||||
}, [actions, pinnedVirtualTabs, activePinnedTabId, store])
|
||||
|
||||
return { augmentedTree, wrappedActions }
|
||||
}
|
||||
@@ -38,7 +38,7 @@ const OPENABLE_SCHEMES = new Set(['http:', 'https:'])
|
||||
* match on a line; callers must reset `lastIndex` before reuse (the
|
||||
* helper does this defensively).
|
||||
*/
|
||||
export const TERMINAL_URL_REGEX = /\bhttps?:\/\/[^\s"'<>\[\]{}|\\^`]+/gi
|
||||
export const TERMINAL_URL_REGEX = /\bhttps?:\/\/[^\s"'<>[\]{}|\\^`]+/gi
|
||||
|
||||
/** A URL match in a terminal line: the text and its 0-based start offset. */
|
||||
export interface TerminalUrlMatch {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Open-tab reconciliation after a file-tree mutation (rename/delete).
|
||||
*
|
||||
* The tree owns the rows; the TABS live in the sidebar state (either split
|
||||
* tree, the bottom panel, or free windows). A rename must retarget every
|
||||
* tab whose `path` is the renamed file (the editor content survives and
|
||||
* later saves land on the new path); a delete must close every tab at or
|
||||
* under the removed path — files and anything inside a removed directory
|
||||
* (a stale tab's next save would fail against a missing path). Both ride
|
||||
* the SERVICE paths (`updateTab`/`closeTab`) rather than direct state
|
||||
* reducers so the registered lifecycle callbacks fire like any other tab
|
||||
* mutation.
|
||||
*/
|
||||
import type { Context } from '../context-types.ts'
|
||||
import { baseName } from './FileTree.tsx'
|
||||
import { allLeaves, type SidebarSnapshot, type SidebarStore, type SidebarTab } from './state.ts'
|
||||
import { isWithinWorkspace } from './paths.ts'
|
||||
|
||||
/** Every open tab that carries a file path (either split tree, the bottom
|
||||
* panel, and free windows — a floating tab is as open as a docked one). */
|
||||
function pathTabsOf(snapshot: SidebarSnapshot): SidebarTab[] {
|
||||
const state = snapshot.state
|
||||
if (state === undefined) return []
|
||||
const tabs: SidebarTab[] = []
|
||||
for (const leaf of allLeaves(state.splits)) tabs.push(...leaf.tabs)
|
||||
for (const leaf of allLeaves(state.bottomSplits)) tabs.push(...leaf.tabs)
|
||||
for (const float of state.floats) tabs.push(float.tab)
|
||||
return tabs.filter(tab => tab.path !== undefined)
|
||||
}
|
||||
|
||||
/** Retarget tabs after `oldPath` became `newPath` (title follows the new
|
||||
* base name, matching how openFile titles editor tabs). */
|
||||
export function retargetPathTabs(ctx: Context, store: SidebarStore, oldPath: string, newPath: string): void {
|
||||
const service = ctx.get('betterSidebar')
|
||||
if (service === undefined) return
|
||||
for (const tab of pathTabsOf(store.getSnapshot())) {
|
||||
if (tab.path === oldPath) service.updateTab(tab.id, { path: newPath, title: baseName(newPath) })
|
||||
}
|
||||
}
|
||||
|
||||
/** Close tabs at or under the removed `target` (a directory takes its whole
|
||||
* subtree of open files with it). */
|
||||
export function closePathTabs(ctx: Context, store: SidebarStore, target: string): void {
|
||||
const service = ctx.get('betterSidebar')
|
||||
if (service === undefined) return
|
||||
for (const tab of pathTabsOf(store.getSnapshot())) {
|
||||
const path = tab.path
|
||||
if (path !== undefined && (path === target || isWithinWorkspace(target, path))) service.closeTab(tab.id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* The sidebar's ONE polling loop: every timed client fetch — subagent live
|
||||
* previews, side-chat transcript deltas, git status, session ops — funnels
|
||||
* here so the cancellation-safety rules are written once instead of four
|
||||
* times. The contract every poller gets:
|
||||
*
|
||||
* - While `enabled`, the task runs on the chosen cadence; the loop restarts
|
||||
* (and the old one is torn down) whenever `enabled`, the task identity, or
|
||||
* any option primitive changes — callers express "scope changed" through
|
||||
* the task's `useCallback` deps, exactly like an effect's dep array.
|
||||
* - Each enabled run owns ONE AbortSignal for its whole lifetime; teardown
|
||||
* (unmount, `enabled` flip, identity change) aborts it AND stops all
|
||||
* scheduling. A task whose fetch settles late must check the signal
|
||||
* before writing state: an aborted fetch is NOT guaranteed to reject
|
||||
* (the transport may deliver the response anyway), so the signal is the
|
||||
* only reliable staleness guard.
|
||||
* - A rejected task never breaks the loop — the scheduler swallows it and
|
||||
* the next tick retries. Sites that surface errors (setError banners and
|
||||
* friends) do so inside the task body; the scheduler always stays silent.
|
||||
*/
|
||||
import { useEffect } from 'react'
|
||||
|
||||
/** One poll tick. See the module doc for the signal contract. */
|
||||
export type PollingTask = (signal: AbortSignal) => Promise<void>
|
||||
|
||||
export interface UsePollingOptions {
|
||||
/** Tick cadence in milliseconds. */
|
||||
intervalMs: number
|
||||
/**
|
||||
* Scheduling mode. `'fixed-interval'` (default) fires ticks on a plain
|
||||
* `setInterval` cadence — an in-flight task never delays the next tick,
|
||||
* overlapping tasks guard their own writes (abort controllers or
|
||||
* generation counters inside the task). `'self-scheduling'` arms the next
|
||||
* tick only after the previous task settles: at most ONE request in
|
||||
* flight, ever, so a slow host never sees request storms.
|
||||
*/
|
||||
mode?: 'fixed-interval' | 'self-scheduling'
|
||||
/** Run one task immediately when the poller (re)starts, before the first
|
||||
* scheduled tick. */
|
||||
immediate?: boolean
|
||||
}
|
||||
|
||||
export function usePolling(
|
||||
enabled: boolean,
|
||||
task: PollingTask,
|
||||
{ intervalMs, mode = 'fixed-interval', immediate = false }: UsePollingOptions,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
const controller = new AbortController()
|
||||
if (mode === 'self-scheduling') {
|
||||
let disposed = false
|
||||
let timer: number | undefined
|
||||
const tick = async (): Promise<void> => {
|
||||
if (disposed) return
|
||||
try {
|
||||
await task(controller.signal)
|
||||
} catch {
|
||||
// A failed poll keeps the last view; the next tick retries.
|
||||
}
|
||||
if (!disposed) timer = window.setTimeout(() => { void tick() }, intervalMs)
|
||||
}
|
||||
if (immediate) void tick()
|
||||
else timer = window.setTimeout(() => { void tick() }, intervalMs)
|
||||
return () => {
|
||||
disposed = true
|
||||
if (timer !== undefined) window.clearTimeout(timer)
|
||||
controller.abort()
|
||||
}
|
||||
}
|
||||
const run = (): void => {
|
||||
// A failed tick keeps the last view; the next tick retries.
|
||||
task(controller.signal).catch(() => {})
|
||||
}
|
||||
if (immediate) run()
|
||||
const timer = window.setInterval(run, intervalMs)
|
||||
return () => {
|
||||
window.clearInterval(timer)
|
||||
controller.abort()
|
||||
}
|
||||
// Option primitives only: a churned options object must not restart the loop.
|
||||
}, [enabled, task, intervalMs, mode, immediate])
|
||||
}
|
||||
@@ -7,8 +7,6 @@
|
||||
|
||||
import z from 'schemastery'
|
||||
import {
|
||||
SIDEBAR_PREFS_DEFAULTS,
|
||||
SIDEBAR_PREFS_NS,
|
||||
TERMINAL_FONT_SIZE_DEFAULT,
|
||||
TERMINAL_FONT_SIZE_MAX,
|
||||
TERMINAL_FONT_SIZE_MIN,
|
||||
|
||||
@@ -119,6 +119,7 @@ export interface SidebarSlotRegisterOptions {
|
||||
locale?: string
|
||||
registrant?: string
|
||||
/** Business-face factory; args depend on the slot scope. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mirrors the host slots signature, where inject args are untyped; unknown[] would reject concrete-typed implementations (contravariance)
|
||||
inject?: (...args: any[]) => Record<string, unknown>
|
||||
children?: Record<string, unknown>
|
||||
}
|
||||
|
||||
+124
-3
@@ -9,15 +9,22 @@
|
||||
* to a uniquely named temp sibling
|
||||
* and are renamed into place, so a failed, aborted, or oversized upload never
|
||||
* leaves a partial file at the target path.
|
||||
*
|
||||
* The tree's rename/delete (below) are link-aware: existence and containment
|
||||
* are verified against the fully resolved target (a symlink pointing outside
|
||||
* the workspace is refused while the fence is armed), but the operation
|
||||
* itself addresses the lexical row path — renaming or deleting a symlink
|
||||
* row renames/unlinks the LINK, never its target, matching what the tree
|
||||
* row visually names (VS Code semantics).
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { once } from 'node:events'
|
||||
import { createWriteStream } from 'node:fs'
|
||||
import { mkdir, rename, rm, stat } from 'node:fs/promises'
|
||||
import { access, lstat, mkdir, realpath, rename, rm, stat, unlink } from 'node:fs/promises'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import type { SidebarHttpRequest } from './context-types.ts'
|
||||
import { requireAbsolute } from './fs-tree.ts'
|
||||
import { isWithin, requireAbsolute } from './fs-tree.ts'
|
||||
import { ensureWorkspacePath, ensureWorkspaceWritePath } from './path-security.ts'
|
||||
import { resolveSessionPath } from './session-path.ts'
|
||||
import { SidebarError } from './wire.ts'
|
||||
|
||||
/** Inputs of one upload: the session scope plus the request body stream. */
|
||||
@@ -95,3 +102,117 @@ export async function writeWorkspaceUpload(input: WorkspaceUploadInput): Promise
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** Inputs of one tree-row rename. */
|
||||
export interface WorkspaceRenameInput {
|
||||
/** The session workspace root; the renamed entry must stay inside it. */
|
||||
cwd: string
|
||||
/** Absolute path of the row as the tree displays it (may be a symlink). */
|
||||
path: string
|
||||
/** The new base name (single segment — rename never moves across directories). */
|
||||
name: string
|
||||
/** Whether workspace containment is enforced (the `workspaceFence` setting; on by default). */
|
||||
fence?: boolean
|
||||
}
|
||||
|
||||
/** Resolve one existing entry for a link-aware mutation: the lexical row path
|
||||
* plus its fully resolved real target (fence-checked). ENOENT becomes an
|
||||
* fs-error, mirroring path-security's resolveRealPath semantics. */
|
||||
async function resolveEntry(
|
||||
cwd: string,
|
||||
target: string,
|
||||
fence: boolean,
|
||||
): Promise<{ absolute: string; real: string; realCwd: string }> {
|
||||
const absolute = requireAbsolute(resolveSessionPath(cwd, target))
|
||||
let real: string
|
||||
let realCwd: string
|
||||
try {
|
||||
;[realCwd, real] = await Promise.all([realpath(cwd), realpath(absolute)])
|
||||
} catch (error) {
|
||||
throw new SidebarError('fs-error', `cannot resolve "${target}": ${error instanceof Error ? error.message : String(error)}`, 400)
|
||||
}
|
||||
if (fence && !isWithin(realCwd, real)) {
|
||||
throw new SidebarError('forbidden', `path "${target}" is outside workspace`, 403)
|
||||
}
|
||||
return { absolute, real, realCwd }
|
||||
}
|
||||
|
||||
/** Whether a path exists (ENOENT → false; other failures propagate). */
|
||||
async function pathExists(target: string): Promise<boolean> {
|
||||
try {
|
||||
await access(target)
|
||||
return true
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename one tree row within its directory: `path` → `<parent>/<name>`.
|
||||
* The new name must be a single path segment (this is rename, not move);
|
||||
* an existing destination is refused (POSIX rename would clobber it
|
||||
* silently); the workspace root itself is never renamable; a symlink row
|
||||
* renames the link, not its target. A no-op rename (same name) succeeds
|
||||
* without touching the filesystem.
|
||||
*
|
||||
* @throws SidebarError with a wire code for shape, containment, existence
|
||||
* and root failures.
|
||||
*/
|
||||
export async function renameWorkspaceEntry(input: WorkspaceRenameInput): Promise<{ path: string }> {
|
||||
const { cwd, path, name, fence = true } = input
|
||||
if (name === '' || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) {
|
||||
throw new SidebarError('bad-request', 'name must be a single path segment', 400)
|
||||
}
|
||||
const { absolute, real, realCwd } = await resolveEntry(cwd, path, fence)
|
||||
if (real === realCwd) {
|
||||
throw new SidebarError('fs-error', 'cannot rename the workspace root', 400)
|
||||
}
|
||||
if (basename(absolute) === name) return { path: absolute }
|
||||
const destination = join(dirname(absolute), name)
|
||||
const safeDestination = await ensureWorkspaceWritePath(cwd, destination, fence)
|
||||
if (await pathExists(safeDestination)) {
|
||||
throw new SidebarError('fs-error', `"${name}" already exists`, 409)
|
||||
}
|
||||
try {
|
||||
await rename(absolute, safeDestination)
|
||||
} catch (error) {
|
||||
throw new SidebarError('fs-error', `cannot rename "${path}" to "${name}": ${error instanceof Error ? error.message : String(error)}`, 400)
|
||||
}
|
||||
return { path: safeDestination }
|
||||
}
|
||||
|
||||
/** Inputs of one tree-row delete. */
|
||||
export interface WorkspaceRemoveInput {
|
||||
/** The session workspace root; the removed entry must stay inside it. */
|
||||
cwd: string
|
||||
/** Absolute path of the row as the tree displays it (may be a symlink). */
|
||||
path: string
|
||||
/** Whether workspace containment is enforced (the `workspaceFence` setting; on by default). */
|
||||
fence?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete one tree row permanently (there is no trash on the host): files are
|
||||
* unlinked, directories removed recursively, a symlink row unlinks the LINK
|
||||
* only (lstat decides, so a link to a directory does not recurse into its
|
||||
* target). The workspace root itself is never removable.
|
||||
*
|
||||
* @throws SidebarError with a wire code for containment, existence and
|
||||
* root failures.
|
||||
*/
|
||||
export async function removeWorkspaceEntry(input: WorkspaceRemoveInput): Promise<{ path: string }> {
|
||||
const { cwd, path, fence = true } = input
|
||||
const { absolute, real, realCwd } = await resolveEntry(cwd, path, fence)
|
||||
if (real === realCwd) {
|
||||
throw new SidebarError('fs-error', 'cannot remove the workspace root', 400)
|
||||
}
|
||||
try {
|
||||
const info = await lstat(absolute)
|
||||
if (info.isDirectory()) await rm(absolute, { recursive: true })
|
||||
else await unlink(absolute)
|
||||
} catch (error) {
|
||||
throw new SidebarError('fs-error', `cannot remove "${path}": ${error instanceof Error ? error.message : String(error)}`, 400)
|
||||
}
|
||||
return { path: absolute }
|
||||
}
|
||||
|
||||
+30
-2
@@ -31,7 +31,7 @@ import {
|
||||
} from './config.ts'
|
||||
import { parentOf, requireAbsolute, listDirectory, rootLabel } from './fs-tree.ts'
|
||||
import { resolveSessionPath } from './session-path.ts'
|
||||
import { writeWorkspaceUpload } from './fs-operations.ts'
|
||||
import { renameWorkspaceEntry, removeWorkspaceEntry, writeWorkspaceUpload } from './fs-operations.ts'
|
||||
import { ensureWorkspacePath, ensureWorkspaceWritePath } from './path-security.ts'
|
||||
import { searchFiles } from './fs-search.ts'
|
||||
import { decodeHtmlUrl } from './html-route.ts'
|
||||
@@ -369,6 +369,28 @@ function buildApi(
|
||||
}
|
||||
return { ok: true }
|
||||
},
|
||||
// The tree row's rename: single-segment name, destination-existence and
|
||||
// workspace-root refusals, link-aware (renames the row, not its target).
|
||||
// fs-operations.ts owns the containment and shape rules.
|
||||
'fs.rename': async (payload) => {
|
||||
const { cwd } = await cwdOf(payload)
|
||||
return renameWorkspaceEntry({
|
||||
cwd,
|
||||
path: requireString(payload, 'path'),
|
||||
name: requireString(payload, 'name'),
|
||||
fence: fenceEnabledOf(getSettings),
|
||||
})
|
||||
},
|
||||
// The tree row's delete (permanent — the host has no trash): recursive
|
||||
// for directories, unlinks a symlink row without touching its target.
|
||||
'fs.remove': async (payload) => {
|
||||
const { cwd } = await cwdOf(payload)
|
||||
return removeWorkspaceEntry({
|
||||
cwd,
|
||||
path: requireString(payload, 'path'),
|
||||
fence: fenceEnabledOf(getSettings),
|
||||
})
|
||||
},
|
||||
'git.worktrees': async (payload) => {
|
||||
const { cwd } = await gitCwdOf(payload)
|
||||
const selected = selectedRepoOf(payload)
|
||||
@@ -452,7 +474,13 @@ function buildApi(
|
||||
'git.show': async (payload) => {
|
||||
const { cwd } = await gitCwdOf(payload)
|
||||
const repoRoot = selectedRepoOf(payload)
|
||||
const path = await resolveGitPath(cwd, requireString(payload, 'path'), repoRoot)
|
||||
// `git show <rev>:<path>` addresses the path inside the revision TREE:
|
||||
// repository-relative, exactly the unified diff's own path form (after
|
||||
// the a// b/ prefix). The absolute filesystem paths resolveGitPath
|
||||
// produces would break the rev:path syntax and fail every read, so the
|
||||
// path passes through as-is — it can only address blobs of this repo's
|
||||
// own revisions, the same surface git.diff/git.log already expose.
|
||||
const path = requireString(payload, 'path')
|
||||
const rev = requireString(payload, 'rev')
|
||||
return { content: await git.show(cwd, rev, path, repoRoot) }
|
||||
},
|
||||
|
||||
@@ -259,7 +259,6 @@ export function buildOpenTurnSnapshot(events: readonly SidechatLogEvent[]): stri
|
||||
let reasoning = ''
|
||||
const tools: string[] = []
|
||||
const pendingCalls = new Map<string, { name: string; args: string }>()
|
||||
let total = 0
|
||||
for (let index = boundary + 1; index < events.length; index++) {
|
||||
const event = events[index]
|
||||
if (event === undefined) continue
|
||||
@@ -299,13 +298,11 @@ export function buildOpenTurnSnapshot(events: readonly SidechatLogEvent[]): stri
|
||||
...(result === '' ? [] : [` Result: ${result}`]),
|
||||
].join('\n')
|
||||
tools.push(line)
|
||||
total += line.length
|
||||
}
|
||||
}
|
||||
for (const [, call] of pendingCalls) {
|
||||
const line = `- \`${call.name}\` (executing) — arguments: \`${call.args}\``
|
||||
tools.push(line)
|
||||
total += line.length
|
||||
}
|
||||
const sections: string[] = []
|
||||
if (text.trim() !== '') sections.push(`Assistant output so far:\n\n${text}`)
|
||||
|
||||
@@ -27,6 +27,7 @@ import type { Agent, AgentSetup, CreateAgentOptions, ResumeAgentOptions } from '
|
||||
import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
|
||||
import type { Context as CordisContext } from '@deepseek-ai/cordis'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { SessionLogOffset } from '@deepseek-ai/dsh-session'
|
||||
import type {
|
||||
Context,
|
||||
SidebarAgentPresetsService,
|
||||
@@ -238,16 +239,28 @@ export function buildSidechatApi(ctx: Context): SidechatRoutes {
|
||||
data: descriptor as unknown as Record<string, unknown>,
|
||||
}
|
||||
const seed = [...inheritance.seed, descriptorEvent]
|
||||
// Fork-marker fields (the exact shape the host's own session.fork uses,
|
||||
// api-session-controller): without `isSeeded` + `inheritedEventCount` the
|
||||
// session treats the whole seed as the child's OWN events, so the child's
|
||||
// Inbox constructor replays the parent's `agent/inbox/spliced` events and
|
||||
// inherits whatever input sat UNCLAIMED in the parent at the click moment
|
||||
// (a queued follow-up, or a tool-result context spliced into next-step
|
||||
// between step boundaries of a long-running turn). The first side prompt
|
||||
// would then claim and send that stale message BEFORE the boundary +
|
||||
// question. The marker keeps `ownEvents()` at the end-seed boundary, so
|
||||
// the inherited inbox replays to empty.
|
||||
const options: CreateAgentOptions = {
|
||||
sessionId: childId,
|
||||
meta: {
|
||||
...(parentSession.header.cwd === undefined ? {} : { cwd: parentSession.header.cwd }),
|
||||
parentSession: parentSession.id,
|
||||
isSeeded: true,
|
||||
origin: 'subagent',
|
||||
delegationDepth: (parentSession.header.delegationDepth ?? 0) + 1,
|
||||
...(agentPreset === undefined ? {} : { agentPreset }),
|
||||
},
|
||||
seed: seed as unknown as readonly SessionEvent[],
|
||||
inheritedEventCount: SessionLogOffset(seed.length),
|
||||
agentOptions: { ...parent.options },
|
||||
setup,
|
||||
signal: AbortSignal.timeout(CREATE_TIMEOUT_MS),
|
||||
|
||||
@@ -18,7 +18,7 @@ import { renderToString } from 'react-dom/server'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { PLUGIN_TOPIC_URL, type PluginEntry } from '../src/client/plugins-shared.ts'
|
||||
import { type PluginEntry } from '../src/client/plugins-shared.ts'
|
||||
import { builtinTabPlugins } from '../src/client/plugins-tabs.ts'
|
||||
import { builtinViewerPlugins } from '../src/client/plugins-viewers.ts'
|
||||
import { AddPluginModal, PluginListBody } from '../src/client/add-plugin-modal.tsx'
|
||||
@@ -30,6 +30,14 @@ import { setupReactAct } from './test-utils.ts'
|
||||
setupReactAct()
|
||||
|
||||
const officeEntry = builtinViewerPlugins[0] as PluginEntry
|
||||
const sentinelEntry = builtinTabPlugins.find(entry => entry.id === '@dsh-external/dsh-sentinel') as PluginEntry
|
||||
|
||||
/** Resolve an i18n-friendly string-or-function value (mirror of textOf).
|
||||
* Catalog names went through the same union as descriptions, so they
|
||||
* render through t() in the active (test: en) locale. */
|
||||
function textOf(value: string | (() => string)): string {
|
||||
return typeof value === 'function' ? value() : value
|
||||
}
|
||||
|
||||
describe('PluginListBody (render)', () => {
|
||||
it('viewer kind renders the topic button and the office catalog entry', () => {
|
||||
@@ -43,7 +51,7 @@ describe('PluginListBody (render)', () => {
|
||||
expect(html).not.toContain('href=')
|
||||
// The seeded catalog entry: name, description, install script,
|
||||
// a jump button (opens the repo in a NEW browser tab) and a copy button.
|
||||
expect(html).toContain(officeEntry.name)
|
||||
expect(html).toContain(textOf(officeEntry.name))
|
||||
expect(html).toContain(officeEntry.install.replaceAll('&', '&'))
|
||||
expect(html).toContain('Open')
|
||||
expect(html).toContain('Copy')
|
||||
@@ -53,9 +61,9 @@ describe('PluginListBody (render)', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
const html = renderToString(createElement(PluginListBody, { service, kind: 'tab' }))
|
||||
expect(html).toContain('dsh-sentinel 唤醒系统')
|
||||
expect(html).toContain(textOf(sentinelEntry.name))
|
||||
expect(html).toContain('github:fuhefei/dsh-sentinel')
|
||||
expect(html).not.toContain(officeEntry.name)
|
||||
expect(html).not.toContain(textOf(officeEntry.name))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -75,9 +83,9 @@ describe('AddPluginModal wiring', () => {
|
||||
})
|
||||
const html = document.body.innerHTML
|
||||
if (kind === 'viewer') {
|
||||
expect(html).toContain(officeEntry.name)
|
||||
expect(html).toContain(textOf(officeEntry.name))
|
||||
} else {
|
||||
expect(html).toContain('dsh-sentinel 唤醒系统')
|
||||
expect(html).toContain(textOf(sentinelEntry.name))
|
||||
}
|
||||
act(() => { root.unmount() })
|
||||
container.remove()
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
ALLOWED_SIGNALS,
|
||||
snapshotOf,
|
||||
tryResizePty,
|
||||
type AgentTerminalSnapshot,
|
||||
} from '../src/agent-pty.ts'
|
||||
|
||||
/**
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user