Compare commits

...
Author SHA1 Message Date
figmar 96cc334143 spike(A): publish apes:profile as an official right-sidebar tab type
Phase A spike of the C8 root fix: instead of only rendering in the fork's
own shell, apes:profile now also registers with DSH's OFFICIAL right
sidebar through its documented two-stage extension protocol:

  1. ctx.sidebarRightTabs.register({ id, kind, priority:'extension',
     title, guide })  — the type declaration
  2. ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register(
     { name:'sidebar.right.pane.tab', key: id }, Body))  — the keyed body

The registry is reached through ctx.inject(['sidebarRightTabs'], ...) (the
same pattern intercept.tsx uses for the async remote.session namespace), so
activation order cannot strand the bridge and a deployment without the
official sidebar stays a silent no-op — the fork's own shell is untouched.

The body reuses the existing ApesProfileView (content is shared, never
duplicated); only the shell-scope binding differs. Guide entry order 70
keeps the apes trio's position in the official entry list.

Verified: typecheck clean; build clean; 1255 passed / 9 skipped (120 files,
6 new bridge specs pinning the two-stage contract, both no-op paths, and the
HMR-safe unwind).

Local commit only — not pushed until the in-app spike is confirmed.
2026-10-03 20:42:13 +08:00
figmar 9ea9e9b9fa fix(c8): match native icon family — outline glyphs at native scale
CI / ci (push) Canceled after 0s
CI / ci-windows (push) Canceled after 0s
CI / plugin-mount (push) Canceled after 0s
The apes tab glyphs (智能体/应用/画像) were fill-only paths spanning only
10-12 of the 16 viewBox units, so they rendered visibly smaller and lighter
than the DSH native glyphs (which fill ~92-96% of the box) in the + new-tab
menu and the right-panel tile grid.

Rewrite them in this repo's own outline recipe (src/client/icons.tsx): 16x16
viewBox, artwork on 1.5-14.5, 1.5px currentColor stroke, round caps/joins.
Renamed IconAgentFill16/IconGaugeFill16/IconUserFill16 ->
IconAgentOutline16/IconAppsOutline16/IconUserOutline16.
2026-10-03 20:05:45 +08:00
figmar 2f3944a8bd fix(c8): fill-style icons — visual unity with native tabs
CI / ci (push) Canceled after 0s
CI / ci-windows (push) Canceled after 0s
CI / plugin-mount (push) Canceled after 0s
Root cause: DSH's @deepseek-ai/dsh-client-ui-primitives only ships
outline variants (mask+stroke) for agent/gauge/user icons, while native
tabs use fill:currentColor paths (solid, heavy visual weight). This made
the apes tabs look 'lighter' than the official ones in the + menu.

Fix: custom fill-style SVG icons in src/client/apes/icons.tsx
(IconAgentFill16, IconGaugeFill16, IconUserFill16) — matching the
fill:currentColor path style of DSH native tabs (IconCodeOutline16,
IconFolderOpen16, etc.).

Also: order 40/45/50 → 60/65/70 (avoid clash with terminal=40,
browser=50 in the native tab order).
2026-10-03 19:31:00 +08:00
figmar fbd183e93f fix(c8): order 60/65/70 — 不与原生 terminal(40)/browser(50) 冲突
CI / ci (push) Canceled after 0s
CI / ci-windows (push) Canceled after 0s
CI / plugin-mount (push) Canceled after 0s
2026-10-03 19:20:20 +08:00
figmar cebc21aef2 fix(c8): sidebar entry unity — DSH Icon*16 + order 40-50
Root cause of the visual split:
1. Icon system mismatch: apes used react-icons (FiCpu/FiGrid/FiUser,
   static 14px ReactNode) while native builtins use DSH's Icon*16
   components (function form, host-controlled size). Two different
   icon libraries → inconsistent visual style.
2. Order 901-903 pushed apes tabs far after native builtins (10-35),
   creating two visually separate groups in the tab strip.

Fix:
- icon: react-icons Fi* → @deepseek-ai/dsh-client-ui-primitives
  Icon*16 (function form, same as builtins):
    agents  → IconAgentPresetOutline16
    apps    → IconGaugeOutline16
    profile → IconUserOutline16
- order: 901/902/903 → 40/45/50 (immediately after native builtins)
- SIDEBAR_SERVICE_VERSION + dsh.plugin.json version → 1.1.0
  (lockstep with package.json, required by manifest tests)

Gate: typecheck ✓ / build ✓ / test 119 files · 1249 passed · 9 skipped · 0 failed ✓
2026-09-16 02:44:35 +08:00
figmar aa28c1fbc7 feat(v1.1.0): adapt to DSH 2.0.10 (0.1.5-rc.2), drop apes:tasks, DSW token styles
C1-C5 env repair:
- align all @deepseek-ai/dsh-* peer+devDeps to 0.1.5-rc.2 (DSH 2.0.10 ships it)
- fix 0.1.5-rc.2 npm packaging defect: 15 primitives runtime deps (anser,
  shiki, katex, marked, ... ) were mis-declared as devDeps in the published
  package -> add to local devDependencies so vitest can resolve them
- fix sidechat-seed contract: dsh-session@0.1.5 assertAssistantSettlementShape
  now requires a top-level data.stream: Array on assistant messages -> seed
  helper emits stream: []

C6 slim tabs:
- remove apes:tasks (overlaps the official DSH session tree in the native
  sidebar); keep apes:agents / apes:apps / apes:profile
- clean dead code: ApesTasksView + job helpers import + apes i18n dead keys
  (tasks/status/duration) and the unused menu-title keys

C7 style align:
- apes view inline colors -> DSW alias tokens (--dsw-alias-bg/border/label/
  state-success/interactive) with literal fallbacks; geometry (font/spacing/
  radius) stays inline (DSH exposes no --ds-* geometry tokens to plugin panels)

gate: typecheck ok / build ok / test 119 files · 1249 passed
2026-09-16 01:57:46 +08:00
figmar 34e86141bd feat(scripts): switch.sh — apes↔upstream 两线对称切换
CI / ci (push) Canceled after 0s
CI / ci-windows (push) Canceled after 0s
CI / plugin-mount (push) Canceled after 0s
- 同一 profile 内二选一(双装不可行: 同 cordis entry id 冲突)
- 探测当前线 → 移除对手线依赖+bundle → 写目标线 → pnpm install → 校验 bundle
- 幂等可重复跑; --status 只读; --dry-run 不写文件; --profile 指定 profile
- aps 线用 file: 本地源码, upstream 线用 registry latest/指定版本
- 修复 pnpm spec 双 @ bug (值不含包名, pnpm 从 key 取包名)
- 双向真实切换验证通过 (aps→upstream→aps, 临时 profile 副本, EXIT=0)
- package.json 加 switch/switch:status/switch:aps/switch:upstream 入口
- switch.sh 加入 files (发布时带上)
2026-09-07 02:25:15 +08:00
figmar c0441d3a7e docs: README 补 v1.0.0 fork 版本更新说明 (4 个 apes tab + 数据接入)
CI / ci (push) Canceled after 0s
CI / ci-windows (push) Canceled after 0s
CI / plugin-mount (push) Canceled after 0s
2026-09-07 02:02:55 +08:00
figmar 427266a191 feat(apes): Phase 2 数据接入 — 4 个信息层 tab 接真实会话数据
CI / ci (push) Canceled after 0s
CI / ci-windows (push) Canceled after 0s
CI / plugin-mount (push) Canceled after 0s
- apes:tasks  接 jobsBySession + running sessions(live 任务列表:
  label/status/kind/耗时 + owner 归属,复用 collectTreeJobs/orderJobs)
- apes:agents 接 subagent 拓扑(rootAncestor + subagentsByParent 目录,
  parent→children activity running/inactive, 降级用 byId origin 过滤)
- apes:profile 接 byId[current](当前会话画像: title/cwd/running + agent 状态)
- apes:apps    展示 cwd/workspace 信息(无 DSH host feed, 保留说明)

数据源: ctx.sessions.list (useSyncExternalStore, uSES 安全订阅)
复用 pure helper: subagent-lineage.ts (rootAncestor/isSideThreadSummary)
  + subagent-jobs.ts (collectTreeJobs/orderJobs/isJobLive/jobDotState)
UI 独立内联样式, 不耦合 builtin CSS module / StateDot (保持 apes/ 隔离)

locales: 加 status/duration/empty/workspace 文案 (zh+en, 含 {param} 插值)
gate: 1250 passed | typecheck exit 0 | tsdown build complete
2026-09-07 01:45:39 +08:00
figmar a7bcdef21b feat(apes): Phase 1b — 补 apes:apps 信息层 tab (4 tab 齐活)
CI / ci (push) Canceled after 0s
CI / ci-windows (push) Canceled after 0s
CI / plugin-mount (push) Canceled after 0s
- tabs.tsx: +FiGrid icon, +AppsView placeholder, +apes:apps descriptor (order 902, profile 推到 903)
- locales.ts: +apesApps/apesAppsTitle/apesAppsHint zh/en 4 key
- tests: 3→4 tab 断言 (id 列表/contract/order 严格递增/disposer/i18n zh+en)

4 个 apes:* tab: tasks(900) / agents(901) / apps(902) / profile(903)
gate: typecheck + build + test 1250 passed / 9 skipped / 0 failed (无回归)
2026-09-06 21:32:49 +08:00
figmar 257b8b4eea feat(apes): Phase 1 — 注册 3 个 apes:* 信息层 tab (tasks/agents/profile)
- 独立 src/client/apes/ 模块 (D1=B), index.tsx 仅 3 处最小改动
- 3 个静态占位 tab (react-icons FiList/FiCpu/FiUser), order 900+ 排在上游 builtins 之后
- 隔离 i18n 命名空间 'apes' (不碰上游 betterSidebar 字典), zh/en 双语
- index.tsx: attachApesLocale + 字典注册 + registerApesTabs effect
- tests/apes-tabs.spec.ts: 9 测试 (注册契约 + disposer + i18n zh/en/兜底)

gate: typecheck + build + test 1250 passed / 9 skipped / 0 failed

apps tab 延后 Phase 1b, messages 已砍 (无 DSH message-stream API)
2026-09-06 21:10:13 +08:00
figmar 2a7e030b3b chore: fork dsh-better-sidebar v0.18.0 as dsh-apes-sidebar v1.0.0
- Rename dsh-better-sidebar → dsh-apes-sidebar across 74 files (262 occurrences)
- Bump version 0.18.0 → 1.0.0 (package.json + dsh.plugin.json + SIDEBAR_SERVICE_VERSION)
- Add forkedFrom metadata to package.json (upstream pin: commit 9e1a034)
- Add fork notice banner to README.md (MIT attribution preserved)
- Keep original MIT copyright (c) 2026 dsh-external
- Full gate green: typecheck + build (6 bundles) + test (1241 passed / 9 skipped)
2026-09-06 18:09:09 +08:00
Atlas Zeng 9e1a034527 Merge pull request #537 from omdsh-dev/feat/adapt-dsh-0.1.2-rc.1
feat: adapt to DSH 0.1.2-rc.1 and bump to v0.18.0 (stable)
2026-09-03 19:38:01 +08:00
Menghuan1918 ab6f960fe4 fix(ui): animate the toggle cluster's recenter between tab strip and session header
The viewport-corner toggle cluster sits at top:3 riding the right panel's
tab strip while expanded, and recenters to top:14 over DSH's session
header row when collapsed — an 11px snap on every expand/collapse. Ride
the panel slide's own duration/easing (--ds-transition-duration-slow +
--ds-ease-in-out) so both moves stay in lockstep; prefers-reduced-motion
already clears .toggleCluster transitions.
2026-09-03 18:15:41 +08:00
Menghuan1918 fbfd59bd1b feat: adapt to DSH 0.1.2-rc.1 and bump to v0.18.0 (stable)
Graduate from the alpha track to the stable channel:

- Baseline: CI mount pin, dsh.plugin.json engines floor, and every
  @deepseek-ai/* peer floor / devDependencies pin move to 0.1.2-rc.1
  (published on the `next` dist-tag). alpha.5 -> rc.1 is a pure version
  bump in the harness (all package.json "version" lines, zero source
  delta), so no code adaptation; every host contract was re-verified
  unchanged and the real-host mount smoke passes.
- dsh-client-locale resumed publishing; its trailing-pin exception is
  gone — peers and devDeps align at 0.1.2-rc.1 (pnpm peers check clean).
- Version: fold the unpublished v0.18.1-alpha.0 / v0.19.0-alpha.0 bumps
  into the v0.18.0 stable release; SIDEBAR_SERVICE_VERSION follows
  package.json (asserted by tests/service.spec.ts).
- Docs: README/README_EN badges + channel notes + a full "since v0.17.1"
  changelog entry (the two folded alpha numbers are annotated), AGENTS.md
  §2/§3 baseline wording, external-plugin-guide version line, and the
  e2e-host-protocol header's re-verified list gains 0.1.2-rc.1.
2026-09-03 17:32:04 +08:00
Atlas Zeng 60f9acdcbe Merge pull request #527 from omdsh-dev/fix/scripts-hardening
fix(scripts): e2e 脚本加固——tarball mtime 选取/DSH_CMD 统一/registry mermaid chunk/e2e-common.sh 去重
2026-09-03 16:30:12 +08:00
Atlas Zeng dd47a2c864 Merge pull request #526 from omdsh-dev/feat/dev-commands
feat(dev): 引入 Makefile 规范化命令面
2026-09-03 16:30:08 +08:00
Atlas Zeng f24f95f6d2 Merge pull request #525 from omdsh-dev/feat/dedup-core
feat(client): 收敛重复实现(api envelope/extOf/lineage/错误归一化)并删除死代码
2026-09-03 16:30:04 +08:00
Atlas Zeng ceed6b244e Merge pull request #524 from omdsh-dev/feat/test-utils
feat(tests): 收敛组件测试样板到共享 test-utils
2026-09-03 16:30:00 +08:00
Menghuan1918 3d23ceb467 fix(ci): pnpm 版本统一由 packageManager 字段供版,移除 action 显式 version 输入 2026-09-03 12:17:28 +08:00
Menghuan1918 c026676f41 refactor(e2e): 抽取 e2e-common.sh,收敛两挂载脚本的重复骨架
e2e-mount.sh 与 e2e-aggregate-mount.sh 约 40% 逐字重复:say/warn/die、
node/pnpm 前置校验、DSH_CMD 与 tarball 解析、scratch home + 清理 trap、
profile 三件套 heredoc、dsh web 启动与就绪轮询。收敛为 scripts/
e2e-common.sh 纯函数库(日志前缀 E2E_TAG 参数化),两脚本 source 之。

行为等价性(差异点全部参数化,既有注释语义原位保留):
- DSH_HOME 子路径:mount 用 $SCRATCH/home,aggregate 直接用 $SCRATCH
  (调用点各自 export);
- 就绪行 grep:mount 要带 token 的完整 URL(head -1 取首个),aggregate
  只取裸 origin(tail -1 取末个)——E2E_READY_RE / E2E_READY_PICK 传入,
  两侧论证注释保留在各自脚本;URL 提取统一为命中串取末段(mount 模式带
  `dsh web: ` 两段前缀故等价于原 awk '{print $3}',aggregate 模式不含
  空白故等价于原整行);
- 额外清理项:aggregate 的 fixture TMP_DIR(mount 不设该变量即跳过);
- 就绪失败文案/尾部行数差异(提前退出 tail -30 vs 超时 tail -40 vs 双
  日志 tail -5)留在调用点。

e2e-common.sh 为纯开发侧文件,不进 npm files 白名单;install.sh /
install.ps1 随 npm 包独立分发,保持自包含,不 source 它。
2026-09-03 12:08:43 +08:00
Menghuan1918 35111e163c docs(readme): 双语补 Makefile 用法、聚合挂载回归与一键安装入口
- 开发与构建:补 Makefile 薄封装示例(help/check/mount/clean)与
  pnpm check:consumer-types 的定位说明(浏览器-only 消费者视角)
- 参与贡献:提交前自检补 make check 与 pnpm test:mount:aggregate
- 安装:补方式三一键脚本(bash scripts/install.sh;Windows install.ps1,
  -h 查看参数);README 与 README_EN 同步镜像
2026-09-03 12:07:35 +08:00
Menghuan1918 6201a2c6e6 build(ci): 补 test:mount:aggregate / clean / packageManager 并对齐 release 校验顺序
- package.json 新增 test:mount:aggregate(scripts/e2e-aggregate-mount.sh 的
  npm 别名,CI plugin-mount lane 改经它驱动,保留 DSH_CMD/DSH_TELEMETRY_DISABLED
  env)与 clean(node rmSync 删 lib,与 build 开头清理同款、跨平台);
  只新增普通 script,不动任何生命周期钩子(market-manifest.spec.ts 对
  packed manifest 的 lifecycle 断言不受影响,已实跑验证)
- packageManager 钉 pnpm@11.8.0(本地实装版本;pnpm 10+ 运行时按该字段
  自切版本,CI action-setup 的 version: 11 输入与其兼容不冲突)
- release.yml 校验顺序统一为 typecheck → test → build → check:consumer-types
  (与 ci.yml 一致),补上缺失的 consumer 类型面守卫;tag 校验等非构建
  步骤位置不变
2026-09-03 12:07:29 +08:00
Menghuan1918 789696aa31 build(dev): 引入 Makefile 命令面薄封装与 .editorconfig 基线
- Makefile 仅转发 package.json scripts(唯一事实源),补 help 自说明、
  clean 清理与 check / mount / mount-aggregate / registry 四个聚合目标;
  check 用单条 recipe 串 typecheck → build → test → check:consumer-types,
  避免 make 前置依赖在 -j 下乱序;lint 目标留给引入 ESLint 的后续 PR
- .editorconfig:root + utf-8/lf/末尾换行/去尾随空格,默认 2 空格,
  Makefile recipe 保持 tab
2026-09-03 12:07:23 +08:00
Menghuan1918 bc1984b2f2 refactor(sidechat): 删除零引用的 sidechatSeed
src/sidechat-core.ts 的 sidechatSeed()(buildSidechatInheritance 的
seed 半边,doc 注明 test convenience)在 src+tests 全仓零引用,也无
barrel 转出口,删除。同文件的 SeedEvent 类型与 copyEvents 仍被
SidechatInheritance 使用,保留;其余导出不动。
2026-09-03 12:05:48 +08:00
Menghuan1918 4002f3cc5c refactor(client): 统一 GitLens 错误归一化与 SubagentView 日志分级
- GitLens.tsx:六处 catch 里的 reason instanceof Error ?
  reason.message : String(reason)(refreshTarget/refresh/loadMoreLog/
  commit/checkout/runConfirmed)收敛为模块级 errorMessage(),
  两处模板字符串插值场景(historyLoadError/checkoutError 前缀)
  同样复用;归一化表达式与输出文本逐字不变。
- SubagentView.tsx:openSubagent/open session 失败的 console.warn
  改为 console.error,与同严重度失败路径对齐(Sidebar.tsx tab
  badge、TerminalView.tsx xterm open、service.ts urlTarget/plugin
  callback 均为 console.error);前缀与消息文本不变。tests/ 无任何
  钉 console.warn 的断言,已核实。
2026-09-03 12:05:48 +08:00
Menghuan1918 69cc8014a7 refactor(client): subagent lineage 遍历收敛为共享 subagent-lineage 模块
rootAncestor / countSubagentDescendants / treeSessionIds 三份「沿
subagent-origin 链上行 + seen 集合防环 fail-soft」实现收敛到新模块
src/client/subagent-lineage.ts:核心走链是单个私有生成器
subagentOriginChain()(seen 防环、只在节点为 subagent 且有 parent 时
前行),三个公开函数迁入并基于它重写;isSideThreadSummary 一并迁入
(countSubagentDescendants 依赖它,迁入可避免 detect↔lineage 循环
依赖),subagent-detect.ts / subagent-jobs.ts re-export 全部迁出
符号,既有 import 调用点零变化。

语义逐字保留的等价性论证:
- rootAncestor:链为空返回 start.id(原循环条件不满足即返回
  current?.id);链非空返回 byId[末节点.parentId]?.id ?? sessionId
  (原循环退出时 current 恰为该行,断链/行缺失回退 sessionId 不变)。
- countSubagentDescendants:生成器在 seen.add 后 yield、消费方判
  parentId === sessionId 即 break——与原循环体内判断顺序一致;
  descendant 的 running 判定、side-thread 过滤位置不变。
- treeSessionIds:根行缺失(byId[rootId] 无行)时任何上行都不可能
  「访问到」根(原实现只匹配真实访问过的行),故提前返回空集;
  根行自身(非 subagent)经 summary.id === rootId 特判入集;其余
  行经链节点 parentId === rootId 判定,等价于原「上行走到 id ===
  rootId 的行」——两种表述指向同一行(链上节点的 next 行)。

directSubagentCount 不迁移:它是对直接子行的单层过滤,没有上行
walk,无共享逻辑(背景侦查将其归入 walk 家族与实际代码不符)。
collectBranchIds 遍历的是 catalog 结构而非 session 链,保持不动。
2026-09-03 12:05:41 +08:00
Menghuan1918 857646ea66 refactor(client): 收敛 /sidebar envelope 解析与取扩展名双实现
- api.ts:call 与 fetchUpload 里逐字相同的响应 envelope 解析
  (json().catch(null) + ok/value 判断 + SidebarApiError 构造)收敛为
  私有 readEnvelope<T>(),两处调用;判断顺序与错误码回退逻辑不变。
- 取扩展名双实现收敛:extOf 的实现迁至零依赖的 paths.ts(lang.ts 与
  service.ts 均可达的基础模块),lang.ts re-export 保持既有导入面
  (tests/file-types.spec.ts 从 lang.ts 导入 extOf);service.ts 删除
  模块私有的同体 extOfPath,matchFileViewer 改用 extOf。方向说明:
  service.ts 不能反向 import lang.ts——lang.ts 携带全部 @codemirror/*
  依赖且只经 editor 懒加载 chunk 进入浏览器,core bundle 静态引用它
  会把 CodeMirror 拉进核心包(构建产物已验证:client.js /
  client-registry.js 中 0 处 codemirror,client-editor.js 不变)。
2026-09-03 12:05:29 +08:00
Menghuan1918 0831505854 test(lib-guards): lib 产物缺失时 chunk-artifact / manifest-consistency 跳过而非 ENOENT
fresh clone 未 build 直接 pnpm test 时,两 spec 的 readFileSync 会
ENOENT 崩溃(此前靠 package.json prepare 钩子隐式兜底)。改为文件级
describe.skipIf 守卫:缺产物时整套显示为 skipped 并 console 提示先
pnpm build(exit 0);产物存在时行为与之前完全一致(本仓 prepare 钩
子产出 lib 后两 spec 照常执行,已验证 9 passed 非 skipped)。
2026-09-03 12:04:41 +08:00
Menghuan1918 936df662b0 test(specs): 收敛 28 个组件 spec 的渲染样板到共享 test-utils
- 7 个 spec 的本地 mount()(createRoot + act 容器循环)替换为
  renderRoot():sidechat-view、subagent-jobs-view、subagent-live-polling、
  lazy-chunk、side-card-section-rows(本地 mount 删除,调用点改名);
  editor-refresh、markdown-manual-refresh(EditorHost 包装 mount 改为
  委托 renderRoot,调用点不动)
- 24 个 spec 的内联 IS_REACT_ACT_ENVIRONMENT 赋值替换为 setupReactAct()
  (subagent-jobs-view / subagent-live-polling / lazy-chunk 原本未设
  flag,保持原样不引入)

全部为语义相同的样板替换:迁移后全量 118 files / 1241 passed +
9 skipped,与迁移前一致。
2026-09-03 12:04:35 +08:00
Menghuan1918 a627a4c84d test(utils): 新建共享 React 测试工具 setupReactAct / renderRoot
收敛组件 spec 两类样板的最小共享面:IS_REACT_ACT_ENVIRONMENT 幂等设置
(vitest 按文件隔离 global,逐 spec 调用与原内联语句语义一致)与
createRoot + act 的分离容器渲染循环。刻意不做查询 DSL / 自动清理 /
新依赖。
2026-09-03 12:04:24 +08:00
Menghuan1918 61c9c550d9 fix(registry): registry 打包补上 mermaid 懒加载 chunk
package.json files 白名单与 tsdown 产物都含 lib/client-mermaid.js(+
.map),registry 通道的复制清单却缺它——经 dsh registry 安装后 Markdown
预览首次加载 mermaid 会拿到 /sidebar/bundle 404。与 terminal/editor
两 chunk 同款补齐。
2026-09-03 12:03:05 +08:00
Menghuan1918 2052d0554b fix(e2e): 聚合 lane 按 mtime 取最新 tarball 并统一 DSH_CMD 缺省解析
e2e-aggregate-mount.sh 选 tarball 用字典序 `ls | head -1`,仓库根堆积
多个历史 tgz 时会把冒烟挂到过期产物上——完整移植 e2e-mount.sh 的
mtime 策略与多 tarball 告警。DSH_CMD 缺省从直连 npx 统一为「PATH dsh
优先、npx 回退」(CI 传 DSH_CMD=dsh 不受影响)。

顺带修一个移植中暴露的边角:候选计数行在零候选时因 pipefail+set -e
静默退出,吞掉下一行的友好报错——补 `|| true`(mount.sh 同步修复)。
2026-09-03 12:02:57 +08:00
Atlas Zeng ad392ceea4 Merge pull request #522 from wang-kaopu/fix/517-client-ssh-open
fix(editor): open SSH remote editor links on the client
2026-09-03 10:54:34 +08:00
Menghuan1918 5dbbde61ce docs(open-with): 同步 SSH 链接客户端打开后的注释与设计偏差记录 2026-09-03 10:50:26 +08:00
Atlas Zeng fe5aa449ac Merge pull request #520 from omdsh-dev/feat/ci-windows
ci: add a Windows lane for the unit/typecheck/build suite
2026-09-03 10:47:53 +08:00
Menghuan1918 c4c4d73389 fix(ci): resolve consumer-type link targets to their physical path
On the Windows lane the @deepseek-ai/cordis link produced TS2307 even
though the link was visibly created: under pnpm its target,
node_modules/@deepseek-ai/cordis, is itself a junction into .pnpm, and a
native symlink pointing AT that junction does not traverse for tsc.
Resolve both link targets with node's native realpath first
(GetFinalPathNameByHandle also expands junctions, which bash-level
resolution may leave untouched), and fail loudly with the link layer
listed when a created link does not resolve to a package root.
2026-09-03 10:42:56 +08:00
Menghuan1918 6d877471d4 fix(pty): contain node-pty's deferred Windows resize after pty exit
node-pty's WindowsTerminal queues resize calls that arrive before the
ConPTY control socket's first data flush (_deferNoArgs). If the pty exits
before the flush, the deferred resize throws 'Cannot resize a pty that
has already exited' inside the socket's 'data' handler — uncatchable by
any caller and fatal to the host process. The Windows CI lane caught
exactly this as an unhandled error while all 1233 tests passed.

Park pre-first-output resizes in our own gate (armed at every spawn
site) and replay them via setImmediate once output has flushed the
deferred list — by then node-pty executes resizes synchronously, so the
exited throw lands in tryResizePty's catch instead of the host. POSIX
terminals resize synchronously (no deferral), so the gate is armed on
Windows only and the other platforms keep their exact behavior.

Also route AgentPtyRegistry.resize through tryResizePty: it previously
called pty.resize bare, missing the just-exited throw guard the tool and
attach-socket paths already had.
2026-09-03 10:42:56 +08:00
Menghuan1918 24db20a796 test: make five suites Windows-runner clean
First ci-windows run exposed four platform assumptions in the fixtures:

- smoke: the scratch repo inherited the runner's system-wide
  core.autocrlf=true (Git for Windows default), so every index restore
  smudged LF to CRLF and broke the byte-exact assertions — pin
  core.autocrlf=false in the fixture (eol policy is not what these
  destructive-op tests are about).
- git / git-worktree: Windows TEMP is the 8.3 short form
  (C:\Users\RUNNER~1\...) while git reports long paths, and
  realpathSync does not expand 8.3 aliases — switch the canonicalizer
  to realpathSync.native (libuv resolves via GetFinalPathNameByHandle,
  which does); same trick as the existing macOS /var workaround.
- bundle-route: the ETag memo revalidates on stat (mtime+size) and
  Windows updates file timestamps lazily, so the equal-size rewrite
  still looked unchanged — change the length too.
- market-manifest: GNU tar (Git Bash) reads the drive-letter colon of
  absolute Windows paths as a remote-host spec — run it from the pack
  dir with the bare tarball name instead.
2026-09-03 10:42:56 +08:00
Menghuan1918 79d7165f91 ci: add a Windows lane for the unit/typecheck/build suite
The pure-logic code carries real win32 branches (shell parsing, WSL path
projection, the PowerShell installer) that mock-injection tests alone
cannot validate. Mirror the ci job on windows-latest (Git Bash as the
step shell) for typecheck / test / build / consumer-type-surface; the
plugin-mount lane (real DSH + Playwright) stays ubuntu-only.

check-consumer-types.sh: force MSYS winsymlinks:nativestrict so Git
Bash's ln -s creates real symlinks instead of deep-copying the repo
(node_modules + full .git history) — inert on Linux/macOS.
2026-09-03 10:42:56 +08:00
Atlas Zeng c78c6dcf25 Merge pull request #503 from jackie-cqz/fix/windows-custom-shell-resolution
fix(terminal): resolve Windows custom shell executables
2026-09-03 10:42:45 +08:00
Atlas Zeng 94c0a74054 Merge pull request #467 from elysia395/fix/markdown-preview-scroll-position
fix(editor): keep reading position across preview/edit switches
2026-09-03 10:42:32 +08:00
Atlas Zeng c9a960884f Merge pull request #461 from Guochaoo/fix/markdown-preview-toc-and-code-header
fix(markdown): preview TOC dismiss-on-outside-click, popover z-index, code-block banner stickiness
2026-09-03 10:42:20 +08:00
Atlas Zeng b4fd80f07b Merge pull request #417 from ch3n4y/fix/at-file-reference-link
fix: keep file basename in @-reference links
2026-09-03 10:42:07 +08:00
Kunping Wang ed2ccc829e fix(editor): 在客户端打开 SSH 远程编辑器链接 2026-09-03 10:32:34 +08:00
Menghuan1918 4742aa1b9c docs(guide): sync onReferenceFile(path, isDir) signature 2026-09-03 10:02:27 +08:00
Ch3n4y 94ffac0224 fix(editor): keep file basename in @-reference links
The explorer @ button appended plain text (@folder/file.ts), which DSH's
composer only decorates as a folder ref (@folder/) — the file name stayed
outside the link. Insert FILE references through DSH's own structured
insert-reference event instead, minting one chip for the whole reference
(displayed as @basename, serialized as the full @path). Directories keep the
plain @dir/ mention so folder completion still works.
2026-09-03 10:00:13 +08:00
elysia395 6ca2364a12 fix(editor): keep reading position across preview/edit switches
The markdown preview container re-mounts on every mode flip (its scrollTop
lives on that DOM element), and the save-then-switch-to-preview reload
(#215) rebuilds the whole TextEditor instance - so after editing and
saving, the preview returned to the top, and entering edit mode opened the
editor at the top instead of where the reader was.

- Module-level per-file (session + path) preview scroll memory, seeded on
  component mount and re-applied in a before-paint layout effect on
  [mode, mdText]; a restoring guard keeps the collapsed-reload clamp from
  polluting the remembered position.
- Preview -> edit handoff: throttle-capture the block text at the preview
  viewport top, reverse-map it to a source line (linesOfSelection) and
  scroll the editor there, with a proportional fallback when the text match
  is ambiguous (ratio === 1, i.e. the reader is exactly at the bottom, also
  lands the editor at the bottom).
- The editor jump writes scrollDOM.scrollTop directly instead of using
  CodeMirror's scrollIntoView: that API walks every scrollable ancestor -
  and even the window when the browser is zoomed (visualViewport <
  innerHeight) - to reveal the target in the sidebar, which dragged the
  whole sidebar/page up when the reader was at the very end of the
  document. A plain scrollTop write on the editor scroller can never
  scroll anything outside the editor.

Closes #466
2026-09-03 09:59:11 +08:00
jackie-cqz 74993839e7 fix(terminal): resolve Windows custom shell executables (#487) 2026-09-03 09:58:42 +08:00
Guochao a2a26901e6 fix(markdown): preview TOC dismiss-on-outside-click, popover z-index, code-block banner stickiness
Three preview (render) bugs where DSH's chat-optimized markdown primitives
leak into the file-preview scroll world:

- CodeBlock banner: DSH's bannerWrap is position:sticky (top:0, z-index:6) so a
  block keeps its copy action reachable inside a per-message scroll container.
  In the preview every fence shares one .editorMd scroll container, so the
  banner pins to the viewport top until the whole block scrolls past and reads
  as a floating header. Neutralize stickiness for non-mermaid fences in the
  preview (mermaid chrome replaces the banner and is excluded via
  data-mermaid-processed).

- TOC popover z-index: .tocBar was z-index:3, below the code banner's 6, so the
  outline panel painted beneath a sticky fence header. Raise the bar to 7.

- TOC dismissal: only the toggle re-click closed the popover. Add a document
  pointerdown listener that closes on an outside press (blank preview space),
  exempting the button and panel so the toggle still flips.

Adds md-toc unit coverage for outside-click dismiss and toggle-preserving
behavior; records the deviations in the TOC design doc.
2026-09-03 09:56:31 +08:00
Atlas Zeng 396017b459 Merge pull request #448 from chendefine/fix/panel-host-scroll-jump
fix(shell): keep an overflow:hidden fallback beneath clip (old-engine compat) + guard the cascade
2026-09-02 23:55:40 +08:00
chendefine 96650fa189 fix(shell): keep an overflow:hidden fallback beneath clip + guard the cascade
shift fix) — but as a BARE declaration. Engines without clip support
(Safari < 16, older Chromium) drop an unsupported declaration at parse
time, which leaves the host `overflow: visible`: no clipping at all,
and the historical "collapsed panels extend the document's scrollable
area, whole page draggable" regression returns on those engines.

Declare `overflow: hidden` BEFORE `overflow: clip`: clip-aware engines
apply the later clip (identical behavior to #453), while old engines
fall back to hidden and keep clipping (with its pre-clip
scrollability — the same state they had before #453, strictly better
than visible). The cascade order is the contract.

Also extends the rule comment (the fix guards the whole
scroll-into-view class — focus moving into off-viewport regions,
nested iframes/workbenches claiming focus while loading — not just the
reveal case) and adds tests/panel-host-css.spec.ts guarding the
cascade (hidden first, clip last), the fixed viewport layer, and that
the degraded rule never reintroduces overflow. Contract documented in
the integration guide §12 (root anchor bullet).
2026-09-02 23:51:13 +08:00
Atlas Zeng 5337ef77dd Merge pull request #398 from SparkElf/fix/desktop-sidebar-layout-mode
fix: preserve desktop shell layout with side card
2026-09-02 23:44:15 +08:00
Atlas Zeng a446192008 Merge pull request #373 from Jstn-1g/fix/162-narrow-auto-activation
fix: avoid forcing mobile drawer on auto-activation
2026-09-02 23:44:06 +08:00
Atlas Zeng f61c161494 Merge pull request #455 from wang-kaopu/fix/issue-399-wsl-paths
fix: resolve Linux absolute paths in WSL workspaces
2026-09-02 23:43:58 +08:00
Atlas Zeng 0607517134 Merge pull request #394 from Beverly621/fix/markdown-frontmatter-preview
fix(editor): hide YAML frontmatter in Markdown preview(在 Markdown 预览中隐藏 YAML Front Matter)
2026-09-02 23:43:49 +08:00
Atlas Zeng b8d3b9ef56 Merge pull request #494 from Cyancat/fix/openpath-intercept-alpha
fix(client): intercept the alpha chat file-open funnel (remote.session.openWorkspacePath)
2026-09-02 23:43:41 +08:00
laoyh e12927e8a9 fix: preserve desktop shell layout with side card 2026-09-02 23:37:40 +08:00
Justin Skowyra 2d2e8534a5 fix: keep restored mobile drawer closed 2026-09-02 23:26:04 +08:00
Justin Skowyra 41da8cb101 docs: align responsive auto-activation contract 2026-09-02 23:26:04 +08:00
Justin Skowyra d6c587e577 fix: avoid forcing mobile drawer on auto-activation 2026-09-02 23:26:04 +08:00
Kunping Wang 180b16de1b fix: 修复 WSL 会话 Linux 路径解析 (#399) 2026-09-02 23:20:17 +08:00
Beverly621 e31130adb8 fix(editor): hide YAML frontmatter in Markdown preview 2026-09-02 23:15:19 +08:00
Cyancat 26d37c1a8f fix(client): intercept the alpha chat file-open funnel (remote.session.openWorkspacePath)
0.1.2-alpha 宿主删除了 ctx.workspaces.openPath,聊天文件点击全部改走
remote 命名空间,旧拦截成为死代码(点击穿透到 xdg-open)。改为在
ctx.inject(['remote.session']) 内以 defineProperty 遮蔽该方法(accessor
属性,赋值无效),接管时返回 RemoteResult 成功包络。语义不变,

设计:docs/plans/2026-08-31-openpath-intercept-alpha-design.md
2026-09-02 23:13:57 +08:00
Atlas Zeng dde0923980 Merge pull request #441 from eg-bole/docs/readme-catalog-icons
docs: add dsh-better-sidebar-icons to the enhancements catalog
2026-09-02 23:11:24 +08:00
Atlas Zeng ec24f7f0fc Merge pull request #470 from g-yixuan/fix-ecosystem-table-sidenote
docs: ecosystem table entry dsh-sidechat → dsh-sidenote
2026-09-02 23:11:16 +08:00
Atlas Zeng 02b2310335 Merge pull request #456 from wang-kaopu/fix/issue-403-sidebar-locate-performance
perf(sidebar): reduce repeated center-column DOM queries
2026-09-02 23:11:10 +08:00
Atlas Zeng 051bab2f61 Merge pull request #361 from northwang-lucky/fix/collapsed-toggle-header-align
fix(client): align collapsed toggle cluster with DSH session header / 折叠态开关组对齐 DSH 会话标题行
2026-09-02 23:11:02 +08:00
Atlas Zeng b531dd8051 Merge pull request #469 from Hoemr/fix/explorer-refresh-on-focus
fix(files): auto-refresh the tree on window focus and via a dispatchable event
2026-09-02 23:10:54 +08:00
Atlas Zeng 3b5e255882 Merge pull request #385 from TaurusWood/fix/free-window-restored-id-collision
fix(state): seed restored free-window ids
2026-09-02 23:10:46 +08:00
Atlas Zeng ded3cf13b0 Merge pull request #454 from wang-kaopu/fix/issue-440-old-git-worktree
fix(git): support worktree listing on older Git versions
2026-09-02 23:10:37 +08:00
Atlas Zeng fc666eee47 Merge pull request #366 from GeekRicardo/fix/terminal-font-monospace-fallback
fix(terminal): 自定义字体解析失败时兜底到等宽,避免掉进比例字体毁掉网格
2026-09-02 23:10:30 +08:00
Atlas Zeng 2a8187431d Merge pull request #509 from Awesome-AI-Pedia/fix/438-reference-button-tail
fix(explorer): pin reference affordance to row tail
2026-09-02 23:09:41 +08:00
Atlas Zeng ffbd9d7882 Merge pull request #427 from eg-bole/fix/selection-popup-dismiss-cursor-insert
fix(editor/conversation): selection popup dismissal + caret-anchored draft insert (#425)
2026-09-02 23:09:15 +08:00
Atlas Zeng 5815ad0f09 Merge pull request #428 from Jstn-1g/fix/426-safe-pty-resize
fix(terminal): contain transient resize failures
2026-09-02 23:08:52 +08:00
Atlas Zeng 4f0d66dbc5 Merge pull request #516 from omdsh-dev/feat/dsh-0.1.2-alpha.5
feat: adapt to DSH 0.1.2-alpha.5 and bump to v0.19.0-alpha.0
2026-09-02 21:50:41 +08:00
Menghuan1918 a78dd57b23 feat: adapt to DSH 0.1.2-alpha.5 and bump to v0.19.0-alpha.0
- pins: CI mount-gate CLI, dsh.plugin.json engines floor, and the
  @deepseek-ai/* peer floor / devDependencies baseline move to
  0.1.2-alpha.5 (dsh-client-locale trails at 0.1.2-alpha.3 — no such
  release upstream; ^0.1.2-alpha.3 already accepts the alpha.5 runtime)
- code: migrate the alpha.4 Session.events removal to snapshotEvents()
  (sidechat transcript live reads, fork inheritance, jobs.output replay,
  subagent activity) and drop the seedLength meta field the host removed
  from CreateAgentOptions.meta
- gates: pnpm peers check clean, typecheck + 1137 unit tests green,
  real-host mount smoke 14/14 on npm-published 0.1.2-alpha.5
2026-09-02 21:43:34 +08:00
Atlas Zeng efc328a8c1 Merge pull request #489 from omdsh-dev/feat/sidebar-perf
perf: 侧边栏性能优化(核心包 -45%)+ 修复底栏拖拽引发的原生左栏突变
2026-09-02 21:33:49 +08:00
yangkun 75a888df14 fix(explorer): pin reference affordance to row tail 2026-09-02 10:55:03 +08:00
Atlas Zeng dd499b2560 Merge pull request #508 from Awesome-AI-Pedia/fix/504-windows-reveal
fix: preserve Windows Explorer reveal selection
2026-09-02 10:45:46 +08:00
yangkun ac02598150 fix: preserve Windows explorer reveal selection 2026-09-02 10:27:43 +08:00
Menghuan1918 e06ce8cef1 test: fix the perf lane's resource typing for typecheck
getEntriesByType widens to PerformanceEntry; transferSize/
decodedBodySize live on PerformanceResourceTiming (the e2e specs sit
outside tsconfig.build.json, so the bundler never saw this).
2026-09-01 16:07:33 +08:00
Menghuan1918 8a01e5d881 docs: record the perf optimization's before/after measurements
The comparison table (3-run medians on the same machine against a real
0.1.2-alpha.2 host): mount latency 377→273ms (-28%), the host's
aggregated client bundle ships 203KB less over the wire (-14%,
matching the 19 dictionaries' gzip weight) and 640KB less decoded
(their source weight), boot settings.get 4→2, the bottom-drag width
leak 504px→0. Frame pacing was already at 8/10ms p50/p95 — the drag
fix removed the host-layout snap, not a frame drop. Structural wins
that don't show in a single sweep (git spawns per poll tick, fold
passes per poll, transcript row re-renders, per-keystroke doc
stringification) are listed with their per-event arithmetic.
2026-09-01 16:07:33 +08:00
Menghuan1918 cf542d6318 perf: tag the center column instead of a :has() selector; stop re-stringifying the editor doc per keystroke
The bottom-push rule anchored on #root :has(> [data-slot=conversation]):
a :has() match cache invalidates on every #root subtree mutation, and
the streaming chat mutates that subtree constantly — every layout-push
frame (the bottom drag writes --dsh-sidebar-height per frame) paid a
whole-document selector re-evaluation. The shell's locator (which
already holds the measured column node for the ResizeObserver) now
tags it with [data-dsh-center-col]; the rule evaluates once per node.
The tag retargets with the ref on boot swap / HMR node replacement and
is removed on unmount; the drag lane pins exactly-one-tagged-node and
its identity as the conversation slot wrapper's parent. The dead
[data-pane] branch (renamed away in alpha hosts) is gone.

TextEditor: the CodeMirror updateListener re-stringified the WHOLE
document into React state on every keystroke (O(docLength) per key,
re-rendering the shell per key). The listener now only flips the dirty
flag; the preview's draft is snapshotted from the live view when
entering preview (or on a content swap), and save() already reads the
live document.
2026-09-01 16:01:34 +08:00
Menghuan1918 c7d58c9b25 perf: move the 19 non-zh/en dictionaries into a lazy locale chunk
The core client bundle statically imported every third-language
dictionary (ja/de/fr/…/zh-HK/MO/TW, ~640KB of source — roughly half
the bundle's weight) for a code path most sessions never enter: the
sidebar's own t() only consults zh/en plus the better-locale override
store, and the dicts' ONLY consumer is that store's register() call.

They now ride the existing lazy-chunk machinery as lib/client-locale.js
(fetched from /sidebar/bundle only when better-locale is actually
installed; a generation counter keeps the async registration moot when
the integration effect re-runs or disposes mid-flight). Until the chunk
lands, t() keeps the zh/en chain; the store's own revision bump on
register re-renders the chrome once the dicts arrive.

Core client.js: 1.46MB → 822KB (-44%). The chunk-boundary key-set
check moves to the existing runtime test (every shipped dict stays
key-set-equal to zh).
2026-09-01 16:01:34 +08:00
Menghuan1918 b7af498427 perf: stabilize renders (transcript row reuse, tree Sets, shared + options, batched drags)
SideChat transcript: every 2s poll rebuilt ALL row objects, so every
row (markdown parse + DOMPurify included) re-rendered even when the
log had not changed. transcriptRows now takes the previous poll's
rows and re-adopts the position-aligned rows whose content is
identical (reuseRows) — only the changed tail rebuilds. The view
feeds the memoized array back through a ref; unit tests pin the
identity contract (unchanged rows keep object identity, changed rows
and everything past the change rebuild, tool-result landings never
re-adopt).

FileTree: expanded/revealed membership was an includes() scan per
rendered row (O(rows × expanded) per render on deep trees); one Set
per render keeps it O(1) per row.

Shell: buildNewTabOptions was inlined in the two Workbench JSX
sites — two fresh arrays per render (drag state, resizes) re-rendered
every LeafView's + affordance. One shared memo now.

DiffPane resize / TerminalView ResizeObserver: pointer and resize
streams fired a setState / glyph-measuring fit() per event; both ride
per-frame batching now (frame-batcher in the core, a local rAF gate
in the lazy terminal chunk). SessionLens sizes each row's content
with a Blob (a UTF-8 encode of the whole edit) — that moved from
per-row-per-render into one memoized pass per changed fold.
2026-09-01 16:01:34 +08:00
Menghuan1918 427552e4f9 perf: cut the boot and poll overhead (one settings fetch, one git process per tick, one fold per poll)
Boot: loadPrefs and loadExternalDisable each fetched the settings
document, serially, before the first paint — and the second had no
timeout, so a stalled wire kept the sidebar unmounted forever. Both
decisions now ride ONE fetch (loadBootDecision) under the existing
2s race.

GitLens: the 2s silent poll re-listed worktrees on every tick (a
second git process per tick for a list that almost never changes, and
a fresh worktrees array re-render each time). Silent ticks now fetch
only the selected checkout's status; every 15th tick (~30s) re-lists
and re-runs auto-selection, so an agent-created linked checkout is
still picked up mid-session.

ChangesTab: extractFileOps parses every accumulated tool/call (up to
4000 events); the poll ran it once for the badge and the render ran
it AGAIN per tick, and the fresh array defeated the downstream memo.
The fold now runs once per poll and its result rides a ref shared by
badge and render (reset on session switch).
2026-09-01 15:58:44 +08:00
Menghuan1918 e2699a099e test: add the perf measurement lane (mount latency, longtasks, bundle cost, drag frames)
A measurement harness, not a pass/fail gate (beyond the mount sanity
it shares with the other lanes): an init-script probe samples the
navigationStart → host-attach latency, a longtask collector runs
through the full built-in tab sweep, sidebar-shaped resource entries
report transfer/decoded/duration, and a frame sampler records the
bottom-strip drag's pacing (p50/p95/max) plus the width-leak guard.

Results print as single-line PERF_JSON records so a shell run can tee
and grep them; before/after comparisons land in
docs/plans/2026-08-31-perf-optimization.md. Baseline (main @ d70db8f,
3 runs): mount ≈ 377ms, 2 longtasks ≈ 161ms, drag p95 = 10ms,
width leak = 504px.
2026-09-01 15:58:44 +08:00
Menghuan1918 659c2a20e2 fix: bottom-strip drag no longer leaks the panel width into the host layout
The bottom drag applied the panel's persisted width preference to
--dsh-sidebar-width regardless of panelOpen (only the idle push effect
gated it through layoutPushSize). With the right panel closed, the
first drag frame jumped the variable 0 → ~400px: #root lost 400px
instantly, the host frame's viewport dropped across its 1024px
auto-collapse breakpoint, and the NATIVE left sidebar snapped to its
56px rail — then snapped back on release. The bottom strip is the only
drag reachable with the panel closed, which is why only it showed.

applyDrag now rides the same gate as the push effect for the PUSHED
width; the panel-side DOM writes (panelRef/bottomRef/lastDragSize)
keep the raw width — the bottom panel's right edge derives from it.

The e2e regression pins the pose: panel closed + bottom open, drag the
strip, and every sampled frame must keep the pushed width at 0, the
host grid template, the left column width and #root's right edge
unchanged. Verified red on the old code (leaked 504px).
2026-09-01 15:58:44 +08:00
Atlas Zeng c1992125d0 Merge pull request #497 from omdsh-dev/fix/dsh-0.1.2-alpha.3
feat: adapt to DSH 0.1.2-alpha.3 and bump to v0.18.1-alpha.0
2026-09-01 15:56:53 +08:00
Menghuan1918 f78da4079a refactor: hoist the alpha.3 transitive peers into devDependencies
Replace the pnpm-workspace.yaml overrides with the plain-repo pattern:
install every peer the dev tree touches (dsh-attachment, dsh-code-runtime,
dsh-scope, dsh-session-projection, dsh-system-prompt, dsh-user-approval,
dsh-util-time) as direct devDependencies pinned to the baseline, same
as the thirteen already there. Root-level hoisting makes pnpm resolve
the peers natively (peers check: zero unmet, no global forcing), and
the list extends the existing bump workflow instead of introducing a
new mechanism.

Verified: typecheck clean, 107 test files / 1137 tests green, and the
real-host mount smoke passes 14/14 against @deepseek-ai/dsh@0.1.2-alpha.3.
2026-09-01 15:47:44 +08:00
Menghuan1918 27a1f405dd docs: correct the alpha.3 peer note — dsh-attachment is declared as a peer, not missing
Upstream declares its dsh-* siblings (dsh-attachment included) as
peerDependencies provided by the host bundle tree, so the host-side
mount is unaffected. The stale copies only surface in this repo's dev
tree, which installs a subset of the peers directly — making the
overrides a standing shim to extend on each baseline bump, not a
temporary patch awaiting an upstream fix.
2026-09-01 15:42:59 +08:00
Menghuan1918 b2722ece5d feat: adapt to DSH 0.1.2-alpha.3 and bump to v0.18.1-alpha.0
Point-by-point audit of all 117 commits between alpha.2 and alpha.3:
every host contract this plugin relies on (token auth, slash RPC,
MarkdownText labels, the event-stream/persistence APIs behind
sidechat.events, SettingsNamespaceInput, SUBAGENT_DESCRIPTOR_VERSION
(still 3), dsh-client-store, the profile loader, the node-pty pin) is
unchanged, so no plugin code needed rewiring. alpha.3's breaking
changes (required BeginSubmissionInput.mode, the renamed
attachment-invalid subagent error, the removed SQLite persistence
backend, the identity-gated projection change feed) do not touch this
plugin.

What did need adapting was the dependency tree: the alpha.3 npm
artifacts ship with workspace-era metadata that does not match their
published code —

- dsh-subagent@0.1.2-alpha.3 imports `admitPromptContent` from
  @deepseek-ai/dsh-attachment without declaring that dependency, so
  resolution floats to a stale 0.1.1-rc.1 copy that lacks the export
  (broke 5 test files at load time);
- six transitive peers (dsh-scope, dsh-session-projection,
  dsh-system-prompt, dsh-user-approval, dsh-util-time, dsh-code-runtime)
  drifted to pre-alpha.3 versions against the ^0.1.2-alpha.3 ranges the
  alpha.3 packages declare;
- the alpha.3 line peers on @deepseek-ai/cordis ^4.0.2 (was ^4.0.1).

Fix: pin the seven stragglers via pnpm-workspace.yaml overrides (with
a note to drop them once upstream fixes its metadata), narrow the
cordis peer to ^4.0.2, and bump the baseline pins — CI mount gate,
dsh.plugin.json engines, peer/devDependencies, README badges, plus
SIDEBAR_SERVICE_VERSION in lockstep.

Verified on the npm-published @deepseek-ai/dsh@0.1.2-alpha.3:
typecheck clean, 107 test files / 1137 tests green, `pnpm peers check`
reports zero unmet peers, and the real-host mount smoke passes 14/14.
2026-09-01 15:02:28 +08:00
Atlas Zeng c567c4cef7 Merge pull request #486 from omdsh-dev/feat/sidechat-render-upgrade
feat(sidechat): main-conversation Blocks, turn usage tail, reconnect banner
2026-08-31 23:32:05 +08:00
Menghuan1918 a48459c5de feat(sidechat): tool-kind leading icons on tool rows
The main conversation's tool row head is a 16px leading slot with the
tool-kind glyph at 14 (GenericToolCard's variant table), swapped for an
error StateDot on failed rows. Mirror it: bash/pwsh -> IconApiOutline14,
read/web_fetch -> IconBrowseOutline16, edit/write -> IconEditOutline16,
grep/glob/web_search -> IconSearchOutline16, anything else ->
IconSparkle16, all from the ui-primitives module seed; CollapsibleRow
gains the matching icon slot. Thinking/injection rows keep their bare
chrome (the host's equivalents are a different row family).
2026-08-31 23:28:20 +08:00
Menghuan1918 561ad66071 feat(sidechat): main-conversation Blocks, turn usage tail, reconnect banner
Rendering-layer upgrade for the Side Chat transcript (data already rides
the raw events; the mapping just stopped discarding it):

- Tool rows with a derivable structure now render the host's
  ui-primitives atoms instead of a generic double <pre>: bash calls map
  onto TerminalBlock (command from call args, output + exit pill
  recovered from the result's trailing exit marker), edit/write map onto
  DiffBlock (call-time literal replacement, refined by the result meta's
  contextual hunks), read maps onto ReadBlock (line-window meta, same
  semantic narrowing the host applies). Every narrowing declines to the
  generic row on malformed input.
- turn/end now emits a turnSummary row: token usage aggregated from
  assistant/message.usage (output summed across steps, input from the
  last request) plus the envelope-time wall duration, printed with the
  main conversation's formatTokens/formatDurationMs cadence.
- The client entry injects 'connection' (DSH 0.1.2-alpha.2+) and the
  thread view shows the host's ConnectionIndicator while the Remote
  transport is down; the reconnect control calls reconnect() and a
  disconnected -> connected transition pulls the transcript immediately
  instead of waiting for the next poll tick.

19 new locale keys propagated across zh/en and all 19 third-language
dictionaries; unit coverage in sidechat-transcript.spec and a new
sidechat-view render spec (jsdom). Real-host mount smoke: 14/14.
2026-08-31 23:28:20 +08:00
Atlas Zeng d70db8fb02 Merge pull request #475 from omdsh-dev/feat/unified-file-changes
feat!: 文件变动统一 tab——Git 面板 + 文件追踪(PR #471)二为一,统一 diff 渲染栈
2026-08-31 18:11:21 +08:00
Menghuan1918 f024fbf092 fix(changes): 会话事件改走宿主 changes.ops 路由 + diff 展开二级设置 + 视觉迭代
CI 修复(plugin-mount 的 ctx.sessions.get TypeError ×1003):
- 客户端 runtime 无事件日志面——badge/SessionLens 原在客户端直读
  ctx.sessions.get(id).events / ctx.on('session/event'),全部不可达。
- 新增宿主路由 changes.ops:live 会话日志优先,冷会话回放
  sessionPersistence.inspect;afterSeq 增量(缺省游标含 seq 0,
  显式游标为 seq > afterSeq,同 sidechat.events 约定),窗口上限
  4000 与客户端累加器一致。
- ChangesTab 轮询(mount 一拉 + visible 2.5s 增量)折入 ops,写
  模块级 opCounts 缓存供 badge 同步读取;SessionLens 改纯展示。

diff 展开二级设置(changesDiffFloat,默认浮窗):
- prefs 四处接线 + git tab settings select(浮窗/面板,图标选项卡)
- 预览面板「展开为 diff tab」按设置落点:浮窗居中弹出或源 pane
  下方 split;i18n 全 21 词典同步。

视觉迭代(真机挂载截图审查后调整):
- Session 视角:kind 徽章为左轨,大小+相对时间右对齐元列;失败
  操作行红色内嵌条;文件组头降为三级标签并加组间距。
- Git 视角:untracked '?' 徽章与新增同绿色;stage 按钮改 plus 图标。
- diff 折叠行:可展开折叠带 › 箭头 + 次级对比度,静态 gap 不再
  显示 pointer/hover;预览头路径改尾部截断(RTL 技巧,保文件名)。

验证:typecheck ✓ / vitest 1123 ✓ / pnpm test:mount(钉 alpha.2 CLI)
14/14 ✓;真机挂载截图(badge=5、双视角、浮窗展开)0 console error。
2026-08-31 17:52:59 +08:00
Menghuan1918 24a3813c63 feat!: 合并 Git 面板与文件追踪为统一「文件变动」tab(双视角 + 统一 diff 栈)
在 PR #471(file-trace)基础上的演进:

- 合并 tab:git tab(id 保留,兼容持久化布局)升级为「文件变动」双视角
  tab——Git 视角(原 GitView 全部能力:staged/unstaged/提交/历史/worktree·
  子仓库选择/右键菜单/确认弹窗)+ 本轮文件视角(原 file-trace:会话事件
  日志实时折叠的模型读写编辑记录,按文件分组、新增类型筛选 chips、相对
  时间);file-trace tab 移除(未随任何版本发布)。
- 统一 diff 组件:新 src/client/diff/ 模块(rows.ts 引擎 + DiffRows/
  DiffFiles 渲染器 + highlight.ts 语法着色)——git diff 与会话操作 diff
  共用一套渲染:mod 改写配对(del-run/add-run 对半标蓝)、行内字符级
  高亮(修复了旧实现旧侧失配不高亮的问题,改为行对象身份键)、上下文
  hunk 折叠、git hunk 间未输出区间按行号范围生成不可展开折叠、语法着色
  色相落位 diff 三色空档、+n −m 统计 chips、行底色 tint;DiffTab 改用
  该栈(git diff tab 从此也有高亮)。
- 交互:两视角共用底部可拖拽预览面板(点击文件/提交/操作即预览,不再
  直接开 diff tab;面板可刷新、可键盘/拖拽调高、git 目标可一键展开为
  独立 diff tab);视角选择与面板高度持久化在 tab.meta;git 行 XY 徽章
  按状态字母着色;选中行高亮。
- i18n:fileTrace* 十键重命名为 changes* 前缀,git 键值更新为「文件
  变动」,新增 7 键(视角/筛选/面板操作),全部 21 个词典同步。
- 结构:src/client/changes/(ChangesTab/GitLens/SessionLens/DiffPane/
  ops)+ src/client/diff/;GitView/DiffView/filetrace/ 删除;git 样式
  从 sidebar.module.css 迁至 changes.module.css。
- 测试:builtins 8→7(git tab 承接 badge pin)、diff-rows(unified
  转换/mod 配对/间隙折叠/统计)、changes-tab(壳交互 jsdom)、
  diff-files-collapse(原 DiffView 折叠行为钉住)、changes-ops(原
  filetrace 数据层)、git-view-worktree 迁移至 GitLens;全量 1122 通过。
- 文档:README ×2 功能矩阵合并、external-plugin-guide §4.4 与内置清单
  7 tab、mount e2e 菜单标题同步。
2026-08-31 01:31:22 +08:00
lhh010 9255040489 Merge origin/main into feat/file-trace-tab: resolve external-plugin-guide version line (keep 8-tab count, adopt v0.18.0-alpha.0 alpha-track wording) 2026-08-31 00:21:10 +08:00
lhh010 02d3936b92 feat: 新增第 8 个内置 tab「文件追踪」——移植 dsh-file-trace 插件
- 数据层:直接折叠会话事件日志(tool/call + tool/result 对)提取文件操作,
  与仓库「读事件日志而非动注册表」的取向一致;含错误文本捕获与
  running 态实时更新(ctx.on('session/event'),不可见时暂停、恢复时追平)
- 视图:操作列表 + 底部可拖拽 diff 面板;行级 diff(删红/增绿/改蓝)+
  行内字符级高亮;读取视图带真实行号;失败操作统一展示真实错误文本
- 语法着色:轻量分词器(关键字/字符串/数字/类型/函数/注释/预处理),
  色相经 color-mix 落在 diff 三色之间的空档;跨行块注释状态按行序推导
- 单实例 tab,+ 菜单 order 55,角标显示操作数;设置页可整体关闭
  (与独立维护的 dsh-file-trace 插件共存)
- i18n:10 个新 key 同步全部 21 个词典;builtins 测试 7→8;
  README/指南内置清单同步
2026-08-30 20:59:50 +08:00
gyixuan 0d1e96921e docs: update ecosystem table entry dsh-sidechat → dsh-sidenote (repo renamed) 2026-08-30 19:44:39 +08:00
Hoemr f23e40facf fix(files): auto-refresh the tree on window focus and via a dispatchable event
The tree caches loaded directories per refresh tick, so content edited
outside DSH (another editor, a sync plugin pulling from a remote) stayed
stale until the manual refresh click.

- bump the tick when the window regains focus, so switching back to DSH
  shows the on-disk state
- also bump on a bubbling 'dsh-sidebar:refresh-files' window event, so
  external plugins (e.g. dsh-better-overleaf after a pull/push) can
  refresh the tree programmatically without reaching into internals
2026-08-30 17:55:37 +08:00
North Wang a8f27c4270 fix(client): align collapsed toggle cluster with the DSH session header
- Open state keeps riding the panel's 34px tab strip (top: 3), unchanged.
- Collapsed, the cluster floats over DSH's session header, whose controls
  (title, "Session log" capsule) are vertically centered at 28px on both
  desktop and narrow viewports; top: 3 left the 28px buttons ~11px too
  high — re-center them at 14px.
- body[data-dsh-sidebar-collapsed] only exists while the right panel is
  closed, so the tab-strip alignment is untouched; with position
  compatibility active, compose the caption strip (strip + 14).
2026-08-29 15:49:39 +08:00
GeekRicardo 0504e3918b fix(terminal): 自定义字体解析失败时兜底到等宽,避免掉进比例字体毁掉网格
在设置页「侧边卡片」→ 终端卡片填入自定义「终端字体」后,若该族名在浏览器里
解析不到,终端不是退化成"没有想要的字体",而是整个网格崩掉:字符间距被撑开、
字形变成比例字形、Nerd Font 图标全部消失。

用户实测(macOS + Chrome,填 `Maple Mono NF CN`):

| 「终端字体」的值 | DevTools Rendered Fonts | 结果 |
| --- | --- | --- |
| `Maple Mono NF CN` | **PingFang SC** | cell 12.09px @ 13px 字号(0.93em),网格崩坏 |
| `Maple Mono NF CN, monospace` | **Menlo** | 间距恢复正常 |

`resolveTerminalFont` 对用户值(以及主题 `--ds-font-family-code` token 值)
原样返回,只有内置的 `DEFAULT_TERMINAL_FONT_FAMILY` 末尾带 `monospace`。

字体栈若不以通用族收尾,浏览器解析不到任何具名字体时会落到**标准字体**
(standard font,macOS 中文环境是 PingFang SC)——它是**比例字体**。
xterm 用它的推进宽度量 cell(0.93em,等宽字体应为 0.6em),网格随即失真。

这条路径比想象中容易踩:终端里跑的 shell 在 host 上,字体却由**浏览器所在机器**
提供,两者不同机时(远程/容器化的 DSH)用户按 host 侧装的字体填名字必然解析失败。

新增 `withMonospaceFallback()`,在 `resolveTerminalFont` 出口保证字体栈以通用族收尾。

- **不做字体嗅探**:终端字体设计文档 §2 把"字体合法性嗅探"列为非目标;本改动
  不检测具名字体是否存在,只补一个通用族尾项。
- **已有通用族则原样保留**:包括用户主动写的非等宽通用族(`sans-serif` 等)
  ——那是用户的选择,不该被覆盖。
- **主题 token 值同样兜底**:它掉进比例字体的后果与用户值完全一样。
- **CSS 全局关键字单独处理**:`inherit` / `initial` / `unset` / `revert` /
  `revert-layer` 追加逗号后是非法 CSS,会被 CSSOM 整条丢弃,反而让 xterm
  一个字体都拿不到;这类值直接回落内置栈。
- 字体栈按**顶层逗号**切分并感知引号、转义与括号,避免拆坏 `"Foo, Bar"`
  与 `var(--x, y)`;通用族匹配大小写不敏感且只认未加引号的项。

- `tests/terminal-font.spec.ts` 4 → 13 例(兜底追加、已有通用族免动、大小写与
  引号边界、顶层逗号切分、幂等性、CSS 全局关键字、空值)
- `pnpm typecheck` 零错误
- 全量 `pnpm test`:941 passed(改动前基线 932 passed,+9 为本次新增);
  另有 31 例失败在本分支基线(干净 main)上同样失败,与本改动无关

互补、不重叠。#190 解决"字体解析成功、但栈里没显式列 Nerd Font 导致补充平面
PUA 图标缺字";本 PR 解决"字体压根没解析到、栈又没有通用族尾项导致落进比例
字体"。两者都落在 `terminal-font.ts` 的同一出口,合并时按先后 rebase 即可。
2026-08-29 15:49:35 +08:00
taurus 30879fff74 fix(state): seed restored free-window ids 2026-08-29 15:31:16 +08:00
taurus 1aba61a008 test(state): cover restored free-window id allocation 2026-08-29 15:31:16 +08:00
懿驹-伯乐 963560a361 fix(conversation): restore the composer caret after the draft insert (#425)
A programmatic setDraft commit resets the controlled textarea's caret
(observed landing at the start of the value), so every later insert probed
the reset position and the stack drifted (A|B + C + D became |DACB).
placeComposerCaretAfterInsert puts the caret right after the inserted text
once the value commit lands - the index accounts for the left-side
separating space - and is guarded by a value-sync check plus a two-frame
retry, so a newer edit or a different composer wins untouched.

spliceInsert now reports the caret index together with the spliced draft
so the placement math cannot drift from the join logic. Nothing else moves:
the pure insertAtCaret surface is unchanged.
2026-08-29 15:31:13 +08:00
懿驹-伯乐 a3379ef17d fix(conversation): insert the selection popup text at the composer caret (#425)
appendToDraft always spliced at the end of the draft. The conversation
service exposes no caret API, so probe the live composer textarea selection
from the DOM (value-sync guarded) and splice at that position with
whitespace-aware joins; an unknown or stale caret falls back to appending.
The explorer @-reference shares this path, so it inserts at the caret too.
2026-08-29 15:31:13 +08:00
懿驹-伯乐 d258f3f8bc fix(editor): dismiss the selection popup when its surface leaves the viewport (#425)
The portaled position:fixed add-to-conversation button stays pinned to its
viewport anchor while the sidebar keeps every tab mounted (tab switches only
set display:none; collapsing the panel translates it off-screen), so the
popup could outlive its editor surface. Extract the popup into
useSelectionPopup and close it on any mousedown outside the button, Escape,
document hidden, window blur, and - the tab-switch/collapse path - when an
IntersectionObserver reports the editor surface off-viewport.
2026-08-29 15:31:13 +08:00
Justin Skowyra cdd5706f5b fix(terminal): contain transient resize failures 2026-08-29 15:31:12 +08:00
Kunping Wang 5d8c33832c fix(git): 兼容旧版 Git 的 worktree 列表 2026-08-29 15:31:08 +08:00
Kunping Wang f1da0fe345 perf(sidebar): 保留兜底定位并限制全树查询频率
- 缓存节点连接期间按 1.5s safety-net 周期完整复核
- html style 变化时立即绕过 fast path 重新定位
- 覆盖流式热路径、定时复核、HMR 信号与节点替换行为
2026-08-29 15:31:05 +08:00
Kunping Wang a6c7cf63c1 perf(sidebar): 避免已定位会话列时重复查询 DOM
- 缓存会话列仍连接时跳过 #root 全树 querySelector
- 保留 detached/HMR 回退定位与 1.5s safety net
- 补充 locator 行为测试覆盖缓存复用与节点替换
2026-08-29 15:31:05 +08:00
懿驹-伯乐 60a2f9026e docs: add dsh-better-sidebar-icons to the enhancements catalog
Adds eg-bole/dsh-better-sidebar-icons (VSCode-style file/folder icon
theme, DOM overlay, npm-published 0.1.2) to the '增强与工具' plugin
list in the README.
2026-08-28 14:59:15 +08:00
206 changed files with 12664 additions and 3020 deletions
+15
View File
@@ -0,0 +1,15 @@
# EditorConfig — https://editorconfig.org
# 仓库统一编辑器基线;缩进等风格细节以各文件的既有约定为准。
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
# Makefile 的 recipe 必须用真实 tab(GNU Make 硬性要求)。
[Makefile]
indent_style = tab
+3 -3
View File
@@ -16,7 +16,7 @@ body:
options:
- label: 我已搜索现有 issue,确认没有**完全相同**的条目(只有完全一致才算重复)| I searched existing issues and confirmed there is no **exact duplicate** (only exactly identical issues count as duplicates)
required: true
- label: 我已确认使用最新版本 dsh-better-sidebar / I confirmed I am on the latest dsh-better-sidebar version
- label: 我已确认使用最新版本 dsh-apes-sidebar / I confirmed I am on the latest dsh-apes-sidebar version
required: false
- type: dropdown
@@ -58,8 +58,8 @@ body:
id: plugin-version
attributes:
label: 插件版本 / Plugin version
description: dsh-better-sidebar 的版本号 | The dsh-better-sidebar version
placeholder: "如 0.13.1(`dsh plugin --profile web add dsh-better-sidebar@latest` 安装的版本)"
description: dsh-apes-sidebar 的版本号 | The dsh-apes-sidebar version
placeholder: "如 0.13.1(`dsh plugin --profile web add dsh-apes-sidebar@latest` 安装的版本)"
validations:
required: false
+56 -11
View File
@@ -22,9 +22,10 @@ jobs:
with:
# Tests run git log against this repository and need the full history.
fetch-depth: 0
# pnpm version is pinned via packageManager in package.json; the action
# reads it from there (specifying both makes action-setup fail on
# purpose with ERR_PNPM_BAD_PM_VERSION).
- uses: pnpm/action-setup@v6
with:
version: 11
- uses: actions/setup-node@v7
with:
node-version: 22
@@ -34,7 +35,7 @@ jobs:
# config; the runner has none by default.
- name: Configure git identity for tests
run: |
git config --global user.name "dsh-better-sidebar-ci"
git config --global user.name "dsh-apes-sidebar-ci"
git config --global user.email "ci@dsh.invalid"
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -50,6 +51,50 @@ jobs:
- name: Check consumer type surface
run: pnpm check:consumer-types
# Windows parity lane for the pure-logic suite. The codebase carries real
# win32 branches (shell parsing, WSL path projection, the PowerShell
# installer) that mock-injection tests alone cannot validate — this lane
# runs the same typecheck / test / build / consumer-type-surface steps on
# a real Windows runtime. The plugin-mount lane (real DSH + Playwright
# Chromium) stays ubuntu-only and is deliberately NOT mirrored here.
ci-windows:
runs-on: windows-latest
timeout-minutes: 30
defaults:
run:
# The runner ships Git Bash: multi-line steps and
# scripts/check-consumer-types.sh work verbatim.
shell: bash
steps:
- uses: actions/checkout@v7
with:
# Tests run git log against this repository and need the full history.
fetch-depth: 0
# pnpm version is pinned via packageManager in package.json; the action
# reads it from there (specifying both makes action-setup fail on
# purpose with ERR_PNPM_BAD_PM_VERSION).
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: 22
cache: pnpm
# Same rationale as the ci job: smoke tests spawn git without an
# identity and rely on ambient config, which the runner lacks.
- name: Configure git identity for tests
run: |
git config --global user.name "dsh-apes-sidebar-ci"
git config --global user.email "ci@dsh.invalid"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Typecheck
run: pnpm typecheck
- name: Test
run: pnpm test
- name: Build
run: pnpm build
- name: Check consumer type surface
run: pnpm check:consumer-types
# Pack the plugin as an npm tarball, mount it into a REAL DSH instance
# through the official `dsh plugin --profile web add` channel, and render
# the DSH web UI headlessly (Playwright Chromium) to prove the plugin
@@ -66,9 +111,10 @@ jobs:
steps:
- uses: actions/checkout@v7
# pnpm version is pinned via packageManager in package.json; the action
# reads it from there (specifying both makes action-setup fail on
# purpose with ERR_PNPM_BAD_PM_VERSION).
- uses: pnpm/action-setup@v6
with:
version: 11
- uses: actions/setup-node@v7
with:
@@ -81,11 +127,10 @@ jobs:
# Pin the DSH CLI the lane mounts into; the profile's bundle stack
# (dsh-base / dsh-web-app) resolves from this installation. Keep in
# step with the plugin's @deepseek-ai/* peer range when bumping.
# DSH 0.1.2-alpha.x is published under the `alpha` dist-tag; the lanes
# speak BOTH RPC dialects (tests/e2e/host-protocol.ts, dot-first with
# slash fallback), so pre-alpha npm releases keep working too.
# DSH 0.1.2-rc.1 is published under the `next` dist-tag; the lanes
# speak the slash-only RPC dialect (tests/e2e/host-protocol.ts).
- name: Install dsh CLI (pinned)
run: npm install -g @deepseek-ai/dsh@0.1.2-alpha.2
run: npm install -g @deepseek-ai/dsh@0.1.2-rc.1
# lib/ is gitignored; the tarball is produced from this build.
- name: Build and pack the npm tarball
@@ -98,13 +143,13 @@ jobs:
run: pnpm test:mount
# Aggregate double-mount regression: a fixture aggregate bundle mounts
# dsh-better-sidebar under its own entry id BEFORE the plugin's bundle
# dsh-apes-sidebar under its own entry id BEFORE the plugin's bundle
# row; the plugin must back off (no duplicate /sidebar/api, boot OK).
# Uses the pinned DSH CLI installed above.
- name: Aggregate double-mount regression
env:
DSH_CMD: dsh
run: bash scripts/e2e-aggregate-mount.sh
run: pnpm test:mount:aggregate
- name: Upload Playwright report on failure
if: failure()
+15 -8
View File
@@ -1,6 +1,6 @@
name: Release
# Publishes `dsh-better-sidebar` to npm via npm Trusted Publishing (OIDC) —
# Publishes `dsh-apes-sidebar` to npm via npm Trusted Publishing (OIDC) —
# no NPM_TOKEN secret required, provenance is attached to the tarball.
#
# Trigger: a GitHub Release is published (tag vX.Y.Z). The tag MUST match
@@ -12,7 +12,7 @@ name: Release
# supports the stable DSH line); stable versions publish as `latest`.
#
# One-time npm-side setup (manual, on npmjs.com):
# 1. package `dsh-better-sidebar` -> Settings -> Publishing access
# 1. package `dsh-apes-sidebar` -> Settings -> Publishing access
# -> Trusted Publishers -> "Add Trusted Publisher".
# 2. Provider: GitHub Actions
# Organization or user: omdsh-dev
@@ -51,9 +51,10 @@ jobs:
# Tests run git log against this repository and need full history.
fetch-depth: 0
# pnpm version is pinned via packageManager in package.json; the action
# reads it from there (specifying both makes action-setup fail on
# purpose with ERR_PNPM_BAD_PM_VERSION).
- uses: pnpm/action-setup@v6
with:
version: 11
- uses: actions/setup-node@v7
with:
@@ -65,7 +66,7 @@ jobs:
# (see tests/smoke.spec.ts); the runner has none by default.
- name: Configure git identity for tests
run: |
git config --global user.name "dsh-better-sidebar-ci"
git config --global user.name "dsh-apes-sidebar-ci"
git config --global user.email "ci@dsh.invalid"
- name: Install dependencies
@@ -84,15 +85,21 @@ jobs:
exit 1
fi
- name: Build
run: pnpm build
- name: Typecheck
run: pnpm typecheck
- name: Test
run: pnpm test
- name: Build
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.
- name: Check consumer type surface
run: pnpm check:consumer-types
- name: Dry-run publish (validation only)
if: github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'true'
run: pnpm publish --dry-run --no-git-checks
+6 -4
View File
@@ -19,24 +19,26 @@
「npm 打包 → 真实挂载 → 无头渲染」门禁(证明打包产物在真实 DSH 挂载后不 crash):`pnpm build && pnpm pack` 产 tarball → `scripts/e2e-mount.sh` 装进全新 scratch profile(`dsh plugin --profile web add file:<tarball>`)并启动真实 `dsh web`(keyless,`--port 0`)→ `tests/e2e/mount.e2e.ts`(Playwright)断言 `[data-dsh-better-sidebar]` 挂载、无错误条/pageerror/console 错误,经「+ 菜单」逐个打开内置 tab(含终端懒加载 chunk),再经文件树打开 seed 文件强制加载 editor chunk(`client-editor.js`)。
本地:`pnpm build && pnpm pack && pnpm exec playwright install chromium && pnpm test:mount`。CI 钉 `@deepseek-ai/dsh@0.1.2-alpha.2`(`alpha` dist-tag;peer 下限 `^0.1.2-alpha.2`)。e2e spec 命名 `*.e2e.ts` + vitest `exclude` 双保险;**改 `exclude` 必须保留默认排除项**(exclude 整体替换默认值)。
本地:`pnpm build && pnpm pack && pnpm exec playwright install chromium && pnpm test:mount`。CI 钉 `@deepseek-ai/dsh@0.1.2-rc.1`(`next` dist-tag;peer 下限 `^0.1.2-rc.1`)。e2e spec 命名 `*.e2e.ts` + vitest `exclude` 双保险;**改 `exclude` 必须保留默认排除项**(exclude 整体替换默认值)。
---
## 3. DSH 0.1.2-alpha 适配要点(alpha 通道)
## 3. DSH 0.1.2 适配要点(0.1.2-rc.1+ 基线)
**v0.18.0-alpha.0 起插件进入 alpha 通道,仅支持 DSH 0.1.2-alpha.x**(peer 下限 `^0.1.2-alpha.2`,CI 钉 `@deepseek-ai/dsh@0.1.2-alpha.2`,npm 走 `alpha` dist-tag)。对 0.1.0-rc.8 ~ 0.1.1-rc.2 的兼容层已删除——双方言 RPC(点分回退)、MarkdownText 双形状 labels、`dsh-client-runtime` externals/inject 残留行;stable DSH 用户请装 v0.17.1(npm `latest`)。发版:release.yml 按版本号是否含 `-` 自动选 `alpha`/`latest` dist-tag。
**v0.18.0 起插件毕业正式版(npm `latest`),仅支持 DSH 0.1.2-rc.1+**(peer 下限 `^0.1.2-rc.1`,CI 钉 `@deepseek-ai/dsh@0.1.2-rc.1`,DSH rc.x 发在 `next` dist-tag)。v0.18.0-alpha.0 进入 alpha 通道时已删除对 0.1.0-rc.8 ~ 0.1.1-rc.2 的兼容层——双方言 RPC(点分回退)、MarkdownText 双形状 labels、`dsh-client-runtime` externals/inject 残留行;stable DSH 用户请固定装 v0.17.1(npm 上的旧 latest,0.1.2-alpha.x 宿主继续用 npm `alpha` 的 v0.18.0-alpha.0)。发版:release.yml 按版本号是否含 `-` 自动选 `alpha`/`latest` dist-tag。
宿主契约(均经真机挂载冒烟 14/14 验证):
1. **一次性 token 鉴权**:就绪行 `dsh web: http://127.0.0.1:<port>/?token=<43字符>`(导航换签名 cookie,干净 URL 401)。`e2e-mount.sh` 的 URL grep 必须延伸到空白(`[^ ]*`,在 `/` 截断丢 token);e2e 统一走 `tests/e2e/host.ts`(token 必选:`parseLaunchUrl` 对裸 origin 直接抛错),带 stamps 导航走 `gotoPage()`(先 addCookies 再直达——token 换 cookie 的 303 会丢弃同 URL 其它 query 参数)。插件 `/sidebar/*` 路由不受影响,同源 fetch 照旧。
2. **Remote gateway 斜杠 RPC(唯一方言)**:`POST /api/workspace/create`,payload 恰为 `{args: {...}}`,**args 按控制器 TS 参数名包装**(`workspace/create`、`session/create` → `{args:{request:{...}}}`;`session/list` 参数名 `_request` **不可省略**——`{}` 也被拒 `args fields do not match the descriptor`);envelope `method` 与路径一致,点分路径 404。请求由 `tests/e2e/host-protocol.ts` 的 `rpcAttempt` 构造、`tests/e2e-host-protocol.spec.ts` 锁定;要调新方法先在真机验参数名再进 `RPC_ARGS_KEY`。
3. **`MarkdownText` labels 嵌套契约**:必填 `labels: { code: { copyLabel, copiedLabel }, footnotes }`(漏传回退硬编码中文)。四个渲染点(mermaid.tsx / MarkdownHtml.tsx / TextEditor.tsx / SideChatView.tsx)统一走 `src/client/markdown-labels.tsx` 的 `markdownTextProps()`。
4. **侧边对话转录走自有路由,不碰客户端宿主 RPC**:`ctx.connection.api`(含 `sessions.history`)在 alpha.1 整体移除,继任 `session/follow|page` 又对 `origin:'subagent'` 会话强制 subagent 地址(普通 `{kind:'session'}` 被 `agent-busy` 拒)且分页 `throughSeq` 不得超当前游标。转录因此由 **`sidechat.events`** 插件路由供给(`src/sidechat-routes.ts`:live 读 `agent.session.events`、冷读 `sessionPersistence.inspect`,服务端 `session/end-seed` 切割 + `afterSeq` 增量);`ctx.connection` 镜像与 inject 已删。设计见 [docs/plans/2026-08-20-sidechat-tab-design.md](docs/plans/2026-08-20-sidechat-tab-design.md) §10。
4. **侧边对话转录走自有路由,不碰客户端宿主 RPC**:`ctx.connection.api`(含 `sessions.history`)在 alpha.1 整体移除,继任 `session/follow|page` 又对 `origin:'subagent'` 会话强制 subagent 地址(普通 `{kind:'session'}` 被 `agent-busy` 拒)且分页 `throughSeq` 不得超当前游标。转录因此由 **`sidechat.events`** 插件路由供给(`src/sidechat-routes.ts`:live 读 `agent.session.snapshotEvents()`(0.1.2-alpha.4 起的按需读 API,前身 `Session.events` 属性已删)、冷读 `sessionPersistence.inspect`,服务端 `session/end-seed` 切割 + `afterSeq` 增量);`ctx.connection` 镜像与 inject 已删。设计见 [docs/plans/2026-08-20-sidechat-tab-design.md](docs/plans/2026-08-20-sidechat-tab-design.md) §10。
5. **`dsh-settings` 无运行时 `settingsNamespace`**:命名空间合法性校验转为编译期模板字面量 `SettingsNamespaceInput`(小写字母开头 + `[a-z0-9-]` 尾部),`'dsh-better-sidebar'` 字面量直接过——宿主侧直接传常量(`src/index.ts` 的 settings inject)。
6. **`dsh-subagent` 的 `SUBAGENT_DESCRIPTOR_VERSION` 2 → 3**:sidechat 种子的 `subagent/descriptor` 版本由宿主包盖章,插件不硬编码;测试断言跟随常量(`tests/sidechat-routes.spec.ts`),勿钉字面量。
7. **`@deepseek-ai/dsh-client-runtime` 包已消亡**(继任 seed 是裸名 `dsh-client-store`,无 `/client` 子路径):peerDependencies、devDependencies、`dsh.client.inject`、chunk externals 白名单(`src/client/chunk-loader.ts` / `tsdown.config.ts` / `tests/chunk-loader.spec.ts` / `tests/manifest-consistency.spec.ts` 四处同步)均已无该条目。
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)。
---
+42
View File
@@ -0,0 +1,42 @@
# dsh-better-sidebar 命令面薄封装:目标仅转发 package.json scripts(唯一事实源),
# 新增/改名 script 不需要动这里;本文件只补目标发现(help)与 CI 门禁聚合
# (check / mount / mount-aggregate / registry)。
# 注:lint 目标留给引入 ESLint 的后续 PR。
.DEFAULT_GOAL := help
help: ## 列出所有可用目标
@awk -F':.*## ' '/^[a-zA-Z0-9_-]+:.*## / {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
install: ## 安装依赖(pnpm install)
pnpm install
build: ## 构建(清 lib/ → tsc → tsdown)
pnpm build
typecheck: ## 类型检查(tsc --noEmit)
pnpm typecheck
test: ## 单元测试(vitest run)
pnpm test
check: ## 聚合校验门禁:typecheck → build → test → check:consumer-types(对齐 CI)
pnpm typecheck && pnpm build && pnpm test && pnpm check:consumer-types
clean: ## 清理构建产物与测试报告(lib/、*.tgz、playwright-report/、test-results/)
rm -rf lib playwright-report test-results
rm -f *.tgz
pack: ## 打 npm tarball(pnpm pack)
pnpm pack
mount: ## 真机挂载冒烟:build + pack → Playwright Chromium → pnpm test:mount
pnpm build && pnpm pack && pnpm exec playwright install chromium && pnpm test:mount
mount-aggregate: ## 聚合双挂载回归:build + pack → pnpm test:mount:aggregate
pnpm build && pnpm pack && pnpm test:mount:aggregate
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
+119 -16
View File
@@ -1,4 +1,6 @@
# dsh-better-sidebar
# dsh-apes-sidebar
> **Fork notice** — This project is a fork of [dsh-better-sidebar](https://github.com/omdsh-dev/DSH-better-sidebar) v0.18.0 (commit `9e1a034`), distributed under the MIT License. Original copyright (c) 2026 dsh-external. This fork adds the `apes:*` information-layer tabs (tasks / agents / apps / messages / profile) on top of the upstream sidebar. See `package.json → forkedFrom` for the exact upstream pin.
<!-- Hero -->
<div align="center">
@@ -9,9 +11,9 @@
<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-alpha.0 alpha 通道):0.1.2-alpha.2" src="https://img.shields.io/badge/DSH-0.1.2--alpha.2_(alpha%20%E9%80%9A%E9%81%93)-4d6bfe" /></a>
<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://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="Git 面板" src="https://img.shields.io/badge/-Git%20面板-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 />
<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 />
通过 <code>registerTab</code> / <code>registerFileViewer</code> 注册新的侧边栏页面与文件预览器。
</div>
@@ -44,7 +46,7 @@
- **🌐 内嵌浏览器**:多开网页 tab,后退 / 前进 / 刷新;内容运行在沙箱 iframe;外链默认按协议分流——HTTP 在侧边栏打开、HTTPS 走系统浏览器(设置页可分别调整)
- **💻 真实终端**:xterm.js + node-pty 真实 shell,断线重连回放;可选为模型注入 `terminal_*` 工具
- **📂 模型侧边栏打开(可选)**:全局设置开启后注入 `sidebar_open` 工具——模型可主动在侧边栏打开文件 / 文件夹(树以该目录为根)/ HTTP(S) 网页
- **🌿 Git 面板**:真 diff + VSCode 式 diff tab、历史、右键暂存 / 提交 / 还原;工作区容器下自动发现子仓库并显示**仓库选择器**,支持 linked worktree 变更发现
- **🌿 文件变动**:Git 视角(真 diff / 历史 / 暂存·提交·还原 / worktree·子仓库选择)与本轮文件视角(模型读 / 写 / 编辑实时追踪,按文件分组、按类型筛选)**双视角合一**;统一 diff 渲染(改蓝配对 + 行内字符级高亮 + 语法着色 + 上下文折叠),底部可拖拽预览面板,可一键展开为独立 diff tab(默认自由浮窗,可在设置改为面板下半 split)
- **🧩 后台任务页**:subagent 拓扑 + 后台任务(退出码 / 实时输出 / 强制终止)
- **💬 侧边对话(beta)**:Codex 风格的侧边线程——继承主会话完整上下文(含进行中的回合与工具调用)独立运行,不进入主会话;线程内可持续追问,一键「保存为新会话」提升为顶层会话
- **🪟 双工作台**:右侧栏 + 底部面板;拖 Tab 拆分 / 合并分栏(可跨面板),移动端自动合并全宽抽屉
@@ -55,16 +57,16 @@
- **⚡ 按需加载**:启动只拉 ~325KB 核心,终端 / 编辑器 / Mermaid 图表等重依赖用到才按需拉取([设计文档](docs/plans/2026-08-12-lazy-chunks-design.md))
- **🌏 多语言**:界面文案跟随 DSH 语言(zh / en)实时切换;安装 `@huanlin/dsh-plugin-better-locale` 后支持日语(ja)等第三语言覆盖(见下方「🌏 第三语言覆盖」)
> 🔌 **核心理念**:服务优先——内置的 7 tab + 6 viewer 与第三方插件通过同一套 `ctx.betterSidebar` API 注册,能力完全对等;官方不再内置、可由生态提供的功能,交由生态插件实现(已有 **28+ 生态插件**,见下方「🌐 插件生态」)。接入文档见「🔌 服务化扩展」与 [外部插件接入指南](./docs/external-plugin-guide.md)。
> 🔌 **核心理念**:服务优先——内置的 8 tab + 6 viewer 与第三方插件通过同一套 `ctx.betterSidebar` API 注册,能力完全对等;官方不再内置、可由生态提供的功能,交由生态插件实现(已有 **28+ 生态插件**,见下方「🌐 插件生态」)。接入文档见「🔌 服务化扩展」与 [外部插件接入指南](./docs/external-plugin-guide.md)。
## 🚀 安装
**前置**:已装好 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-alpha.0 alpha 通道):0.1.2-alpha.2" src="https://img.shields.io/badge/DSH-0.1.2--alpha.2_(alpha%20%E9%80%9A%E9%81%93)-4d6bfe" /></a>
<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>
> 🧪 **alpha 通道**:`v0.18.0-alpha.0` 起适配 DSH **0.1.2-alpha.x**(npm dist-tag `alpha`),不再支持 0.1.0-rc.8 ~ 0.1.1-rc.2——DSH stable 用户请继续用 `@latest`(v0.17.1);DSH 0.1.2-alpha.2 用户安装 `dsh-better-sidebar@alpha`。
> 📌 **正式版**:`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)。
```sh
dsh plugin --profile web add dsh-better-sidebar@latest # 首次会因 pnpm 11 拦截 node-pty 构建脚本而失败(依赖已写入)
@@ -85,6 +87,8 @@ dsh plugin --profile web add dsh-better-sidebar@latest # 重跑即成功
遇到报错先查 https://github.com/omdsh-dev/DSH-better-sidebar README 的常见问题表。
```
**方式三:一键脚本**——克隆本仓库后执行 `bash scripts/install.sh`(macOS / Linux / Windows Git Bash;Windows 原生环境用 `install.ps1`;`-h` 查看参数),自动完成 add → 放行构建脚本 → 重跑安装。
<details>
<summary><b>更新</b></summary>
@@ -162,14 +166,14 @@ dsh registry enable dsh-external/dsh-better-sidebar
|---|---|
| **🗂️ 文件工作台:资源管理器**<br/><sub>支持两种格式的资源管理器:内嵌在文件预览中 / 独立显示文件树。懒加载目录树、软链接按目标类型展示(目录软链接可展开、失效链接标红)、全局文件名搜索、上传文件/文件夹与拖放上传、右键菜单(在新 Tab 打开 / 在侧边打开 / 复制路径)、悬浮 `@文件` 一键引用进输入框。</sub><br/><div align="center"><img width="420" alt="文件资源管理器" src="https://github.com/user-attachments/assets/a410bfd2-a8ba-43e6-873e-22417756e94d" /></div> | **📝 Markdown · 图片 · PDF 内联预览**<br/><sub>Markdown 预览支持 **Mermaid 图表**(`securityLevel: 'strict'` 安全渲染 + 二次清洗;点击图表弹窗放大、滚轮缩放、拖拽平移)、**README 级内嵌 HTML**(徽章墙 `<div align=center>`、`<details>` 折叠块内嵌 markdown、表格单元格内联标签——DOMPurify 白名单消毒真实渲染,`<script>` 等活性内容剥除,本地图片经会话媒体路由重写)与**浮动目录大纲**(≥3 标题出现,点击平滑跳转、自动展开折叠块);图片 / PDF 走媒体路由内联展示;Office 三件套由生态插件补齐。</sub><br/><div align="center"><img width="420" alt="Markdown + Mermaid 预览" src="https://github.com/user-attachments/assets/fe0e5182-55bb-45cc-b98b-a2877c2bdd38" /></div> |
| **🖥️ CodeMirror 代码编辑器**<br/><div align="center"><img width="420" alt="CodeMirror 代码编辑器" src="https://github.com/user-attachments/assets/b44b488e-568c-4ee0-b96c-e9c906598a77" /></div> | **🖼️ 图片内联预览**<br/><div align="center"><img width="420" alt="图片内联预览" src="https://github.com/user-attachments/assets/f9a58c30-5b7a-48b5-9e22-37d7e071f593" /></div> |
| **💻 真实终端**<br/><sub>xterm.js + node-pty 真实 shell(不是模拟器):断线重连 transcript 回放、shell / shellArgs 可配置(设置页或 `cordis.patch.yml`)、可选为模型注入 `terminal_*` 工具(agent 可直接开终端跑命令)。</sub><br/><div align="center"><img width="420" alt="真实终端" src="https://github.com/user-attachments/assets/0dad6ad3-ff3f-4b5a-86d2-f832ce65323e" /></div> | **🌿 Git 面板**<br/><sub>暂存 / 取消暂存 / 提交(`Ctrl+Enter`)/ 还原,历史列表;点击改动文件打开 **VSCode 式 diff tab**(红绿行级对比)。</sub><br/><div align="center"><img width="420" alt="Git 面板" src="https://github.com/user-attachments/assets/e7fc1220-305f-4bca-8583-e77ab4f4fa78" /></div> |
| **🌐 内嵌浏览器**<br/><sub>多开网页 tab:后退 / 前进 / 刷新 / 地址栏;内容运行在**不透明源沙箱 iframe**(界面实时显示沙箱状态,可按页面临时解锁);聊天里的外链点击可被接管到侧边栏打开(按协议分流,可配)。</sub><br/><div align="center"><img width="420" alt="内嵌浏览器" src="https://github.com/user-attachments/assets/9bc6b65a-64fc-4942-a685-76e391e55606" /></div> | **🧩 任务页:子代理拓扑 + 后台任务**<br/><sub>子代理树实时拓扑(运行状态、批量实时预览)+ 后台任务清单(退出码 / 实时输出 / 强制终止);新子代理 / 新任务可自动展开侧边栏(可关)。</sub><br/><div align="center"><img width="420" alt="任务页:子代理拓扑" src="https://github.com/user-attachments/assets/dcd8ed2f-59fa-405b-937b-2d250f5034dd" /></div> |
| **💻 真实终端**<br/><sub>xterm.js + node-pty 真实 shell(不是模拟器):断线重连 transcript 回放、shell / shellArgs 可配置(设置页或 `cordis.patch.yml`)、可选为模型注入 `terminal_*` 工具(agent 可直接开终端跑命令)。</sub><br/><div align="center"><img width="420" alt="真实终端" src="https://github.com/user-attachments/assets/0dad6ad3-ff3f-4b5a-86d2-f832ce65323e" /></div> | **🌿 文件变动:Git 视角 + 本轮文件**<br/><sub>双视角合一:**Git 视角**保留完整源代码管理(暂存 / 取消暂存 / 提交(`Ctrl+Enter`)/ 还原、历史、worktree 与子仓库选择);**本轮文件视角**实时折叠会话事件日志,记录模型读 / 写 / 编辑的每个文件(按文件分组、按类型筛选、操作数角标)。点击任意改动在底部**可拖拽预览面板**查看统一 diff——删红 / 增绿 / 改蓝配对 + 行内字符级高亮 + 语法着色 + 上下文折叠——也可一键展开为 VSCode 式独立 diff tab(同一渲染栈)。</sub><br/><div align="center"><img width="420" alt="文件变动" src="https://github.com/user-attachments/assets/e7fc1220-305f-4bca-8583-e77ab4f4fa78" /></div> |
| **🌐 内嵌浏览器**<br/><sub>多开网页 tab:后退 / 前进 / 刷新 / 地址栏;内容运行在**不透明源沙箱 iframe**(界面实时显示沙箱状态,可按页面临时解锁);聊天里的外链点击可被接管到侧边栏打开(按协议分流,可配)。</sub><br/><div align="center"><img width="420" alt="内嵌浏览器" src="https://github.com/user-attachments/assets/9bc6b65a-64fc-4942-a685-76e391e55606" /></div> | **🧩 任务页:子代理拓扑 + 后台任务**<br/><sub>子代理树实时拓扑(运行状态、批量实时预览)+ 后台任务清单(退出码 / 实时输出 / 强制终止);新子代理 / 新任务可自动激活任务页,宽屏同时展开侧边栏,窄屏不强制展开全屏抽屉(可关)。</sub><br/><div align="center"><img width="420" alt="任务页:子代理拓扑" src="https://github.com/user-attachments/assets/dcd8ed2f-59fa-405b-937b-2d250f5034dd" /></div> |
| **💬 侧边对话(beta)**<br/><sub>Codex 风格侧边线程:**每个对话一个独立 Tab**;线程继承主会话完整上下文(含进行中回合,以 interrupted 诚实冻结)独立运行,不污染主会话;可持续追问、重启冷恢复;一键「保存为新会话」提升为顶层会话。</sub><br/><div align="center"><img width="420" alt="侧边对话(beta)" src="https://github.com/user-attachments/assets/3a338c36-f5de-4000-95f3-4b1cd04f60fc" /></div> | **🪟 双工作台:右侧栏 + 底部面板 + 分栏**<br/><sub>右侧栏与底部面板可同时展开;拖 Tab 到分栏边缘**拆分**、拖到中间**合并**(可跨面板);面板宽高左缘/上缘拖拽调节;移动端自动合并为全宽抽屉;把 tab 拖到主会话区域可变为**自由窗口**(悬浮 / 缩放 / 置顶,拖回 pane 停靠)。</sub><br/><div align="center"><img width="420" alt="双工作台(右侧栏 + 底部面板)" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" /></div> |
| **⚙️ 声明式设置**<br/><sub>设置页「侧边卡片」分区:每个 tab / 预览器一张小卡片,独立开关(高亮启用态 + 品牌开关滑块);二级设置经卡片底部「功能设置」条弹窗(开关 / 文本 / 数字 / 下拉);插件自有设置持久化在 `pluginSettings`。</sub><br/><div align="center"><img width="420" alt="声明式设置:侧边卡片" src="https://github.com/user-attachments/assets/0800ca64-621e-48da-b7df-aecfddc3ec29" /></div> | **📱 移动端**<br/><sub>窄屏(<768px)自动切换为全宽抽屉:底栏 tab 一次性并入右侧栏,触屏拖拽可调。</sub><br/><div align="center"><img width="360" alt="移动端全宽抽屉" src="https://github.com/user-attachments/assets/a82ba78a-f4cf-4d85-80e8-050a05beb144" /></div> |
## 🌐 插件生态
`ctx.betterSidebar` 服务向所有插件开放两个扩展点:**`registerTab`(注册侧边栏页面)** 与 **`registerFileViewer`(注册文件预览器)**。内置的 7 tab + 6 viewer 与第三方插件走同一套 API,能力完全对等。
`ctx.betterSidebar` 服务向所有插件开放两个扩展点:**`registerTab`(注册侧边栏页面)** 与 **`registerFileViewer`(注册文件预览器)**。内置的 8 tab + 6 viewer 与第三方插件走同一套 API,能力完全对等。
```ts
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
@@ -214,7 +218,7 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
| [v587d/dsh-anysearch-refs](https://github.com/v587d/dsh-anysearch-refs) | <img alt="stars" src="https://img.shields.io/github/stars/v587d/dsh-anysearch-refs?style=flat&color=4d6bfe" /> | AnySearch 搜索结果引用卡片:搜索词、来源摘要、关键词高亮 |
| [mlosun/dsh-docs-panel](https://github.com/mlosun/dsh-docs-panel) | <img alt="stars" src="https://img.shields.io/github/stars/mlosun/dsh-docs-panel?style=flat&color=4d6bfe" /> | 全局文档面板:随身 Markdown 笔记,任何工作区随时可读 |
| [lnyuqian/dsh-skill-sidebar](https://github.com/lnyuqian/dsh-skill-sidebar) | <img alt="stars" src="https://img.shields.io/github/stars/lnyuqian/dsh-skill-sidebar?style=flat&color=4d6bfe" /> | 技能面板:扫描本机技能目录,4-6 字功能短语 + 一键复制调用 + 置顶 |
| [g-yixuan/dsh-sidechat](https://github.com/g-yixuan/dsh-sidechat) | <img alt="stars" src="https://img.shields.io/github/stars/g-yixuan/dsh-sidechat?style=flat&color=4d6bfe" /> | Codex 风格侧边对话 + 划选引用注释(轻量消费插件) |
| [g-yixuan/dsh-sidenote](https://github.com/g-yixuan/dsh-sidenote) | <img alt="stars" src="https://img.shields.io/github/stars/g-yixuan/dsh-sidenote?style=flat&color=4d6bfe" /> | Codex 风格侧边对话 + 划选引用注释(轻量消费插件) |
| [thirsty5034/dsh-ssh-tunnel](https://github.com/thirsty5034/dsh-ssh-tunnel) | <img alt="stars" src="https://img.shields.io/github/stars/thirsty5034/dsh-ssh-tunnel?style=flat&color=4d6bfe" /> | 多主机 SSH 隧道 + SSH 管理器页 |
| [thirsty5034/dsh-git-forge](https://github.com/thirsty5034/dsh-git-forge) | <img alt="stars" src="https://img.shields.io/github/stars/thirsty5034/dsh-git-forge?style=flat&color=4d6bfe" /> | GitHub / Gitea 账号、项目授权与推送策略 |
| [YesSanSan/dsh-conversation-outline](https://github.com/YesSanSan/dsh-conversation-outline) | <img alt="stars" src="https://img.shields.io/github/stars/YesSanSan/dsh-conversation-outline?style=flat&color=4d6bfe" /> | 对话大纲页:按轮次结构化展示、一键跳转、LLM 一句话标题 |
@@ -248,6 +252,7 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
| 插件 | ⭐ | 简介 |
|---|---|---|
| [eg-bole/dsh-better-sidebar-icons](https://github.com/eg-bole/dsh-better-sidebar-icons) | <img alt="stars" src="https://img.shields.io/github/stars/eg-bole/dsh-better-sidebar-icons?style=flat&color=4d6bfe" /> | VSCode 风格文件 / 文件夹图标主题:文件树与编辑器 Tab 换上熟悉的开发环境图标(vscode-icons 移植,纯 DOM 覆盖零侵入,安装 / 卸载零残留) |
| [dong-victor/dsh-better-sidebar-terminal-plus](https://github.com/dong-victor/dsh-better-sidebar-terminal-plus) | <img alt="stars" src="https://img.shields.io/github/stars/dong-victor/dsh-better-sidebar-terminal-plus?style=flat&color=4d6bfe" /> | 终端增强:内嵌 Nerd Font 图标字体、修复 xterm 图标渲染、稳定终端 cwd |
| [Max-Null/dsh-sidebar-preview-select](https://github.com/Max-Null/dsh-sidebar-preview-select) | <img alt="stars" src="https://img.shields.io/github/stars/Max-Null/dsh-sidebar-preview-select?style=flat&color=4d6bfe" /> | 预览划选增强:侧边栏预览里划选文本 → 浮动「发送到会话」 |
| [Hoemr/dsh-quicklook](https://github.com/Hoemr/dsh-quicklook) | <img alt="stars" src="https://img.shields.io/github/stars/Hoemr/dsh-quicklook?style=flat&color=4d6bfe" /> | QuickLook 式空格预览:活动文件标签页按 Space 全尺寸查看图片 / PDF / 文本,Space 或 Esc 关闭 |
@@ -258,7 +263,95 @@ 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-alpha.0 alpha 通道):0.1.2-alpha.2" src="https://img.shields.io/badge/DSH-0.1.2--alpha.2_(alpha%20%E9%80%9A%E9%81%93)-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.0 正式版):0.1.5-rc.2+" src="https://img.shields.io/badge/DSH-0.1.5--rc.2%2B-4d6bfe" /></a> · 完整发布历史见 [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases)
### v1.1.0(fork · DSH 2.0.10 / 0.1.5-rc.2 适配 + 精简 + 风格对齐)
> 📌 **DSH 2.0.10 适配**:跟随 DSH 2.0.10 内发的 `@deepseek-ai/dsh-*@0.1.5-rc.2`,peer + devDeps 全量对齐。
**🗑 精简 tab(去 `apes:tasks`)**
- 移除 `apes:tasks` —— 与 DSH 原生侧栏的官方会话树功能重叠,保留 **3 个** `apes:*` 信息层 tab:`apes:agents` / `apes:apps` / `apes:profile`
- 同步清理死代码:`ApesTasksView` + `collectTreeJobs`/`orderJobs`/`isJobLive`/`jobDotState` import + apes i18n 死 key(tasks/status/duration)
**🎨 风格对齐 DSW 设计令牌**
- apes 视图内联样式的颜色全部改用 DSW alias 令牌(`--dsw-alias-bg-*` / `--dsw-alias-border-*` / `--dsw-alias-label-*` / `--dsw-alias-state-success-*` / `--dsw-alias-interactive-*`),与 DSH 原生侧栏视觉统一;`var(...)` 带字面 fallback 保证非 DSH 环境(单测 harness)仍可渲染
- 字号 / 间距 / 圆角保持内联值(DSH 未对插件面板公开 `--ds-*` 几何令牌,其自身 vendor bundle 亦内联)
**🔧 0.1.5-rc.2 适配修复**
- primitives npm 发布缺陷(15 个运行时依赖被误列为 devDeps)→ 补进本地 `devDependencies` 供 vitest 内联解析
- sidechat seed 校验契约升级:`dsh-session@0.1.5` 的 `assertAssistantSettlementShape` 要求 assistant 消息的 `data` 顶层带 `stream: Array` → 测试 seed helper 对齐
- 完整 gate:typecheck ✓ / build ✓ / test 119 files · 1249 passed ✓
---
### v1.0.0(fork · apes 信息层)
> 📌 **fork 独立版本线**:自 upstream `dsh-better-sidebar@v0.18.0`(commit `9e1a034`)fork,新增 `apes:*` 信息层 tab。upstream 历史版本见下方 v0.18.0 段。
**🪧 新增 4 个 `apes:*` 信息层 tab**(独立模块 `src/client/apes/`,与 upstream builtin tab 解耦)
- **`apes:tasks` 任务**:接 `ctx.sessions.list` 的 `jobsBySession` + running sessions —— live 任务列表(label / 运行中·完成·失败 / kind / 耗时 + owner 归属)
- **`apes:agents` 智能体**:接 `subagentsByParent` 目录(降级 `byId`)—— subagent 拓扑(main → 直接 children 的 activity:running / inactive)
- **`apes:apps` 应用**:展示当前工作区 cwd(`scope.cwd` + `byId[current].cwd`;无 DSH host feed,保留说明占位)
- **`apes:profile` 画像**:接 `byId[current]` —— 当前会话画像(title / cwd / running / agent 状态)
**🔌 数据接入**
- 数据源 `ctx.sessions.list.getSnapshot()` 经 `useSyncExternalStore` 响应式订阅(snapshot 含 `current` / `byId` / `subagentsByParent` / `jobsBySession`)
- 复用 upstream pure helper(无 UI / Node 依赖):`subagent-lineage.ts`(`rootAncestor` / `isSideThreadSummary`)+ `subagent-jobs.ts`(`collectTreeJobs` / `orderJobs` / `isJobLive` / `jobDotState`)
- UI 独立内联样式,不耦合 builtin CSS module / `StateDot`(保持 apes 模块隔离,cherry-pick upstream 时只碰 `index.tsx` 一处)
**🌏 i18n**:4 个视图全部走 `t(key)`,`zh` 默认 + `en` fallback,状态 / 耗时 / 空态均有双语词条
### 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
- 💬 **侧边对话渲染升级**(#486):主对话级 Blocks 结构、turn 用量尾标、断线重连横幅
- ⚙️ **工作区路径围栏开关**(#458):新增 `workspaceFence` 声明式设置键,工作区外路径 403 时错误面一键关闭并指引
**⚡ 性能**
- 🚀 **核心包 -45%**(#489):19 个非中英词典 chunk 懒加载、渲染稳定化(转录行复用 / tree Sets / 批量 drag)、启动与轮询开销削减(单次 settings fetch、每 tick 单 git 进程);新增 perf 测量 lane;顺带修复底栏拖拽把面板宽度泄漏进宿主布局、原生左栏突跳的 bug
- 📉 会话列重复 DOM 查询削减(#456)
**🐛 修复(节选)**
- ✏️ 编辑器 / Markdown:SSH 远程链接客户端打开(#522)、预览/编辑切换保持阅读位置(#467)、预览隐藏 YAML frontmatter(#394)、TOC 外点关闭与层级修复(#461)、@-引用保留 basename(#417)
- 💻 终端 / 平台:Windows 自定义 shell 解析(#503)、resize 失败容错(#428)、字体解析兜底等宽(#366)、WSL 会话 Linux 绝对路径(#455)、Windows Explorer reveal 保留选中(#508)
- 🗂️ 布局 / 状态 / 文件树 / 对话:桌面 shell 布局与侧卡片共存(#398)、窄屏自动激活不强制抽屉(#373)、自由窗口 id 恢复冲突(#385)、窗口聚焦自动刷新文件树(#469)、引用提示固定行尾(#509)、划选弹窗关闭与草稿插入锚定(#427)、折叠态开关组对齐(#361)、旧引擎滚动跳变兼容(#448)、旧版 Git worktree 列表(#454)
**🧰 CI 与内部**
- Windows CI 车道(#520)、Makefile 命令面规范化(#526)、e2e 脚本加固 + 聚合双挂载回归(#527)、共享组件测试工具收敛样板(#524)、重复实现收敛与死代码清理(#525)
**🌐 生态收录**
- 新收录 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 补齐)
### 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** 正式版。
- 🔗 **适配 DSH 0.1.2-alpha.5(npm 已发布,`alpha` dist-tag)**:CI 挂载门禁钉版、`dsh.plugin.json` engines 下限与 `@deepseek-ai/*` peer / devDependencies 基线升至 0.1.2-alpha.5(真机挂载冒烟 14/14 验证)。`dsh-client-locale` 上游停在 0.1.2-alpha.3 未发新版,其 peer 下限 / devDep 保持并天然兼容 alpha.5 运行时(`pnpm peers check` 零失配,无需新增提升传递 peer)。代码适配了 alpha.4 的兼容性标记改动——`Session.events` 属性移除,迁移到按需读 API `snapshotEvents()`(sidechat 转录 live 读、fork 继承、`jobs.output` 回放、subagent 活跃度共 8 处),新建线程 meta 中宿主已删的 `seedLength` 一并移除;alpha.4 其余变化(双向 `send_message`、自定义模型发现复用 Profile 请求头、`SessionSeq`/`SessionLogOffset` 强类型)与 alpha.5(升级启动修复)经核实不触及本插件其余表面。
### v0.18.1-alpha.0
> 🧪 **alpha 通道**:本版仅支持 **DSH 0.1.2-alpha.x**(peer 下限 `^0.1.2-alpha.3`,npm dist-tag `alpha`,安装 `dsh-better-sidebar@alpha`)。该版本号未单独发布,内容已并入 **v0.18.0** 正式版。
- 🔗 **适配 DSH 0.1.2-alpha.3(npm 已发布,`alpha` dist-tag)**:CI 挂载门禁钉版、`dsh.plugin.json` engines 下限与 `@deepseek-ai/*` peer / devDependencies 基线升至 0.1.2-alpha.3(真机挂载冒烟 14/14 验证)。逐点核查了 alpha.2 → alpha.3 全部 117 个 commit:插件依赖的宿主契约(token 鉴权、斜杠 RPC、`MarkdownText` labels、`sidechat.events` 所依赖的事件流与持久化 API、`SettingsNamespaceInput`、`SUBAGENT_DESCRIPTOR_VERSION`(仍为 3)、`dsh-client-store`、profile 加载器、node-pty 钉版)均无变化,无需代码适配;alpha.3 的破坏性改动(`BeginSubmissionInput.mode` 必填、subagent 错误码改名 `attachment-invalid`、SQLite 持久化后端移除、投影 change feed 收紧为 identity-gated)经核实均不触及本插件。
### v0.18.0-alpha.0
@@ -354,7 +447,7 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
- 🖥️ **终端跨会话切换保活**([#323](https://github.com/omdsh-dev/DSH-better-sidebar/pull/323)):切到其他会话不再被当作瞬时掉线——客户端卸载时发送 `park` 控制帧,主机跳过 30s 重连宽限倒计时;切回会话(`open()` 取消 parked)或显式关闭恢复正常生命周期;agent 终端保持无限期存活
- 📂 **文件树上传遮罩不再拦截 Tab 拖拽**([#317](https://github.com/omdsh-dev/DSH-better-sidebar/pull/317)):拖拽 Tab(重排 / 跨 pane split)经过资源管理器时不再弹上传遮罩、不吞事件——统一按 `dataTransfer.types` 含 `Files` 门控(与面板宿主 shield 一致),Tab 正常落下;OS 文件拖拽行为不变
- 💬 **子代理自动展开去抖**([#314](https://github.com/omdsh-dev/DSH-better-sidebar/pull/314)):Side Chat 线程创建不再误弹任务页——0→N 触发 500ms 重臂并对实时快照按原基线重评估,标题过滤器识别线程后才放行;真实子代理依然自动展开
- 💬 **子代理自动展开去抖**([#314](https://github.com/omdsh-dev/DSH-better-sidebar/pull/314)):Side Chat 线程创建不再误弹任务页——0→N 触发 500ms 重臂并对实时快照按原基线重评估,标题过滤器识别线程后才放行;真实子代理依然自动激活任务页(宽屏展开侧边栏,窄屏只准备 Tab、不强制展开抽屉)
### v0.15.0
@@ -459,7 +552,7 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
## 🔌 服务化扩展
从 v0.4.0 起暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏页面与文件预览器(内置 7 tab + 6 viewer 亦通过同一服务注册)。v0.12.1 补齐基座能力(完整类型导出、能力探测、状态订阅、tab 角标、生命周期回调、定向打开、插件自有设置等)。
从 v0.4.0 起暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏页面与文件预览器(内置 8 tab + 6 viewer 亦通过同一服务注册)。v0.12.1 补齐基座能力(完整类型导出、能力探测、状态订阅、tab 角标、生命周期回调、定向打开、插件自有设置等)。
完整接入文档(全字段、匹配算法、HMR 陷阱、声明式设置、版本探测、自由窗口与皮肤契约):**[`docs/external-plugin-guide.md`](./docs/external-plugin-guide.md)**;仓库开发规则(硬约束 / CI / 发版)见 [`AGENTS.md`](./AGENTS.md)。
@@ -472,13 +565,23 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
## 🛠️ 开发与构建
```sh
pnpm install # @deepseek-ai/* devDependencies 已发布(基线 0.1.2-alpha.2,alpha dist-tag),直接解析、无需令牌
pnpm install # @deepseek-ai/* devDependencies 已发布(基线 0.1.2-rc.1,next dist-tag),直接解析、无需令牌
pnpm typecheck # tsc --noEmit
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
```
**Make 薄封装**(`make help` 查看全部目标;package.json 仍是唯一事实源):
```sh
make check # 聚合校验门禁:typecheck → build → test → check:consumer-types(对齐 CI)
make mount # 真机挂载冒烟:build + pack → 安装 Chromium → pnpm test:mount
make clean # 清理 lib/、*.tgz、playwright-report/、test-results/
```
`pnpm check:consumer-types`:对外类型声明面守卫——以浏览器-only 消费者(无 `@types/node`、`skipLibCheck: false`)的视角对构建出的 `lib/types` 做类型检查,需先 `pnpm build`。
**架构**:单 npm 包、host/client 双半结构——host(`src/index.ts`):`/sidebar/api/*` JSON API、`/sidebar/file` 媒体路由、`/sidebar/html` 预览路由、`/sidebar/ws/terminal` WebSocket(fs / git / pty / 预览,全部会话级 + 信任围栏);client(`src/client/index.tsx`):portal 侧边栏 + 各视图 + 拦截;状态按会话持久化 localStorage。插件按 DSH 官方规范组织(无 default 导出、双 client bundle),运行期不依赖 npm / checkout(`@deepseek-ai/*` 由 web profile 提供)。
## 🔐 安全
@@ -504,7 +607,7 @@ Windows / Linux / macOS 三平台适配(macOS 日常验证;其余经单元
- **代码改动走 PR**:`feat/*` / `fix/*` 分支开发 → `gh pr create`;纯文档改动可直接推 main
- **收录生态插件**:给仓库打 `dsh-better-sidebar` topic + 向 [`src/client/plugins-tabs.ts`](./src/client/plugins-tabs.ts) / [`plugins-viewers.ts`](./src/client/plugins-viewers.ts) 提 PR
- **提交前自检**:`pnpm typecheck && pnpm build && pnpm test`(CI 另有 npm 打包 → 真实挂载 → 无头渲染门禁 `pnpm test:mount`)
- **提交前自检**:`pnpm typecheck && pnpm build && pnpm test`(或 `make check` 一键聚合;CI 另有 npm 打包 → 真实挂载 → 无头渲染门禁 `pnpm test:mount`,及聚合双挂载回归 `pnpm test:mount:aggregate`)
- 仓库工作规范见 [`AGENTS.md`](./AGENTS.md)(含仓库硬约束与 CI 说明)
## ⭐ Star History
+73 -14
View File
@@ -9,9 +9,9 @@
<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-alpha.0 alpha track): 0.1.2-alpha.2" src="https://img.shields.io/badge/DSH-0.1.2--alpha.2_(alpha%20track)-4d6bfe" /></a>
<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://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 &amp; 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="Git panel" src="https://img.shields.io/badge/-Git%20panel-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 />
<img alt="File management" src="https://img.shields.io/badge/-File%20management-4d6bfe" /> <img alt="Edit &amp; 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 />
register new sidebar pages and file viewers via <code>registerTab</code> / <code>registerFileViewer</code>.
</div>
@@ -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
- **🌿 Git Panel**: real diff + VSCode-style diff tabs, history, right-click to stage / commit / revert; under a workspace container it discovers child repositories and shows a **repository selector**, with linked-worktree change discovery
- **🌿 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
- **🧩 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
@@ -55,16 +55,16 @@
- **⚡ On-demand Loading**: only ~325KB core at startup; heavy deps (terminal / editor / mermaid diagrams) load on demand ([design](docs/plans/2026-08-12-lazy-chunks-design.md))
- **🌏 i18n**: UI text follows DSH's language (zh / en) with live switching; with the optional `@huanlin/dsh-plugin-better-locale` peer, 19 third-language overlays (ja / de / fr / …) are available
> 🔌 **Core principle**: service-first — the 7 built-in tabs + 6 viewers register through the same `ctx.betterSidebar` API as third-party plugins, with fully equal capabilities; anything the ecosystem can provide better is delegated to ecosystem plugins (**28+ ecosystem plugins** already — see "🌐 Plugin Ecosystem" below). See "🔌 Service API" and the [external plugin guide](./docs/external-plugin-guide.md).
> 🔌 **Core principle**: service-first — the 8 built-in tabs + 6 viewers register through the same `ctx.betterSidebar` API as third-party plugins, with fully equal capabilities; anything the ecosystem can provide better is delegated to ecosystem plugins (**28+ ecosystem plugins** already — see "🌐 Plugin Ecosystem" below). See "🔌 Service API" and the [external plugin guide](./docs/external-plugin-guide.md).
## 🚀 Installation
**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-alpha.0 alpha track): 0.1.2-alpha.2" src="https://img.shields.io/badge/DSH-0.1.2--alpha.2_(alpha%20track)-4d6bfe" /></a>
<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>
> 🧪 **Alpha track**: starting with `v0.18.0-alpha.0` the plugin targets DSH **0.1.2-alpha.x** (npm dist-tag `alpha`) and drops 0.1.0-rc.8 ~ 0.1.1-rc.2 — stable-DSH users should stay on `@latest` (v0.17.1); DSH 0.1.2-alpha.2 users install `dsh-better-sidebar@alpha`.
> 📌 **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).
```sh
dsh plugin --profile web add dsh-better-sidebar@latest # first run fails: pnpm 11 blocks node-pty build scripts (the dependency is still written)
@@ -85,6 +85,8 @@ Install the dsh-better-sidebar plugin (a sidebar workbench for DSH):
If anything fails, check the troubleshooting table in the README at https://github.com/omdsh-dev/DSH-better-sidebar
```
**Option 3: one-shot script** — from a clone of this repo, run `bash scripts/install.sh` (macOS / Linux / Windows Git Bash; native Windows uses `install.ps1`; `-h` for options) — it automates add → approve-builds → re-run.
<details>
<summary><b>Updating</b></summary>
@@ -162,14 +164,14 @@ Update: `git pull && pnpm install && pnpm build` → `node scripts/package-regis
|---|---|
| **🗂️ File Workbench: Explorer**<br/><sub>Two explorer modes: embedded in the file preview / standalone file tree. Lazy-loading directory tree, symlinks classified by target kind (directory links expand, dangling links flagged), global filename search, file/folder upload buttons plus drag-drop upload, context menu (open in new tab / open to the side / copy paths), and a hover `@file` button that references a file straight into the composer.</sub><br/><div align="center"><img width="420" alt="File explorer" src="https://github.com/user-attachments/assets/a410bfd2-a8ba-43e6-873e-22417756e94d" /></div> | **📝 Inline Preview: Markdown · Images · PDF**<br/><sub>The Markdown preview renders **Mermaid diagrams** (strict-mode safe rendering + a second sanitize pass; click a diagram for a zoom modal with wheel-zoom and drag-pan), **README-level inline HTML** (badge walls `<div align=center>`, `<details>` blocks nesting markdown, inline tags in table cells — DOMPurify-sanitized, `<script>` stripped, local media rewritten to the session media route) and a floating **table of contents** (appears with ≥3 headings, smooth-scroll jumping, auto-expanding folded blocks); images / PDFs display inline via the media route; the Office suite is covered by an ecosystem plugin.</sub><br/><div align="center"><img width="420" alt="Markdown + Mermaid preview" src="https://github.com/user-attachments/assets/fe0e5182-55bb-45cc-b98b-a2877c2bdd38" /></div> |
| **🖥️ CodeMirror editor**<br/><div align="center"><img width="420" alt="CodeMirror editor" src="https://github.com/user-attachments/assets/b44b488e-568c-4ee0-b96c-e9c906598a77" /></div> | **🖼️ Inline image preview**<br/><div align="center"><img width="420" alt="Inline image preview" src="https://github.com/user-attachments/assets/f9a58c30-5b7a-48b5-9e22-37d7e071f593" /></div> |
| **💻 Real Terminal**<br/><sub>xterm.js + node-pty real shell (not an emulator): transcript replay on reconnect, configurable shell / shellArgs (settings page or `cordis.patch.yml`), and optional `terminal_*` model tools so the agent can open terminals and run commands itself.</sub><br/><div align="center"><img width="420" alt="Real terminal" src="https://github.com/user-attachments/assets/0dad6ad3-ff3f-4b5a-86d2-f832ce65323e" /></div> | **🌿 Git Panel**<br/><sub>Stage / unstage / commit (`Ctrl+Enter`) / revert, plus a history list; clicking a changed file opens a **VSCode-style diff tab** (line-level red/green).</sub><br/><div align="center"><img width="420" alt="Git panel" src="https://github.com/user-attachments/assets/e7fc1220-305f-4bca-8583-e77ab4f4fa78" /></div> |
| **🌐 Embedded Browser**<br/><sub>Multiple web tabs with back / forward / reload / address bar; content runs in an **opaque-origin sandboxed iframe** (live sandbox status in the UI, per-page temporary unlock available); external-link clicks in the chat can be taken over into the sidebar (protocol-based routing, configurable).</sub><br/><div align="center"><img width="420" alt="Embedded browser" src="https://github.com/user-attachments/assets/9bc6b65a-64fc-4942-a685-76e391e55606" /></div> | **🧩 Tasks: Agent Topology + Background Jobs**<br/><sub>Live subagent-tree topology (run states, batched live previews) plus the background-jobs list (exit codes / live output / force-kill); new subagents / jobs can auto-expand the sidebar (configurable).</sub><br/><div align="center"><img width="420" alt="Tasks: subagent topology" src="https://github.com/user-attachments/assets/dcd8ed2f-59fa-405b-937b-2d250f5034dd" /></div> |
| **💻 Real Terminal**<br/><sub>xterm.js + node-pty real shell (not an emulator): transcript replay on reconnect, configurable shell / shellArgs (settings page or `cordis.patch.yml`), and optional `terminal_*` model tools so the agent can open terminals and run commands itself.</sub><br/><div align="center"><img width="420" alt="Real terminal" src="https://github.com/user-attachments/assets/0dad6ad3-ff3f-4b5a-86d2-f832ce65323e" /></div> | **🌿 Changes: Git lens + This-Session lens**<br/><sub>Two lenses on "what changed?": the **Git lens** keeps the full source-control surface (stage / unstage / commit (`Ctrl+Enter`) / revert, history, worktree and child-repo selectors); the **This Session** lens folds the session event log live, recording every file the model read / wrote / edited (grouped by file, kind-filtered, op-count badge). Clicking any change previews it in the **draggable bottom pane** with the unified diff — del red / add green / mod-blue pairing + intra-line character highlights + syntax coloring + context folding — or expands into a VSCode-style dedicated diff tab (same rendering stack).</sub><br/><div align="center"><img width="420" alt="Changes" src="https://github.com/user-attachments/assets/e7fc1220-305f-4bca-8583-e77ab4f4fa78" /></div> |
| **🌐 Embedded Browser**<br/><sub>Multiple web tabs with back / forward / reload / address bar; content runs in an **opaque-origin sandboxed iframe** (live sandbox status in the UI, per-page temporary unlock available); external-link clicks in the chat can be taken over into the sidebar (protocol-based routing, configurable).</sub><br/><div align="center"><img width="420" alt="Embedded browser" src="https://github.com/user-attachments/assets/9bc6b65a-64fc-4942-a685-76e391e55606" /></div> | **🧩 Tasks: Agent Topology + Background Jobs**<br/><sub>Live subagent-tree topology (run states, batched live previews) plus the background-jobs list (exit codes / live output / force-kill); new subagents / jobs can auto-activate the Tasks page, expanding the sidebar on wide viewports without forcing narrow full-screen drawers open (configurable).</sub><br/><div align="center"><img width="420" alt="Tasks: subagent topology" src="https://github.com/user-attachments/assets/dcd8ed2f-59fa-405b-937b-2d250f5034dd" /></div> |
| **💬 Side Chat (beta)**<br/><sub>Codex-style side threads: **one independent tab per conversation**; the thread inherits the parent's full context (including the in-progress turn, honestly frozen as "interrupted") and runs independently without polluting the main session; follow-ups survive restarts; one click promotes the thread to a top-level session.</sub><br/><div align="center"><img width="420" alt="Side Chat (beta)" src="https://github.com/user-attachments/assets/3a338c36-f5de-4000-95f3-4b1cd04f60fc" /></div> | **🪟 Dual Workbench: Sidebar + Bottom Panel + Split Panes**<br/><sub>The right sidebar and the bottom panel can stay open together; drag a tab to a pane edge to **split**, to the middle to **merge** (works across panels); panel width/height drag from the left/top edge; on mobile everything merges into a full-width drawer; drag a tab onto the main conversation area to turn it into a **free window** (float / resize / raise, drag back onto a pane to dock).</sub><br/><div align="center"><img width="420" alt="Dual workbench (right sidebar + bottom panel)" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" /></div> |
| **⚙️ Declarative Settings**<br/><sub>The "Side card" section in DSH settings: one small card per tab / viewer with an independent toggle (highlighted enabled state + brand switch); secondary settings open from the "Feature settings" strip at the card bottom (switch / text / number / select rows); plugin-owned settings persist under `pluginSettings`.</sub><br/><div align="center"><img width="420" alt="Declarative settings: side cards" src="https://github.com/user-attachments/assets/0800ca64-621e-48da-b7df-aecfddc3ec29" /></div> | **📱 Mobile**<br/><sub>On narrow screens (<768px) the panels become a full-width drawer: bottom-panel tabs merge into the sidebar once, with touch-friendly dragging.</sub><br/><div align="center"><img width="360" alt="Mobile full-width drawer" src="https://github.com/user-attachments/assets/a82ba78a-f4cf-4d85-80e8-050a05beb144" /></div> |
## 🌐 Plugin Ecosystem
The `ctx.betterSidebar` service opens two extension points to every plugin: **`registerTab` (sidebar pages)** and **`registerFileViewer` (file previewers)**. The 7 built-in tabs + 6 viewers register through the exact same API — fully equal capabilities.
The `ctx.betterSidebar` service opens two extension points to every plugin: **`registerTab` (sidebar pages)** and **`registerFileViewer` (file previewers)**. The 8 built-in tabs + 6 viewers register through the exact same API — fully equal capabilities.
```ts
import type {} from 'dsh-better-sidebar' // triggers the ctx.betterSidebar type merge
@@ -263,7 +265,54 @@ 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-alpha.0 alpha track): 0.1.2-alpha.2" src="https://img.shields.io/badge/DSH-0.1.2--alpha.2_(alpha%20track)-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.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
### v0.18.0
> 📌 **Stable release** (npm `latest`): this release targets **DSH 0.1.2-rc.1+ only** (peer floor `^0.1.2-rc.1`); 0.1.0-rc.8 ~ 0.1.1-rc.2 are not supported — stable-DSH users should stay pinned to `dsh-better-sidebar@0.17.1`, hosts on 0.1.2-alpha.x keep `dsh-better-sidebar@alpha` (v0.18.0-alpha.0).
All changes since v0.17.1 (the two intermediate version numbers v0.18.1-alpha.0 / v0.19.0-alpha.0 were never published; their content is folded into this release):
**🔗 Host track graduation**
- **Onto the DSH 0.1.2 line and graduating to stable**: v0.18.0-alpha.0 dropped the 0.1.1-rc.x compatibility layer and fixed blank Side Chat transcripts on alpha.1+ (#472); the baseline then climbed through alpha.3 (#497) and alpha.5 (#516, `Session.events` → `snapshotEvents()`); the chat file-open funnel moved to `remote.session.openWorkspacePath` (#494) and the "Show in folder" reveal scroll was scoped to the tree body (#453). This release pins the baseline to **DSH 0.1.2-rc.1** (zero source delta vs alpha.5 — a pure version bump; `dsh-client-locale` resumed publishing, everything aligned to rc.1; real-host mount smoke 14/14 in CI)
**✨ Features**
- 🌿 **Unified "Changes" tab** (#475): 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); 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
- 💬 **Side Chat rendering upgrade** (#486): main-conversation-grade Blocks structure, per-turn usage tails, reconnect banner
- ⚙️ **Workspace path fence toggle** (#458): a new `workspaceFence` declarative settings key with a one-click off affordance and guidance on 403 error surfaces
**⚡ Performance**
- 🚀 **Core bundle -45%** (#489): 19 non-zh/en dictionary chunks lazy-loaded, render stabilization (transcript row reuse / tree Sets / batched drags), startup & polling cost cuts (single settings fetch, one git process per tick); a new perf measurement lane; also fixes the bottom-pane drag leaking panel width into the host layout and jumping the native left sidebar
- 📉 Fewer repeated center-column DOM queries (#456)
**🐛 Fixes (selection)**
- ✏️ Editor / Markdown: SSH remote editor links open on the client (#522), reading position kept across preview/edit switches (#467), YAML frontmatter hidden in preview (#394), TOC dismiss-on-outside-click + popover z-index (#461), file basename kept in @-reference links (#417)
- 💻 Terminal / platform: Windows custom shell executables resolve (#503), transient resize failures contained (#428), monospace fallback for unresolvable fonts (#366), Linux absolute paths in WSL workspaces (#455), Windows Explorer reveal selection preserved (#508)
- 🗂️ Layout / state / file tree / chat: desktop shell layout coexists with the side card (#398), no forced mobile drawer on auto-activation (#373), restored free-window id conflicts (#385), tree auto-refresh on window focus (#469), reference affordance pinned to row tail (#509), selection-popup dismissal + caret-anchored draft insert (#427), collapsed toggle cluster aligned (#361), old-engine scroll-jump compat (#448), worktree listing on older Git (#454)
**🧰 CI & internals**
- Windows CI lane (#520), Makefile command surface (#526), e2e script hardening + aggregate double-mount regression (#527), shared component test utils deduplicating boilerplate (#524), duplicate-implementation convergence + dead code removal (#525)
**🌐 Ecosystem**
- 10+ new curated plugins: dsh-better-sidebar-icons (#441), dsh-sidenote (#451, formerly 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), and more (each with 18+ language i18n coverage)
### v0.19.0-alpha.0
> 🧪 **Alpha track**: this release targets **DSH 0.1.2-alpha.x only** (peer floor `^0.1.2-alpha.5`, npm dist-tag `alpha`, install `dsh-better-sidebar@alpha`). This version number was never published separately; its content is folded into the **v0.18.0** stable release.
- 🔗 **Adapted to DSH 0.1.2-alpha.5 (published to npm, `alpha` dist-tag)**: the CI mount gate's pin, the `dsh.plugin.json` engines floor, and the `@deepseek-ai/*` peer / devDependencies baseline moved up to 0.1.2-alpha.5 (verified by a real-host mount smoke 14/14). `dsh-client-locale` has no alpha.5 upstream (newest is 0.1.2-alpha.3), so its peer floor / devDep pin trail the baseline and already accept the alpha.5 runtime (`pnpm peers check` reports zero mismatches — no new transitive peers to hoist). The code adapts to alpha.4's compatibility-flagged change — the `Session.events` property was removed, migrated to the on-demand `snapshotEvents()` API (8 sites: sidechat transcript live reads, fork inheritance, `jobs.output` replay, subagent activity), and the `seedLength` meta field the host dropped was removed from thread creation; alpha.4's remaining changes (bidirectional `send_message`, custom-model discovery reusing Profile headers, `SessionSeq`/`SessionLogOffset` strong typing) and alpha.5's upgrade-startup fix were verified not to touch any other plugin surface.
### v0.18.1-alpha.0
> 🧪 **Alpha track**: this release targets **DSH 0.1.2-alpha.x only** (peer floor `^0.1.2-alpha.3`, npm dist-tag `alpha`, install `dsh-better-sidebar@alpha`). This version number was never published separately; its content is folded into the **v0.18.0** stable release.
- 🔗 **Adapted to DSH 0.1.2-alpha.3 (published to npm, `alpha` dist-tag)**: the CI mount gate's pin, the `dsh.plugin.json` engines floor, and the `@deepseek-ai/*` peer / devDependencies baseline moved up to 0.1.2-alpha.3 (verified by a real-host mount smoke 14/14). All 117 commits between alpha.2 and alpha.3 were audited point by point: every host contract this plugin relies on (token auth, slash RPC, `MarkdownText` labels, the event-stream and persistence APIs behind `sidechat.events`, `SettingsNamespaceInput`, `SUBAGENT_DESCRIPTOR_VERSION` (still 3), `dsh-client-store`, the profile loader, the node-pty pin) is unchanged, so no code adaptation was needed; alpha.3's breaking changes (the required `BeginSubmissionInput.mode`, the renamed `attachment-invalid` subagent error, the removed SQLite persistence backend, the identity-gated projection change feed) were all verified not to touch this plugin.
### v0.18.0-alpha.0
@@ -359,7 +408,7 @@ All changes since v0.15.0:
- 🖥️ **Terminal pty stays alive across conversation switches** ([#323](https://github.com/omdsh-dev/DSH-better-sidebar/pull/323)): switching sessions is no longer treated as a transient drop — the client sends a `park` control frame on unmount and the host skips the 30s reconnect-grace countdown; switching back (`open()` cancels parked) or explicitly closing the tab resumes the normal lifecycle; agent terminals keep their indefinite lifetime
- 📂 **File-tree upload overlay no longer intercepts Tab drags** ([#317](https://github.com/omdsh-dev/DSH-better-sidebar/pull/317)): dragging tabs (reorder / cross-pane split) across the explorer no longer shows the upload overlay or swallows the event — gated on `dataTransfer.types` containing `Files` (consistent with the panel-host shield), so tabs land normally; OS file drags behave as before
- 💬 **Subagent auto-open debounced** ([#314](https://github.com/omdsh-dev/DSH-better-sidebar/pull/314)): Side Chat thread creation no longer pops the task page — the 0→N trigger rearms for 500ms and re-evaluates the original baseline against the live snapshot, by which time the title filter recognizes the thread; genuine subagents still auto-open
- 💬 **Subagent auto-open debounced** ([#314](https://github.com/omdsh-dev/DSH-better-sidebar/pull/314)): Side Chat thread creation no longer pops the task page — the 0→N trigger rearms for 500ms and re-evaluates the original baseline against the live snapshot, by which time the title filter recognizes the thread; genuine subagents still auto-activate the Tasks page (wide viewports expand the sidebar, while narrow viewports prepare the tab without forcing the drawer open)
### v0.15.0
@@ -455,7 +504,7 @@ All changes since v0.14.0:
## 🔌 Service API
Since v0.4.0 the plugin exposes the `ctx.betterSidebar` service — other plugins can register sidebar pages and file viewers (the 7 built-in tabs + 6 viewers register through the same service). v0.12.1 completed the base capabilities (complete type exports, capability detection, state subscription, tab badges, lifecycle callbacks, targeted open, plugin-owned settings, etc.).
Since v0.4.0 the plugin exposes the `ctx.betterSidebar` service — other plugins can register sidebar pages and file viewers (the 8 built-in tabs + 6 viewers register through the same service). v0.12.1 completed the base capabilities (complete type exports, capability detection, state subscription, tab badges, lifecycle callbacks, targeted open, plugin-owned settings, etc.).
Full integration docs (complete fields, matching algorithm, HMR pitfalls, declarative settings, version detection, float windows and the skinning contract): **[`docs/external-plugin-guide.md`](./docs/external-plugin-guide.md)**; repository rules (hard constraints / CI / release) live in [`AGENTS.md`](./AGENTS.md).
@@ -468,13 +517,23 @@ The dashed cards at the end of the "Sidebar content" / "File viewers" grids in t
## 🛠️ Development & Build
```sh
pnpm install # @deepseek-ai/* devDependencies resolve (baseline 0.1.2-alpha.2, alpha dist-tag) — no token needed
pnpm install # @deepseek-ai/* devDependencies resolve (baseline 0.1.2-rc.1, next dist-tag) — no token needed
pnpm typecheck # tsc --noEmit
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
```
**Make thin wrappers** (`make help` lists every target; package.json stays the single source of truth):
```sh
make check # aggregate gate: typecheck → build → test → check:consumer-types (mirrors CI)
make mount # real-mount smoke: build + pack → install Chromium → pnpm test:mount
make clean # remove lib/, *.tgz, playwright-report/, test-results/
```
`pnpm check:consumer-types`: the consumer-facing declaration-surface guard — type-checks the built `lib/types` from a browser-only consumer's perspective (no `@types/node`, `skipLibCheck: false`); run `pnpm build` first.
**Architecture**: a single npm package with host/client halves — host (`src/index.ts`): `/sidebar/api/*` JSON API, `/sidebar/file` media route, `/sidebar/html` preview route, `/sidebar/ws/terminal` WebSocket (fs / git / pty / preview, all session-scoped with a trust fence); client (`src/client/index.tsx`): portal sidebar + views + interception; state persisted per session in localStorage. Organized per DSH official conventions (no default export, dual client bundles); no dependency on npm / checkout at runtime (`@deepseek-ai/*` provided by the web profile).
## 🔐 Security
@@ -513,7 +572,7 @@ WeChat / QQ group QR codes will live here. After uploading the QR images (drag t
- **Code changes go through PRs**: develop on a `feat/*` / `fix/*` branch, then `gh pr create`; docs-only changes may be pushed to main directly
- **Curate an ecosystem plugin**: tag your repo with `dsh-better-sidebar` + PR a `PluginEntry` into [`src/client/plugins-tabs.ts`](./src/client/plugins-tabs.ts) / [`plugins-viewers.ts`](./src/client/plugins-viewers.ts)
- **Before submitting**: `pnpm typecheck && pnpm build && pnpm test` (CI additionally gates on npm-pack → real-mount → headless-render via `pnpm test:mount`)
- **Before submitting**: `pnpm typecheck && pnpm build && pnpm test` (or `make check` for the one-shot aggregate; CI additionally gates on npm-pack → real-mount → headless-render via `pnpm test:mount`, plus the aggregate double-mount regression `pnpm test:mount:aggregate`)
- See [`AGENTS.md`](./AGENTS.md) for the repository rules (hard constraints, CI lanes, release flow)
## ⭐ Star History
+7 -7
View File
@@ -1,12 +1,12 @@
# dsh-better-sidebar bundle patch
# dsh-apes-sidebar bundle patch
#
# This file is the `dsh.bundle.patch` layer of the published npm package: when
# the plugin is installed through the official CLI —
#
# dsh plugin --profile <name> add dsh-better-sidebar@<version>
# dsh plugin --profile <name> add dsh-apes-sidebar@<version>
#
# — the command reconciles `dsh.profile.bundles` against installed packages
# and, seeing this declaration, appends `dsh-better-sidebar` to the bundle
# and, seeing this declaration, appends `dsh-apes-sidebar` to the bundle
# stack. The profile boot then merges THIS patch (a single `insert` of the
# plugin row) exactly like the manual cordis.patch.yml mount line used
# before. No profile file edits needed — one command installs and mounts.
@@ -23,7 +23,7 @@
#
# - insert:
# - id: better-sidebar
# name: 'dsh-better-sidebar'
# name: 'dsh-apes-sidebar'
# config:
# shell: /bin/zsh
# shellArgs:
@@ -35,7 +35,7 @@
# `web-ui-better-sidebar`). Two mounts register /sidebar/api twice and fail the
# whole plugin tree at boot ("duplicate prefix route"). The `!!js` disabled
# expression backs THIS row off when another *enabled* entry already mounts
# `dsh-better-sidebar`; the aggregate instance then owns the sidebar. The
# `dsh-apes-sidebar`; the aggregate instance then owns the sidebar. The
# loader evaluates it when this row is processed (entry-list order, only rows
# before this one are visible), so the aggregate bundle must precede this one
# in `dsh.profile.bundles` — the standard install order, since `dsh plugin add`
@@ -45,5 +45,5 @@
# fail loudly at the loader as before.
- insert:
- id: better-sidebar
name: 'dsh-better-sidebar'
disabled: !!js "[...ctx.loader.entries()].some((e) => e.options.name === 'dsh-better-sidebar' && e.options.id !== 'better-sidebar' && !e.disabled)"
name: 'dsh-apes-sidebar'
disabled: !!js "[...ctx.loader.entries()].some((e) => e.options.name === 'dsh-apes-sidebar' && e.options.id !== 'better-sidebar' && !e.disabled)"
+13 -9
View File
@@ -2,8 +2,8 @@
> 面向 **消费插件开发者**:如何让你的插件向 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-alpha.0**(alpha 通道,仅支持 DSH 0.1.2-alpha.x;stable 线为 v0.17.1)。
> 权威代码:`src/client/service.ts`(服务实现)、`src/client/builtins/`(内置 7 tab + 6 viewer 参考实现)、`lib/types/client/service.d.ts`(类型声明)。
> 适用版本:**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)。
> 权威代码:`src/client/service.ts`(服务实现)、`src/client/builtins/`(内置 8 tab + 6 viewer 参考实现)、`lib/types/client/service.d.ts`(类型声明)。
> 仓库开发规则(硬约束 / CI / 发版)见 [AGENTS.md](../AGENTS.md)。
---
@@ -15,7 +15,7 @@ better-sidebar 从 v0.4.0 起把自己改造成一个**注册表服务**:
- **新页面(tab)**:注册一种新的侧边栏 tab 类型,出现在侧边栏 `+` 菜单里,用户点击后在自己的分栏里打开你的 React 页面;
- **文件预览器(file viewer)**:注册一种文件类型预览器,让用户在侧边栏打开文件时走你的渲染组件(覆盖或补充内置的 image/pdf/code 等)。
内置的 7 个 tab(editor / git / subagent / sidechat / terminal / browser / diff)和 6 个 viewer(image / pdf / markdown / html / code / binary-download)**自己也是通过同一套 API 注册的**(吃自己的狗粮),所以外部插件的能力与内置功能完全对等。
内置的 7 个 tab(editor / git——「文件变动」统一 tab(Git 视角 + 本轮文件视角)/ subagent / sidechat / terminal / browser / diff)和 6 个 viewer(image / pdf / markdown / html / code / binary-download)**自己也是通过同一套 API 注册的**(吃自己的狗粮),所以外部插件的能力与内置功能完全对等。
关键机制一句话:better-sidebar 的 client half 在 `apply()` 开头执行 `ctx.provide('betterSidebar', service)`(`src/client/index.tsx`),消费插件在 `inject` 里声明 `'betterSidebar'`,Cordis 保证服务就绪后才激活你的插件,然后你调用 `ctx.betterSidebar.registerTab(...)` / `registerFileViewer(...)` 完成注册,返回的 disposer 由 Cordis fiber 在卸载(HMR / 禁用)时自动调用。
@@ -256,7 +256,10 @@ interface TabComponentProps {
// 以下由内置 tab 使用,外部 tab 可忽略:
expanded?: string[] // 文件树的展开目录集
onToggleDir?: (path: string) => void
onReferenceFile?: (path: string) => void
// 在会话 composer 插入一条 @ 引用。isDir=true 为目录:纯文本 `@dir/`,
// 保留宿主文件夹装饰与补全;false 为文件:走宿主结构化引用 chip
// (显示 @basename、序列化为完整 @path),宿主拒绝时回退纯文本。
onReferenceFile?: (path: string, isDir: boolean) => void
onOpenFile?: (path: string) => void
onOpenDiff?: (tab: SidebarTab) => void
onSubagentJump?: (childSessionId: string) => void
@@ -359,12 +362,12 @@ ctx.effect(() => {
| id | order | single | hidden | 用途 |
|---|---|---|---|---|
| `editor` | 10 | 否(按 path 去重) | 否 | 唯一「文件窗口」(编辑/预览 + 资源管理)。chrome 恒合并形态:路径输入框 + 编辑器控件 + 可开关内嵌文件树(全局搜索 `fs.search`;状态存 `tab.meta.treeOpen/treeWidth`)。`editorExplorer`:关(默认)= 按 path 新开,无路径窗口 = 纯资源管理器;开 = 树点击/Enter 经 `updateTab` 原地切换(id/meta 不变),无路径窗口 = 带 chrome 空窗口。树右键「在新 Tab 中打开」「在侧边打开」(pane 右侧 split)。新会话 seed 空文件窗口(`title:'Files'`);旧 `explorer` tab 经 `sanitizeState` 迁移 |
| `git` | 20 | 是 | 否 | Git 面板 |
| `git` | 20 | 是 | 是(本轮文件操作数) | 「文件变动」统一 tab(id 保留 `git` 以兼容持久化布局):**Git 视角**(原 Git 面板:staged/unstaged / 提交 / 历史 / worktree·子仓库选择)+ **本轮文件视角**(原 file-trace:模型读/写/编辑实时折叠,按文件分组、类型筛选);会话事件经插件自有宿主路由 `changes.ops` 供给(live 日志优先、冷会话回放持久化记录,`afterSeq` 增量),badge 读 tab 轮询写入的同步缓存。两视角共用底部可拖拽预览面板(`tab.meta.lens/previewH` 持久化),diff 渲染统一走 `src/client/diff/`(`DiffRows`/`DiffFiles`:mod 配对 + 行内高亮 + 语法着色 + 上下文折叠);Git 目标可展开为独立 diff tab——落点由二级设置 `changesDiffFloat` 决定(默认**自由浮窗**居中弹出,可选面板下半 split),设置项见 tab descriptor 的 `settings.toggles` select |
| `subagent` | 30 | 是 | 否 | 子代理拓扑 |
| `sidechat` | 35 | 否(`sidechat:<uuid>`,按 `meta.threadId` 去重) | 否 | 侧边对话(每对话一 Tab):打开即建空线程(首条消息赢得标签并同步标题);线程 = 插件自建子会话(种子继承父会话上下文,进行中回合以 `interrupted` 闭合;种子带合法 `subagent/descriptor`,SubagentView 按 `Side: ` 前缀过滤),`origin:'subagent'` 隐藏于主列表;走 `/sidebar/api/sidechat.*` 路由;头部菜单切换/重开(`parkSidechatReopen` + 确定性 id),关 Tab 释放 live agent;重开经 `collectOwnEvents` 回源到种子边界;「保存为新会话」= `session.fork`(`this` 敏感)。[设计文档](plans/2026-08-20-sidechat-tab-design.md) |
| `terminal` | 40 | 否(`terminal:<n>`) | 否 | 终端。v0.17.0+ 右键「固定到工作区/全局」:跨会话不消失,TabBar 内联虚拟 Tab(`pinned:<homeSessionId>:<tabId>`),就地按 home scope 连 PTY;global 全会话可见、workspace 仅同 cwd;`tab.pin = { scope, homeCwd? }` 随会话持久化,渲染期解析(`collectPinnedTabs` → `createPinnedVirtualTab` → `injectPinnedIntoTree`) |
| `browser` | 50 | 否(`browser:<n>`) | 否 | 内嵌浏览器(沙箱 iframe,可设置关沙箱) |
| `diff` | -1 | 否(按 id 去重) | 是 | 差异查看(GitView 触发) |
| `diff` | -1 | 否(按 id 去重) | 是 | 差异查看(changes tab 的预览面板「展开为独立页签」触发,同一渲染栈) |
你的 `id` 不可与上述重复,否则 `registerTab` 抛 `"tab type \"X\" already registered"`。
@@ -691,7 +694,7 @@ ctx.effect(() =>
)
```
> ⚠️ **`toggles` 的 key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `agentOpenTools` / `terminalFontFamily` / `terminalFontSize` / `editorExplorer` / `workspaceFence` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks` / `browserInterceptHttp` / `browserInterceptHttps`)。**v0.12.0 起设置 seam 已开放**:你自己的设置走 `pluginToggles`(声明式行)或 `render`(自定义面板),值持久化在 `pluginSettings[id]`——不再需要宿主 schema 字段,也不再被 seam 丢弃。值须 JSON 可序列化(行控件只产出 string/number/boolean;自定义面板自行负责)。
> ⚠️ **`toggles` 的 key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `agentOpenTools` / `terminalFontFamily` / `terminalFontSize` / `editorExplorer` / `workspaceFence` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks` / `browserInterceptHttp` / `browserInterceptHttps` / `changesDiffFloat`)。**v0.12.0 起设置 seam 已开放**:你自己的设置走 `pluginToggles`(声明式行)或 `render`(自定义面板),值持久化在 `pluginSettings[id]`——不再需要宿主 schema 字段,也不再被 seam 丢弃。值须 JSON 可序列化(行控件只产出 string/number/boolean;自定义面板自行负责)。
### 8.1 内置键 `workspaceFence`(工作区路径围栏)
@@ -727,6 +730,7 @@ ctx.effect(() =>
| **i18n 跟随** | 文案跟随 DSH `ctx.locale`(词典在 `betterSidebar` 命名空间;Host-backed `locale.preference` 优先于浏览器语言并实时切换;缺失回退浏览器)。消费插件**不要**依赖内部 `t()`——标题传字符串或 `() => string` |
| **第三语言覆盖(ja 等)** | 可选 peer `@huanlin/dsh-plugin-better-locale`(optional)提供 ja/ko 覆盖,**借用 DSH 英文槽位**(仅 DSH=en 时生效,zh 下惰性)。经 `ctx.get('betterLocale')` 注入 `t()`;未安装整段 no-op |
| **懒加载 chunk** | 重依赖(xterm/CodeMirror)在独立 bundle(`lib/client-<name>.js`),经 `/sidebar/bundle` 按需下发;factory 赋到 `globalThis.__dshChunks__[<name>]`,由 `src/client/chunk-loader.ts` 物化,**不经** `__ModuleLoader__`。对消费插件透明 |
| **聊天文件打开漏斗(alpha 宿主)** | 聊天里一切文件打开(工具行 / 产物行 / 正文提及 / 行内代码路径)汇入 `ctx.remote.session.openWorkspacePath`(Typert remote 命名空间,cordis 服务 key `remote.session`,方法为 **accessor 属性**、异步挂载)。better-sidebar 的「聊天区文件在侧边栏打开」即在 `ctx.inject(['remote.session'], …)` 内以 defineProperty 遮蔽该方法(`src/client/openpath-intercept.ts`)。你的插件若要观测/旁路聊天文件打开,走同一服务;不要假设 pre-alpha 的 `ctx.workspaces.openPath` 存在(alpha 的 `IWorkspaces` 已无此方法) |
---
@@ -751,8 +755,8 @@ ctx.effect(() =>
- **面板表面**:右/底面板背景 = `var(--dsw-alias-bg-layer-1)`。**绝不消费 `--dsw-specific-sidebar-fill`**(宿主左导航专属,皮肤按左导航语义覆盖它,面板消费会失去填充)。换面板表面 = 覆写 `--dsw-alias-bg-layer-1`。
- **终端/编辑器表面**:`effectiveTokenValue` 读 `--dsw-alias-bg-base`——`transparent` 与 alpha < 0.9 的半透明值回退不透明底色(文字不叠背景画,issue #90);≥ 0.9 放行。
- **根锚点**:宿主 div 带 `data-dsh-better-sidebar`(append 到 body);其内**面板宿主层** `[data-dsh-panel-host]`(`fixed; inset:0; z-25; pointer-events:none`,v0.13.1+),面板/开关簇 absolute 定位,免疫中间层 transform 劫持;页面级 transform 触发 `data-dsh-panel-host-degraded` 降级。皮肤作用域覆盖限定在 `[data-dsh-better-sidebar]` 内。
- **布局变量**(`<html>` 上,面板打开时有效):`--dsh-sidebar-width` / `--dsh-sidebar-height`。宿主推挤 = `#root` margin/width calc + centerCol `margin-bottom`;锚点用**复合选择器双保险**(禁止 `nth-child`):`#root [data-dsh-frame] > [data-pane="conversation"]` 与 `#root :has(> [data-slot="conversation"])`(两代命名同元素,`drag-layout.e2e.ts` 断言)。
- **根锚点**:宿主 div 带 `data-dsh-better-sidebar`(append 到 body);其内**面板宿主层** `[data-dsh-panel-host]`(`fixed; inset:0; z-25; pointer-events:none; overflow:hidden+clip`,v0.13.1+),面板/开关簇 absolute 定位,免疫中间层 transform 劫持;页面级 transform 触发 `data-dsh-panel-host-degraded` 降级。`overflow` 级联是**契约**(`hidden` 兜底 + `clip` 收尾,`tests/panel-host-css.spec.ts` 守护):`hidden` 盒子仍是滚动容器,脚本滚动或浏览器 scroll-into-view 修正(焦点移入视口外区域、嵌套 iframe/工作台加载时抢焦点、面板滑出动画中 focus() 落点)会沿最近可滚祖先滚走整层——面板与开关簇集体偏离视口角(computed left/right 仍"正确",偏移藏在盒子自身 scroll offset 里);`clip` 裁剪语义相同但不产生滚动盒,任何路径都滚不动这层。皮肤作用域覆盖限定在 `[data-dsh-better-sidebar]` 内。
- **布局变量**(`<html>` 上,面板打开时有效):`--dsh-sidebar-width` / `--dsh-sidebar-height`。右面板宽度 = AppFrame 的 `padding-right` 预留(新版 `#root [data-dsh-frame]` / rc.8 `#root > [data-slot="root"] > div` 双锚点),AppFrame border box 保持完整桌面视口宽度(Harness 以此判定桌面/窄屏布局,避免插件面板展开误入窄屏);AppFrame 的 details 拖拽手柄按同一变量向左平移贴合列边缘。底部面板仍走 centerCol `margin-bottom`;centerCol 锚点 = **JS 标注**(禁止 `nth-child`):侧栏 shell 的定位器给测得的 centerCol 节点打 `[data-dsh-center-col]` 标签(`Sidebar.tsx` locate,节点更换/HMR 时随 ref 迁移),`layout.css` 用 `#root [data-dsh-center-col]` 选中(`drag-layout.e2e.ts` 断言恰一节点且为 `[data-slot="conversation"]` 的父级;frame 宽度与桌面 Session Log 由 `desktop-layout.e2e.ts` 断言)。
- **桌面信号与标题栏**(v0.14.1+ 四方案模型 `SidebarPrefs.titleBarScheme`,唯一决策点 `src/client/titlebar-strip.ts` 纯函数):
- 壳信号(只读,不自动触发修改):URL `dsh-desktop-mode` / `dsh-desktop-platform` / 可选 `dsh-desktop-titlebar-inset`(0–120 clamp)。
- **strip 取值链**:⓪ `web` 方案强制 0;① `navigator.windowControlsOverlay` 真实几何(`wco.ts` 订阅 `geometrychange`,**为 0 也权威**,`visible=false` 幽灵 API 视为缺失);② URL inset;③ 壳预设 `stripFor`(仅 `preset`);④ 手动 `titleBarStripPx`(仅 `custom`);⑤ 0。驱动 `body[data-dsh-title-bar-compat]` + `--dsh-title-bar-strip`。
@@ -51,7 +51,7 @@
### 4.4 行为修正
- `state.ts` `loadState`:窄屏新会话默认 `panelOpen=false`(首开才生效;用户手动展开后照常持久化)。
- `state.ts` `loadState`:窄屏加载一律以 `panelOpen=false` 开始(包括持久化布局恢复);仅覆盖抽屉可见性,右侧/底部 pane、tab 与自由窗口状态原样恢复。宽屏仍按持久化值或新会话偏好决定展开状态。
- `service.ts` `openTab`:窄屏且 seed 带 `path`/`url` 且面板收起 → 自动展开抽屉(dedupe-focus 分支同样生效);纯类型打开不展开;宽屏行为不变。
### 4.5 样式(`sidebar.module.css`)
@@ -61,7 +61,7 @@
### 4.6 测试
- `tests/breakpoints.spec.ts`:断点边界(767 窄 / 768 宽;1024 明确为宽)。
- `tests/unit.spec.ts`:`migrateBottomTabs` 4 例(并入第一 leaf 且底部清空/面板关/activePane 重指、右树为 split 时并入最左 leaf、幂等性、空底部树但 activePane 在底部树时重指)+ 窄屏新会话默认收起(stub `window.innerWidth=390`)。
- `tests/unit.spec.ts`:`migrateBottomTabs` 4 例(并入第一 leaf 且底部清空/面板关/activePane 重指、右树为 split 时并入最左 leaf、幂等性、空底部树但 activePane 在底部树时重指)+ 窄屏新会话默认收起;`tests/state.spec.ts` 守护窄屏恢复强制收起且 pane / free-window 状态不丢,768px 宽屏仍恢复持久化展开值。
- `tests/service.spec.ts`:窄屏 auto-expand 5 例(390 < 768 仍窄)。
## 5. 边界情况
@@ -259,3 +259,4 @@ document.body
- 本地 Node 24 跑 `test:mount` 需 `node --expose-internals` 启动 dsh CLI(HMR 服务要求,CI Node 22 无此问题)——本地用 DSH_CMD 包装,不改仓库脚本。
- **CR #232 修复(2026-08-19)**:① 降级同步改为按**未修正几何**判定(跟踪自身补偿量,祖先 transform 消失才退出循环);② chunk 重验证成为 `loadChunk` 屏障(重验证挂起期间不提供缓存,杜绝 HMR 竞态陈旧 exports);③ 键盘 inset 公式补 `visualViewport.offsetTop` 并监听 `scroll`。
- **z-index 40→25(2026-08-22)**:设计初稿的"30–99 无任何 DSH 元素"(源自 #52 修复记录)低估了官方 ui-cordis 的动态插件面板(fixed z-index 30,挂 `sidebar.footer.action` 槽)——better-sidebar 底部面板曾盖住其清单/审批面。下调宿主层至 25(AppFrame overlayLayer 20 之上、ui-cordis 30 与 DSH 浮层栈 100+ 之下),折叠按钮簇 45 保留为 host 内部层叠;`AGENTS.md` §8 与 `2026-08-15-skin-compat-design.md` 已同步。
- **桌面布局判定修正(2026-08-26)**:`#root` 的 width/margin 推挤会缩小 AppFrame 的 border box,触发 Harness `<1024px` 窄屏判定,使桌面左侧栏自动折叠、Session Log 退化为 fixed icon,并挤乱后台任务操作区。右面板宽度改为 AppFrame 内部 `padding-right`(新版 `[data-dsh-frame]` 与 rc.8 root-slot direct child 双锚点),Frame 与 `#root` 保持视口宽度;details 拖拽手柄按同一变量平移。`desktop-layout.e2e.ts` 守护 frame 宽度、桌面 Session Log 文本按钮与面板不重叠,`drag-layout.e2e.ts` 继续守护拖拽 1:1。
@@ -87,10 +87,12 @@ Cursor (SSH)
- 校验:`reveal` 路径必须绝对(`requireAbsolute`);`url` 必须是 `scheme://` 自定义协议(拒绝 http/https)。
- **Windows 平台命令为约定实现,需在 Windows 实机验证**(本机为 macOS;CI 覆盖 linux)。
**实施偏差(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 补充信息)。
## 已知限制
- 路径含 `#`/`?` 的文件名经 URL 打开可能被浏览器当作 fragment/query(不处理,注释说明)。
- 编辑器未安装/协议未注册:由 OS 弹提示或静默失败,不做安装检测。
- 编辑器未安装/协议未注册:由 OS 弹提示或静默失败,不做安装检测。SSH 客户端分支同理——浏览器端 `location.assign` 无法探测协议 handler 是否存在,仍返回 `{started: true}`。
- 自定义编辑器仅支持 URL 模板式,不支持 CLI 命令式;无 `{dir}` 占位符。
- Linux reveal 退化为打开所在目录(精确 select 需要各文件管理器私有协议)。
- SSH 模式为**全局**(非每会话):DSH 无远程会话概念,文件树路径即宿主路径;该模型对应"DSH 跑在远端开发机上"的场景。
@@ -99,6 +101,7 @@ Cursor (SSH)
- `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/open-with-settings.spec.tsx`:SSH 输入、添加/删除(删除剪枝 pinned)、无效提示、VSCode 系开关。
- `tests/side-card-section.spec.tsx`:`SettingsBody` 行列表 + render 共存、仅 render 时不变。
@@ -50,3 +50,9 @@ DSH 宿主的 `MarkdownText` 出于聊天安全把 raw HTML 按字面文本渲
- GFM 脚注跨段不保(宿主本就把脚注渲染为纯文本);`srcset` 不重写(实际只有远程 URL);HTML 实体不解码;标题锚点 permalink、TOC 滚动联动高亮不做;Sidechat 不动;不换渲染器。
- CommonMark HTML 块类型 1(script/pre/style/textarea 按闭标签终止)与类型 7(单行完整标签)不做专门处理——块级名单 + 空行终止已覆盖 README 级真实用法。
## 5. 后续修正记录(bug 反馈)
- **代码块表头在预览中悬浮过长**:DSH `CodeBlock` 的 `bannerWrap` 是 `position: sticky; top: 0; z-index: 6`——在聊天里每个消息块拥有自己的滚动容器,表头粘住是对的;但在文件预览中所有 fence 共享一个 `.editorMd` 滚动容器,表头会钉在视口顶部直到整个块滚过,读作悬浮在文档上。修正:预览容器内对非 mermaid 的 `.md-code-block` 首子元素(即 `bannerWrap`)恢复 `position: static; z-index: auto`(mermaid 块已被 `data-mermaid-processed` 排除,其自身 chrome 取代了表头)。见 `src/client/sidebar.module.css` `.editorMd :global(.md-code-block:not([data-mermaid-processed])) > :global(div):first-child`。
- **TOC 面板被代码块表头压住**:`.tocBar` 原 `z-index: 3`,低于代码块表头的 `6`,弹出面板(位于 bar 的层叠上下文内)会被 sticky 表头盖住。修正:`.tocBar` 提升到 `7`(仍低于传送门选择弹窗的 `60` 与 mermaid 模态的 `1000`)。
- **TOC 只能再点按钮收起**:新增文档级 `pointerdown` 监听,点击落在按钮与面板之外(预览空白处)即收起;按钮/面板豁免,保证按钮仍可翻转。见 `src/client/md-toc.tsx`(与 Esc 关闭共用同一 effect)。
@@ -0,0 +1,72 @@
# 聊天文件打开拦截的 0.1.2-alpha 适配(openPath 漏斗迁移)
日期:2026-08-31 分支:`fix/openpath-intercept-alpha`
## 问题
0.1.2-alpha.x 宿主上,聊天里点击文件链接(工具行、产物行、正文行内代码路径)直接落到系统默认应用(Linux 即 `xdg-open`),「聊天区文件在侧边栏打开」(`interceptOpenPath`,默认开)形同虚设。
**根因**:alpha 架构把聊天打开文件的漏斗从客户端服务换成了远程调用——`ui-chat/src/client/apply.ts` 注入 ChatView 的 `openFile` 直连 `ctx.remote.session.openWorkspacePath({ path })` → 宿主 `session-controller` → `openNativePath`。插件的 `wrapOpenPath` 包的是旧漏斗 `ctx.workspaces.openPath`,而 alpha 的 `IWorkspaces`(`api/workspace-controller/src/client/service.ts`)**已删除 `openPath` 方法**——wrap 只是给 service 实例挂了个永无调用方的新属性,死代码。这是 #435/#472 适配的漏网项(挂载冒烟不点聊天文件链接,抓不到)。
**连带影响**:#158 的聊天路径/行号超链接(`owner.openFile` → openPath 假设)同样失效。(#171 的「以默认应用打开」旁路 `openPathWithSystem` 已随重构消失,现行走插件自有 `open.external` 路由,不受影响。)
**版本前提**:插件已进 alpha 通道(#472,peer `^0.1.2-alpha.2`,pre-alpha 兼容层已删)——本修复只面向 alpha 宿主,**整体替换**旧漏斗,不做双版本兼容。
## 新漏斗的事实(宿主源码核实)
1. **唯一生产调用方**:`ui-chat` apply 的 `openFile` 注入(工具行 / 产物行 / 正文提及 / 行内代码路径全部经 ChatView 的 `requestOpenFile` 汇入)。调用前路径已被 `resolveWorkspacePath(cwd, path)` 解析成绝对路径——与旧漏斗收到的形态一致。`canOpenWorkspacePath` 仅被 ui-deliverables 用于按钮可见性,无打开语义,不拦。
2. **`remote.session` 是 cordis 服务**(gateway client 的 `RemoteNamespaceService`,service key `remote.session`,经 `ctx.plugin` 注册):
- 方法以 `Object.defineProperty(service, method, { configurable: true, enumerable: true, get })` 安装,getter 在**访问时**读 `methods` 表返回调用闭包(因此直接赋值包装无效——无 setter);
- 命名空间整组方法与 service 构造在**同一同步窗口**安装——`ctx.inject(['remote.session'], cb)` 回调触发时方法必已就位;
- contribution 卸载/重挂载时服务 dispose/重建,inject 回调随之重跑——包装天然自愈。
3. 宿主端成功返回 `{ opened: true }`;失败抛 TypertRemoteFailure(ChatView 弹错误框)。接管方必须返回同形成功值。
## 方案:`wrapOpenWorkspacePath`(沿用「包单漏斗」模式,defineProperty 版)
改写 `src/client/openpath-intercept.ts`:
```ts
// 伪代码
const descriptor = Object.getOwnPropertyDescriptor(service, 'openWorkspacePath')
const original = service.openWorkspacePath // 访问 getter,捕获当前调用闭包
Object.defineProperty(service, 'openWorkspacePath', {
configurable: true, enumerable: true, writable: true,
value: (request: { path: string }, signal?: AbortSignal) => {
if (takeover()) { /* 接管 */ return Promise.resolve({ opened: true }) }
return original.call(service, request, signal) // 放行 = 宿主 native open
},
})
// dispose:descriptor 存在则原样 defineProperty 恢复,否则 delete —— HMR 安全
```
- **接管语义不变**(复用现有 deps):`!suspended && interceptOpenPath !== false && tabsEnabled.editor !== false && 有 current session`;`isFolderRevealPath` → `revealInExplorer`;其余 → `openInSidebar`(`:line` 拆分在既有链路内,#158 功能随之恢复)。
- **进入时机**:`registerOpenPathInterception` 改为 `ctx.inject(['remote.session'], ...)` 内包装;宿主裁掉该服务时回调永不触发 = 不拦截不报错。
- **已知取舍**:包装期间若 contribution 重挂方法记录,捕获的 `original` 闭包指向旧记录(getter 访问期绑定语义决定);session 命名空间实际常驻,可接受,代码注释记录。
## 改动清单
| 文件 | 变更 |
|------|------|
| `src/client/openpath-intercept.ts` | 重写为 `wrapOpenWorkspacePath`(defineProperty 包装/恢复);`isFolderRevealPath` 保留 |
| `src/client/intercept.tsx` | `registerOpenPathInterception` 改走 `ctx.inject(['remote.session'], …)`;deps 不变 |
| `src/context-types.ts` | `SidebarWorkspacesService{openPath}` 替换为 `SidebarRemoteSessionService{openWorkspacePath(request): Promise<{opened: boolean}>}` |
| `src/client/index.tsx` | inject 数组移除 `'workspaces'`(全仓唯一消费者是旧拦截,typecheck 验证后删) |
| `tests/openpath-intercept.spec.ts` | 重写:getter 形态假服务、接管/放行/恢复、inject 晚到场景 |
| `docs/external-plugin-guide.md` | §10 平台陷阱补「聊天文件打开拦截走 remote.session.openWorkspacePath」一条 |
| AGENTS.md §3 | 新增一条 alpha 宿主契约记录(对齐 #463 的做法) |
## 验证
- 单测:接管 / 放行 / 文件夹揭示手势三态;dispose 恢复原 descriptor;inject 延迟出现。
- 门禁:`pnpm typecheck` → `pnpm test` → `pnpm build` → `pnpm test:mount`(alpha.2 真机 14/14 不回归;mount lane 不驱动聊天点击,拦截正确性靠单测 + 手测)。
- 真机手测:让模型在回复里输出一个工作区文件路径(行内代码)→ 点击落侧边栏编辑器;关闭「聊天区文件在侧边栏打开」后点击 → 系统默认应用;`path:line` 链接 → 侧边栏跳行(#158 回归)。
## 实施偏差记录
- **接管返回值必须是 typert `RemoteResult` 包络**(初版实现踩坑修正):plan 中「接管返回 `{ opened: true }`」有误——gateway client 的每个 remote 方法都把业务值折叠成 `{ ok: true, value }` / `{ ok: false, error }`,ChatView 的 `openFile` 按 `result.ok` 分支、falsy 时读 `result.error.message`。初版返回裸业务值导致「文件已在侧边栏打开,但仍弹『无法打开文件: Cannot read properties of undefined (reading 'message')』」。修正为接管时返回 `{ ok: true, value: { opened: true } }`(`OpenWorkspacePathResult` 类型钉住,单测断言包络形状)。
## 不做
- pre-alpha 双版本兼容(alpha-only 通道,#472 已删兼容层)。
- `canOpenWorkspacePath` 包装(仅按钮可见性)。
- DSH 侧任何改动(仓库硬约束 §1)。
@@ -0,0 +1,87 @@
# 侧边栏性能优化 + 底栏拖拽布局突变修复
**日期**:2026-08-31(2026-09-01 rebase 到 main c199212 并在 alpha.3 上复测)
**状态**:已实施(分支 `feat/sidebar-perf`,单 PR 多 commit)
**目标宿主**:DSH v0.1.2-alpha.2 起开发,宿主参考 `/Users/menghuan/Code/deepseek-harness`(只读);main 已切 alpha.3(v0.18.1-alpha.0),rebase 后在 alpha.3 真机全量复测(§3.1)
**测量环境**:macOS arm64 本机,真实 `dsh web`(npx 钉版)+ scratch profile 挂载 tarball,Playwright Chromium 无头,每指标 3 次取中位数(`tests/e2e/perf.e2e.ts` 输出 `PERF_JSON`)。
## 1. 修复的 bug:底栏拖拽引发原生左侧边栏突变
**症状**:右侧面板关闭时拖动底栏 resizer,DSH 原生左侧边栏突然折叠/跳动;松手后弹回。插件自己的右侧边栏不受影响。
**根因**(源码级定位,`Sidebar.tsx`):底栏拖拽的 `applyDrag` 把「持久化的右栏宽度偏好」(如 400px)无条件写进 `--dsh-sidebar-width`,不看 `panelOpen`;而静止时的 push effect 走 `layoutPushSize` 门控(面板关闭 → 0)。于是拖拽首帧该变量 0 → ~400px:
1. `layout.css` 的 `#root { margin-right; width: calc(100% - …) }` 瞬间挤掉宿主 400px(拖拽期间 transition 被 `body[data-dsh-sidebar-dragging]` 关闭——瞬跳而非动画);
2. 宿主 `AppFrame` 的 ResizeObserver → `setViewport` → viewport 跌破 1024px 断点 → `computeColumns` 把原生左栏折叠成 56px rail(0.3s grid 轨道动画);
3. 松手 `commitDrag` → store 提交 → push effect 又写回 0 → `#root` 弹回 → 左栏再展开。
插件右栏在 `#root` 之外的 `document.body` fixed 层(`[data-dsh-panel-host]`),几何上碰不到——这正是「左栏突变、右栏无恙」的不对称来源。底栏是**唯一**在 `panelOpen === false` 时可达的拖拽(宽度/角点拖拽都要求面板开着),所以只有它触发。`abortDrag` 路径此前已修过(走 `layoutPushSize` 门控),move/up 路径漏网。
**修复**:`applyDrag` 里 `writeGeometry` 的 width 实参加上与 push effect 一致的门控(`!narrow && panelOpen ? min(width, vw) : 0`);面板 DOM 写入(`panelRef`/`bottomRef`/`lastDragSize`)保持原始 width(底栏 `right` 定位派生自它)。
**验证**:e2e 回归(`drag-layout.e2e.ts`)——右面板关闭 + 底栏打开拖 strip,逐帧断言 pushed width 恒 0、宿主 grid 模板 / 左栏渲染宽度 / `#root` 右边缘全程不变。旧代码上实测泄漏 504px(红),修复后通过(绿)。
## 2. 性能改动清单
| # | 改动 | 类别 | commit |
|---|---|---|---|
| 1 | boot 路径 `loadPrefs` + `loadExternalDisable` 合并为一次 `settingsGet`(原来同文档串行拉两次,第二个无超时——settings RPC 挂起则侧边栏永不出现) | 启动 | perf: cut the boot and poll overhead |
| 2 | GitLens 2s silent 轮询不再每 tick 重列 worktrees(每 tick 少 spawn 1 个 git 进程;每 15 tick ≈30s 才重列+自动选择一次) | 运行时开销 | 同上 |
| 3 | ChangesTab 的 `extractFileOps` fold 从「每 poll 两遍(badge 一遍 + render 一遍)」改为每 poll 一遍、结果进 ref 共享(4000 条事件 × 每条 JSON.parse) | CPU | 同上 |
| 4 | SideChat 转录行引用复用:`transcriptRows` 接受上次结果,位置对齐内容相同的行复用旧对象——2s 轮询只重渲染变化的尾部(原来全部行连同 markdown 重解析每轮重渲染) | 渲染 | perf: stabilize renders |
| 5 | FileTree `expanded`/`revealed` 数组 → Set(原来每行 `includes()` 线性扫描,整树 O(rows×expanded)) | 渲染 | 同上 |
| 6 | `buildNewTabOptions` 从两处 JSX 内联调用改为共享 memo(原来每次 render 两个新数组,击穿 LeafView 的 props 比较) | 渲染 | 同上 |
| 7 | DiffPane 高度拖拽、TerminalView ResizeObserver 走每帧批处理(原来每 pointer/resize 事件一次 setState / 一次含字形度量的 fit());SessionLens 的 `new Blob().size`(整段编辑内容的 UTF-8 编码)从每行每 render 移到每变化一次的 memo | 渲染 | 同上 |
| 8 | 19 个非 zh/en 字典移入 lazy `locale` chunk(`lib/client-locale.js`,仅安装 better-locale 时按需拉取;核心包只留 zh/en——`t()` 本来也只查 zh/en + override store) | 启动体积 | perf: move the 19 dictionaries |
| 9 | 底栏推挤锚点从 `:has(> [data-slot=conversation])` 换成 shell 定位器打的 `[data-dsh-center-col]` 标注(`:has()` 匹配缓存随流式聊天的 #root 子树变动不断失效,每次布局推挤帧全文档重估) | 拖拽帧成本 | perf: tag the center column |
| 10 | TextEditor 不再每键 `doc.toString()` 全文字符串化进 React state(O(docLength)/键);listener 只翻 dirty,进预览/换文件时从 live view 快照一次;保存本就现读 | 编辑 | 同上 |
## 3. Before / After 对比
测量说明:`mount-sweep` = 冷导航 → 插件挂载 → 展开面板 → 依次打开全部 6 个内置 tab;`bottom-drag` = 面板关闭 + 底栏打开时拖 12 步的 rAF 帧间隔。同机同夜连续跑,数字用于相对比较而非绝对承诺。
| 指标 | before(main d70db8f) | after(本分支) | 变化 |
|---|---|---|---|
| 挂载时延(navigationStart → host attach,中位) | 376.9 ms | 272.9 ms | **-28%** |
| 挂载+扫 tab 期间 longtask 数 / 总时长 / 最长 | 2 / 161 ms / 96 ms | 2 / 146 ms / 84 ms | 总时长 -9%,最长 -13% |
| 核心 client.js 体积(未压缩) | 1.46 MB | 0.80 MB | **-45%** |
| 宿主聚合 client bundle 传输量(含全部插件,gzip) | 1,436,478 B | 1,233,778 B | **-203 KB / -14%** |
| 宿主聚合 client bundle 解码体积 | 5,136,894 B | 4,497,363 B | -640 KB(即字典总量,吻合) |
| 侧栏 shape 的资源请求数 / 其中 settings.get | 20 / ×4 | 18 / ×2 | boot 合并生效 |
| 底栏拖拽帧间隔 p50 / p95 / max | 8 / 10 / 11 ms | 8 / 10 / 11 ms | 持平(本就不掉帧;修复的是布局突变) |
| 底栏拖拽 pushed width 泄漏(右面板关闭时) | **504 px**(bug) | **0 px** | 修复 |
### 结构性收益(不随单次测量波动)
- **核心 bundle**:19 个字典(源码 ~640KB)出核心包,`client.js` 1.46MB → 822KB(构建产物实测)。绝大多数会话(未装 better-locale)永远不拉 `client-locale.js`(gzip 171KB);装了的用户按需拉一次(HTTP 缓存 + ETag 复用)。
- **boot RPC**:settings 文档从 2 次串行 → 1 次;`loadExternalDisable` 有了 2s 超时兜底。
- **GitLens 轮询的 git 进程 spawn**:每分钟 60 个(2 个/tick × 30 tick)→ ~34 个(1 个/tick + 每 15 tick 多 1 个),-43%。
- **ChangesTab fold**:每 2.5s poll 的 fold 次数 2 → 1(每次 fold 对至多 4000 条事件逐条处理、tool/call 还要 `JSON.parse`)。
- **SideChat 转录**:内容未变的行不再重建对象 → 下游 React reconcile + markdown 解析 + DOMPurify 全部跳过(引用稳定性由 `sidechat-transcript.spec.ts` 契约钉住)。
- **TextEditor**:每次按键的 O(文档长度) 字符串化 + shell 重渲染 → 0(仅 dirty 布尔)。
### 3.1 alpha.3 复测(rebase 到 main c199212 之后)
main 在本分支开发期间合入了 alpha.3 适配(peer `^0.1.2-alpha.3`,v0.18.1-alpha.0)与 sidechat 渲染升级(新增 `turnSummary` 行 kind 与 tool `card`——`rowsEqual` 已补齐两者,card 只在 tool/result 落地时变化、被 `executing`/`failed`/`resultText` 的比较覆盖,无需值比较)。rebase 后在 alpha.3 真机重测(同法 3 次中位,before 侧 = main c199212 代码 + 本分支的测量工具):
| 指标 | before(main c199212) | after(本分支) | 变化 |
|---|---|---|---|
| 挂载时延(中位) | 372.3 ms | 316.5 ms | **-15%** |
| longtask 总时长 / 最长 | 170 / 100 ms | 165 / 96 ms | -3% / -4% |
| 宿主聚合 bundle 传输 | 1,456,668 B | 1,246,595 B | **-210 KB / -14%** |
| boot settings.get | ×4 | ×2 | 合并生效 |
| 底栏拖拽 pushed width 泄漏 | **504 px**(复现) | **0 px** | 修复 |
全部收益在新 main 基线上成立;完整 mount lane(17 测试)在 alpha.3 真机全绿。§3 主表为 alpha.2 上的原始测量记录(两端同宿主,对比自洽),保留作历史基线。
## 4. 守护
- e2e:`drag-layout.e2e.ts` 新增底栏回归(宿主布局全程静止)与 center-col 标注断言(恰一节点、为 conversation slot 的父级);`perf.e2e.ts` 的 width-leak 断言在每次测量 lane 里复检。
- 单测:`prefs.spec.ts`(boot 决策单次 fetch)、`sidechat-transcript.spec.ts`(行引用复用契约)、`locales.spec.ts`(chunk 内全部字典 key-set 与 zh 一致)、`layout-css.spec.ts`(锚点选择器契约,禁 `:has` 回潮)、`bundle-route.spec.ts`(chunk 清单含 locale)。
- 完整 mount lane(17 测试)在本分支全绿(alpha.2 与 alpha.3 真机各跑一轮)。
## 5. 明确不做(取舍)
- `pinnedEntries` 的 store 版本号改造、连续 `store.reduce` 合并:侵入 store API,收益不明确,KISS 取舍。
- FileTree / SideChat 列表虚拟化:Set 化 + 引用复用后若真实负载仍不达标再立项。
- `e2e-mount.sh` 的 `command -v "$DSH_CMD"` 对带空格命令失效(回退未钉版本的 npx)是**宿主侧脚本的既有行为**,本地以 `DSH_CMD=/tmp/dsh-alpha2.sh` 包装脚本绕过,不改脚本(CI 全局安装钉版本,不受影响)。
+3 -3
View File
@@ -1,10 +1,10 @@
{
"id": "dsh-external/dsh-better-sidebar",
"version": "0.18.0-alpha.0",
"id": "dsh-external/dsh-apes-sidebar",
"version": "1.1.0",
"main": "./lib/index.js",
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器",
"engines": {
"dsh": ">=0.1.2-alpha.2"
"dsh": ">=0.1.2-rc.1"
},
"contributes": {
"tools": [],
+68 -32
View File
@@ -1,12 +1,18 @@
{
"name": "dsh-better-sidebar",
"version": "0.18.0-alpha.0",
"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.",
"name": "dsh-apes-sidebar",
"version": "1.1.0",
"description": "APE fork of dsh-better-sidebar v0.18.0: VSCode-like right sidebar (explorer / editor / terminal / git / browser) + apes:* information-layer tabs (agents / apps / profile). Forked from omdsh-dev/DSH-better-sidebar under MIT.",
"type": "module",
"repository": {
"type": "git",
"url": "https://github.com/omdsh-dev/DSH-better-sidebar"
},
"forkedFrom": {
"name": "dsh-better-sidebar",
"version": "0.18.0",
"commit": "9e1a034",
"url": "https://github.com/omdsh-dev/DSH-better-sidebar"
},
"publishConfig": {
"access": "public"
},
@@ -39,6 +45,7 @@
"engines": {
"node": ">=20"
},
"packageManager": "pnpm@11.8.0",
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
@@ -65,6 +72,7 @@
"src",
"scripts/install.sh",
"scripts/install.ps1",
"scripts/switch.sh",
"cordis.patch.yml",
"README.md",
"README_EN.md",
@@ -79,24 +87,30 @@
"prepare": "tsdown",
"test": "vitest run",
"test:mount": "bash scripts/e2e-mount.sh",
"check:consumer-types": "bash scripts/check-consumer-types.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",
"switch": "bash scripts/switch.sh",
"switch:status": "bash scripts/switch.sh --status",
"switch:aps": "bash scripts/switch.sh aps",
"switch:upstream": "bash scripts/switch.sh upstream"
},
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-agent": "^0.1.2-alpha.2",
"@deepseek-ai/dsh-client-locale": "^0.1.2-alpha.2",
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.2-alpha.2",
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.2-alpha.2",
"@deepseek-ai/dsh-client-ui-settings": "^0.1.2-alpha.2",
"@deepseek-ai/dsh-client-ui-slots": "^0.1.2-alpha.2",
"@deepseek-ai/dsh-host-webserver": "^0.1.2-alpha.2",
"@deepseek-ai/dsh-invariants": "^0.1.2-alpha.2",
"@deepseek-ai/dsh-llm": "^0.1.2-alpha.2",
"@deepseek-ai/dsh-session": "^0.1.2-alpha.2",
"@deepseek-ai/dsh-settings": "^0.1.2-alpha.2",
"@deepseek-ai/dsh-subagent": "^0.1.2-alpha.2",
"@deepseek-ai/dsh-tools": "^0.1.2-alpha.2",
"@deepseek-ai/cordis": "^4.0.2",
"@deepseek-ai/dsh-agent": "^0.1.5-rc.2",
"@deepseek-ai/dsh-client-locale": "^0.1.5-rc.2",
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.5-rc.2",
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.5-rc.2",
"@deepseek-ai/dsh-client-ui-settings": "^0.1.5-rc.2",
"@deepseek-ai/dsh-client-ui-slots": "^0.1.5-rc.2",
"@deepseek-ai/dsh-host-webserver": "^0.1.5-rc.2",
"@deepseek-ai/dsh-invariants": "^0.1.5-rc.2",
"@deepseek-ai/dsh-llm": "^0.1.5-rc.2",
"@deepseek-ai/dsh-session": "^0.1.5-rc.2",
"@deepseek-ai/dsh-settings": "^0.1.5-rc.2",
"@deepseek-ai/dsh-subagent": "^0.1.5-rc.2",
"@deepseek-ai/dsh-tools": "^0.1.5-rc.2",
"@huanlin/dsh-plugin-better-locale": "^0.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
@@ -140,19 +154,26 @@
},
"devDependencies": {
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/dsh-agent": "0.1.2-alpha.2",
"@deepseek-ai/dsh-client-locale": "0.1.2-alpha.2",
"@deepseek-ai/dsh-client-ui-conversation": "0.1.2-alpha.2",
"@deepseek-ai/dsh-client-ui-primitives": "0.1.2-alpha.2",
"@deepseek-ai/dsh-client-ui-settings": "0.1.2-alpha.2",
"@deepseek-ai/dsh-client-ui-slots": "0.1.2-alpha.2",
"@deepseek-ai/dsh-host-webserver": "0.1.2-alpha.2",
"@deepseek-ai/dsh-invariants": "0.1.2-alpha.2",
"@deepseek-ai/dsh-llm": "0.1.2-alpha.2",
"@deepseek-ai/dsh-session": "0.1.2-alpha.2",
"@deepseek-ai/dsh-settings": "0.1.2-alpha.2",
"@deepseek-ai/dsh-subagent": "0.1.2-alpha.2",
"@deepseek-ai/dsh-tools": "0.1.2-alpha.2",
"@deepseek-ai/dsh-agent": "0.1.5-rc.2",
"@deepseek-ai/dsh-attachment": "0.1.5-rc.2",
"@deepseek-ai/dsh-client-locale": "0.1.5-rc.2",
"@deepseek-ai/dsh-client-ui-conversation": "0.1.5-rc.2",
"@deepseek-ai/dsh-client-ui-primitives": "0.1.5-rc.2",
"@deepseek-ai/dsh-client-ui-settings": "0.1.5-rc.2",
"@deepseek-ai/dsh-client-ui-slots": "0.1.5-rc.2",
"@deepseek-ai/dsh-code-runtime": "0.1.5-rc.2",
"@deepseek-ai/dsh-host-webserver": "0.1.5-rc.2",
"@deepseek-ai/dsh-invariants": "0.1.5-rc.2",
"@deepseek-ai/dsh-llm": "0.1.5-rc.2",
"@deepseek-ai/dsh-scope": "0.1.5-rc.2",
"@deepseek-ai/dsh-session": "0.1.5-rc.2",
"@deepseek-ai/dsh-session-projection": "0.1.5-rc.2",
"@deepseek-ai/dsh-settings": "0.1.5-rc.2",
"@deepseek-ai/dsh-subagent": "0.1.5-rc.2",
"@deepseek-ai/dsh-system-prompt": "0.1.5-rc.2",
"@deepseek-ai/dsh-tools": "0.1.5-rc.2",
"@deepseek-ai/dsh-user-approval": "0.1.5-rc.2",
"@deepseek-ai/dsh-util-time": "0.1.5-rc.2",
"@huanlin/dsh-plugin-better-locale": "^0.1.0",
"@playwright/test": "^1.62.1",
"@types/node": "^24.0.0",
@@ -169,6 +190,21 @@
"tsdown": "^0.22.2",
"typescript": "^5.6.0",
"unrun": "^0.2.39",
"vitest": "^4.1.8"
"vitest": "^4.1.8",
"anser": "^2.3.5",
"shiki": "^4.3.1",
"@shikijs/langs": "^4.3.1",
"katex": "^0.16.47",
"mdast-util-from-markdown": "^2.0.3",
"mdast-util-gfm": "^3.1.0",
"mdast-util-math": "^3.0.0",
"micromark-core-commonmark": "^2.0.3",
"micromark-extension-gfm": "^3.0.0",
"micromark-extension-math": "^3.1.0",
"micromark-factory-space": "^2.0.1",
"micromark-util-character": "^2.1.1",
"micromark-util-classify-character": "^2.0.1",
"micromark-util-sanitize-uri": "^2.0.1",
"micromark-util-symbol": "^2.0.1"
}
}
+195
View File
@@ -0,0 +1,195 @@
{
"name": "dsh-apes-sidebar",
"version": "1.0.0",
"description": "APE fork of dsh-better-sidebar v0.18.0: VSCode-like right sidebar (explorer / editor / terminal / git / browser) + apes:* information-layer tabs (tasks / agents / apps / messages / profile). Forked from omdsh-dev/DSH-better-sidebar under MIT.",
"type": "module",
"repository": {
"type": "git",
"url": "https://github.com/omdsh-dev/DSH-better-sidebar"
},
"forkedFrom": {
"name": "dsh-better-sidebar",
"version": "0.18.0",
"commit": "9e1a034",
"url": "https://github.com/omdsh-dev/DSH-better-sidebar"
},
"publishConfig": {
"access": "public"
},
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./client/service": {
"types": "./lib/types/client/service.d.ts",
"default": "./lib/client.js"
},
"./client/api": {
"types": "./lib/types/client/service.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"engines": {
"node": ">=20"
},
"packageManager": "pnpm@11.8.0",
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
},
"client": {
"inject": [
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-slots",
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-client-modules"
],
"platform": "web"
}
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/client-registry.js",
"lib/client-terminal.js",
"lib/client-editor.js",
"lib/client-mermaid.js",
"lib/types/**/*.d.ts",
"src",
"scripts/install.sh",
"scripts/install.ps1",
"scripts/switch.sh",
"cordis.patch.yml",
"README.md",
"README_EN.md",
"LICENSE"
],
"scripts": {
"build": "node -e \"require('node:fs').rmSync('lib',{recursive:true,force:true})\" && tsc -p tsconfig.build.json && tsdown",
"prepublishOnly": "pnpm build",
"typecheck": "tsc --noEmit",
"bundle": "tsdown",
"watch": "tsdown --watch",
"prepare": "tsdown",
"test": "vitest run",
"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",
"switch": "bash scripts/switch.sh",
"switch:status": "bash scripts/switch.sh --status",
"switch:aps": "bash scripts/switch.sh aps",
"switch:upstream": "bash scripts/switch.sh upstream"
},
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.2",
"@deepseek-ai/dsh-agent": "^0.1.2-rc.1",
"@deepseek-ai/dsh-client-locale": "^0.1.2-rc.1",
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.2-rc.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.2-rc.1",
"@deepseek-ai/dsh-client-ui-settings": "^0.1.2-rc.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.1.2-rc.1",
"@deepseek-ai/dsh-host-webserver": "^0.1.2-rc.1",
"@deepseek-ai/dsh-invariants": "^0.1.2-rc.1",
"@deepseek-ai/dsh-llm": "^0.1.2-rc.1",
"@deepseek-ai/dsh-session": "^0.1.2-rc.1",
"@deepseek-ai/dsh-settings": "^0.1.2-rc.1",
"@deepseek-ai/dsh-subagent": "^0.1.2-rc.1",
"@deepseek-ai/dsh-tools": "^0.1.2-rc.1",
"@huanlin/dsh-plugin-better-locale": "^0.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"peerDependenciesMeta": {
"@huanlin/dsh-plugin-better-locale": {
"optional": true
}
},
"dependencies": {
"@codemirror/commands": "^6.10.4",
"@codemirror/lang-cpp": "^6.0.3",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-go": "^6.0.1",
"@codemirror/lang-html": "^6.4.12",
"@codemirror/lang-java": "^6.0.2",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.2",
"@codemirror/lang-php": "^6.0.2",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/lang-rust": "^6.0.2",
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/lang-vue": "^0.1.3",
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/lang-yaml": "^6.1.3",
"@codemirror/language": "^6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
"@codemirror/search": "^6.7.1",
"@codemirror/state": "^6.7.1",
"@codemirror/view": "^6.43.8",
"@lezer/highlight": "^1.2.3",
"clsx": "^2.1.1",
"dompurify": "^3.4.14",
"mermaid": "^11.16.1",
"node-pty": "^1.1.0",
"react-icons": "5.7.0",
"rxjs": "^7.8.2",
"schemastery": "^3.18.0",
"ws": "^8.18.0"
},
"devDependencies": {
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/dsh-agent": "0.1.2-rc.1",
"@deepseek-ai/dsh-attachment": "0.1.2-rc.1",
"@deepseek-ai/dsh-client-locale": "0.1.2-rc.1",
"@deepseek-ai/dsh-client-ui-conversation": "0.1.2-rc.1",
"@deepseek-ai/dsh-client-ui-primitives": "0.1.2-rc.1",
"@deepseek-ai/dsh-client-ui-settings": "0.1.2-rc.1",
"@deepseek-ai/dsh-client-ui-slots": "0.1.2-rc.1",
"@deepseek-ai/dsh-code-runtime": "0.1.2-rc.1",
"@deepseek-ai/dsh-host-webserver": "0.1.2-rc.1",
"@deepseek-ai/dsh-invariants": "0.1.2-rc.1",
"@deepseek-ai/dsh-llm": "0.1.2-rc.1",
"@deepseek-ai/dsh-scope": "0.1.2-rc.1",
"@deepseek-ai/dsh-session": "0.1.2-rc.1",
"@deepseek-ai/dsh-session-projection": "0.1.2-rc.1",
"@deepseek-ai/dsh-settings": "0.1.2-rc.1",
"@deepseek-ai/dsh-subagent": "0.1.2-rc.1",
"@deepseek-ai/dsh-system-prompt": "0.1.2-rc.1",
"@deepseek-ai/dsh-tools": "0.1.2-rc.1",
"@deepseek-ai/dsh-user-approval": "0.1.2-rc.1",
"@deepseek-ai/dsh-util-time": "0.1.2-rc.1",
"@huanlin/dsh-plugin-better-locale": "^0.1.0",
"@playwright/test": "^1.62.1",
"@types/node": "^24.0.0",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.1",
"@types/ws": "^8.5.10",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^5.5.0",
"cordis": "^4.0.0-rc.8",
"jsdom": "^29.1.1",
"lightningcss": "^1.32.0",
"react": "^18.2.0",
"react-dom": "18.2.0",
"tsdown": "^0.22.2",
"typescript": "^5.6.0",
"unrun": "^0.2.39",
"vitest": "^4.1.8"
}
}
+195
View File
@@ -0,0 +1,195 @@
{
"name": "dsh-apes-sidebar",
"version": "1.0.0",
"description": "APE fork of dsh-better-sidebar v0.18.0: VSCode-like right sidebar (explorer / editor / terminal / git / browser) + apes:* information-layer tabs (tasks / agents / apps / messages / profile). Forked from omdsh-dev/DSH-better-sidebar under MIT.",
"type": "module",
"repository": {
"type": "git",
"url": "https://github.com/omdsh-dev/DSH-better-sidebar"
},
"forkedFrom": {
"name": "dsh-better-sidebar",
"version": "0.18.0",
"commit": "9e1a034",
"url": "https://github.com/omdsh-dev/DSH-better-sidebar"
},
"publishConfig": {
"access": "public"
},
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./client/service": {
"types": "./lib/types/client/service.d.ts",
"default": "./lib/client.js"
},
"./client/api": {
"types": "./lib/types/client/service.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"engines": {
"node": ">=20"
},
"packageManager": "pnpm@11.8.0",
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
},
"client": {
"inject": [
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-slots",
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-client-modules"
],
"platform": "web"
}
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/client-registry.js",
"lib/client-terminal.js",
"lib/client-editor.js",
"lib/client-mermaid.js",
"lib/types/**/*.d.ts",
"src",
"scripts/install.sh",
"scripts/install.ps1",
"scripts/switch.sh",
"cordis.patch.yml",
"README.md",
"README_EN.md",
"LICENSE"
],
"scripts": {
"build": "node -e \"require('node:fs').rmSync('lib',{recursive:true,force:true})\" && tsc -p tsconfig.build.json && tsdown",
"prepublishOnly": "pnpm build",
"typecheck": "tsc --noEmit",
"bundle": "tsdown",
"watch": "tsdown --watch",
"prepare": "tsdown",
"test": "vitest run",
"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",
"switch": "bash scripts/switch.sh",
"switch:status": "bash scripts/switch.sh --status",
"switch:aps": "bash scripts/switch.sh aps",
"switch:upstream": "bash scripts/switch.sh upstream"
},
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.2",
"@deepseek-ai/dsh-agent": "^0.1.5-rc.2",
"@deepseek-ai/dsh-client-locale": "^0.1.5-rc.2",
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.5-rc.2",
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.5-rc.2",
"@deepseek-ai/dsh-client-ui-settings": "^0.1.5-rc.2",
"@deepseek-ai/dsh-client-ui-slots": "^0.1.5-rc.2",
"@deepseek-ai/dsh-host-webserver": "^0.1.5-rc.2",
"@deepseek-ai/dsh-invariants": "^0.1.5-rc.2",
"@deepseek-ai/dsh-llm": "^0.1.5-rc.2",
"@deepseek-ai/dsh-session": "^0.1.5-rc.2",
"@deepseek-ai/dsh-settings": "^0.1.5-rc.2",
"@deepseek-ai/dsh-subagent": "^0.1.5-rc.2",
"@deepseek-ai/dsh-tools": "^0.1.5-rc.2",
"@huanlin/dsh-plugin-better-locale": "^0.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"peerDependenciesMeta": {
"@huanlin/dsh-plugin-better-locale": {
"optional": true
}
},
"dependencies": {
"@codemirror/commands": "^6.10.4",
"@codemirror/lang-cpp": "^6.0.3",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-go": "^6.0.1",
"@codemirror/lang-html": "^6.4.12",
"@codemirror/lang-java": "^6.0.2",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.2",
"@codemirror/lang-php": "^6.0.2",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/lang-rust": "^6.0.2",
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/lang-vue": "^0.1.3",
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/lang-yaml": "^6.1.3",
"@codemirror/language": "^6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
"@codemirror/search": "^6.7.1",
"@codemirror/state": "^6.7.1",
"@codemirror/view": "^6.43.8",
"@lezer/highlight": "^1.2.3",
"clsx": "^2.1.1",
"dompurify": "^3.4.14",
"mermaid": "^11.16.1",
"node-pty": "^1.1.0",
"react-icons": "5.7.0",
"rxjs": "^7.8.2",
"schemastery": "^3.18.0",
"ws": "^8.18.0"
},
"devDependencies": {
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/dsh-agent": "0.1.5-rc.2",
"@deepseek-ai/dsh-attachment": "0.1.5-rc.2",
"@deepseek-ai/dsh-client-locale": "0.1.5-rc.2",
"@deepseek-ai/dsh-client-ui-conversation": "0.1.5-rc.2",
"@deepseek-ai/dsh-client-ui-primitives": "0.1.5-rc.2",
"@deepseek-ai/dsh-client-ui-settings": "0.1.5-rc.2",
"@deepseek-ai/dsh-client-ui-slots": "0.1.5-rc.2",
"@deepseek-ai/dsh-code-runtime": "0.1.5-rc.2",
"@deepseek-ai/dsh-host-webserver": "0.1.5-rc.2",
"@deepseek-ai/dsh-invariants": "0.1.5-rc.2",
"@deepseek-ai/dsh-llm": "0.1.5-rc.2",
"@deepseek-ai/dsh-scope": "0.1.5-rc.2",
"@deepseek-ai/dsh-session": "0.1.5-rc.2",
"@deepseek-ai/dsh-session-projection": "0.1.5-rc.2",
"@deepseek-ai/dsh-settings": "0.1.5-rc.2",
"@deepseek-ai/dsh-subagent": "0.1.5-rc.2",
"@deepseek-ai/dsh-system-prompt": "0.1.5-rc.2",
"@deepseek-ai/dsh-tools": "0.1.5-rc.2",
"@deepseek-ai/dsh-user-approval": "0.1.5-rc.2",
"@deepseek-ai/dsh-util-time": "0.1.5-rc.2",
"@huanlin/dsh-plugin-better-locale": "^0.1.0",
"@playwright/test": "^1.62.1",
"@types/node": "^24.0.0",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.1",
"@types/ws": "^8.5.10",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^5.5.0",
"cordis": "^4.0.0-rc.8",
"jsdom": "^29.1.1",
"lightningcss": "^1.32.0",
"react": "^18.2.0",
"react-dom": "18.2.0",
"tsdown": "^0.22.2",
"typescript": "^5.6.0",
"unrun": "^0.2.39",
"vitest": "^4.1.8"
}
}
+440 -688
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -8,6 +8,15 @@ allowBuilds:
node-pty: true
protobufjs: true
# The @deepseek-ai/* packages declare their dsh-* siblings as peerDependencies
# (the DSH host bundle provides them all at mount time). This repo installs
# every peer the dev tree needs as a direct devDependency pinned to the
# baseline (see package.json), so pnpm resolves them instead of floating to
# stale copies — e.g. dsh-attachment 0.1.1-rc.1 predates the
# `admitPromptContent` export that dsh-subagent's artifacts import. When
# bumping the DSH baseline, audit with `pnpm peers check` and hoist any
# newly-unmet transitive peers into devDependencies too.
minimumReleaseAgeExclude:
- '@deepseek-ai/*'
- '@deepseek-ai/cordis-plugin-include@1.0.5-rc.1 || 1.0.6'
+35 -12
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Consumer-facing type surface check: build a tiny standalone consumer that
# imports `dsh-better-sidebar/client/service` WITHOUT @types/node and with
# imports `dsh-apes-sidebar/client/service` WITHOUT @types/node and with
# skipLibCheck: false, then type-check it with the repo's tsc.
#
# This is the exact scenario the v0.12.0 API work cares about: the shipped
@@ -10,6 +10,13 @@
set -euo pipefail
cd "$(dirname "$0")/.."
# Git Bash (MSYS) `ln -s` deep-copies directories by default — copying this
# repo (node_modules + full .git history) would be catastrophic. Force real
# native symlinks instead: the GitHub Windows runners (Developer Mode, admin
# user) can create them, and nativestrict makes ln fail loudly rather than
# silently falling back to a copy. Inert on Linux/macOS.
export MSYS="winsymlinks:nativestrict"
# The check reads the BUILT declaration surface (lib/types) — fail loudly
# instead of silently passing when the build output is missing.
if [ ! -f lib/types/client/service.d.ts ]; then
@@ -21,21 +28,37 @@ WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
mkdir -p "$WORK/node_modules"
ln -s "$(pwd)" "$WORK/node_modules/dsh-better-sidebar"
# Resolve link targets to their PHYSICAL path first: under pnpm,
# node_modules/@deepseek-ai/cordis is itself a symlink/junction into .pnpm,
# and on Windows a native symlink pointing AT a junction does not traverse
# for tsc (TS2307 on a link that visibly exists). Node's NATIVE realpath
# (GetFinalPathNameByHandle on Windows) expands junctions, which bash-level
# resolution may leave untouched; node is guaranteed here (tsc runs below).
resolve_dir() { node -e 'process.stdout.write(require("node:fs").realpathSync.native(process.argv[1]))' "$1"; }
ln -s "$(resolve_dir "$(pwd)")" "$WORK/node_modules/dsh-apes-sidebar"
# The vendored cordis base (and, through its realpath, cosmokit / standard-schema)
# must resolve so the consumer can type `Context` against the DSH runtime scope.
mkdir -p "$WORK/node_modules/@deepseek-ai"
ln -s "$(pwd)/node_modules/@deepseek-ai/cordis" "$WORK/node_modules/@deepseek-ai/cordis"
ln -s "$(resolve_dir "$(pwd)/node_modules/@deepseek-ai/cordis")" "$WORK/node_modules/@deepseek-ai/cordis"
# Loud guard: both links must resolve to a package root, or tsc below would
# report misleading module-resolution errors.
for pkg_json in "$WORK/node_modules/dsh-apes-sidebar/package.json" "$WORK/node_modules/@deepseek-ai/cordis/package.json"; do
if [ ! -f "$pkg_json" ]; then
echo "[check-consumer-types] FAIL: $pkg_json does not resolve (broken link). Link layer:" >&2
ls -la "$WORK/node_modules" "$WORK/node_modules/@deepseek-ai" >&2 || true
exit 1
fi
done
cat > "$WORK/check.ts" <<'EOF'
import { SIDEBAR_FEATURES, SIDEBAR_SERVICE_VERSION } from 'dsh-better-sidebar/client/service'
import type {} from 'dsh-better-sidebar/client/service'
import { SIDEBAR_FEATURES, SIDEBAR_SERVICE_VERSION } from 'dsh-apes-sidebar/client/service'
import type {} from 'dsh-apes-sidebar/client/service'
import type {
BetterSidebarService, FileViewerDescriptor, OpenTabSeed, SidebarSettingsRenderProps,
TabComponentProps, TabDescriptor,
} from 'dsh-better-sidebar/client/service'
import type { SessionScope, SidebarSnapshot, SidebarState, SidebarStore, SidebarTab } from 'dsh-better-sidebar/client/service'
import type { SidebarPrefs } from 'dsh-better-sidebar/client/service'
} from 'dsh-apes-sidebar/client/service'
import type { SessionScope, SidebarSnapshot, SidebarState, SidebarStore, SidebarTab } from 'dsh-apes-sidebar/client/service'
import type { SidebarPrefs } from 'dsh-apes-sidebar/client/service'
// The vendored-cordis path: `Context` from '@deepseek-ai/cordis' plus the
// side-effect type import above must expose `ctx.betterSidebar` — the consumer
// never needs to import this package's own Context type.
@@ -103,14 +126,14 @@ set -e
# Fail only on errors that mention OUR package / declarations / the fixture.
# Upstream noise (vendored cordis / cosmokit declaration warnings under the
# repo's pinned TS lib) lives anywhere under node_modules and is filtered.
if grep -E "dsh-better-sidebar|lib/types|check\.ts" "$WORK/strict.log" | grep -vE "node_modules/[^ ]*(cordis|cosmokit)"; then
echo "[check-consumer-types] FAIL: errors in the dsh-better-sidebar declaration surface:" >&2
grep -E "dsh-better-sidebar|lib/types|check\.ts" "$WORK/strict.log" >&2
if grep -E "dsh-apes-sidebar|lib/types|check\.ts" "$WORK/strict.log" | grep -vE "node_modules/[^ ]*(cordis|cosmokit)"; then
echo "[check-consumer-types] FAIL: errors in the dsh-apes-sidebar declaration surface:" >&2
grep -E "dsh-apes-sidebar|lib/types|check\.ts" "$WORK/strict.log" >&2
exit 1
fi
if [ "$STATUS" -ne 0 ]; then
echo "[check-consumer-types] pass 2 note: strict mode only reports upstream declaration noise:"
grep -v "dsh-better-sidebar\|lib/types\|check\.ts" "$WORK/strict.log" | head -5 || true
grep -v "dsh-apes-sidebar\|lib/types\|check\.ts" "$WORK/strict.log" | head -5 || true
fi
echo "[check-consumer-types] OK: the client/service declaration surface is node-free and self-contained."
+29 -82
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env bash
# =============================================================================
# dsh-better-sidebar 聚合双挂载冒烟(CI + 本地):
# dsh-apes-sidebar 聚合双挂载冒烟(CI + 本地):
#
# 场景:一个聚合 bundle(tests/fixtures/aggregate-better-sidebar)先以独立
# 条目 id 挂载 dsh-better-sidebar,插件自身的 bundle patch 后到 —— 旧行为
# 条目 id 挂载 dsh-apes-sidebar,插件自身的 bundle patch 后到 —— 旧行为
# 两个实例都注册 /sidebar/api,整个插件树启动失败(duplicate prefix
# route);新行为插件行检测到已有启用中的同包名挂载后自动 disabled,由
# 聚合行接管 sidebar。
@@ -20,9 +20,9 @@
# bash scripts/e2e-aggregate-mount.sh
#
# 环境变量(均可省略):
# DSH_CMD dsh 命令;缺省 `npx -y --package @deepseek-ai/dsh dsh`
# (与 pm2 启动器同源,避免依赖可能失效的 PATH dsh)
# TARBALL 插件 tarball;缺省仓库根 dsh-better-sidebar-*.tgz(须已 pack)
# DSH_CMD dsh 命令;缺省 PATH 上的 `dsh`,回退 npx 拉官方包
# (同 e2e-mount.sh)
# TARBALL 插件 tarball;缺省仓库根 dsh-apes-sidebar-*.tgz(须已 pack)
# PORT 固定端口(默认 0 = OS 分配,从日志解析 URL)
# DSH_HOME_BASE scratch 根目录(默认系统临时目录)。脚本始终在其下新建
# 本调用拥有的独立子目录,只写入/删除该子目录;调用方提供
@@ -30,6 +30,8 @@
# KEEP_HOME 非空时保留 scratch 目录(调试用)
#
# 退出码 = 0 通过;非 0 失败。服务器与 scratch 目录由 trap 兜底清理。
# 日志/前置校验/DSH_CMD 与 tarball 解析/scratch profile 三件套/清理 trap/
# dsh web 启动与就绪轮询的共享骨架见 scripts/e2e-common.sh。
# =============================================================================
set -euo pipefail
@@ -37,55 +39,33 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
FIXTURE_DIR="$ROOT/tests/fixtures/aggregate-better-sidebar"
DSH_CMD="${DSH_CMD:-npx -y --package @deepseek-ai/dsh dsh}"
E2E_TAG=e2e-aggregate-mount
source "$SCRIPT_DIR/e2e-common.sh"
DSH_CMD="${DSH_CMD:-dsh}"
TARBALL="${TARBALL:-}"
PORT="${PORT:-0}"
KEEP_HOME="${KEEP_HOME:-}"
say() { printf '\033[32m[e2e-aggregate-mount]\033[0m %s\n' "$*"; }
warn() { printf '\033[33m[e2e-aggregate-mount]\033[0m %s\n' "$*" >&2; }
die() { printf '\033[31m[e2e-aggregate-mount]\033[0m %s\n' "$*" >&2; exit 1; }
e2e_require_cmd node "DSH 运行需要 Node.js >= 20"
e2e_require_cmd pnpm "dsh plugin 转发给 pnpm"
e2e_require_cmd npm "打包 fixture 需要"
e2e_require_cmd curl
command -v node >/dev/null 2>&1 || die "未找到 node(DSH 运行需要 Node.js >= 20)"
command -v pnpm >/dev/null 2>&1 || die "未找到 pnpm(dsh plugin 转发给 pnpm)"
command -v npm >/dev/null 2>&1 || die "未找到 npm(打包 fixture 需要)"
command -v curl >/dev/null 2>&1 || die "未找到 curl"
e2e_resolve_dsh_cmd
if [ -z "$TARBALL" ]; then
TARBALL="$(ls "$ROOT"/dsh-better-sidebar-*.tgz 2>/dev/null | head -1 || true)"
fi
[ -n "$TARBALL" ] && [ -f "$TARBALL" ] || die "未找到插件 tarball:先在本仓库跑 pnpm build && npm pack,或用 TARBALL 指定"
e2e_resolve_tarball || die "未找到插件 tarball:先在本仓库跑 pnpm build && npm pack,或用 TARBALL 指定"
# ── scratch home ────────────────────────────────────────────────────────────
# 始终在本调用拥有的全新目录里运行:调用方给了 DSH_HOME_BASE(可能是真实
# ~/.dsh)时,只在其下新建子目录并只删除该子目录;缺省时直接用系统临时
# 目录。调用方提供的目录本身绝不写入或删除。
DSH_HOME_BASE="${DSH_HOME_BASE:-}"
if [ -n "$DSH_HOME_BASE" ]; then
SCRATCH="$(mktemp -d "$DSH_HOME_BASE/dsh-e2e-agg.XXXXXX")"
else
SCRATCH="$(mktemp -d /tmp/dsh-e2e-agg.XXXXXX)"
fi
# DSH_HOME 直接用 $SCRATCH(mount lane 用子路径 home/——两 lane 既有差异);
# DSH_HOME_BASE 语义与清理 trap 见 e2e-common.sh。
e2e_make_scratch dsh-e2e-agg
export DSH_HOME="$SCRATCH"
LOG_DIR="$DSH_HOME/logs"; mkdir -p "$LOG_DIR"
OUT_LOG="$LOG_DIR/dsh-web.out.log"; ERR_LOG="$LOG_DIR/dsh-web.err.log"
SERVER_PID=""
TMP_DIR=""
cleanup() {
if [ -n "$SERVER_PID" ] && kill -0 "$SERVER_PID" 2>/dev/null; then
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
fi
if [ -n "$TMP_DIR" ] && [ -d "$TMP_DIR" ]; then
rm -rf "$TMP_DIR"
fi
if [ -z "$KEEP_HOME" ] && [ -d "$SCRATCH" ]; then
rm -rf "$SCRATCH"
else
warn "KEEP_HOME 已设置,保留 $SCRATCH"
fi
}
trap cleanup EXIT
trap e2e_cleanup EXIT
# ── 打包 fixture 聚合包 ─────────────────────────────────────────────────────
TMP_DIR="$(mktemp -d)"
@@ -94,38 +74,10 @@ FIXTURE_TGZ="$(ls "$TMP_DIR"/*.tgz 2>/dev/null | head -1 || true)"
[ -n "$FIXTURE_TGZ" ] || die "fixture 打包失败"
# ── 引导 scratch profile(web 模板)────────────────────────────────────────
# 先写 pnpm-workspace.yaml 的 allowBuilds / minimumReleaseAgeExclude,避免
# pnpm 11 strict-dep-builds 拦截 node-pty/protobufjs——同 e2e-mount.sh。
# 三件套 heredoc 及 pnpm-workspace 的 allowBuilds / minimumReleaseAgeExclude
# 理由见 e2e-common.sh 的 e2e_write_profile。
PROFILE_DIR="$DSH_HOME/profiles/web"
mkdir -p "$PROFILE_DIR"
cat > "$PROFILE_DIR/package.json" <<EOF
{
"name": "dsh-profile-web",
"private": true,
"dependencies": {},
"dsh": {
"profile": {
"bundles": ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app"]
}
}
}
EOF
printf '[]\n' > "$PROFILE_DIR/cordis.patch.yml"
cat > "$PROFILE_DIR/pnpm-workspace.yaml" <<'EOF'
packages:
- .
nodeLinker: hoisted
autoInstallPeers: false
allowBuilds:
node-pty: true
protobufjs: true
minimumReleaseAgeExclude:
- dsh-better-sidebar
- '@deepseek-ai/*'
EOF
e2e_write_profile "$PROFILE_DIR"
# ── 组装 profile:聚合先行,插件后到 ──────────────────────────────────────
say "安装 fixture 聚合包(先行)…"
@@ -135,20 +87,15 @@ $DSH_CMD plugin --profile web add "file:$TARBALL"
# ── 启动真实 dsh web ───────────────────────────────────────────────────────
say "启动 dsh web(--port ${PORT},日志 ${LOG_DIR})…"
$DSH_CMD web --port "$PORT" >"$OUT_LOG" 2>"$ERR_LOG" &
SERVER_PID=$!
e2e_start_dsh_web "$OUT_LOG" "$ERR_LOG"
# 等待启动 URL 或进程退出(最多 120s)。这里有意只取 origin:0.1.2-alpha.1+
# 的就绪行是 `…/?token=<43字符>` 鉴权 URL,但下方的探活全部打插件的
# `/sidebar/api/*` 路由——webserver carrier 不做鉴权(只有 /api、index 与
# /sidebar/api/* 路由——webserver carrier 不做鉴权(只有 /api、index 与
# remote.mux 升级在 browser auth 之后),origin 拼路径即正确且两版通用。
URL=""
for _ in $(seq 1 120); do
if ! kill -0 "$SERVER_PID" 2>/dev/null; then break; fi
URL="$(grep -oE 'http://127\.0\.0\.1:[0-9]+' "$OUT_LOG" | tail -1 || true)"
[ -n "$URL" ] && break
sleep 1
done
E2E_READY_RE='http://127\.0\.0\.1:[0-9]+'
E2E_READY_PICK=tail
e2e_wait_dsh_web_ready "$OUT_LOG" || true
if [ -z "$URL" ]; then
warn "out log 尾部:$(tail -5 "$OUT_LOG" 2>/dev/null || true)"
warn "err log 尾部:$(tail -5 "$ERR_LOG" 2>/dev/null || true)"
+175
View File
@@ -0,0 +1,175 @@
# =============================================================================
# scripts/e2e-common.sh — e2e-mount.sh / e2e-aggregate-mount.sh 的共享骨架:
# 日志三件套、前置校验、DSH_CMD / tarball 解析、scratch home 与清理 trap、
# scratch profile 三件套、dsh web 后台启动与就绪轮询。
#
# 纯开发侧库文件,不进 npm 包(package.json files 白名单无它);
# install.sh / install.ps1 随 npm 包独立分发,必须自包含,绝不 source 本文件。
#
# 用法:调用方先设 E2E_TAG(say/warn/die 的日志前缀)再 source:
# E2E_TAG=e2e-mount
# source "$SCRIPT_DIR/e2e-common.sh"
#
# 依赖调用方已 `set -euo pipefail` 并定义 ROOT(仓库根绝对路径)。
# 两侧脚本有意保留的差异(行为等价,靠参数 / 调用点区分):
# - DSH_HOME 子路径:mount 用 $SCRATCH/home,aggregate 直接用 $SCRATCH;
# - 就绪行 grep 正则:mount 要带 token 的完整 URL,aggregate 只取 origin
# (两侧的理由见各自脚本的就绪行注释);
# - 额外清理项:aggregate 的 fixture 打包 TMP_DIR(mount 不设即跳过)。
# =============================================================================
: "${E2E_TAG:?必须先设置 E2E_TAG(日志前缀)再 source e2e-common.sh}"
# ── 日志三件套 ───────────────────────────────────────────────────────────────
say() { printf '\033[32m[%s]\033[0m %s\n' "$E2E_TAG" "$*"; }
warn() { printf '\033[33m[%s]\033[0m %s\n' "$E2E_TAG" "$*" >&2; }
die() { printf '\033[31m[%s]\033[0m %s\n' "$E2E_TAG" "$*" >&2; exit 1; }
# ── 前置校验 ─────────────────────────────────────────────────────────────────
# e2e_require_cmd <cmd> [用途]:不在 PATH 上即 die(沿用两脚本原有报错文案)。
e2e_require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
if [ $# -ge 2 ]; then
die "未找到 $1($2)"
else
die "未找到 $1"
fi
fi
}
# ── DSH_CMD 解析 ─────────────────────────────────────────────────────────────
# PATH 上的 dsh 优先,否则 npx 拉官方包(同 scripts/install.sh)。DSH_CMD
# 缺省值(`dsh`)由调用方从环境变量取好传入。
e2e_resolve_dsh_cmd() {
if ! command -v "$DSH_CMD" >/dev/null 2>&1; then
if command -v npx >/dev/null 2>&1; then
say "PATH 上无 ${DSH_CMD},回退 npx -y --package @deepseek-ai/dsh"
DSH_CMD="npx -y --package @deepseek-ai/dsh dsh"
else
die "未找到 $DSH_CMD 或 npx;请先安装 DSH CLI(npm i -g @deepseek-ai/dsh)或用 DSH_CMD 指定"
fi
fi
}
# ── tarball 解析 ─────────────────────────────────────────────────────────────
# TARBALL 已显式给出时原样使用;否则从仓库根 glob 里选。多个候选时取 mtime
# 最新——`ls | head -1` 的字典序会拿到旧版本号的历史 tarball,把冒烟挂到
# 过期产物上。未找到候选时返回非零,由调用方按各自文案 die。
e2e_resolve_tarball() {
if [ -z "${TARBALL:-}" ]; then
TARBALL="$(ls -t "$ROOT"/dsh-apes-sidebar-*.tgz 2>/dev/null | head -1 || true)"
local count
count="$(ls "$ROOT"/dsh-apes-sidebar-*.tgz 2>/dev/null | wc -l | tr -d ' ' || true)"
[ "$count" -le 1 ] || warn "发现 $count 个 tarball,按 mtime 选用最新:$(basename "$TARBALL")(建议清理其余)"
fi
[ -n "$TARBALL" ] && [ -f "$TARBALL" ]
}
# ── scratch home 与清理 ──────────────────────────────────────────────────────
# 始终在本调用拥有的全新目录里运行:调用方给了 DSH_HOME_BASE(可能是真实
# ~/.dsh)时,只在其下新建子目录并只删除该子目录;缺省时直接用系统临时
# 目录。调用方提供的目录本身绝不写入或删除。
# $1 = mktemp 模板短名(dsh-e2e-mount / dsh-e2e-agg,便于区分两 lane 的残留)。
# DSH_HOME 用 $SCRATCH 还是其子路径由调用方自行 export(两 lane 的既有差异)。
e2e_make_scratch() {
if [ -n "${DSH_HOME_BASE:-}" ]; then
SCRATCH="$(mktemp -d "$DSH_HOME_BASE/$1.XXXXXX")"
else
SCRATCH="$(mktemp -d /tmp/$1.XXXXXX)"
fi
}
# EXIT trap 兜底清理:杀 dsh web 后台进程;清 aggregate lane 的 fixture 打包
# 目录(TMP_DIR——mount lane 不设该变量即整段跳过);最后按 KEEP_HOME 决定
# 是否删除 scratch。结尾显式 exit 回传 trap 触发时的退出码。
e2e_cleanup() {
local code=$?
if [ -n "${SERVER_PID:-}" ] && kill -0 "$SERVER_PID" 2>/dev/null; then
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
fi
if [ -n "${TMP_DIR:-}" ] && [ -d "${TMP_DIR:-}" ]; then
rm -rf "$TMP_DIR"
fi
if [ -z "${KEEP_HOME:-}" ]; then
if [ -d "${SCRATCH:-}" ]; then
rm -rf "$SCRATCH"
fi
else
warn "KEEP_HOME 已设置,保留 $SCRATCH"
fi
exit "$code"
}
# ── scratch profile 三件套 ───────────────────────────────────────────────────
# 引导 scratch profile(web 模板,镜像 dsh initProfile);先写
# pnpm-workspace.yaml 的 allowBuilds / minimumReleaseAgeExclude,避免 pnpm 11
# strict-dep-builds 拦截 node-pty/protobufjs 或拒绝 <24h 新版本——同 install.sh。
# @deepseek-ai/* 通配与仓库根 pnpm-workspace.yaml 同策:钉的 DSH alpha 常在
# 发布后 24h 内跑 lane,没有豁免会被 minimumReleaseAge 直接拒装。
e2e_write_profile() {
mkdir -p "$1"
cat > "$1/package.json" <<EOF
{
"name": "dsh-profile-web",
"private": true,
"dependencies": {},
"dsh": {
"profile": {
"bundles": ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app"]
}
}
}
EOF
printf '[]\n' > "$1/cordis.patch.yml"
cat > "$1/pnpm-workspace.yaml" <<'EOF'
packages:
- .
nodeLinker: hoisted
autoInstallPeers: false
allowBuilds:
node-pty: true
protobufjs: true
minimumReleaseAgeExclude:
- dsh-apes-sidebar
- '@deepseek-ai/*'
EOF
}
# ── dsh web 启动 + 就绪轮询 ──────────────────────────────────────────────────
# 后台启动 dsh web(--port 0 = OS 分配,避免端口冲突;keyless 可起)。
# $1 = stdout 日志;$2 = stderr 日志(缺省合并进 $1:mount lane 单文件,
# aggregate lane 分 out/err 两个文件)。写入全局 SERVER_PID。
e2e_start_dsh_web() {
if [ $# -ge 2 ]; then
$DSH_CMD web --port "$PORT" >"$1" 2>"$2" &
else
$DSH_CMD web --port "$PORT" >"$1" 2>&1 &
fi
SERVER_PID=$!
}
# 轮询日志等待就绪行(最多 120s)。$1 = 参与轮询的日志文件;就绪行 ERE 与
# 多条命中的取舍(head/tail)由 E2E_READY_RE / E2E_READY_PICK 传入——这是
# 两条 lane 有意保留的差异(token URL vs 裸 origin,理由见各自脚本的就绪
# 行注释)。URL 提取统一为「命中串按空白分段取末段」:mount 的模式带
# `dsh web: ` 前缀(两段)故末段即完整 URL;aggregate 的模式不含空白故末段
# 即 origin——与原先两侧的 awk '{print $3}' / 直接取整行等价。
# 返回:0 = URL 已写入全局 URL;1 = 120s 超时;2 = 服务进程提前退出。
e2e_wait_dsh_web_ready() {
URL=""
local _
for _ in $(seq 1 120); do
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
return 2
fi
if URL="$(grep -oE "$E2E_READY_RE" "$1" | "${E2E_READY_PICK:-head}" -1 | awk '{print $NF}')" && [ -n "$URL" ]; then
return 0
fi
sleep 1
done
return 1
}
+39 -99
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# =============================================================================
# dsh-better-sidebar 挂载冒烟编排(CI + 本地):
# dsh-apes-sidebar 挂载冒烟编排(CI + 本地):
#
# 1. 用官方 CLI 把 npm 打包产物(tarball)真实挂载进一个全新 scratch
# profile(`dsh plugin --profile web add file:<tarball>`,触发
@@ -14,7 +14,7 @@
#
# 环境变量(均可省略):
# DSH_CMD dsh 命令;缺省 PATH 上的 `dsh`,回退 npx 拉官方包
# TARBALL 插件 tarball;缺省仓库根 dsh-better-sidebar-*.tgz(须已 pack)
# TARBALL 插件 tarball;缺省仓库根 dsh-apes-sidebar-*.tgz(须已 pack)
# PORT 固定端口(默认 0 = OS 分配,从日志解析 URL)
# DSH_HOME_BASE 覆盖 scratch 根目录(默认系统临时目录)。脚本始终在其下
# 新建本调用拥有的独立子目录,只写入/删除该子目录;调用方
@@ -22,54 +22,37 @@
# KEEP_HOME 非空时保留 scratch home(调试用)
#
# 退出码 = playwright 的退出码;服务器与 scratch 目录由 trap 兜底清理。
# 日志/前置校验/DSH_CMD 与 tarball 解析/scratch profile 三件套/清理 trap/
# dsh web 启动与就绪轮询的共享骨架见 scripts/e2e-common.sh。
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
E2E_TAG=e2e-mount
source "$SCRIPT_DIR/e2e-common.sh"
DSH_CMD="${DSH_CMD:-dsh}"
PORT="${PORT:-0}"
TARBALL="${TARBALL:-}"
GREP_FILTER=""
if [ "${1:-}" = "--grep" ]; then GREP_FILTER="${2:?--grep 需要参数}"; fi
say() { printf '\033[32m[e2e-mount]\033[0m %s\n' "$*"; }
warn() { printf '\033[33m[e2e-mount]\033[0m %s\n' "$*" >&2; }
die() { printf '\033[31m[e2e-mount]\033[0m %s\n' "$*" >&2; exit 1; }
e2e_require_cmd node "DSH 运行需要 Node.js >= 20"
e2e_require_cmd pnpm "dsh plugin 转发给 pnpm"
command -v node >/dev/null 2>&1 || die "未找到 node(DSH 运行需要 Node.js >= 20)"
command -v pnpm >/dev/null 2>&1 || die "未找到 pnpm(dsh plugin 转发给 pnpm)"
e2e_resolve_dsh_cmd
# dsh CLI 解析:PATH 上的 dsh 优先,否则 npx 拉官方包(同 scripts/install.sh)
if ! command -v "$DSH_CMD" >/dev/null 2>&1; then
if command -v npx >/dev/null 2>&1; then
say "PATH 上无 ${DSH_CMD},回退 npx -y --package @deepseek-ai/dsh"
DSH_CMD="npx -y --package @deepseek-ai/dsh dsh"
else
die "未找到 $DSH_CMD 或 npx;请先安装 DSH CLI(npm i -g @deepseek-ai/dsh)或用 DSH_CMD 指定"
fi
fi
# tarball 解析(多个候选时取 mtime 最新——`ls | head -1` 的字典序会拿到
# 旧版本号的历史 tarball,把冒烟挂到过期产物上)
if [ -z "$TARBALL" ]; then
TARBALL="$(ls -t "$ROOT"/dsh-better-sidebar-*.tgz 2>/dev/null | head -1 || true)"
COUNT="$(ls "$ROOT"/dsh-better-sidebar-*.tgz 2>/dev/null | wc -l | tr -d ' ')"
[ "$COUNT" -le 1 ] || warn "发现 $COUNT 个 tarball,按 mtime 选用最新:$(basename "$TARBALL")(建议清理其余)"
fi
[ -n "$TARBALL" ] && [ -f "$TARBALL" ] || die "找不到 tarball(TARBALL 或 \$ROOT/dsh-better-sidebar-*.tgz)——先运行 pnpm build && pnpm pack"
e2e_resolve_tarball || die "找不到 tarball(TARBALL 或 \$ROOT/dsh-apes-sidebar-*.tgz)——先运行 pnpm build && pnpm pack"
TARBALL="$(cd "$(dirname "$TARBALL")" && pwd)/$(basename "$TARBALL")"
say "tarball: $TARBALL"
# scratch home(每次全新,绝不触碰真实 ~/.dsh):调用方给了 DSH_HOME_BASE
# 时,只在其下新建本调用拥有的子目录并只删除该子目录;缺省时直接用系统
# 临时目录。
if [ -n "${DSH_HOME_BASE:-}" ]; then
SCRATCH="$(mktemp -d "$DSH_HOME_BASE/dsh-e2e-mount.XXXXXX")"
else
SCRATCH="$(mktemp -d /tmp/dsh-e2e-mount.XXXXXX)"
fi
# scratch home(每次全新,绝不触碰真实 ~/.dsh;DSH_HOME_BASE 语义见
# e2e-common.sh)。DSH_HOME 用子路径 home/(aggregate lane 直接用 $SCRATCH,
# 两 lane 既有差异):playwright lane 还需要独立的 workspace 目录,与
# DSH_HOME 的 profiles 布局分开。
e2e_make_scratch dsh-e2e-mount
export DSH_HOME="$SCRATCH/home"
WORKSPACE_DIR="$SCRATCH/workspace"
LOG_DIR="$SCRATCH"
@@ -78,77 +61,33 @@ mkdir -p "$DSH_HOME/profiles/web" "$WORKSPACE_DIR"
say "scratch home: ${DSH_HOME}(DSH_HOME=${DSH_HOME})"
SERVER_PID=""
cleanup() {
local code=$?
if [ -n "$SERVER_PID" ] && kill -0 "$SERVER_PID" 2>/dev/null; then
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
fi
if [ -z "${KEEP_HOME:-}" ]; then
rm -rf "$SCRATCH"
else
warn "KEEP_HOME 已设置,保留 $SCRATCH"
fi
exit "$code"
}
trap cleanup EXIT
trap e2e_cleanup EXIT
# 步骤 1:引导 scratch profile(web 模板,镜像 dsh initProfile;先写
# pnpm-workspace.yaml 的 allowBuilds / minimumReleaseAgeExclude,避免 pnpm 11
# strict-dep-builds 拦截 node-pty/protobufjs 或拒绝 <24h 新版本——同 install.sh。
# @deepseek-ai/* 通配与仓库根 pnpm-workspace.yaml 同策:钉的 DSH alpha 常在
# 发布后 24h 内跑 lane,没有豁免会被 minimumReleaseAge 直接拒装)
# 步骤 1:引导 scratch profile(三件套 heredoc 及其理由见 e2e-common.sh
# 的 e2e_write_profile)
PROFILE_DIR="$DSH_HOME/profiles/web"
cat > "$PROFILE_DIR/package.json" <<EOF
{
"name": "dsh-profile-web",
"private": true,
"dependencies": {},
"dsh": {
"profile": {
"bundles": ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app"]
}
}
}
EOF
printf '[]\n' > "$PROFILE_DIR/cordis.patch.yml"
cat > "$PROFILE_DIR/pnpm-workspace.yaml" <<'EOF'
packages:
- .
nodeLinker: hoisted
autoInstallPeers: false
allowBuilds:
node-pty: true
protobufjs: true
minimumReleaseAgeExclude:
- dsh-better-sidebar
- '@deepseek-ai/*'
EOF
e2e_write_profile "$PROFILE_DIR"
# 步骤 2:官方 CLI 安装 tarball + bundle 协调(真实挂载路径)
say "执行 dsh plugin --profile web add file:$TARBALL ..."
$DSH_CMD plugin --profile web add "file:$TARBALL"
# 步骤 3:校验挂载生效(dsh.profile.bundles 含 dsh-better-sidebar)
# 步骤 3:校验挂载生效(dsh.profile.bundles 含 dsh-apes-sidebar)
if ! node -e '
const fs = require("fs");
const p = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
const bundles = p.dsh?.profile?.bundles ?? [];
process.exit(bundles.includes("dsh-better-sidebar") ? 0 : 1);
process.exit(bundles.includes("dsh-apes-sidebar") ? 0 : 1);
' "$PROFILE_DIR/package.json"; then
warn "dsh-better-sidebar 未出现在 dsh.profile.bundles 中——挂载未注册"
warn "dsh-apes-sidebar 未出现在 dsh.profile.bundles 中——挂载未注册"
cat "$PROFILE_DIR/package.json"
exit 1
fi
say "挂载已注册:dsh.profile.bundles 包含 dsh-better-sidebar"
say "挂载已注册:dsh.profile.bundles 包含 dsh-apes-sidebar"
# 步骤 4:启动 dsh web(--port 0 = OS 分配,避免端口冲突;keyless 可起)
say "启动 dsh web(port=${PORT})..."
$DSH_CMD web --port "$PORT" > "$WEB_LOG" 2>&1 &
SERVER_PID=$!
e2e_start_dsh_web "$WEB_LOG"
# 就绪行解析:DSH 0.1.2-alpha.1+ 打印的是带一次性 token 的鉴权 URL
# (`dsh web: http://127.0.0.1:<port>/?token=<43字符>`,页面导航用它换取
@@ -156,19 +95,20 @@ SERVER_PID=$!
# 匹配必须延伸到空白(`[^ ]*`)——在 `/` 处截断会丢掉 token,alpha.1+
# 宿主上的整条 lane 都会挂在首屏 401。LAN 后缀 `(LAN: …)` 是下一个
# 空格分段,不会被并进来。
URL=""
for _ in $(seq 1 120); do
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
echo "=== dsh web 提前退出,日志尾部 ===" >&2
tail -30 "$WEB_LOG" >&2 || true
exit 1
fi
if URL="$(grep -oE 'dsh web: http://127\.0\.0\.1:[0-9]+[^ ]*' "$WEB_LOG" | head -1 | awk '{print $3}')" && [ -n "$URL" ]; then
break
fi
sleep 1
done
[ -n "$URL" ] || { echo "=== 120s 内未等到 dsh web 就绪,日志尾部 ===" >&2; tail -40 "$WEB_LOG" >&2 || true; exit 1; }
E2E_READY_RE='dsh web: http://127\.0\.0\.1:[0-9]+[^ ]*'
E2E_READY_PICK=head
WAIT_RC=0
e2e_wait_dsh_web_ready "$WEB_LOG" || WAIT_RC=$?
if [ "$WAIT_RC" -eq 2 ]; then
echo "=== dsh web 提前退出,日志尾部 ===" >&2
tail -30 "$WEB_LOG" >&2 || true
exit 1
fi
if [ "$WAIT_RC" -ne 0 ]; then
echo "=== 120s 内未等到 dsh web 就绪,日志尾部 ===" >&2
tail -40 "$WEB_LOG" >&2 || true
exit 1
fi
say "dsh web 就绪:${URL}(pid ${SERVER_PID})"
# 步骤 5:运行无头渲染 lane
+10 -10
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env bash
# =============================================================================
# dsh-better-sidebar 一键安装脚本(官方 CLI 方式,macOS / Linux / Windows Git Bash)
# dsh-apes-sidebar 一键安装脚本(官方 CLI 方式,macOS / Linux / Windows Git Bash)
#
# 通过 DSH 官方插件命令安装 npm 包并自动挂载:
# dsh plugin --profile web add dsh-better-sidebar@<version>
# dsh plugin --profile web add dsh-apes-sidebar@<version>
#
# 包内声明了 dsh.bundle.patch(cordis.patch.yml):CLI 的 bundle 协调会把它
# 自动加进 profile 的 dsh.profile.bundles,下次启动即挂载——无需手动写
@@ -38,8 +38,8 @@
# minimumReleaseAgeExclude(幂等),放行本插件,避免"重跑一次才成功"。
# - 老版本(<0.10.2)用手动挂载行,bundle 通道激活后需移除,否则双挂载
# (Node 半挂两次、页面两个侧边栏)。脚本会幂等移除 better-sidebar 挂载行。
# - 回滚:dsh plugin --profile web remove dsh-better-sidebar,或把 profile 依赖
# 改回 "dsh-better-sidebar": "link:<路径>" 再 pnpm install。
# - 回滚:dsh plugin --profile web remove dsh-apes-sidebar,或把 profile 依赖
# 改回 "dsh-apes-sidebar": "link:<路径>" 再 pnpm install。
# =============================================================================
set -euo pipefail
@@ -47,7 +47,7 @@ set -euo pipefail
for arg in "$@"; do
if [ "$arg" = "-h" ] || [ "$arg" = "--help" ]; then
cat <<'EOF'
dsh-better-sidebar 一键安装 / 依赖修复脚本
dsh-apes-sidebar 一键安装 / 依赖修复脚本
用法:
bash scripts/install.sh [版本] [--restart] [--dry-run] [--profile <名>]
@@ -67,7 +67,7 @@ done
DSH_HOME="${DSH_HOME:-${HOME:-${USERPROFILE:-}}/.dsh}"
REGISTRY="${REGISTRY:-https://registry.npmjs.org}"
PKG="dsh-better-sidebar"
PKG="dsh-apes-sidebar"
DSH_CMD="${DSH_CMD:-dsh}"
RESTART=false
@@ -118,11 +118,11 @@ if (!/^\s*allowBuilds:\s*$/m.test(t)) {
}
}
// minimumReleaseAgeExclude:放行本插件(版本无关),避免 <24h 新版本被拒
if (!/^\s*-\s+dsh-better-sidebar\s*$/m.test(t)) {
if (!/^\s*-\s+dsh-apes-sidebar\s*$/m.test(t)) {
if (/^\s*minimumReleaseAgeExclude:\s*$/m.test(t)) {
t = t.replace(/^(\s*minimumReleaseAgeExclude:\s*)$/m, "$1\n - dsh-better-sidebar");
t = t.replace(/^(\s*minimumReleaseAgeExclude:\s*)$/m, "$1\n - dsh-apes-sidebar");
} else {
t += "\nminimumReleaseAgeExclude:\n - dsh-better-sidebar\n";
t += "\nminimumReleaseAgeExclude:\n - dsh-apes-sidebar\n";
}
}
if (t !== before) fs.writeFileSync(p, t);
@@ -224,7 +224,7 @@ if ! node -e '
const bundles = p.dsh?.profile?.bundles ?? [];
process.exit(bundles.includes(process.argv[2]) ? 0 : 1);
' "$PROFILE_DIR/package.json" "$PKG"; then
warn "dsh-better-sidebar 未出现在 dsh.profile.bundles 中——挂载未注册。"
warn "dsh-apes-sidebar 未出现在 dsh.profile.bundles 中——挂载未注册。"
warn "若上面的 pnpm 输出提示 ignored build scripts,请确认 $WS_YML 的 allowBuilds 后重跑本脚本。"
exit 1
fi
+4 -2
View File
@@ -6,7 +6,7 @@
* pnpm build
* node scripts/package-registry.mjs
* dsh registry install ./registry
* dsh registry enable dsh-external/dsh-better-sidebar
* dsh registry enable dsh-external/dsh-apes-sidebar
*
* The registry install copies the whole source directory (`cp -r`), so
* installing the repo root would drag in `node_modules/` (machine-local
@@ -35,6 +35,8 @@ const files = [
'lib/client-terminal.js.map',
'lib/client-editor.js',
'lib/client-editor.js.map',
'lib/client-mermaid.js',
'lib/client-mermaid.js.map',
'README.md',
'LICENSE',
]
@@ -52,4 +54,4 @@ for (const file of files) {
cpSync(source, target)
}
console.log(`registry/ assembled (${files.length} files)`)
console.log('next: dsh registry install ./registry && dsh registry enable dsh-external/dsh-better-sidebar')
console.log('next: dsh registry install ./registry && dsh registry enable dsh-external/dsh-apes-sidebar')
+294
View File
@@ -0,0 +1,294 @@
#!/usr/bin/env bash
# =============================================================================
# dsh-apes-sidebar 两线切换脚本(apes fork 线 ↔ upstream better-sidebar 线)
#
# 同一个 DSH profile 内**二选一**切换侧边栏插件——双装不可行(两条线都注册
# 进 `ctx.betterSidebar`,cordis entry id 冲突,第二个被丢弃)。切换 =
# 换 profile 依赖 + 换 dsh.profile.bundles + pnpm install + 提示重启。
#
# aps 本 fork 线(dsh-apes-sidebar,含 4 个 apes:* 信息层 tab)
# 依赖:dsh-apes-sidebar: <file: 本地路径>(本地开发)或
# dsh-apes-sidebar@<版本>(npm,发布后)
# upstream upstream 线(dsh-better-sidebar,官方 8 tab + 生态)
# 依赖:dsh-better-sidebar@<版本>(registry,默认 latest)
#
# 用法:
# bash scripts/switch.sh aps # 切到本 fork 线(本地 file: 安装)
# bash scripts/switch.sh upstream # 切到 upstream 线(registry 安装)
# bash scripts/switch.sh upstream 0.18.0 # 指定 upstream 版本
# bash scripts/switch.sh --status # 只看当前线,不改任何东西
#
# 通用 flag(两线均适用):
# --profile <名> 目标 profile(缺省 desktop)
# --dry-run 只打印将执行的操作,不写任何文件
# -h/--help 打印本帮助
#
# 环境(均可省略,脚本自动探测):
# DSH_HOME 默认 ~/.dsh
# REGISTRY 默认 https://registry.npmjs.org
# DSH_CMD 默认 PATH 上的 dsh,缺省 npx -y --package @deepseek-ai/dsh
# APS_SRC aps 线本地源码路径(缺省 = 本仓库根,即 file: 指向自己)
#
# 机制(幂等,可重复跑):
# 1. 探测当前线(依赖里哪个包在)
# 2. 与目标线相同 → 提示后直接退出(不重装)
# 3. 移除另一线的依赖 + 从 bundles 摘除另一线的 bundle
# 4. 写入目标线依赖 + 确保 workspace 放行 node-pty/protobufjs 构建
# 5. cd profile && pnpm install(reconcileProfilePnpmWorkspace 会重建
# node_modules——这是预期行为,两线不共存)
# 6. 确保 bundles 含目标线包名(bundle 通道挂载生效的判据)
# 7. 幂等移除 cordis.patch.yml 里对手线的手动挂载行(避免双挂载)
# 8. 提示重启 DSH
#
# 回滚:跑 `bash scripts/switch.sh <另一线>` 即可切回去。
# =============================================================================
set -euo pipefail
for arg in "$@"; do
if [ "$arg" = "-h" ] || [ "$arg" = "--help" ]; then
cat <<'EOF'
dsh-apes-sidebar 两线切换(同一 profile 内二选一)
用法:
bash scripts/switch.sh <aps|upstream> [版本]
bash scripts/switch.sh --status
aps 切到本 fork 线(dsh-apes-sidebar,4 个 apes:* tab)
upstream 切到 upstream 线(dsh-better-sidebar,官方)
<版本> 仅对 upstream 生效(缺省 latest)
--status 只看当前线,不改动
--profile 目标 profile(缺省 desktop)
--dry-run 只打印操作,不写文件
-h/--help 本帮助
环境变量:DSH_HOME、REGISTRY、DSH_CMD、APS_SRC(aps 本地源码路径)
EOF
exit 0
fi
done
DSH_HOME="${DSH_HOME:-${HOME:-${USERPROFILE:-}}/.dsh}"
REGISTRY="${REGISTRY:-https://registry.npmjs.org}"
DSH_CMD="${DSH_CMD:-dsh}"
# aps 线本地源码:缺省 = 本仓库根(脚本在 scripts/ 下,根 = 上一级)
APS_SRC="${APS_SRC:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
APS_PKG="dsh-apes-sidebar"
UP_PKG="dsh-better-sidebar"
DRY_RUN=false
STATUS_ONLY=false
TARGET=""
UP_VERSION=""
PROFILE_NAME="desktop"
while [ $# -gt 0 ]; do
case "$1" in
--status) STATUS_ONLY=true ;;
--dry-run) DRY_RUN=true ;;
--profile)
if [ $# -lt 2 ]; then echo "--profile 需要一个 profile 名(如 desktop)" >&2; exit 2; fi
PROFILE_NAME="$2"; shift ;;
aps|upstream) TARGET="$1" ;;
-h|--help) : ;;
-*) echo "未知参数: ${1}(用 -h 查看用法)" >&2; exit 2 ;;
*) UP_VERSION="$1" ;; # 版本参数(仅 upstream 用)
esac
shift
done
PROFILE_DIR="$DSH_HOME/profiles/$PROFILE_NAME"
PKG_JSON="$PROFILE_DIR/package.json"
WS_YML="$PROFILE_DIR/pnpm-workspace.yaml"
PATCH_YML="$PROFILE_DIR/cordis.patch.yml"
say() { printf '\033[32m[switch]\033[0m %s\n' "$*"; }
warn() { printf '\033[33m[warn]\033[0m %s\n' "$*" >&2; }
die() { printf '\033[31m[error]\033[0m %s\n' "$*" >&2; exit 1; }
command -v node >/dev/null 2>&1 || die "未找到 node(DSH 运行需要 Node.js ≥ 20)。"
[ -d "$PROFILE_DIR" ] || die "找不到 profile 目录:${PROFILE_DIR}(请先初始化 ${PROFILE_NAME} profile)"
[ -f "$PKG_JSON" ] || die "找不到 ${PKG_JSON}(profile 未初始化)"
# ── 探测当前线(依赖里哪个包在)─────────────────────────────────────────
current_line() {
node -e '
const p = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
const deps = p.dependencies || {};
if (deps["dsh-apes-sidebar"]) console.log("aps");
else if (deps["dsh-better-sidebar"]) console.log("upstream");
else console.log("none");
' "$PKG_JSON"
}
if [ "$STATUS_ONLY" = true ]; then
CUR="$(current_line)"
say "profile: ${PROFILE_NAME} (${PROFILE_DIR})"
case "$CUR" in
aps) say "当前线:aps(${APS_PKG})" ;;
upstream) say "当前线:upstream(${UP_PKG})" ;;
none) say "当前线:无(两条线都未装)" ;;
esac
# 附带显示 bundles 里的 sidebar 条目
node -e '
const p = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
const b = (p.dsh && p.dsh.profile && p.dsh.profile.bundles) || [];
const side = b.filter(x => /sidebar/.test(x));
console.log("bundles 里的 sidebar 条目: " + (side.length ? side.join(", ") : "(无)"));
' "$PKG_JSON"
exit 0
fi
[ -n "$TARGET" ] || die "缺少目标线(aps | upstream)。用 --status 查看当前线。"
CUR="$(current_line)"
case "$TARGET" in
aps) T_PKG="$APS_PKG"; T_DEP="file:${APS_SRC}" ;;
upstream)
T_PKG="$UP_PKG"
# pnpm 从依赖 key 取包名,值只给版本 spec(不含包名,否则双 @)
case "${UP_VERSION:-latest}" in
latest) T_DEP="latest" ;;
*) T_DEP="$UP_VERSION" ;;
esac
;;
esac
[ "$CUR" = "$TARGET" ] && {
say "当前已是 ${TARGET} 线(${T_PKG}),无需切换。"
say "如需强制重装:先跑 --status 确认,再手动移除依赖后重跑。"
exit 0
}
OTHER_PKG=""
case "$TARGET" in
aps) OTHER_PKG="$UP_PKG" ;;
upstream) OTHER_PKG="$APS_PKG" ;;
esac
say "切换:${CUR} → ${TARGET}(profile: ${PROFILE_NAME})"
say "目标依赖:${T_DEP}"
if [ "$DRY_RUN" = true ]; then
say "[dry-run] 步骤 1:移除 ${OTHER_PKG:-(无)} 依赖 + 从 bundles 摘除 ${OTHER_PKG:-(无)}"
say "[dry-run] 步骤 2:写入 ${T_PKG} 依赖(${T_DEP})"
say "[dry-run] 步骤 3:确保 ${WS_YML} 放行 node-pty/protobufjs 构建"
say "[dry-run] 步骤 4:cd ${PROFILE_DIR} && pnpm install(重建 node_modules)"
say "[dry-run] 步骤 5:确保 bundles 含 ${T_PKG}"
say "[dry-run] 步骤 6:幂等移除 ${PATCH_YML} 里 ${OTHER_PKG:-对手线} 的手动挂载行"
say "[dry-run] 步骤 7:提示重启 DSH"
exit 0
fi
# ── 步骤 1+2+5 合并:一次 node 调用完成依赖 + bundles 的对称改写 ─────────
# 移除对手线依赖 + bundle,写入目标线依赖 + 确保目标 bundle 在列。幂等。
node -e '
const fs = require("fs");
const p = process.argv[1];
const keep = process.argv[2]; // 目标包名
const depSpec = process.argv[3]; // 目标依赖 spec(file:... 或 pkg@ver)
const remove = process.argv[4]; // 对手包名(可能为空串)
const t = JSON.parse(fs.readFileSync(p, "utf8"));
t.dependencies = t.dependencies || {};
// 移除对手线依赖
if (remove) { const had = t.dependencies[remove]; delete t.dependencies[remove]; if (had) console.log("removed dep: " + remove + "=" + had); }
// 写入目标线依赖
t.dependencies[keep] = depSpec;
// bundles:摘除对手线,确保目标线在列
const prof = (t.dsh && t.dsh.profile) || {};
const b = prof.bundles || [];
const nb = b.filter(x => x !== remove);
if (!nb.includes(keep)) nb.push(keep);
t.dsh = t.dsh || {}; t.dsh.profile = prof; prof.bundles = nb;
fs.writeFileSync(p, JSON.stringify(t, null, 2) + "\n");
console.log("deps now: " + JSON.stringify(t.dependencies));
console.log("bundles now: " + nb.join(", "));
' "$PKG_JSON" "$T_PKG" "$T_DEP" "$OTHER_PKG"
say "依赖 + bundles 已改写"
# ── 步骤 3:确保 workspace 放行 node-pty/protobufjs 构建(幂等)─────────
if [ -f "$WS_YML" ]; then
WS_RESULT="$(node -e '
const fs = require("fs");
const p = process.argv[1];
let t = fs.readFileSync(p, "utf8");
const before = t;
t = t.replace(/^(\s*)(node-pty|protobufjs):.*$/gm, "$1$2: true");
if (!/^\s*allowBuilds:\s*$/m.test(t)) {
t += "\nallowBuilds:\n node-pty: true\n protobufjs: true\n";
} else {
for (const k of ["node-pty", "protobufjs"]) {
if (!new RegExp("^\\s*" + k + ":\\s*true\\s*$", "m").test(t)) {
t = t.replace(/^(\s*allowBuilds:\s*)$/m, "$1\n " + k + ": true");
}
}
}
if (t !== before) fs.writeFileSync(p, t);
console.log(t === before ? "unchanged" : "updated");
' "$WS_YML")"
[ "$WS_RESULT" = "updated" ] && say "已确保 ${WS_YML}:allowBuilds(node-pty/protobufjs: true)" \
|| say "workspace 设置已就绪,跳过"
else
warn "找不到 ${WS_YML},跳过 workspace 预写(若 pnpm install 报 ignored build scripts 需手动补)"
fi
# ── 步骤 4:pnpm install(重建 node_modules——两线不共存,这是预期)──────
command -v pnpm >/dev/null 2>&1 || die "未找到 pnpm。请安装 pnpm 9+ 并加入 PATH。"
say "执行 cd ${PROFILE_DIR} && pnpm install(reconcileProfilePnpmWorkspace 会重建 node_modules)..."
if ! (cd "$PROFILE_DIR" && pnpm install); then
die "pnpm install 失败。请确认网络可用、依赖 spec 正确,然后重试。"
fi
# ── 步骤 5 校验:bundles 含目标包(挂载生效判据)────────────────────────
if ! node -e '
const p = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
const b = (p.dsh && p.dsh.profile && p.dsh.profile.bundles) || [];
process.exit(b.includes(process.argv[2]) ? 0 : 1);
' "$PKG_JSON" "$T_PKG"; then
warn "${T_PKG} 未出现在 dsh.profile.bundles——挂载未注册,请检查 ${PKG_JSON}。"
exit 1
fi
say "bundle 已注册:dsh.profile.bundles 含 ${T_PKG}(下次启动自动挂载)"
# ── 步骤 6:幂等移除对手线在 cordis.patch.yml 里的手动挂载行 ─────────────
if [ -f "$PATCH_YML" ]; then
MOUNT_RESULT="$(node -e '
const fs = require("fs");
const p = process.argv[1];
const removePkg = process.argv[2];
if (!removePkg) { console.log("none"); process.exit(0); }
const lines = fs.readFileSync(p, "utf8").split("\n");
const out = [];
let i = 0;
let removed = false;
while (i < lines.length) {
const line = lines[i];
if (/^[ \t]*- insert:\s*$/.test(line)) {
const block = [line];
let j = i + 1;
while (j < lines.length && lines[j].trim() !== "" && !/^- \s/.test(lines[j])) {
block.push(lines[j]);
j++;
}
if (block.some((l) => new RegExp("id:\\s*" + removePkg.replace(/[-/]/g, "\\$&") + "\\b").test(l))) {
while (out.length && /^[ \t]*#/.test(out[out.length - 1])) out.pop();
i = j;
removed = true;
continue;
}
}
out.push(line);
i++;
}
if (!removed) console.log("none");
else { const t = out.join("\n").replace(/\n{3,}/g, "\n\n"); fs.writeFileSync(p, t); console.log("removed"); }
' "$PATCH_YML" "$OTHER_PKG")"
[ "$MOUNT_RESULT" = "removed" ] && say "已从 ${PATCH_YML} 移除 ${OTHER_PKG} 的手动挂载行" \
|| say "无 ${OTHER_PKG} 手动挂载行,跳过"
else
say "无 ${PATCH_YML},跳过手动挂载行清理"
fi
# ── 步骤 7:提示重启 ────────────────────────────────────────────────────
say "切换完成:${CUR} → ${TARGET}(${T_PKG})"
say "下一步:重启 DSH 并硬刷新(Cmd/Ctrl+Shift+R)使新副本生效。"
+82 -3
View File
@@ -13,7 +13,7 @@
*/
import { randomUUID } from 'node:crypto'
import type { IPty } from 'node-pty'
import { ensureSpawnHelper, shellSpawnArgs } from './pty-manager.ts'
import { ensureSpawnHelper, resolveShellExecutable, shellSpawnArgs } from './pty-manager.ts'
import { loadRequiredNodePty, type NodePtyModule } from './pty-deps.ts'
import { SidebarError } from './wire.ts'
@@ -40,6 +40,79 @@ export function clampDims(cols: number, rows: number): { cols: number; rows: num
return { cols: clamp(cols), rows: clamp(rows) }
}
/**
* Per-pty Windows resize-gate state (see {@link armPtyResizeGate}).
*/
interface ResizeGateState {
/** Whether the pty has produced any output yet (ConPTY flush completed). */
sawData: boolean
/** Latest dims requested before the first output; replayed once it arrives. */
pending?: { cols: number; rows: number }
}
/**
* node-pty's Windows terminal queues resize calls that arrive before the
* ConPTY control socket's first data flush (`_deferNoArgs` in
* windowsTerminal.js). If the pty exits before the queue flushes, the
* deferred resize throws inside the socket's 'data' handler — uncatchable
* by any caller and fatal to the host process. POSIX terminals have no such
* queue (resize is synchronous), so the gate is armed on Windows only:
* {@link tryResizePty} parks dims requested before the first output and
* replays them after the flush, when node-pty executes resizes
* synchronously (and the throw for an exited pty is catchable).
*/
const ptyResizeGates = new WeakMap<object, ResizeGateState>()
/**
* Arm the Windows pre-ready resize gate for one freshly spawned pty.
* No-op on POSIX and for injected ptys without `onData`.
*/
export function armPtyResizeGate(pty: IPty): void {
if (process.platform !== 'win32') return
if (typeof pty.onData !== 'function' || ptyResizeGates.has(pty)) return
const state: ResizeGateState = { sawData: false }
ptyResizeGates.set(pty, state)
pty.onData(() => {
if (state.sawData) return
state.sawData = true
const dims = state.pending
state.pending = undefined
if (dims === undefined) return
// node-pty's deferred flush shares this very 'data' dispatch; hop past
// it so the replayed resize executes synchronously (catchable) instead
// of being queued into the same deferred list we are draining.
setImmediate(() => { tryResizePty(pty, dims.cols, dims.rows) })
})
}
/**
* Best-effort resize for WebSocket-driven terminal views. Layout animation
* can briefly produce unusable dimensions, and node-pty can reject a resize
* after the socket setup's outer try/catch has returned. Ignore that one
* frame so the host stays alive and a later valid measurement can retry.
* Returns whether node-pty accepted the resize (or parked it for replay on
* the first output — the Windows pre-ready window).
*/
export function tryResizePty(
pty: Pick<IPty, 'resize'>,
cols: number,
rows: number,
): boolean {
if (!Number.isFinite(cols) || !Number.isFinite(rows)) return false
const dims = clampDims(cols, rows)
const gate = ptyResizeGates.get(pty)
if (gate !== undefined && !gate.sawData) {
gate.pending = dims
return true
}
try {
pty.resize(dims.cols, dims.rows)
return true
} catch {
return false
}
}
/**
* Serializable snapshot of one agent terminal — the shape the model sees
* through `terminal_list` and the sidebar sees through the push endpoint.
@@ -219,13 +292,15 @@ export class AgentPtyRegistry {
): string {
const uuid = randomUUID()
const dims = clampDims(cols, rows)
const pty = this.nodePty.spawn(shell ?? this.shell, shellSpawnArgs(shellArgs ?? this.shellArgs), {
const executable = resolveShellExecutable(shell ?? this.shell)
const pty = this.nodePty.spawn(executable, shellSpawnArgs(shellArgs ?? this.shellArgs), {
name: 'xterm-256color',
cols: dims.cols,
rows: dims.rows,
cwd,
env: { ...process.env },
})
armPtyResizeGate(pty)
const handle: AgentTerminalHandle = {
uuid,
sessionId,
@@ -353,7 +428,11 @@ export class AgentPtyRegistry {
resize(uuid: string, cols: number, rows: number): { cols: number; rows: number } {
const handle = this.expect(uuid)
const dims = clampDims(cols, rows)
if (!handle.exited) handle.pty.resize(dims.cols, dims.rows)
// Through tryResizePty: node-pty throws on a just-exited pty (the exit
// event may not have flipped `handle.exited` yet), and on Windows a
// pre-ready resize must go through the gate instead of node-pty's own
// deferred queue (which flushes uncatchably inside its socket handler).
if (!handle.exited) tryResizePty(handle.pty, dims.cols, dims.rows)
return dims
}
+1 -1
View File
@@ -19,7 +19,7 @@ import { fileURLToPath } from 'node:url'
import type { Context, SidebarHttpRequest, SidebarHttpResponse } from './context-types.ts'
/** The chunk names the client may request (mirror of src/client/chunk-loader.ts). */
export const CHUNK_NAMES = ['terminal', 'editor', 'mermaid'] as const
export const CHUNK_NAMES = ['terminal', 'editor', 'mermaid', 'locale'] as const
export type ChunkName = (typeof CHUNK_NAMES)[number]
/** Directory of this host-half module (lib/ — the chunk scripts live next to it). */
+7 -6
View File
@@ -1,18 +1,19 @@
/**
* The diff tab: one change opened from the git panel, like VSCode's diff
* The diff tab: one change opened from the changes tab, like VSCode's diff
* editor. A worktree ref loads the file's unified diff (`git diff`, staged or
* not; untracked files — which git diff never covers — render as a full-file
* addition from their content), a commit ref loads the commit's full patch
* (`git.show`-style). The header carries a refresh button because the tab
* stays mounted while the git panel's staging/discard operations change the
* very content it shows.
* stays mounted while the changes tab's staging/discard operations change the
* 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 { 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 { DiffView } from './DiffView.tsx'
import { DiffFiles } from './diff/DiffFiles.tsx'
import { t } from './locales.ts'
import { resolveSidebarPath } from './produced-files.ts'
import css from './sidebar.module.css'
@@ -102,8 +103,8 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif
{!loading && error === null && data !== null && (
<>
{data.untracked !== undefined
? <DiffView diff="" untrackedPath={diff.kind === 'worktree' ? diff.path : ''} untrackedContent={data.untracked} />
: <DiffView diff={data.diff} />}
? <DiffFiles diff="" untrackedPath={diff.kind === 'worktree' ? diff.path : ''} untrackedContent={data.untracked} />
: <DiffFiles diff={data.diff} />}
{data.diff === '' && data.untracked === undefined && (
<div className={css.gitEmpty}>{t('diffEmpty')}</div>
)}
-305
View File
@@ -1,305 +0,0 @@
/**
* The real diff surface for the git panel: parses the host's unified diff
* text (`git diff` / `git show`) and renders it VSCode-style — per-file
* sections with hunks (`@@ -a,b +c,d @@` headers), old/new line-number
* gutters, and aligned context / deleted / added rows colored through the
* DSH tokens. Untracked files produce no `git diff` output, so the caller
* can pass the file content to render as a full-file addition instead.
*
* The parser is a pure function (`parseUnifiedDiff`) so the interesting
* cases are unit-tested without a DOM.
*/
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import clsx from 'clsx'
import { t } from './locales.ts'
import css from './sidebar.module.css'
/** One rendered diff line. */
export interface DiffLine {
kind: 'ctx' | 'del' | 'add' | 'meta'
/** The line content without its diff marker ('' for the no-newline marker). */
text: string
/** Old-side line number (null for pure additions / metadata). */
oldNum: number | null
/** New-side line number (null for pure deletions / metadata). */
newNum: number | null
}
/** One parsed hunk. */
export interface DiffHunk {
/** The old-side start line (`-a[,b]`). */
oldStart: number
/** The new-side start line (`+c[,d]`). */
newStart: number
/** The section text after the trailing `@@` (may be empty). */
header: string
lines: DiffLine[]
}
/** One parsed file section of a unified diff. */
export interface DiffFile {
/** The `---` path verbatim ('/dev/null' for a new file). */
oldPath: string
/** The `+++` path verbatim ('/dev/null' for a deleted file). */
newPath: string
/** The file changed with binary content: no hunks to draw. */
binary: boolean
hunks: DiffHunk[]
}
/** The parsed unified diff. */
export interface ParsedDiff {
files: DiffFile[]
}
/** Parse the hunk header `@@ -a[,b] +c[,d] @@ section` (section may contain '@@'). */
function parseHunkHeader(line: string): { oldStart: number; newStart: number; header: string } | null {
const match = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/.exec(line)
if (match === null) return null
return { oldStart: Number(match[1]), newStart: Number(match[3]), header: match[5] ?? '' }
}
/**
* Parse `git diff --no-color` output into file sections and hunks. Rows
* outside a file section (leading noise) and metadata rows between the
* `diff --git`/`---`/`+++` headers and the first hunk (index lines, mode
* changes, rename/similarity lines) are skipped; a section that never
* reaches a hunk (a mode/rename-only change) stays hunkless so the caller
* can still draw its path.
*/
export function parseUnifiedDiff(text: string): ParsedDiff {
const files: DiffFile[] = []
let current: DiffFile | null = null
let inHunk = false
let hunk: DiffHunk | null = null
let oldNum = 0
let newNum = 0
const flushHunk = (): void => {
if (current !== null && hunk !== null) current.hunks.push(hunk)
hunk = null
inHunk = false
}
for (const raw of text.split('\n')) {
if (raw.startsWith('diff --git ')) {
flushHunk()
current = { oldPath: '', newPath: '', binary: false, hunks: [] }
files.push(current)
continue
}
if (current === null) continue
if (raw.startsWith('Binary files ') || raw === 'GIT binary patch') {
flushHunk()
current.binary = true
continue
}
if (raw.startsWith('--- ')) {
flushHunk()
current.oldPath = raw.slice(4)
continue
}
if (raw.startsWith('+++ ')) {
current.newPath = raw.slice(4)
continue
}
const header = parseHunkHeader(raw)
if (header !== null) {
flushHunk()
hunk = { oldStart: header.oldStart, newStart: header.newStart, header: header.header, lines: [] }
oldNum = header.oldStart
newNum = header.newStart
inHunk = true
continue
}
if (!inHunk || hunk === null) continue
const marker = raw[0]
if (marker === '\\') {
// `\ No newline at end of file`: metadata attached to the previous row.
hunk.lines.push({ kind: 'meta', text: raw.slice(1), oldNum: null, newNum: null })
continue
}
if (marker === ' ') {
hunk.lines.push({ kind: 'ctx', text: raw.slice(1), oldNum, newNum })
oldNum += 1
newNum += 1
} else if (marker === '-') {
hunk.lines.push({ kind: 'del', text: raw.slice(1), oldNum, newNum: null })
oldNum += 1
} else if (marker === '+') {
hunk.lines.push({ kind: 'add', text: raw.slice(1), oldNum: null, newNum })
newNum += 1
} else {
// Not a diff line (a hunk can never contain one): stop the hunk.
flushHunk()
}
}
flushHunk()
return { files }
}
/** Build the untracked-file shape: one file, one hunk of pure additions. */
function untrackedFile(path: string, content: string): DiffFile {
const lines: DiffLine[] = []
const body = content.endsWith('\n') ? content.slice(0, -1) : content
if (body !== '') {
let num = 1
for (const line of body.split('\n')) {
lines.push({ kind: 'add', text: line, oldNum: null, newNum: num })
num += 1
}
}
return { oldPath: '/dev/null', newPath: `b/${path}`, binary: false, hunks: [{ oldStart: 0, newStart: 1, header: '', lines }] }
}
/** Strip the `a/` / `b/` prefix git puts on diff paths (not on /dev/null). */
function displayPath(path: string): string {
if (path === '/dev/null') return path
if (path.startsWith('a/') || path.startsWith('b/')) return path.slice(2)
return path
}
/** The file header badge: added / deleted / renamed / binary ('' for a plain edit). */
function fileTag(file: DiffFile): string | null {
if (file.binary) return t('diffBinary')
if (file.oldPath === '/dev/null') return t('diffAdded')
if (file.newPath === '/dev/null') return t('diffDeleted')
const oldPath = displayPath(file.oldPath)
const newPath = displayPath(file.newPath)
if (oldPath !== newPath) return t('diffRenamed')
return null
}
/** Cap the flattened rows like DiffBlock: head + tail, expand button between. */
const MAX_DIFF_ROWS = 500
const TEST_PATH = /(^|\/)(?:__tests__|tests?|specs?|fixtures?|mocks?|snapshots?)(?:\/|$)|\.(?:test|spec)\.[^/]+$/i
const DOC_PATH = /(^|\/)(?:docs?|documentation)(?:\/|$)|(^|\/)(?:readme|changelog|contributing|license|authors|notice)(?:\.[^/]*)?$/i
const GENERATED_PATH = /(^|\/)(?:dist|build|coverage|generated|vendor|node_modules)(?:\/|$)|(^|\/)(?:package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?|composer\.lock|cargo\.lock|poetry\.lock)$/i
const SOURCE_PATH = /\.(?:js|jsx|mjs|cjs|ts|tsx|mts|cts|py|pyw|rb|php|java|kt|kts|scala|go|rs|swift|c|h|cc|cpp|cxx|hpp|hh|hxx|cs|fs|fsx|vb|dart|lua|r|ex|exs|erl|hrl|clj|cljs|cljc|groovy|sh|bash|zsh|fish|ps1|sql|vue|svelte|astro|html|htm|css|scss|sass|less)$/i
/** Source files open by default; tests, docs, generated files and unknown types stay folded. */
function defaultExpandedFiles(files: DiffFile[]): Set<number> {
const expanded = new Set<number>()
files.forEach((file, index) => {
const path = displayPath(file.newPath === '/dev/null' ? file.oldPath : file.newPath)
if (!file.binary && file.hunks.length > 0
&& !TEST_PATH.test(path) && !DOC_PATH.test(path) && !GENERATED_PATH.test(path)
&& SOURCE_PATH.test(path)) {
expanded.add(index)
}
})
return expanded
}
export interface DiffViewProps {
/** Unified diff text (`git.diff` or `git.commit-diff` payloads). */
diff: string
/** Untracked-file content: when present, renders as a full-file addition instead of parsing. */
untrackedPath?: string
untrackedContent?: string
}
export function DiffView({ diff, untrackedPath, untrackedContent }: DiffViewProps) {
const parsed = useMemo<ParsedDiff>(() => {
if (untrackedPath !== undefined) {
return { files: [untrackedFile(untrackedPath, untrackedContent ?? '')] }
}
return parseUnifiedDiff(diff)
}, [diff, untrackedPath, untrackedContent])
const [expanded, setExpanded] = useState(false)
const [expandedFiles, setExpandedFiles] = useState<Set<number>>(() => defaultExpandedFiles(parsed.files))
useEffect(() => { setExpandedFiles(defaultExpandedFiles(parsed.files)) }, [parsed])
// Flatten into display rows so the cap can slice a single list.
const rows = useMemo(() => {
const out: Array<{ key: string; file: DiffFile; fileIndex: number; type: 'path' | 'hunk' | 'line'; hunk?: DiffHunk; line?: DiffLine }> = []
parsed.files.forEach((file, fileIndex) => {
out.push({ key: `f${fileIndex}`, file, fileIndex, type: 'path' })
if (file.binary || !expandedFiles.has(fileIndex)) return
file.hunks.forEach((hunk, hunkIndex) => {
out.push({ key: `f${fileIndex}h${hunkIndex}`, file, fileIndex, type: 'hunk', hunk })
hunk.lines.forEach((line, lineIndex) => {
out.push({ key: `f${fileIndex}h${hunkIndex}l${lineIndex}`, file, fileIndex, type: 'line', hunk, line })
})
})
})
return out
}, [parsed, expandedFiles])
const hidden = rows.length - MAX_DIFF_ROWS
const capped = hidden > 0 && !expanded
const headLines = Math.ceil(MAX_DIFF_ROWS / 2)
const tailLines = MAX_DIFF_ROWS - headLines
const head = capped ? rows.slice(0, headLines) : rows
const tail = capped ? rows.slice(rows.length - tailLines) : []
if (rows.length === 0) return null
const renderRow = (row: (typeof rows)[number]): ReactNode => {
if (row.type === 'path') {
const tag = fileTag(row.file)
const from = displayPath(row.file.oldPath)
const to = displayPath(row.file.newPath)
const expandable = !row.file.binary && row.file.hunks.length > 0
const fileExpanded = expandedFiles.has(row.fileIndex)
return (
<button
key={row.key}
type="button"
className={css.gitDiffFile}
disabled={!expandable}
aria-expanded={expandable ? fileExpanded : undefined}
onClick={() => {
setExpandedFiles(current => {
const next = new Set(current)
if (next.has(row.fileIndex)) next.delete(row.fileIndex)
else next.add(row.fileIndex)
return next
})
}}
>
{expandable && <span aria-hidden="true" className={clsx(css.gitDiffFileChevron, fileExpanded && css.gitDiffFileChevronExpanded)}>›</span>}
<span className={css.gitDiffFilePath}>{to}</span>
{from !== to && <span className={css.gitDiffFileOld}>← {from}</span>}
{tag !== null && <span className={css.gitDiffFileTag}>{tag}</span>}
</button>
)
}
if (row.type === 'hunk') {
const hunk = row.hunk!
return (
<div key={row.key} className={css.gitDiffHunk}>
<span className={css.gitDiffHunkHeader}>@@ -{hunk.oldStart},{hunk.lines.filter(l => l.oldNum !== null).length} +{hunk.newStart},{hunk.lines.filter(l => l.newNum !== null).length} @@</span>
{hunk.header !== '' && <span className={css.gitDiffHunkSection}>{hunk.header}</span>}
</div>
)
}
const line = row.line!
const lineClass = line.kind === 'del' ? css.gitDiffDel : line.kind === 'add' ? css.gitDiffAdd : line.kind === 'meta' ? css.gitDiffMeta : css.gitDiffCtx
return (
<div key={row.key} className={clsx(css.gitDiffLine, lineClass)}>
{line.kind === 'meta'
? <span className={css.gitDiffMetaText}>{line.text}</span>
: (
<>
<span className={css.gitDiffNum}>{line.oldNum ?? ''}</span>
<span className={css.gitDiffNum}>{line.newNum ?? ''}</span>
<span className={css.gitDiffCode}>{line.text}</span>
</>
)}
</div>
)
}
return (
<div className={css.gitDiff}>
{head.map(renderRow)}
{hidden > 0 && (
<button type="button" className={css.gitDiffExpand} aria-expanded={expanded} onClick={() => { setExpanded(value => !value) }}>
{expanded ? t('diffCollapse') : t('diffExpand', { count: hidden })}
</button>
)}
{tail.map(renderRow)}
</div>
)
}
+6 -5
View File
@@ -100,7 +100,7 @@ export function EditorHost(props: {
expanded: string[]
revealed: string[]
onToggleDir: (path: string) => void
onReferenceFile: (path: string) => void
onReferenceFile: (path: string, isDir: boolean) => void
}) {
const { ctx, store, scope, tab, expanded, revealed, onToggleDir, onReferenceFile } = props
const path = tab.path ?? ''
@@ -192,10 +192,11 @@ export function EditorHost(props: {
}
/** The context menu's "open with" action: reveal the path in the OS file
* manager, or hand the target's URL (a local `file` URL, or the SSH-remote
* form for VSCode-family editors in remote mode) to the host's external
* opener. Failures are logged only — a missing handler is the OS's
* dialog, not a sidebar error. */
* manager, or hand the target's URL to its opener — local `file` URLs go
* to the host's external opener, while the SSH-remote form for
* VSCode-family editors launches on the browser/client machine (see
* api.openExternal). Failures are logged only — a missing handler is the
* OS's/browser's dialog, not a sidebar error. */
const openWith = (targetId: string, absolute: string): void => {
const target = openWithTargets.find(item => item.id === targetId)
if (target === undefined) return
+1 -1
View File
@@ -3,7 +3,7 @@
* outside workspace`) is never shown as-is: the editor / file-tree error
* slots render the localized reason plus a one-click global off — the click
* flips the `workspaceFence` pref through the settings route, adopts the
* returned document into the store (so every prefs reader — GitView's open
* returned document into the store (so every prefs reader — the changes tab's open
* guard, the settings page — flips with it), and calls `onDisabled` so the
* caller retries the failed operation immediately.
*/
+16 -10
View File
@@ -20,7 +20,7 @@
* (VSCode semantics — a drop on a file row targets its parent directory),
* and `busy` gates new drags while one upload is in flight.
*/
import { useCallback, useEffect, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import clsx from 'clsx'
import {
@@ -131,8 +131,8 @@ export function FileTree(props: {
onOpenWith?: (targetId: string, path: string) => void
/** Toggle one target's pinned state (the submenu row's pushpin). */
onToggleOpenWithPin?: (targetId: string) => void
/** Insert `@<relative path>` into the composer draft. */
onReferenceFile: (path: string) => void
/** Insert `@<relative path>` into the composer draft (file vs directory). */
onReferenceFile: (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. */
@@ -332,7 +332,7 @@ export function FileTree(props: {
title={t('referenceFile')}
onClick={(event) => {
event.stopPropagation()
onReferenceFile(entry.path)
onReferenceFile(entry.path, entry.isDir)
}}
>
{t('referenceFile')}
@@ -441,6 +441,12 @@ export function FileTree(props: {
const root = cwd
// Membership is tested per rendered row (deep trees run this thousands of
// times per render): includes() made every row O(expanded), the whole tree
// O(rows × expanded). One Set per render keeps it O(1) per row.
const expandedSet = useMemo(() => new Set(expanded), [expanded])
const revealedSet = useMemo(() => new Set(revealed), [revealed])
const renderLevel = (dir: string, depth: number): ReactNode => {
const level = data[dir]
if (level === undefined) {
@@ -466,7 +472,7 @@ export function FileTree(props: {
const entries = level.entries ?? []
return entries.map(entry => {
if (entry.isDir) {
const isOpen = expanded.includes(entry.path)
const isOpen = expandedSet.has(entry.path)
return (
<div key={entry.path}>
<div
@@ -475,9 +481,9 @@ export function FileTree(props: {
className={clsx(
css.explorerRow, css.explorerDir, entry.hidden && css.explorerHidden,
dropTarget === entry.path && css.explorerRowDropTarget,
revealed.includes(entry.path) && css.explorerRowRevealed,
revealedSet.has(entry.path) && css.explorerRowRevealed,
)}
data-dsh-revealed={revealed.includes(entry.path) ? 'true' : undefined}
data-dsh-revealed={revealedSet.has(entry.path) ? 'true' : undefined}
style={{ paddingLeft: depth * 22 + 6 }}
onClick={() => { onToggle(entry.path) }}
onKeyDown={(event) => {
@@ -507,9 +513,9 @@ export function FileTree(props: {
className={clsx(
css.explorerRow, entry.hidden && css.explorerHidden, entry.broken && css.explorerBroken,
dropTarget === parentOf(entry.path) && css.explorerRowDropTarget,
revealed.includes(entry.path) && css.explorerRowRevealed,
revealedSet.has(entry.path) && css.explorerRowRevealed,
)}
data-dsh-revealed={revealed.includes(entry.path) ? 'true' : undefined}
data-dsh-revealed={revealedSet.has(entry.path) ? 'true' : undefined}
style={{ paddingLeft: depth * 22 + 6 }}
title={entry.broken ? `${entry.path} — ${t('brokenSymlink')}` : entry.path}
onClick={() => { onOpenFile(entry.path) }}
@@ -564,7 +570,7 @@ export function FileTree(props: {
title={t('referenceFile')}
onClick={(event) => {
event.stopPropagation()
onReferenceFile(root)
onReferenceFile(root, true)
}}
>
{t('referenceFile')}
+2 -2
View File
@@ -26,14 +26,14 @@ export class RenderBoundary extends Component<{ children?: ReactNode; className?
}
componentDidCatch(error: Error, info: ErrorInfo): void {
console.error('[dsh-better-sidebar] render error:', error, info.componentStack)
console.error('[dsh-apes-sidebar] render error:', error, info.componentStack)
}
render(): ReactNode {
if (this.state.error !== null) {
return (
<div className={this.props.className}>
<span>dsh-better-sidebar: {this.state.error}</span>
<span>dsh-apes-sidebar: {this.state.error}</span>
<button
type="button"
className={css.terminalRetry}
+29
View File
@@ -221,6 +221,18 @@
transform: rotate(90deg);
}
/* Tool-kind leading glyph (the main conversation's 16px slot; the icon
* itself renders at 14). Failed rows pass a StateDot instead. */
.sidechatRowIcon {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
color: var(--dsw-alias-label-tertiary);
}
.sidechatRowLabel {
flex: none;
max-width: 60%;
@@ -280,6 +292,23 @@
border-top: 1px solid var(--dsw-alias-hairline);
}
/* Host Block atoms (terminal/diff/read) inside a row body draw their own
* chrome; only a hair of breathing room is added on top. */
.sidechatRowBody > * {
margin-top: 2px;
}
/* Turn-tail metrics line: quiet, right-aligned, ink-weight only. */
.sidechatTurnSummary {
align-self: flex-end;
max-width: 88%;
padding: 0 2px;
font: var(--dsw-font-xxs-12);
font-variant-numeric: tabular-nums;
color: var(--dsw-alias-label-tertiary);
user-select: none;
}
/* Shimmer sweep text: generating (streaming row labels, creating hero). */
.sidechatShimmerText {
background-image: linear-gradient(
+171 -15
View File
@@ -27,15 +27,27 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useSyncExternalStore } from 'react'
import clsx from 'clsx'
import {
ConnectionIndicator,
DiffBlock,
IconApiOutline14,
IconBrowseOutline16,
IconChevronRightOutline14,
IconEditOutline16,
IconNewChatOutline16,
IconPlusOutline16,
IconSearchOutline16,
IconSendOutline16,
IconSparkle16,
IconStopFill16,
MarkdownText,
Menu,
ReadBlock,
StateDot,
TerminalBlock,
type DiffBlockLabels,
type MenuEntry,
type ReadBlockLabels,
type TerminalBlockLabels,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { markdownTextProps } from './markdown-labels.tsx'
import { IconHistoryOutline16, IconSaveOutline16 } from './icons.tsx'
@@ -48,7 +60,14 @@ import {
threadTrailingPending,
type SidechatThreadInfo,
} from '../sidechat-core.ts'
import { toolArgsSummary, transcriptRows, type SidechatTranscriptRow } from './sidechat-transcript.ts'
import {
formatDurationMs,
formatTokens,
toolArgsSummary,
transcriptRows,
type SidechatToolCard,
type SidechatTranscriptRow,
} from './sidechat-transcript.ts'
import { api } from './api.ts'
import { t } from './locales.ts'
import type { SessionScope } from './api.ts'
@@ -100,6 +119,9 @@ interface RowLabels {
copiedLabel: string
thinkLabel: string
injectionLabel: string
terminal: TerminalBlockLabels
diff: DiffBlockLabels
read: ReadBlockLabels
}
/** Merge history entries by event seq (newest wins), log order preserved. */
@@ -132,8 +154,13 @@ function CollapsibleRow(props: {
mono?: boolean
streaming?: boolean
failed?: boolean
/** 16px leading glyph (tool-kind icon, the main conversation's row head). */
icon?: React.ReactNode
children?: React.ReactNode
}): React.ReactNode {
const leading = props.icon === undefined ? null : (
<span className={css.sidechatRowIcon}>{props.icon}</span>
)
const label = (
<span
className={clsx(
@@ -151,6 +178,7 @@ function CollapsibleRow(props: {
if (props.children === undefined) {
return (
<div className={clsx(css.sidechatRowLine, css.sidechatRowStatic, props.failed === true && css.sidechatRowFailed)}>
{leading}
{label}
{meta}
</div>
@@ -168,6 +196,7 @@ function CollapsibleRow(props: {
<span className={css.sidechatRowChevron}>
<IconChevronRightOutline14 size={12} />
</span>
{leading}
{label}
{meta}
</summary>
@@ -176,6 +205,52 @@ function CollapsibleRow(props: {
)
}
/** The host Block body for a structured tool card (main-conversation atoms:
* terminal surface, diff hunks, line-numbered read window). */
function toolCardBody(card: SidechatToolCard, executing: boolean, labels: RowLabels): React.ReactNode {
if (card.type === 'terminal') {
return (
<TerminalBlock
command={card.command}
cwd={card.cwd}
output={card.output}
exitCode={card.exitCode}
signal={card.signal}
running={executing}
labels={labels.terminal}
/>
)
}
if (card.type === 'diff') {
return <DiffBlock diffs={card.diffs} labels={labels.diff} />
}
return <ReadBlock label={card.label} lines={card.lines} totalLines={card.totalLines} lang={card.lang} labels={labels.read} />
}
/** The tool row's 16px leading slot, the way the main conversation draws it
* (GenericToolCard's variant table): the tool-kind glyph at 14, replaced by
* an error StateDot on failed rows. */
function toolLeading(name: string, failed: boolean): React.ReactNode {
if (failed) return <StateDot state="error" />
switch (name) {
case 'bash':
case 'pwsh':
return <IconApiOutline14 size={14} />
case 'read':
case 'web_fetch':
return <IconBrowseOutline16 size={14} />
case 'edit':
case 'write':
return <IconEditOutline16 size={14} />
case 'grep':
case 'glob':
case 'web_search':
return <IconSearchOutline16 size={14} />
default:
return <IconSparkle16 size={14} />
}
}
/** One row renderer (React keys ride the source event seq). */
function renderRow(row: SidechatTranscriptRow, labels: RowLabels): React.ReactNode {
switch (row.kind) {
@@ -207,22 +282,40 @@ function renderRow(row: SidechatTranscriptRow, labels: RowLabels): React.ReactNo
<div className={css.sidechatRowProse}>{row.text}</div>
</CollapsibleRow>
)
case 'tool': {
const body = (
<>
{row.args !== undefined && <pre className={css.sidechatRowCode}>{row.args}</pre>}
{row.resultText !== undefined && <pre className={css.sidechatRowCode}>{row.resultText}</pre>}
</>
case 'turnSummary': {
// Quiet turn-tail metrics: token usage + wall duration, main-pane
// StatsLine formatting. Parts appear only when computable.
const parts: string[] = []
if (row.inputTokens !== undefined && row.outputTokens !== undefined) {
parts.push(t('sideChatTurnUsage', { input: formatTokens(row.inputTokens), output: formatTokens(row.outputTokens) }))
}
if (row.durationMs !== undefined) parts.push(formatDurationMs(row.durationMs))
if (parts.length === 0) return null
return (
<div key={`${row.kind}:${row.seq}`} className={css.sidechatTurnSummary}>
{parts.join(' · ')}
</div>
)
}
case 'tool': {
const body = row.card !== undefined
? toolCardBody(row.card, row.executing === true, labels)
: (
<>
{row.args !== undefined && <pre className={css.sidechatRowCode}>{row.args}</pre>}
{row.resultText !== undefined && <pre className={css.sidechatRowCode}>{row.resultText}</pre>}
</>
)
return (
<CollapsibleRow
key={`${row.kind}:${row.seq}`}
label={row.name}
meta={toolArgsSummary(row.args)}
icon={toolLeading(row.name, row.failed)}
mono
streaming={row.executing === true}
failed={row.failed}
{...(row.args === undefined && row.resultText === undefined ? {} : { children: body })}
{...(row.args === undefined && row.resultText === undefined && row.card === undefined ? {} : { children: body })}
/>
)
}
@@ -237,15 +330,35 @@ export function SideChatView(props: {
visible: boolean
}): React.ReactNode {
const { ctx, scope, tab, visible } = props
const rowLabels = useMemo<RowLabels>(
() => ({
const rowLabels = useMemo<RowLabels>(() => {
// Shared Block chrome: copy buttons reuse the sidebar's copy pair, the
// collapse/expand family is common to every Block kind.
const shared = {
copy: t('copy'),
copied: t('copied'),
collapse: t('sideChatBlockCollapse'),
collapseAria: t('sideChatBlockCollapseAria'),
expand: (hidden: number) => t('sideChatBlockExpand', { hidden }),
expandAria: (hidden: number) => t('sideChatBlockExpandAria', { hidden }),
}
return {
copyLabel: t('copy'),
copiedLabel: t('copied'),
thinkLabel: t('sideChatThink'),
injectionLabel: t('sideChatInjection'),
}),
[],
)
terminal: {
...shared,
signal: (signal: string) => t('sideChatBlockSignal', { signal }),
exitCode: (exitCode: number) => t('sideChatBlockExitCode', { code: exitCode }),
running: t('sideChatBlockRunning'),
failed: t('sideChatBlockFailed'),
done: t('sideChatBlockDone'),
noOutput: t('sideChatBlockNoOutput'),
},
diff: { ...shared, files: (count: number) => t('sideChatBlockFiles', { count }) },
read: { ...shared, window: (shown: number, total: number) => t('sideChatBlockWindow', { shown, total }) },
}
}, [])
// The session list feed: thread rows (the header menu) + running states.
const list = useSyncExternalStore(
@@ -270,6 +383,8 @@ export function SideChatView(props: {
const [menuOpen, setMenuOpen] = useState(false)
const cacheRef = useRef<ThreadCache>({ entries: [] })
// The previous poll's rows (see the mapping's reuse pass below).
const prevRowsRef = useRef<SidechatTranscriptRow[]>([])
const controllerRef = useRef<AbortController | null>(null)
const scrollRef = useRef<HTMLDivElement | null>(null)
const composerRef = useRef<HTMLTextAreaElement | null>(null)
@@ -277,6 +392,15 @@ export function SideChatView(props: {
const summary = threadId === undefined ? undefined : list.byId[threadId]
const running = summary?.running === true
// Connection recovery state (DSH 0.1.2-alpha.2+): drives the disconnect
// banner and an immediate catch-up pull when the wire comes back. The poll
// loop itself stays silent on wire failures; absent service (older host)
// reads as `undefined` = never show the banner.
const connectionState = useSyncExternalStore(
useMemo(() => (callback: () => void) => ctx.connection?.state.subscribe(callback) ?? (() => {}), [ctx]),
useCallback(() => ctx.connection?.state.getSnapshot(), [ctx]),
)
/** The agent-identity badge of the thread header (preset · model). */
const agentBadge = useMemo(() => {
if (info === null) return ''
@@ -358,6 +482,7 @@ export function SideChatView(props: {
// the composer — it owns the first message of a fresh thread.
useEffect(() => {
cacheRef.current = { entries: [] }
prevRowsRef.current = []
controllerRef.current?.abort()
setError(null)
setSaved(false)
@@ -382,8 +507,27 @@ export function SideChatView(props: {
useEffect(() => () => { controllerRef.current?.abort() }, [])
const rows = useMemo(
() => (threadId === undefined ? [] : transcriptRows(cacheRef.current.entries)),
// When the wire recovers (disconnected → connected), pull immediately
// instead of waiting for the next poll tick — or, on an idle thread that
// stopped polling, forever.
const prevConnectionRef = useRef(connectionState)
useEffect(() => {
const previous = prevConnectionRef.current
prevConnectionRef.current = connectionState
if (previous === 'disconnected' && connectionState === 'connected' && threadId !== undefined) {
void fetchThread(threadId)
void fetchInfo(threadId)
}
}, [connectionState, threadId, fetchThread, fetchInfo])
// The previous poll's rows ride into the mapping so unchanged rows keep
// their object identity (see reuseRows): the 2s poll re-renders only the
// changed tail instead of re-parsing markdown for the whole transcript.
const rows = useMemo(() => {
const next = threadId === undefined ? [] : transcriptRows(cacheRef.current.entries, prevRowsRef.current)
prevRowsRef.current = next
return next
},
// The cache is a ref; revision bumps on every successful pull.
// eslint-disable-next-line react-hooks/exhaustive-deps
[threadId, revision],
@@ -560,6 +704,18 @@ export function SideChatView(props: {
<IconSaveOutline16 />
</button>
</div>
{connectionState !== undefined && connectionState !== 'connected' && (
<ConnectionIndicator
state={connectionState}
disconnectedLabel={t('sideChatConnDisconnected')}
reconnectLabel={t('sideChatConnReconnect')}
connectingLabel={t('sideChatConnConnecting')}
recoveredLabel={t('sideChatConnRecovered')}
reconnectActionLabel={t('sideChatConnReconnectAction')}
restartActionLabel={t('sideChatConnRestartAction')}
onReconnect={() => { ctx.connection?.reconnect() }}
/>
)}
{!canSave && !freshThread && <div className={css.sidechatHint}>{t('sideChatNoTurn')}</div>}
{canSave && trailingPending
&& <div className={css.sidechatHint}>{t('sideChatPendingDrop')}</div>}
+94 -42
View File
@@ -33,7 +33,7 @@ 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 { appendToDraft } from './conversation-draft.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,
@@ -47,6 +47,7 @@ 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'
@@ -127,7 +128,7 @@ function injectUserCss(attr: string, id: string, cssText: string): HTMLStyleElem
* pane; sessionId/cwd cover onReferenceFile). */
interface TabContentProps extends TabContentMemoKey {
onToggleDir: (path: string) => void
onReferenceFile: (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). */
@@ -315,7 +316,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// session header's right-aligned utilities (the "Session log" download
// capsule) must yield. layout.css keys off this body attribute to push the
// header's right padding out past the cluster. Only the CLOSED panel needs
// it — an open panel already squeezes `#root` left, moving the header clear.
// it — an open panel reserves AppFrame padding, moving the header clear.
const collapsed = state === undefined || !state.panelOpen
useEffect(() => {
if (collapsed) document.body.setAttribute('data-dsh-sidebar-collapsed', '')
@@ -407,6 +408,20 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
}, [sessionId, summaryCwd])
const cwd = summaryCwd ?? fetchedCwd
// The + menu options ride a memo so the two Workbenches share ONE array
// identity across renders that did not change the store (drag state,
// viewport resize): fresh arrays per render re-rendered every LeafView's
// + affordance whether or not anything tab-related moved.
const newTabOptions = useMemo(
() => (state === undefined || sessionId === undefined ? [] : buildNewTabOptions(state, ctx, { sessionId, cwd })),
// state is the whole session state — every field it wraps is fair game
// 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
@@ -448,7 +463,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
if (closed) return
failures += 1
if (failures >= FAILURE_LIMIT) {
console.error('[dsh-better-sidebar] agent-terminals connection failed; stopping reconnect loop', sessionId)
console.error('[dsh-apes-sidebar] agent-terminals connection failed; stopping reconnect loop', sessionId)
return
}
retry = window.setTimeout(connect, 2000)
@@ -520,7 +535,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
if (closed) return
failures += 1
if (failures >= FAILURE_LIMIT) {
console.error('[dsh-better-sidebar] agent-opens connection failed; stopping reconnect loop', sessionId)
console.error('[dsh-apes-sidebar] agent-opens connection failed; stopping reconnect loop', sessionId)
return
}
retry = window.setTimeout(connect, 2000)
@@ -538,9 +553,12 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
/**
* 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 Subagent tab type is enabled in settings,
* open the panel (if collapsed) and focus the Subagent page
* (single-instance: an existing tab is focused, never duplicated).
* 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.
@@ -566,10 +584,15 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
if (!detectNewDirectSubagent(baseline, ctx.sessions.list.getSnapshot(), sessionId)) return
if (!store.getPrefs().autoOpenSubagent) return
if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return
store.reduce(s => s.panelOpen ? s : togglePanel(s))
// Pin the landing to the right panel: the auto-opened Subagent page must
// appear where the panel just expanded, not in a bottom-panel pane the
// user last touched.
// 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)
@@ -586,11 +609,12 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
/**
* 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 Jobs tab type is enabled, open the panel
* (if collapsed) and focus the Jobs page. 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.
* 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(() => {
@@ -600,7 +624,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
if (!detectNewJob(prev, sessionList, sessionId)) return
if (!store.getPrefs().autoOpenJobs) return
if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return
store.reduce(s => s.panelOpen ? s : togglePanel(s))
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])
@@ -677,9 +703,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// 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 margin-right push 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).
// 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
@@ -738,10 +764,13 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// (observed: a 1px sliver at the viewport's left edge).
const locate = (): void => {
if (disposed) return
const col = document.querySelector('#root [data-slot="conversation"]')
?.parentElement as HTMLElement | undefined
// 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
@@ -754,8 +783,13 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// 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.
// 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)
@@ -771,7 +805,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// 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 a querySelector to every
// 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
@@ -798,8 +832,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// 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 cheap when nothing
// changed (one querySelector + an identity compare; no forced layout).
// 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
@@ -808,6 +843,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
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
@@ -993,7 +1029,14 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// drag write-only (no React re-render mid-drag).
bottomRef.current?.style.setProperty('right', `${(window.innerWidth - centerRectRef.current.right) + (width - (state?.width ?? 0))}px`)
const bottomPush = !narrow && state?.bottomOpen === true ? height + keyboardInset : 0
writeGeometry(width, bottomPush)
// The pushed width must ride the same gate as the committed push effect
// (layoutPushSize): a collapsed right panel pushes 0. The bottom strip is
// the only drag reachable with the panel closed — writing the panel's
// persisted width preference here squeezed #root mid-drag, dropped the
// host viewport across its 1024px auto-collapse breakpoint, and the
// native left sidebar snapped to its 56px rail (and back on release).
const pushWidth = !narrow && state?.panelOpen === true ? Math.min(width, window.innerWidth) : 0
writeGeometry(pushWidth, bottomPush)
}
// Drags write at most once per frame: pointer events fire several times
@@ -1333,17 +1376,26 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
}, [actions, pinnedVirtualTabs, activePinnedTabId, store])
/**
* The explorer's @-reference button: append `@<relative path>` to the
* session's composer draft (space-separated). The conversation service is
* resolved lazily through `ctx.get` (the inject-free read — the app's own
* plugins read 'conversation' the same way); a missing service or scope
* degrades to a logged no-op, never a crash. Defined above the no-session
* early return — a hook must never sit behind a conditional return
* (React counts hooks per render).
* The explorer's @-reference button. Directories append the folder mention
* (`@dir/`) as plain text so DSH's folder decoration and completion keep
* working; files insert a structured chip like the native `@` picker, so
* the whole reference stays one link instead of decorating only the
* leading folder. Resolves the session-scope ctx and the conversation
* input service at click time; a missing service or scope degrades to a
* logged no-op, never a crash. Defined above the no-session early return
* — a hook must never sit behind a conditional return (React counts hooks
* per render).
*/
const referenceInChat = useCallback((path: string): void => {
const referenceInChat = useCallback((path: string, isDir: boolean): void => {
if (sessionId === undefined) return
appendToDraft(ctx, sessionId, `@${relativeTo(cwd ?? '', path)}`)
const rel = relativeTo(cwd ?? '', path)
if (isDir) {
appendToDraft(ctx, sessionId, `@${rel === '.' ? './' : `${rel}/`}`)
return
}
if (!insertFileReference(ctx, sessionId, rel)) {
appendToDraft(ctx, sessionId, `@${rel}`)
}
}, [ctx, sessionId, cwd])
if (state === undefined || sessionId === undefined) {
@@ -1416,7 +1468,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
try {
value = descriptor.badge(ctx, { sessionId, cwd }, state)
} catch (error) {
console.error('[dsh-better-sidebar] tab badge error:', error)
console.error('[dsh-apes-sidebar] tab badge error:', error)
return null
}
if (value === null || value === undefined || value === '') return null
@@ -1574,7 +1626,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
<Workbench
state={state}
tree={augmentedTree}
newTabOptions={buildNewTabOptions(state, ctx, { sessionId, cwd })}
newTabOptions={newTabOptions}
actions={wrappedActions}
onNewTab={onNewTab}
renderTab={renderTab}
@@ -1665,8 +1717,8 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// Direct from the center column's measured right edge: the bottom
// panel spans ONLY the center column, ending exactly at the
// details column's left edge (the details column sits between the
// center and the right panel, and the right panel's margin-right
// push is already baked into centerRect.right).
// center and the right panel, and the right panel's reserved frame
// padding is already baked into centerRect.right).
right: window.innerWidth - centerRectRef.current.right,
// The seam against the open right panel needs its own hairline
// (the right panel's border-left alone is covered by this panel's
@@ -1729,7 +1781,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
<Workbench
state={state}
tree={state.bottomSplits}
newTabOptions={buildNewTabOptions(state, ctx, { sessionId, cwd })}
newTabOptions={newTabOptions}
actions={actions}
onNewTab={onNewTab}
renderTab={(tab, active, paneId) => renderTab(tab, active, paneId, 'bottom')}
+2 -2
View File
@@ -715,7 +715,7 @@ export function SubagentView(props: {
try {
sessions.openSubagent?.(address)
} catch (error) {
console.warn('[dsh-better-sidebar] openSubagent failed:', error)
console.error('[dsh-apes-sidebar] openSubagent failed:', error)
}
}, [sessions, onOpenChild])
@@ -725,7 +725,7 @@ export function SubagentView(props: {
try {
sessions.open?.(rootId)
} catch (error) {
console.warn('[dsh-better-sidebar] open session failed:', error)
console.error('[dsh-apes-sidebar] open session failed:', error)
}
}, [sessions, rootId])
+1 -1
View File
@@ -88,7 +88,7 @@ export function TabBar(props: {
} = props
const [menuOpen, setMenuOpen] = useState(false)
// The tab right-click context menu: the target tab plus the cursor
// position (the portaled Menu anchors there, following GitView/FileTree).
// position (the portaled Menu anchors there, following the git lens/FileTree).
const [tabMenu, setTabMenu] = useState<{ tabId: string; x: number; y: number } | null>(null)
const [dragOver, setDragOver] = useState(false)
const listRef = useRef<HTMLDivElement>(null)
+19 -8
View File
@@ -253,7 +253,7 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
failures += 1
if (failures >= FAILURE_LIMIT) {
const detail = event.reason !== '' ? ` (${event.code}: ${event.reason})` : ` (${event.code})`
console.error('[dsh-better-sidebar] terminal connection failed:', event.code, event.reason, url)
console.error('[dsh-apes-sidebar] terminal connection failed:', event.code, event.reason, url)
setFatal(`${t('terminalConnectFailed')}${detail}`)
return
}
@@ -268,13 +268,23 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
const inputSub = term.onData((data) => {
if (socket !== null && socket.readyState === WebSocket.OPEN) socket.send(data)
})
// Resize streams fire per layout frame during panel open/close
// animations; fit() measures glyphs, so coalesce to one fit per
// animation frame (same pattern as the core's frame-batcher, kept
// local — this view lives in the lazy terminal chunk and does not
// import core-bundle modules).
let resizeFrame: number | null = null
const observer = new ResizeObserver(() => {
try {
fit.fit()
sendResize()
} catch {
// The terminal may be mid-dispose; ignore.
}
if (resizeFrame !== null) return
resizeFrame = requestAnimationFrame(() => {
resizeFrame = null
try {
fit.fit()
sendResize()
} catch {
// The terminal may be mid-dispose; ignore.
}
})
})
observer.observe(host)
@@ -313,7 +323,7 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
fit.fit()
sendResize()
} catch (error) {
console.error('[dsh-better-sidebar] xterm open failed:', error)
console.error('[dsh-apes-sidebar] xterm open failed:', error)
}
})
@@ -323,6 +333,7 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
cancelOpen()
window.clearTimeout(retry)
observer.disconnect()
if (resizeFrame !== null) cancelAnimationFrame(resizeFrame)
fontSub()
schemeSub()
inputSub.dispose()
+182 -71
View File
@@ -13,7 +13,7 @@
* the FileViewerProps toolbar callbacks so the host's path-input header
* renders the controls instead.
*/
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import clsx from 'clsx'
import { EditorState } from '@codemirror/state'
@@ -22,12 +22,14 @@ import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
import { IconCheckOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import { markdownTextProps } from './markdown-labels.tsx'
import { api, htmlUrl } from './api.ts'
import { markdownPreviewSource } from './markdown-frontmatter.ts'
import { rewriteLocalImageUrls } from './markdown-images.ts'
import { languageForPath } from './lang.ts'
import { cmSurfaceTheme, CmThemeCompartment } from './cm-themes.ts'
import { isDarkScheme, subscribeColorScheme } from './theme.ts'
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'
@@ -41,13 +43,6 @@ import css from './sidebar.module.css'
/** Previewable files (rendered output vs source editing). */
type ViewMode = 'preview' | 'edit'
/** The floating "add to conversation" action: payload + viewport anchor. */
interface SelectionPopup {
insert: string
left: number
top: number
}
/**
* 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
@@ -57,6 +52,14 @@ interface SelectionPopup {
*/
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
* would remount at the top. Keyed by session + path; a fresh entry reads 0
* (new file opens at the top), re-opens/toggles restore the last position. */
const previewScrollMemory = new Map<string, number>()
const previewScrollKey = (scope: { sessionId: string }, path: string): string => `${scope.sessionId}::${path}`
export function TextEditor(props: FileViewerProps) {
const { ctx, scope, path, viewerId, content, truncated } = props
const [mode, setMode] = useState<ViewMode>('preview')
@@ -71,36 +74,39 @@ export function TextEditor(props: FileViewerProps) {
const themeCompRef = useRef<CmThemeCompartment | null>(null)
/** The app's resolved color scheme; the editor re-themes in place on flips. */
const [dark, setDark] = useState(() => isDarkScheme())
/** The floating "add to conversation" popup (viewport-anchored; null = hidden). */
const [popup, setPopup] = useState<SelectionPopup | null>(null)
/** Live mirror of the popup state for click-time reads (no re-render race). */
const popupRef = useRef<SelectionPopup | null>(null)
/** The markdown preview container (selection-containment + line lookup). */
const mdRef = useRef<HTMLDivElement>(null)
const markdown = viewerId === 'markdown'
const html = viewerId === 'html'
/** Preview scroll position across the preview<->edit toggle. The preview
* container re-mounts on every mode switch and its scrollTop lives on that
* element, so capture it on scroll and restore after each remount. Seeded
* from the module-level per-file memory so a full viewer rebuild
* (save-then-switch-to-preview reload) also keeps the position. */
const previewScrollRef = useRef(previewScrollMemory.get(previewScrollKey(scope, path)) ?? 0)
/** True while a programmatic restore is in flight; raw scroll events caused
* by the restore (or by the browser clamping a collapsed reload container
* to 0) must not overwrite the remembered position. */
const restoringRef = useRef(false)
/** Preview-side handoff data for the preview -> edit switch: the text at the
* top of the preview viewport (best-effort) plus the scroll ratio. Captured
* throttled on preview scroll; consumed when entering edit mode so the
* editor opens where the reader was instead of at the file top. */
const previewSyncRef = useRef<{ text: string | null; ratio: number }>({ text: null, ratio: 0 })
const anchorThrottleRef = useRef(false)
const hidePopup = (): void => {
popupRef.current = null
setPopup(null)
}
/** Anchor the popup above the selection center; clamp inside the viewport. */
const showPopup = (insert: string, left: number, top: number): void => {
const next: SelectionPopup = {
insert,
left: Math.min(Math.max(left, 80), window.innerWidth - 80),
top,
}
popupRef.current = next
setPopup(next)
}
/** The popup button's click: insert the stored payload into the draft. */
const commitPopup = (): void => {
const current = popupRef.current
if (current === null) return
appendToDraft(ctx, scope.sessionId, current.insert)
hidePopup()
}
/**
* The floating "add to conversation" popup (viewport-anchored; null =
* hidden). The hook owns show/hide/commit plus the global dismissal
* listeners (outside mousedown, Escape, hidden tab/window, surface
* leaving the viewport) — see selection-popup.ts.
*/
const selectionPopup = useSelectionPopup({
onCommit: (insert) => { appendToDraft(ctx, scope.sessionId, insert) },
// The surface that must stay on screen: the markdown preview container
// in preview mode, the CodeMirror host otherwise.
getSurface: () => (markdown && mode === 'preview' ? mdRef.current : hostRef.current),
})
useEffect(() => subscribeColorScheme(() => { setDark(isDarkScheme()) }), [])
@@ -110,15 +116,23 @@ export function TextEditor(props: FileViewerProps) {
setDraft(null)
setDirty(false)
setSaveState('idle')
hidePopup()
selectionPopup.hide()
}, [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])
// Create the CodeMirror editor once the content is loaded. The view owns
// the document; React only tracks dirty/draft state through the update
// listener. For markdown the view stays mounted while previewing (hidden),
// so unsaved edits survive the preview/edit toggle. The theme + syntax
// colors live in a compartment so a scheme flip reconfigures only that
// part — the document, undo history and scroll position survive.
// the document; React only tracks dirty state through the update listener
// (the draft — the preview's text — is snapshotted from the live view on
// entering preview, not re-stringified per keystroke). For markdown the
// view stays mounted while previewing (hidden), so unsaved edits survive
// the preview/edit toggle. The theme + syntax colors live in a compartment
// so a scheme flip reconfigures only that part — the document, undo
// history and scroll position survive.
useEffect(() => {
if (content === undefined) return
const host = hostRef.current
@@ -139,7 +153,6 @@ export function TextEditor(props: FileViewerProps) {
...(language !== null ? [language] : []),
CodeMirrorView.updateListener.of((update) => {
if (update.docChanged) {
setDraft(update.state.doc.toString())
setDirty(true)
}
}),
@@ -159,33 +172,33 @@ export function TextEditor(props: FileViewerProps) {
...(viewerId === 'code' || viewerId === 'markdown' ? [
CodeMirrorView.updateListener.of((update) => {
if (update.geometryChanged || update.viewportChanged) {
hidePopup()
selectionPopup.hide()
return
}
if (!update.view.hasFocus) {
hidePopup()
selectionPopup.hide()
return
}
if (!(update.selectionSet || update.docChanged || update.focusChanged)) return
const sel = update.state.selection.main
if (sel.empty) {
hidePopup()
selectionPopup.hide()
return
}
const text = update.state.sliceDoc(sel.from, sel.to)
if (text.trim() === '') {
hidePopup()
selectionPopup.hide()
return
}
// Page coordinates (the document root may scroll); the popup is
// position:fixed, so convert to viewport coordinates.
const rect = update.view.coordsAtPos(sel.head)
if (rect === null) {
hidePopup()
selectionPopup.hide()
return
}
const doc = update.state.doc
showPopup(
selectionPopup.show(
buildSelectionInsert(path, scope.cwd, {
start: doc.lineAt(sel.from).number,
end: doc.lineAt(sel.to).number,
@@ -220,12 +233,62 @@ export function TextEditor(props: FileViewerProps) {
// The editor may have been display:none while previewing; re-measure when
// it becomes visible again (CodeMirror sizes itself on reveal). A mode
// flip also invalidates any anchored selection popup.
// flip also invalidates any anchored selection popup. When entering edit
// from a scrolled preview, the editor opens where the reader was: the line
// mapped from the text anchored at the preview viewport top (or, failing a
// unique text match, the proportional scroll position).
useEffect(() => {
hidePopup()
if (mode === 'edit') viewRef.current?.requestMeasure()
selectionPopup.hide()
if (mode !== 'edit') return
const view = viewRef.current
if (view === null) return
const sync = previewSyncRef.current
if (!markdown) return
const doc = view.state.doc
let target: number | undefined
if (sync.text !== null) {
const lines = linesOfSelection(mdText, sync.text)
if (lines !== null) target = doc.line(Math.min(lines.start, doc.lines)).from
}
// ratio === 1 means the reader was at the very bottom (e.g. the last
// block's text is a repeated filler line that linesOfSelection rejects
// as ambiguous) — it must still land the editor at the bottom.
if (target === undefined && sync.ratio > 0 && sync.ratio <= 1) {
target = Math.max(1, Math.min(doc.length - 1, Math.round(doc.length * sync.ratio)))
}
if (target === undefined) return
// Position the editor by writing its OWN scroller directly (after a fresh
// measure) instead of CodeMirror's scrollIntoView: that path walks every
// scrollable ancestor — and even the window when the browser is zoomed
// (visualViewport < innerHeight) — to reveal the target, which dragged
// the whole sidebar up when the reader was at the very end of the
// document. A plain scrollTop write on the editor scroller can never
// touch anything outside the editor.
view.requestMeasure()
view.dispatch({ selection: { anchor: target } })
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const block = view.lineBlockAt(target)
view.scrollDOM.scrollTop = Math.max(0, block.top - 8)
// Force CodeMirror to re-measure and re-render its virtualized
// viewport at the NEW scroll position (its scroll-observer is async
// and can lag a direct write).
view.requestMeasure()
})
})
}, [mode])
// Snapshot the live document into the draft whenever the preview needs
// it: entering preview (the markdown preview renders `draft ?? content`)
// and a content swap (the view was just re-created above, so the read is
// the new document — matching the reset-to-null of a clean tab). The
// updateListener used to re-stringify the WHOLE document on every
// keystroke (O(docLength) per key) for a draft only preview reads.
useEffect(() => {
const view = viewRef.current
setDraft(view === null ? null : view.state.doc.toString())
}, [mode, content])
const save = (): void => {
const view = viewRef.current
if (view === null || savingRef.current) return
@@ -242,29 +305,47 @@ export function TextEditor(props: FileViewerProps) {
})
}
const markdown = viewerId === 'markdown'
const html = viewerId === 'html'
/** The markdown source the preview renders (draft wins over saved content). */
const mdText = draft ?? content ?? ''
/** The preview source: `mdText` with local image destinations rewritten to
* absolute media URLs (see {@link rewriteLocalImageUrls}); the raw
* `mdText` stays untouched for selection/line lookup and for mermaid-block
* detection, which are unaffected by image syntax. */
/** Preview-only source with a closed leading YAML frontmatter block hidden.
* The raw `mdText` stays untouched for editing, saving, and selection line
* lookup. All preview renderers share this source so plain Markdown,
* Mermaid, and documents containing raw HTML behave consistently. */
const previewMdText = markdown ? markdownPreviewSource(mdText) : mdText
// Re-apply the remembered preview scroll position whenever the preview
// container mounts or its content changes (mode flip back to preview, or a
// same-file reload — e.g. the save-then-switch-to-preview reload — which
// temporarily collapses the container and clamps scrollTop to 0). Restored
// in a before-paint layout effect so the user never sees the top flash.
useLayoutEffect(() => {
if (mode !== 'preview') return
const el = mdRef.current
if (el === null || previewScrollRef.current <= 0) return
if (el.scrollHeight <= el.clientHeight) return
if (el.scrollTop === previewScrollRef.current) return
restoringRef.current = true
el.scrollTop = previewScrollRef.current
requestAnimationFrame(() => { restoringRef.current = false })
}, [mode, previewMdText])
/** The preview source with local image destinations rewritten to absolute
* media URLs (see {@link rewriteLocalImageUrls}). */
const previewText = markdown
? rewriteLocalImageUrls(mdText, scope, path, window.location.origin)
: mdText
? rewriteLocalImageUrls(previewMdText, scope, path, window.location.origin)
: previewMdText
/** md/mermaid block split for the preview (mermaid fences lift out). Split
* only in preview mode: edit-mode keystrokes must not re-scan the source. */
const mdBlocks = useMemo(
() => (markdown && mode === 'preview' ? splitMermaidBlocks(mdText) : []),
[markdown, mode, mdText],
() => (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. */
const htmlInfo = useMemo(
() => (markdown && mode === 'preview' ? analyzeMarkdownHtml(mdText) : null),
[markdown, mode, mdText],
() => (markdown && mode === 'preview' ? analyzeMarkdownHtml(previewMdText) : null),
[markdown, mode, previewMdText],
)
const hasMermaid = useMemo(
() => htmlInfo !== null
@@ -295,22 +376,22 @@ export function TextEditor(props: FileViewerProps) {
const handlePreviewMouseUp = (): void => {
const sel = window.getSelection()
if (sel === null || sel.isCollapsed || sel.anchorNode === null || sel.focusNode === null) {
hidePopup()
selectionPopup.hide()
return
}
const host = mdRef.current
if (host === null || !host.contains(sel.anchorNode) || !host.contains(sel.focusNode)) {
hidePopup()
selectionPopup.hide()
return
}
const text = sel.toString()
if (text.trim() === '') {
hidePopup()
selectionPopup.hide()
return
}
const rect = sel.getRangeAt(0).getBoundingClientRect()
const lines = linesOfSelection(mdText, text)
showPopup(
selectionPopup.show(
buildSelectionInsert(path, scope.cwd, lines ?? undefined, text),
rect.left + rect.width / 2,
rect.top,
@@ -401,7 +482,36 @@ export function TextEditor(props: FileViewerProps) {
className={css.editorMd}
ref={mdRef}
onMouseUp={handlePreviewMouseUp}
onScroll={hidePopup}
onScroll={(event) => {
const el = event.currentTarget
if (!restoringRef.current && el.scrollHeight > el.clientHeight) {
previewScrollRef.current = el.scrollTop
previewScrollMemory.set(previewScrollKey(scope, path), el.scrollTop)
}
if (!anchorThrottleRef.current) {
anchorThrottleRef.current = true
setTimeout(() => { anchorThrottleRef.current = false }, 120)
const ratio = el.scrollHeight > el.clientHeight
? el.scrollTop / (el.scrollHeight - el.clientHeight)
: 0
// The first block below the viewport's top edge, chosen with
// layout coordinates (caretRangeFromPoint needs an in-viewport
// point and returns null when the panel is partially off-screen).
// Its text is the anchor the editor syncs to on preview -> edit.
let text: string | null = null
const base = el.getBoundingClientRect()
const blocks = el.querySelectorAll('h1, h2, h3, h4, h5, h6, p, li')
for (const block of blocks) {
if (block.getBoundingClientRect().top - base.top + el.scrollTop >= el.scrollTop - 2) {
const t = (block.textContent ?? '').replace(/[ \t\r\n]+/g, ' ').trim()
if (t.length >= 8) text = t
break
}
}
previewSyncRef.current = { text, ratio }
}
selectionPopup.hide()
}}
>
{/* The fence copy-button labels must come from this plugin's own
dictionary: the DSH MarkdownText/CodeBlock are cordis-free and
@@ -449,15 +559,16 @@ export function TextEditor(props: FileViewerProps) {
/>
</>
)}
{popup !== null && createPortal(
{selectionPopup.popup !== null && createPortal(
<button
type="button"
ref={selectionPopup.buttonRef}
className={css.selectionPopup}
style={{ left: popup.left, top: popup.top }}
style={{ left: selectionPopup.popup.left, top: selectionPopup.popup.top }}
// Keep the selection (and CodeMirror focus) alive until the click
// commits — without this the popup unmounts before click lands.
onMouseDown={(event) => { event.preventDefault() }}
onClick={commitPopup}
onClick={selectionPopup.commit}
>
{t('addToConversation')}
</button>,
+16 -1
View File
@@ -62,7 +62,7 @@ export function TreePanel(props: {
openWithSsh?: boolean
onOpenWith?: (targetId: string, path: string) => void
onToggleOpenWithPin?: (targetId: string) => void
onReferenceFile: (path: string) => void
onReferenceFile: (path: string, isDir: boolean) => void
/** Full-window presentation: the panel fills its host instead of docking
* at a fixed width. */
full?: boolean
@@ -72,6 +72,21 @@ export function TreePanel(props: {
const [results, setResults] = useState<{ matches: string[]; truncated: boolean } | null>(null)
const [error, setError] = useState<string | null>(null)
const [refreshTick, setRefreshTick] = useState(0)
// The tree caches loaded directories per refresh tick, so content changed
// outside DSH (another editor, a sync tool) stays stale until the manual
// refresh click. Re-focusing the window bumps the tick automatically, and
// integrations can force a refresh by dispatching a bubbling
// `dsh-sidebar:refresh-files` event on `window`.
useEffect(() => {
const bump = (): void => { setRefreshTick(tick => tick + 1) }
window.addEventListener('focus', bump)
window.addEventListener('dsh-sidebar:refresh-files', bump)
return () => {
window.removeEventListener('focus', bump)
window.removeEventListener('dsh-sidebar:refresh-files', bump)
}
}, [])
/** One-line upload status under the search row ('' hides the hint). */
const [uploadStatus, setUploadStatus] = useState('')
/** Whether the status line is a failure/cancel (error color, stays visible). */
+50
View File
@@ -0,0 +1,50 @@
/**
* Tab glyphs for the apes tabs (智能体 / 应用 / 画像).
*
* These follow the SAME recipe as this repo's own icon set in
* `src/client/icons.tsx` — the one the sidebar already uses for 终端 /
* 浏览器 / 文件变动: a 16×16 viewBox whose artwork spans 1.5–14.5, a 1.5px
* `currentColor` stroke, round caps/joins, `fill: none`.
*
* That recipe is what makes an icon sit at the native visual weight in the
* `+` new-tab menu and the right-panel tile grid: DSH's own glyphs fill
* ~92–96% of the box, so artwork padded any tighter renders visibly small
* next to them.
*
* (An earlier revision drew these as fill-only paths spanning only 10–12 of
* the 16 units — that is why they read as a separate, lighter family.)
*/
import type { ReactNode } from 'react'
/** Agent glyph: a rounded robot head with two fill eyes. */
export function IconAgentOutline16({ size = 16, className }: { size?: number; className?: string }): ReactNode {
return (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
<rect x="1.5" y="3" width="13" height="11.5" rx="4.2" stroke="currentColor" strokeWidth="1.5" />
<circle cx="5.8" cy="8.6" r="1.25" fill="currentColor" />
<circle cx="10.2" cy="8.6" r="1.25" fill="currentColor" />
</svg>
)
}
/** Apps glyph: a 2×2 grid of rounded squares. */
export function IconAppsOutline16({ size = 16, className }: { size?: number; className?: string }): ReactNode {
return (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
<rect x="1.5" y="1.5" width="5.5" height="5.5" rx="1.8" stroke="currentColor" strokeWidth="1.5" />
<rect x="9" y="1.5" width="5.5" height="5.5" rx="1.8" stroke="currentColor" strokeWidth="1.5" />
<rect x="1.5" y="9" width="5.5" height="5.5" rx="1.8" stroke="currentColor" strokeWidth="1.5" />
<rect x="9" y="9" width="5.5" height="5.5" rx="1.8" stroke="currentColor" strokeWidth="1.5" />
</svg>
)
}
/** Profile glyph: a head over shoulders. */
export function IconUserOutline16({ size = 16, className }: { size?: number; className?: string }): ReactNode {
return (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
<circle cx="8" cy="4.9" r="3.4" stroke="currentColor" strokeWidth="1.5" />
<path d="M1.5 14.5c0-3.3 2.8-5.3 6.5-5.3s6.5 2 6.5 5.3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
}
+43
View File
@@ -0,0 +1,43 @@
/**
* apes:* information-layer tabs — the fork's own contribution on top of the
* upstream dsh-better-sidebar. Three tabs ship (agents / apps / profile);
* `apes:tasks` was removed (C6) because it overlaps the official DSH session
* tree in the native sidebar, and `messages` is deferred (no DSH
* message-stream API yet — tracked separately).
*
* Isolated under `src/client/apes/` so the upstream `builtins/` tree stays
* untouched: cherry-picking upstream releases only has to keep the single
* `registerApesTabs(...)` call site in `index.tsx` (see D1 = module B).
*
* Every descriptor is registered through the same `BetterSidebarService`
* the builtins use (dogfood), so the apes tabs get the full sidebar
* lifecycle for free: + menu, enable/disable switches, badge pills,
* dedupe/focus, and HMR-safe disposal via the returned disposer.
*/
import type { Context } from '../../context-types.ts'
import type { BetterSidebarService } from '../service.ts'
import { apesTabs } from './tabs.tsx'
// Re-exported so index.tsx can register the apes dictionaries + attach the
// locale service in one import (keeps the apes module self-contained).
export { APES_LOCALE_NS, apesZh, apesEn, attachApesLocale } from './locales.ts'
/**
* Register the apes:* tabs with the sidebar service. Returns a disposer that
* unregisters all of them (cordis auto-invokes it on fiber disposal, so HMR
* re-activation re-registers cleanly — same contract as registerBuiltins).
*/
export function registerApesTabs(
_ctx: Context,
service: BetterSidebarService,
): () => void {
const disposers: (() => void)[] = []
for (const tab of apesTabs) {
disposers.push(service.registerTab(tab))
}
return () => {
for (const d of disposers) {
try { d() } catch { /* already disposed */ }
}
}
}
+90
View File
@@ -0,0 +1,90 @@
/**
* apes:* tab i18n — isolated from the upstream `betterSidebar` locale
* namespace (D1 = module B). The fork keeps its own `apes` namespace and
* its own `t()` so that adding apes keys never touches the upstream
* `locales.ts` (whose `CopyKey = keyof typeof zh` is closed over its own
* dicts). The `t()` mirrors the upstream implementation's resolution order
* (active-locale chain → key fallback) but reads the apes dicts; it is NOT
* wired to the better-locale override store in Phase 1 (those third-language
* overrides ride the lazy `locale` chunk that only covers `betterSidebar`
* entries — apes keys fall back to zh/en, which is acceptable for the
* placeholder round and gets the override integration in Phase 2).
*/
/** The apes locale namespace (registered with ctx.locale under this key). */
export const APES_LOCALE_NS = 'apes'
/** The zh dictionary (source of truth). */
export const apesZh = {
// View titles
apesAgentsTitle: '智能体',
apesAppsTitle: '应用',
apesProfileTitle: '画像',
// Agents view
apesAgentsEmpty: '当前会话没有子智能体',
apesAgentsRoot: '主智能体',
apesAgentRunning: '运行中',
apesAgentInactive: '空闲',
apesAgentOneShot: '一次性',
apesAgentContinuable: '可续',
// Profile view
apesProfileCurrent: '当前会话',
apesProfileCwd: '工作目录',
apesProfileNoCwd: '未知',
apesProfileRunning: '运行中',
apesProfileIdle: '空闲',
/** Guide-page capsule description (official right sidebar's entry list). */
apesProfileGuideDesc: '当前会话的身份与工作目录',
// Apps view
apesAppsNoFeed: '当前 DSH 版本未提供工作区应用数据源',
apesAppsWorkspace: '工作区',
} as const
/** The en dictionary (checked against zh — every zh key must appear here). */
export const apesEn: Record<keyof typeof apesZh, string> = {
apesAgentsTitle: 'Agents',
apesAppsTitle: 'Apps',
apesProfileTitle: 'Profile',
apesAgentsEmpty: 'No subagents in this session',
apesAgentsRoot: 'Main agent',
apesAgentRunning: 'running',
apesAgentInactive: 'idle',
apesAgentOneShot: 'one-shot',
apesAgentContinuable: 'continuable',
apesProfileCurrent: 'Current session',
apesProfileCwd: 'Working directory',
apesProfileNoCwd: 'unknown',
apesProfileRunning: 'running',
apesProfileIdle: 'idle',
apesProfileGuideDesc: "This session's identity and working directory",
apesAppsNoFeed: 'This DSH version exposes no workspace-app feed',
apesAppsWorkspace: 'Workspace',
}
export type ApesCopyKey = keyof typeof apesZh
/** Resolve the currently active DSH locale ('zh' | 'en' today). */
export function attachApesLocale(service: { getSnapshot(): { active: string } } | undefined): void {
apesLocaleService = service
}
let apesLocaleService: { getSnapshot(): { active: string } } | undefined
/**
* Translate an apes key. Resolution order: active-locale chain (zh dict when
* the DSH preference is zh, en dict otherwise) → key fallback (shows the key
* itself rather than `undefined`). Mirrors the upstream t() minus the
* better-locale override (Phase 2).
*/
export function t(key: ApesCopyKey, params?: Record<string, string | number>): string {
const active = apesLocaleService?.getSnapshot().active ?? 'zh'
const dict = active.toLowerCase().startsWith('zh') ? apesZh : apesEn
let text: string | undefined = dict[key]
if (text === undefined) text = key
if (params !== undefined) {
for (const [name, value] of Object.entries(params)) {
text = text.replaceAll(`{${name}}`, String(value))
}
}
return text
}
+187
View File
@@ -0,0 +1,187 @@
/**
* Native right-sidebar bridge (Phase A spike) — registers apes:* tabs as
* OFFICIAL `@deepseek-ai/dsh-client-ui-sidebar-right` tab types through the
* public two-stage protocol its README documents:
*
* 1. TYPE — `ctx.sidebarRightTabs.register({ id, kind, priority, title, guide? })`
* a static declaration with no runtime hooks; the `id` is the type's
* identity in the tab system AND the key its body registers under.
* 2. BODY — `ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register(
* { name: 'sidebar.right.pane.tab', key: <id> }, Body))`
* the keyed seat dispatches a tab's body to the type registered for its
* `kind`; the body reads `useTabInfo()` (injected by the seat) for
* `{ sidebar, panel, tab }`.
*
* ACTIVATION ORDER — `sidebarRightTabs` is installed by that package's own
* `apply()` through `ctx.reflect.provide(...)`, so it may not exist yet when
* our plugin activates. We therefore reach it through `ctx.inject(['sidebarRightTabs'], …)`
* (the same pattern intercept.tsx uses for the async `remote.session`
* namespace): the callback runs when the service exists and re-runs after a
* remount, so the bridge is order-independent and HMR-safe. Deployments
* without the official right sidebar simply never see the callback — the
* fork's own shell stays the only surface, which is also what the render-time
* `guide`/type registration tolerates.
*
* SCOPE — the spike registers exactly ONE tab (`apes:profile` / 画像) to prove
* the path end-to-end before deciding on the full nine-tab migration. The
* existing `service.registerTab` route in `tabs.tsx` is deliberately left
* untouched, so both surfaces coexist during the spike and the whole spike
* reverts by deleting this file plus its one call site.
*
* Side effect worth knowing (inherent to the official default-page rule): the
* dock's default page depends on how many guide entries are registered —
* exactly one auto-opens it, zero or many open the guide page. Our guide entry
* therefore turns a fresh-empty dock from "Files" into the guide page listing
* every entry capsule.
*/
import { useMemo, type ReactNode } from 'react'
import { IconUserOutline16 } from './icons.tsx'
import { t } from './locales.ts'
import { ApesProfileView } from './tabs.tsx'
import type { TabComponentProps } from '../service.ts'
/** The apes type's identity in the official tab system. Package-scoped so it
* can never collide with a native or third-party registration. */
const PROFILE_ID = 'dsh-apes-sidebar/profile'
/** The kind the panel opens the tab with (`openTab(kind)`); `apes:` prefixed so
* it reads as ours in the official registry. */
const PROFILE_KIND = 'apes:profile'
/** The official registry's definition shape (the subset we use). */
interface NativeTabDefinition {
id: string
kind: string
priority?: 'builtin' | 'extension' | 'fallback'
title: () => string
guide?: Array<{
order: number
title: () => string
description?: () => string
icon?: (props: { size?: number; className?: string }) => ReactNode
}>
}
/** The registry service face the official package provides. */
interface NativeTabRegistry {
register(definition: NativeTabDefinition): () => void
}
/** The slots service face (structural subset of `@deepseek-ai/dsh-client-ui-slots`). */
interface NativeSlots {
inject(key: string, callback: () => () => void): () => void
register(options: {
name: string
key?: string
locale?: string
inject?: (...args: unknown[]) => Record<string, unknown>
}, component: (props: Record<string, unknown>) => ReactNode): () => void
}
/** What the seat hands a tab body in addition to our own injected props. */
interface NativeTabBodyProps {
useTabInfo?: () => unknown
sessionId?: string
ctx: TabComponentProps['ctx']
}
/** The tab info the seat's `useTabInfo()` hook returns (the subset we read). */
interface NativeTabInfo {
sidebar?: { expanded?: boolean; fullscreen?: boolean }
panel?: { id?: string }
tab?: { id?: string; title?: string; visible?: boolean }
}
/** Fallback reader for an absent seat hook (never in production — the seat
* always injects `tabInfo`; this keeps the body hook-safe either way, since
* the fallback calls no hooks of its own). */
function fallbackTabInfo(): NativeTabInfo {
return { sidebar: { expanded: true }, panel: { id: '' }, tab: { visible: true } }
}
/**
* The official surface's body for 画像: the SAME `ApesProfileView` the fork
* already ships, re-bound to the official tab scope. The view reads only
* `props.ctx` (the sessions feed) and `props.scope` (sessionId / cwd), so the
* shell-only props are filled with placeholders — which is the spike's whole
* point: the panel content is shared, never duplicated.
*/
function ProfileNativeBody(props: NativeTabBodyProps): ReactNode {
const useInfo = props.useTabInfo ?? fallbackTabInfo
const info = useInfo() as NativeTabInfo | undefined
const sessionId = props.sessionId
?? props.ctx.sessions.list.getSnapshot().current
?? ''
const scope = useMemo(() => ({ sessionId }), [sessionId])
const viewProps = {
ctx: props.ctx,
scope,
visible: info?.tab?.visible ?? true,
store: undefined,
tab: {
id: info?.tab?.id ?? PROFILE_ID,
type: PROFILE_KIND,
title: info?.tab?.title ?? t('apesProfileTitle'),
},
} as unknown as TabComponentProps
return <ApesProfileView {...viewProps} />
}
/**
* Register the apes:profile tab as an official right-sidebar tab type.
* @param ctx — the client context whose `inject` waits for the registry service.
* @returns a disposer that disposes the inject fiber (which unwinds both stages).
*/
export function registerApesNativeTabs(ctx: unknown): () => void {
const anyCtx = ctx as {
inject?: (deps: string[], callback: (fctx: unknown) => void) => { dispose(): void }
}
if (typeof anyCtx.inject !== 'function') return () => {}
const fiber = anyCtx.inject(['sidebarRightTabs'], (fctx: unknown) => {
const inner = fctx as {
get?: (name: string) => unknown
slots?: NativeSlots
effect?: (callback: () => () => void, label?: string) => unknown
}
const registry = inner.get?.('sidebarRightTabs') as NativeTabRegistry | undefined
const slots = inner.slots
if (registry === undefined || slots === undefined || typeof inner.effect !== 'function') return
inner.effect(() => {
const disposers: Array<() => void> = []
try {
// Stage 1 — the type. A page type: it recognizes no resource address
// and is opened by kind; `extension` is the band for types contributed
// from outside the product (highest rank, so it can also take over a
// builtin of the same kind).
disposers.push(registry.register({
id: PROFILE_ID,
kind: PROFILE_KIND,
priority: 'extension',
title: () => t('apesProfileTitle'),
guide: [{
order: 70,
title: () => t('apesProfileTitle'),
description: () => t('apesProfileGuideDesc'),
icon: IconUserOutline16,
}],
}))
// Stage 2 — the body, dispatched through the keyed seat. `inject`
// hands the body the client context the view reads its feed from.
disposers.push(slots.inject('sidebar.right.pane.tab', () => slots.register({
name: 'sidebar.right.pane.tab',
key: PROFILE_ID,
inject: () => ({ ctx }),
}, ProfileNativeBody as unknown as (props: Record<string, unknown>) => ReactNode)))
} catch (error) {
// A half-registered bridge must never take the fork's own sidebar
// down: unwind what landed and let the own-shell surface carry on.
console.error('[dsh-apes-sidebar] native right-sidebar registration failed:', error)
}
return () => {
for (const dispose of disposers.reverse()) {
try { dispose() } catch { /* already disposed */ }
}
}
}, 'dsh-apes-sidebar: apes native tab (profile)')
})
return () => { void fiber.dispose() }
}
+237
View File
@@ -0,0 +1,237 @@
/**
* apes:* tab views (D2 = data wiring). Each tab reads live data from the
* sessions list feed (`ctx.sessions.list`) via `useSyncExternalStore` and
* reuses the fork's PURE lineage/job helpers — these are framework-free and
* live outside `builtins/`, so importing them does not couple the apes module
* to upstream UI and survives cherry-picks.
*
* Three information-layer tabs (C6: `apes:tasks` removed — it overlaps the
* official DSH session tree in the native sidebar):
* - apes:agents → the subagent topology rooted at the main agent of the
* current session (rootAncestor + per-parent catalogs).
* - apes:apps → no DSH host feed today: shows the workspace (cwd) of the
* current session, otherwise an honest "no feed" note.
* - apes:profile → the current session's summary row (title, cwd, running).
*
* Visual unity (C7): colors use the DSW design-token aliases
* (`--dsw-alias-*`) so the apes panels match the native DSH sidebar
* surface; the runtime defines these on :root. Font sizes / spacing /
* radius stay inline (DSH exposes no public `--ds-*` geometry alias for
* plugin panels — its own vendor bundles inline those too).
*
* Data sources (structural mirror — runtime provided by the DSH host):
* - `ctx.sessions.list.getSnapshot()` → { current, byId, subagentsByParent? };
* `subscribe(cb)` is uSES-safe.
* - `scope.sessionId` = the currently-open session.
*/
import { useCallback, useMemo, useState, type CSSProperties } from 'react'
import { useSyncExternalStore } from 'react'
import { IconAgentOutline16, IconAppsOutline16, IconUserOutline16 } from './icons.tsx'
import type { TabComponentProps, TabDescriptor } from '../service.ts'
import type {
SidebarSessionList,
SidebarSessionSummary,
SidebarSubagentCatalog,
SidebarSubagentChildEntry,
} from '../../context-types.ts'
import { rootAncestor, isSideThreadSummary } from '../subagent-lineage.ts'
import { t } from './locales.ts'
/**
* DSW design-token color aliases. The DSH runtime defines these on :root
* (same set the native dsh-client-ui-* vendor bundles consume). We reference
* them by name via `var(...)` with a literal fallback so the panels still
* render in a non-DSH context (unit harness) with a sane dark palette.
*/
const DSW = {
bgBase: 'var(--dsw-alias-bg-base, #1a1a1a)',
bgLayer1: 'var(--dsw-alias-bg-layer-1, #202020)',
borderL1: 'var(--dsw-alias-border-l1, #2a2a2a)',
borderL2: 'var(--dsw-alias-border-l2, #303030)',
labelPrimary: 'var(--dsw-alias-label-primary, #e8e8e8)',
labelSecondary: 'var(--dsw-alias-label-secondary, #a0a0a0)',
labelTertiary: 'var(--dsw-alias-label-tertiary, #7a7a7a)',
success: 'var(--dsw-alias-state-success-primary, #52c41a)',
interactiveHover: 'var(--dsw-alias-interactive-bg-hover, rgba(255,255,255,0.06))',
} as const
/** Shared inline styles (KISS: one object, no CSS module). Colors via DSW. */
const S: Record<string, CSSProperties> = {
body: { padding: 12, display: 'flex', flexDirection: 'column', gap: 12 },
title: { margin: 0, fontSize: 14, fontWeight: 600, color: DSW.labelPrimary },
hint: { margin: 0, fontSize: 12, color: DSW.labelTertiary },
list: { display: 'flex', flexDirection: 'column', gap: 8 },
card: { border: `1px solid ${DSW.borderL1}`, borderRadius: 8, padding: 10, display: 'flex', flexDirection: 'column', gap: 6, background: DSW.bgLayer1 },
cardRow: { display: 'flex', alignItems: 'center', gap: 8 },
cardLabel: { fontSize: 13, fontWeight: 500, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: DSW.labelPrimary },
cardSecondary: { fontSize: 11, color: DSW.labelSecondary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
badge: { fontSize: 11, color: DSW.labelTertiary, flexShrink: 0 },
}
/** A small colored status dot (self-contained, not the builtin StateDot). */
function Dot(props: { color: string }) {
const style: CSSProperties = {
width: 8, height: 8, borderRadius: '50%', background: props.color, flexShrink: 0, display: 'inline-block',
}
return <span aria-hidden style={style} />
}
/** Subscribe to the sessions list feed (uSES-safe). Returns the live snapshot. */
function useSessionList(ctx: TabComponentProps['ctx']): SidebarSessionList {
const sessions = ctx.sessions
return useSyncExternalStore(
useMemo(() => (callback: () => void) => sessions.list.subscribe(callback), [sessions]),
useCallback(() => sessions.list.getSnapshot(), [sessions]),
)
}
/** One level of the subagent topology (rooted at the main agent). */
function agentChildren(
byId: Readonly<Record<string, SidebarSessionSummary>>,
parentSessionId: string,
): SidebarSessionSummary[] {
return Object.values(byId).filter(
s => s.origin === 'subagent' && s.parentId === parentSessionId && !isSideThreadSummary(s),
)
}
/** apes:agents — the subagent topology of the current session's tree. */
function ApesAgentsView(props: TabComponentProps) {
const list = useSessionList(props.ctx)
const { byId, subagentsByParent } = list
const rootId = useMemo(() => rootAncestor(byId, props.scope.sessionId), [byId, props.scope.sessionId])
const rootSummary = rootId === undefined ? undefined : byId[rootId]
const rootCatalog = rootId === undefined ? undefined : subagentsByParent?.[rootId]
// The topology: the main agent + its direct subagent children (one level,
// with per-child activity). A lazy catalog (subagentsByParent) is preferred
// for the child list when present; otherwise fall back to durable byId rows.
// (`.filter` does not narrow a union — narrow explicitly to child entries.)
const children: SidebarSubagentChildEntry[] = (rootCatalog?.entries ?? [])
.filter((e): e is SidebarSubagentChildEntry => e.kind === 'child')
.concat(rootCatalog === undefined
? agentChildren(byId, rootId ?? '').map<SidebarSubagentChildEntry>(s => ({
kind: 'child', id: s.id, mode: 'continuable',
activity: s.running === true ? 'running' : 'inactive',
label: s.displayTitle, hasChildren: agentChildren(byId, s.id).length > 0,
}))
: [])
return (
<div style={S.body}>
<h2 style={S.title}>{t('apesAgentsTitle')}</h2>
{rootSummary === undefined && children.length === 0
? <p style={S.hint}>{t('apesAgentsEmpty')}</p>
: (
<div style={S.list}>
{/* Root (main agent) node */}
{rootSummary !== undefined && (
<div style={S.card}>
<div style={S.cardRow}>
<Dot color={rootSummary.running === true ? DSW.success : DSW.labelTertiary} />
<span style={S.cardLabel} title={rootSummary.displayTitle}>{t('apesAgentsRoot')}: {rootSummary.displayTitle}</span>
</div>
<div style={S.cardRow}>
<span style={S.badge}>{rootSummary.running === true ? t('apesAgentRunning') : t('apesAgentInactive')}</span>
</div>
</div>
)}
{/* Direct subagent children */}
{children.map(entry => {
const summary = byId[entry.id]
const running = entry.activity === 'running'
const label = entry.label ?? summary?.displayTitle ?? entry.id
return (
<div key={entry.id} style={{ ...S.card, marginLeft: 14, borderLeft: `2px solid ${DSW.borderL2}` }}>
<div style={S.cardRow}>
<Dot color={running ? DSW.success : DSW.labelTertiary} />
<span style={S.cardLabel} title={label}>{label}</span>
</div>
<div style={S.cardRow}>
<span style={S.cardSecondary}>
{entry.mode === 'one-shot' ? t('apesAgentOneShot') : t('apesAgentContinuable')}
{' · '}
{running ? t('apesAgentRunning') : t('apesAgentInactive')}
</span>
</div>
</div>
)
})}
</div>
)}
</div>
)
}
/** apes:profile — the current session's identity (title, cwd, running state). */
export function ApesProfileView(props: TabComponentProps) {
const list = useSessionList(props.ctx)
const current = list.current
const summary = current === undefined ? undefined : list.byId[current]
const cwd = summary?.cwd ?? props.scope.cwd
return (
<div style={S.body}>
<h2 style={S.title}>{t('apesProfileTitle')}</h2>
{summary === undefined
? <p style={S.hint}>{t('apesProfileNoCwd')}</p>
: (
<div style={S.list}>
<div style={S.card}>
<div style={S.cardRow}>
<Dot color={summary.running === true ? DSW.success : DSW.labelTertiary} />
<span style={S.cardLabel} title={summary.displayTitle}>{t('apesProfileCurrent')}</span>
</div>
<div style={S.cardRow}>
<span style={S.badge}>{summary.running === true ? t('apesProfileRunning') : t('apesProfileIdle')}</span>
</div>
</div>
<div style={S.card}>
<div style={S.cardRow}>
<span style={S.cardLabel}>{summary.displayTitle}</span>
</div>
<div style={S.cardRow}>
<span style={S.cardSecondary}>{t('apesProfileCwd')}: {cwd ?? t('apesProfileNoCwd')}</span>
</div>
</div>
</div>
)}
</div>
)
}
/** apes:apps — no DSH host feed today; shows the current workspace (cwd). */
function ApesAppsView(props: TabComponentProps) {
const list = useSessionList(props.ctx)
const current = list.current
const summary = current === undefined ? undefined : list.byId[current]
const cwd = summary?.cwd ?? props.scope.cwd
return (
<div style={S.body}>
<h2 style={S.title}>{t('apesAppsTitle')}</h2>
<p style={S.hint}>{t('apesAppsNoFeed')}</p>
<div style={S.card}>
<div style={S.cardRow}>
<span style={S.cardLabel}>{t('apesAppsWorkspace')}</span>
</div>
<div style={S.cardRow}>
<span style={S.cardSecondary}>{cwd ?? t('apesProfileNoCwd')}</span>
</div>
</div>
</div>
)
}
/**
* The three apes:* tab descriptors (C6: `apes:tasks` removed — overlaps the
* official DSH session tree). Order 901-903: agents/apps/profile. Each is
* already shaped as `TabDescriptor` (component + single-instance + icon
* element + i18n title function) so `registerApesTabs` can hand them straight
* to `service.registerTab`. Information-layer tabs are single-instance.
*/
export const apesTabs: TabDescriptor[] = [
{ id: 'apes:agents', title: () => t('apesAgentsTitle'), icon: (size: number) => <IconAgentOutline16 size={size} />, order: 60, single: true, component: ApesAgentsView },
{ id: 'apes:apps', title: () => t('apesAppsTitle'), icon: (size: number) => <IconAppsOutline16 size={size} />, order: 65, single: true, component: ApesAppsView },
{ id: 'apes:profile', title: () => t('apesProfileTitle'), icon: (size: number) => <IconUserOutline16 size={size} />, order: 70, single: true, component: ApesProfileView },
]
+79 -24
View File
@@ -9,6 +9,7 @@
import { encodeHtmlUrl } from '../html-route.ts'
import type { LastActivity } from '../subagent-activity.ts'
import type { SidechatLogEvent, SidechatThreadInfo } from '../sidechat-core.ts'
import type { SidebarSessionEvent } from '../context-types.ts'
import type { BrowserProbeResult } from './browser.ts'
/** One wire failure. */
@@ -128,6 +129,25 @@ export type TerminalDepsStatus =
note?: string
}
/**
* Parse one `/sidebar` JSON response envelope into its value. A non-ok
* status, an unparseable body, or any shape other than `{ok: true, value}`
* surfaces as {@link SidebarApiError} carrying the wire code (falling back
* to the HTTP status). Shared by the JSON api route and the raw upload
* route, whose envelopes are identical.
*/
async function readEnvelope<T>(response: Response): Promise<T> {
const parsed: { ok?: boolean; value?: unknown; error?: { code?: string; message?: string } } | null
= await response.json().catch(() => null)
if (!response.ok || parsed === null || parsed.ok !== true || parsed.value === undefined) {
throw new SidebarApiError(
parsed?.error?.code ?? 'http',
parsed?.error?.message ?? `HTTP ${response.status}`,
)
}
return parsed.value as T
}
async function call<T>(method: string, payload: Record<string, unknown>, signal?: AbortSignal): Promise<T> {
let response: Response
try {
@@ -140,15 +160,7 @@ async function call<T>(method: string, payload: Record<string, unknown>, signal?
} catch (error) {
throw new SidebarApiError('network', error instanceof Error ? error.message : String(error))
}
const parsed: { ok?: boolean; value?: unknown; error?: { code?: string; message?: string } } | null
= await response.json().catch(() => null)
if (!response.ok || parsed === null || parsed.ok !== true || parsed.value === undefined) {
throw new SidebarApiError(
parsed?.error?.code ?? 'http',
parsed?.error?.message ?? `HTTP ${response.status}`,
)
}
return parsed.value as T
return readEnvelope<T>(response)
}
/**
@@ -179,15 +191,7 @@ async function fetchUpload<T>(
if (error instanceof DOMException && error.name === 'AbortError') throw error
throw new SidebarApiError('network', error instanceof Error ? error.message : String(error))
}
const parsed: { ok?: boolean; value?: unknown; error?: { code?: string; message?: string } } | null
= await response.json().catch(() => null)
if (!response.ok || parsed === null || parsed.ok !== true || parsed.value === undefined) {
throw new SidebarApiError(
parsed?.error?.code ?? 'http',
parsed?.error?.message ?? `HTTP ${response.status}`,
)
}
return parsed.value as T
return readEnvelope<T>(response)
}
/** One request's session scope: the conversation id plus its cwd when known. */
@@ -215,6 +219,51 @@ function gitPayload(scope: SessionScope, worktree: string | undefined, extra: Re
return scopePayload(scope, { ...(worktree !== undefined && worktree !== '' ? { worktree } : {}), ...extra })
}
/** One external-open request from the file tree. */
type OpenExternalPayload =
| { action: 'reveal'; path: string }
| { action: 'url'; url: string }
/** The host route's success shape. */
type OpenExternalResult = { started: boolean }
/**
* Remote VSCode-family URLs must be consumed on the browser/client machine:
* the DSH host can be a headless remote server with no editor or DISPLAY.
* Local editor URLs and reveal actions still belong to the host opener.
*/
function shouldOpenExternalOnClient(payload: OpenExternalPayload): payload is { action: 'url'; url: string } {
if (payload.action !== 'url') return false
let parsed: URL
try {
parsed = new URL(payload.url)
} catch {
return false
}
return parsed.protocol !== 'http:'
&& parsed.protocol !== 'https:'
&& parsed.hostname === 'vscode-remote'
&& parsed.pathname.startsWith('/ssh-remote+')
}
/**
* Dispatch an external-open request to the correct machine. SSH remote-editor
* URLs stay in the synchronous user-click chain and navigate the client so
* its registered vscode:// / cursor:// handler can launch. Everything else
* keeps using the DSH host route.
*/
function openExternal(payload: OpenExternalPayload): Promise<OpenExternalResult> {
if (!shouldOpenExternalOnClient(payload)) {
return call<OpenExternalResult>('open.external', payload)
}
try {
window.location.assign(payload.url)
return Promise.resolve({ started: true })
} catch (error) {
return Promise.reject(error)
}
}
/** The sidebar API surface (session scope threaded through every call). */
export const api = {
sessionCwd: (scope: SessionScope, signal?: AbortSignal) =>
@@ -258,6 +307,14 @@ 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),
/** 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
* event-log face, so the lens polls this delta route. */
changesOps: (scope: SessionScope, afterSeq?: number, signal?: AbortSignal) =>
call<{ events: SidebarSessionEvent[]; lastSeq: number }>('changes.ops', scopePayload(scope, {
...(afterSeq !== undefined && afterSeq > 0 ? { afterSeq } : {}),
}), signal),
/** Discard the worktree changes of one file (the index is untouched). */
gitDiscard: (scope: SessionScope, path: string, worktree?: string) =>
call<{ ok: true }>('git.discard', gitPayload(scope, worktree, { path })),
@@ -341,12 +398,10 @@ export const api = {
* check; see the host's browser.probe route). */
browserProbe: (url: string, signal?: AbortSignal) =>
call<BrowserProbeResult>('browser.probe', { url }, signal),
/** External open for the file tree's "open with" menu: reveal a path in
* the OS file manager, or hand a custom-scheme URL (vscode://, cursor://,
* zed://, custom editors) to its registered handler. The host launches
* the platform opener (argv, no shell). */
openExternal: (payload: { action: 'reveal'; path: string } | { action: 'url'; url: string }) =>
call<{ started: boolean }>('open.external', payload),
/** External open for the file tree's "open with" menu. Remote SSH editor
* URLs are launched on the browser/client machine; reveal and local URLs
* keep using the host's platform opener. */
openExternal,
}
/** Absolute URL of the media route for one path (images only). */
+50 -12
View File
@@ -1,13 +1,14 @@
/**
* The 7 built-in tab descriptors: the plugin registers its own pages
* (editor / git / subagent / sidechat / terminal / browser / diff) through
* (editor / git — the unified changes tab / subagent / sidechat / terminal /
* browser / diff) through
* the same {@link BetterSidebarService} external plugins use — eating its
* own dogfood. The terminal descriptor owns its quota (`TERMINAL_LIMIT`)
* and mints `terminal:<uuid>` ids through `createTab`; the browser mints
* `browser:<n>` the same way (no quota). The editor IS the files window
* (the old standalone explorer merged into it).
*/
import { IconBranchOutline16, IconCodeOutline16, IconFolderOpen16, IconNewChatOutline16, IconPanelLeftOutline16, IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconCodeOutline16, IconFolderOpen16, IconNewChatOutline16, IconPanelLeftOutline16, IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from '../../context-types.ts'
import { allLeaves, isAgentTabId, type SidebarState } from '../state.ts'
import { t } from '../locales.ts'
@@ -15,13 +16,13 @@ import { openSidebarFile } from '../intercept.tsx'
import { EditorHost } from '../EditorHost.tsx'
import { OpenWithSettings } from '../open-with-settings.tsx'
import { lazyChunkComponent } from '../lazy-chunk.tsx'
import { GitView } from '../GitView.tsx'
import { ChangesTab, opCountOf } from '../changes/ChangesTab.tsx'
import { DiffTab } from '../DiffTab.tsx'
import { SubagentView } from '../SubagentView.tsx'
import { consumeSidechatSeed, SideChatView, sidechatThreadIdOf } from '../SideChatView.tsx'
import { api } from '../api.ts'
import { BrowserView } from '../BrowserView.tsx'
import { IconTerminalOutline16, IconDiffOutline16, IconGlobeOutline16 } from '../icons.tsx'
import { IconTerminalOutline16, IconDiffOutline16, IconGlobeOutline16, IconFloatWindowOutline16, IconPanelBottomOutline16 } from '../icons.tsx'
import { TERMINAL_FONT_SIZE_MAX, TERMINAL_FONT_SIZE_MIN } from '../../prefs-shared.ts'
import type { ComponentType } from 'react'
import type { SessionScope } from '../api.ts'
@@ -139,18 +140,55 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
),
},
{
// The unified changes tab (id kept as 'git' so persisted layouts keep
// resolving): the Git lens is the former source-control panel; the
// session lens is the former file-trace tab (PR #471). Both preview
// through one shared diff stack. The badge reads the op-count cache
// the tab's event poll publishes (the client ctx exposes no event
// log, and the git status needs a fetch — both stay out of the badge).
id: 'git',
title: () => t('git'),
icon: (size: number) => <IconBranchOutline16 size={size} />,
title: () => t('changes'),
icon: (size: number) => <IconDiffOutline16 size={size} />,
order: 20,
single: true,
component: ({ ctx, store, scope, visible, onOpenDiff }) => (
<GitView
scope={scope}
badge: (_ctx, scope) => {
const count = opCountOf(scope.sessionId)
return count === undefined || count === 0 ? null : count
},
// Declarative settings: the diff-open picker (free window vs docked
// pane) renders as an iconed select row under the changes card's gear
// in the Side card settings page.
settings: {
toggles: [{
key: 'changesDiffFloat',
type: 'select',
title: () => t('changesDiffOpenTitle'),
desc: () => t('changesDiffOpenDesc'),
options: [
{
value: true,
icon: (size: number) => <IconFloatWindowOutline16 size={size} />,
title: () => t('changesDiffOpenFloat'),
desc: () => t('changesDiffOpenFloatDesc'),
},
{
value: false,
icon: (size: number) => <IconPanelBottomOutline16 size={size} />,
title: () => t('changesDiffOpenPane'),
desc: () => t('changesDiffOpenPaneDesc'),
},
],
}],
},
component: ({ ctx, store, scope, tab, visible, onOpenFile, onOpenDiff }) => (
<ChangesTab
ctx={ctx}
store={store}
scope={scope}
tab={tab}
visible={visible}
onOpenFile={(path) => { openSidebarFile(ctx, store, scope.sessionId, path) }}
onOpenDiff={onOpenDiff ?? (() => { /* no-op */ })}
onOpenDiff={onOpenDiff}
/>
),
},
@@ -161,7 +199,7 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
order: 30,
single: true,
// Declarative settings: the auto-open switches render under this row in
// the Side card settings page (the Jobs page's own related settings).
// the Side card settings page (the Tasks page's related settings).
settings: {
toggles: [{
key: 'autoOpenSubagent',
@@ -337,7 +375,7 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
},
{
id: 'diff',
title: () => t('git'),
title: () => t('changes'),
icon: (size: number) => <IconDiffOutline16 size={size} />,
order: -1,
hidden: true,
+82
View File
@@ -0,0 +1,82 @@
/** Stable selector for the DSH AppFrame conversation slot. */
const CENTER_COLUMN_SELECTOR = '#root [data-slot="conversation"]'
/** Match Sidebar's existing last-resort retry cadence (issue #248). */
export const CENTER_COLUMN_REVALIDATE_MS = 1500
interface CenterColumnResolveOptions {
/** Explicit document for tests / callers that do not yet have a cached node. */
document?: Document
/** Injectable DOM lookup for tests. */
query?: () => Element | null
/** Injectable clock for deterministic retry-boundary tests. */
now?: () => number
/** Injectable <html style> fingerprint for deterministic HMR tests. */
htmlStyle?: () => string | null
/** Override only for tests; production follows the 1.5s safety-net cadence. */
revalidateMs?: number
}
/** Last full DOM validation for each center-column node. Weak keys avoid leaks. */
const validatedAt = new WeakMap<HTMLElement, number>()
/**
* The html-style watcher is an HMR/layout resync signal. Remember the last
* fingerprint per document so a style change can bypass the connected-node
* fast path immediately, without requiring Sidebar to run a second locator.
*/
const documentStyleState = new WeakMap<Document, string | null>()
/**
* Resolve the AppFrame center column while keeping streaming mutations cheap.
*
* Sidebar intentionally keeps both recovery mechanisms that predate #403:
* the `#root` subtree MutationObserver catches boot/HMR DOM swaps, and the
* 1.5s interval is a last-resort safety net for interleavings no observer is
* guaranteed to see (#248). The expensive part was letting every scheduled
* locate scan `#root` with querySelector at streaming-token cadence.
*
* Once a connected column is cached, normal calls therefore reuse it without
* a document query. A full query is still forced when either:
* - the cached node disconnects;
* - `<html style>` changes (the existing HMR/layout resync signal); or
* - 1.5s has elapsed since the last full validation (the safety-net cadence).
*
* This preserves recovery semantics while bounding whole-tree selector work
* to low-frequency validation instead of chat mutation frequency.
*/
export function resolveCenterColumn(
current: HTMLElement | null,
options: CenterColumnResolveOptions = {},
): HTMLElement | undefined {
const doc = options.document ?? current?.ownerDocument ?? document
const now = (options.now ?? Date.now)()
const revalidateMs = options.revalidateMs ?? CENTER_COLUMN_REVALIDATE_MS
const htmlStyle = (options.htmlStyle ?? (() => doc.documentElement.getAttribute('style')))()
const previousStyle = documentStyleState.get(doc)
const styleChanged = previousStyle !== undefined && previousStyle !== htmlStyle
documentStyleState.set(doc, htmlStyle)
if (current !== null && current.isConnected) {
const lastValidation = validatedAt.get(current)
// A connected node passed in for the first time is already a trustworthy
// cache hit. Start its safety-net window without paying an eager query.
if (lastValidation === undefined && !styleChanged) {
validatedAt.set(current, now)
return current
}
if (!styleChanged && lastValidation !== undefined && now - lastValidation < revalidateMs) {
return current
}
}
const query = options.query ?? (() => doc.querySelector(CENTER_COLUMN_SELECTOR))
const col = query()?.parentElement as HTMLElement | null | undefined
if (col === null || col === undefined || !col.isConnected) return undefined
validatedAt.set(col, now)
return col
}
+213
View File
@@ -0,0 +1,213 @@
/**
* The unified changes tab: one tab, two lenses on "what changed?" — Git
* (repository truth: staged/unstaged files, commit box, history) and the
* session round (agent truth: every file the model read, wrote, or edited).
* Both lenses preview their selections in a shared resizable bottom pane
* ({@link DiffPane}); git targets expand into the dedicated diff tab —
* docked in a pane, or floated as a free window per the tab's setting.
* The active lens and the pane height persist in the tab's meta, so the tab
* reopens exactly where it was left.
*
* The session events ride the host's `changes.ops` route (the client
* runtime exposes no event-log face): the tab pulls the delta past its
* cursor while visible, folds it into ops, and publishes the op count to a
* module-level cache the tab-strip badge reads.
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import type { SidebarSessionEvent } from '../../context-types.ts'
import type { TabComponentProps } from '../service.ts'
import { t } from '../locales.ts'
import { api } from '../api.ts'
import { floatTab, type SidebarDiffRef } from '../state.ts'
import { GitLens } from './GitLens.tsx'
import { SessionLens } from './SessionLens.tsx'
import { DiffPane, diffTabOf, type ChangesPreview } from './DiffPane.tsx'
import { extractFileOps, knownContentBefore, type FileOp } from './ops.ts'
import css from './changes.module.css'
/** The default preview pane height (px) before the first drag. */
const PANE_HEIGHT_DEFAULT = 300
/** Cap on accumulated events: the lens shows the recent window, not eternity
* (the host enforces the same bound per response). */
const EVENTS_CAP = 4000
/** Live op count per session: the tab's poller writes, the badge reads (a
* badge cannot fetch — it must resolve synchronously during render). */
const opCounts = new Map<string, number>()
/** The session's traced-op count as of the last poll (undefined before the
* tab has ever pulled; 0 hides the badge pill). */
export function opCountOf(sessionId: string): number | undefined {
return opCounts.get(sessionId)
}
type Lens = 'git' | 'session'
/** The persisted tab meta (JSON-serializable; rides the layout). */
interface ChangesMeta {
lens?: Lens
previewH?: number
}
export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpenDiff }: TabComponentProps) {
const meta = (tab.meta ?? {}) as ChangesMeta
const [lens, setLens] = useState<Lens>(meta.lens === 'session' ? 'session' : 'git')
const [preview, setPreview] = useState<ChangesPreview | null>(null)
const [paneHeight, setPaneHeight] = useState<number>(
typeof meta.previewH === 'number' && meta.previewH >= 140 ? meta.previewH : PANE_HEIGHT_DEFAULT,
)
// ── Session-event accumulation: one pull on mount, then a 2.5s delta
// poll while visible (paused otherwise; the next visible tick catches
// up). The cursor is the last delivered seq, so each poll ships only
// what the accumulator lacks. ────────────────────────────────────────
const eventsRef = useRef<readonly SidebarSessionEvent[]>([])
// The fold of eventsRef as of the last poll. extractFileOps parses every
// accumulated tool/call (up to EVENTS_CAP events); running it once per
// poll and REUSING the result across renders (the render used to re-fold
// the whole window, twice per tick, and the fresh array defeated the
// downstream memo on every poll) keeps the 2.5s tick at one fold.
const opsRef = useRef<readonly FileOp[]>([])
const seqRef = useRef(0)
const pollGen = useRef(0)
const [opsError, setOpsError] = useState(false)
const [tick, setTick] = useState(0)
const pull = useCallback(async (): Promise<void> => {
const generation = pollGen.current
try {
const { events, lastSeq } = await api.changesOps(scope, seqRef.current)
if (generation !== pollGen.current) return
if (events.length > 0) {
const merged = [...eventsRef.current, ...events]
eventsRef.current = merged.length > EVENTS_CAP ? merged.slice(merged.length - EVENTS_CAP) : merged
}
if (lastSeq > seqRef.current) seqRef.current = lastSeq
const folded = extractFileOps(eventsRef.current)
opsRef.current = folded
opCounts.set(scope.sessionId, folded.length)
setOpsError(false)
setTick(value => value + 1)
} catch {
// Offline / route unavailable: keep the last fold; surface it inline
// only while nothing has ever loaded.
if (generation === pollGen.current) setOpsError(true)
}
}, [scope.sessionId, scope.cwd])
useEffect(() => {
pollGen.current += 1
eventsRef.current = []
opsRef.current = []
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])
// tick only forces the re-render; the fold reads the ref directly.
void tick
const ops = opsRef.current
/** Persist a meta patch onto the tab (lens choice, pane height). */
const patchMeta = (patch: ChangesMeta): void => {
ctx.get('betterSidebar')?.updateTab(tab.id, {
meta: { ...(tab.meta as ChangesMeta | undefined ?? {}), ...patch },
})
}
const chooseLens = (next: Lens): void => {
if (next === lens) return
setLens(next)
patchMeta({ lens: next })
}
/** Preview one git change (worktree file or commit) from the Git lens. */
const previewGit = (ref: SidebarDiffRef): void => {
setPreview({ kind: 'git', ref })
}
/** Preview one session op (with its best-effort prior content snapshot). */
const previewOp = (path: string, op: FileOp): void => {
setPreview({ kind: 'op', path, op, prior: knownContentBefore(ops, path, op) })
}
/** Expand the current git preview into the dedicated diff tab: docked
* into the shell's diff pane, or floated as a free window centered on
* the viewport when the tab's diff-open setting asks for it (default). */
const expandPreview = (): void => {
if (preview?.kind !== 'git') return
const diffTab = diffTabOf(preview.ref)
onOpenDiff?.(diffTab)
if (store.getPrefs().changesDiffFloat !== false) {
const x = Math.round(window.innerWidth / 2)
const y = Math.round(window.innerHeight / 2)
store.reduce(state => floatTab(state, diffTab.id, x, y))
}
}
const previewKey = (target: ChangesPreview): string => target.kind === 'git'
? (target.ref.kind === 'worktree'
? `git:w:${target.ref.path}:${target.ref.staged ? 's' : 'u'}`
: `git:c:${target.ref.hashFull}`)
: `op:${target.op.callId}`
return (
<div className={css.root}>
<div className={css.lensBar}>
<div className={css.lensSwitch} role="group" aria-label={t('changes')}>
<button
type="button"
className={css.lensButton}
data-active={lens === 'git' ? 'true' : undefined}
aria-pressed={lens === 'git'}
onClick={() => { chooseLens('git') }}
>
{t('changesGitLens')}
</button>
<button
type="button"
className={css.lensButton}
data-active={lens === 'session' ? 'true' : undefined}
aria-pressed={lens === 'session'}
onClick={() => { chooseLens('session') }}
>
{t('changesSessionLens')}
</button>
</div>
</div>
{lens === 'git'
? (
<GitLens
scope={scope}
store={store}
visible={visible}
onOpenFile={onOpenFile ?? (() => { /* no-op */ })}
onPreview={previewGit}
selectedRef={preview !== null && preview.kind === 'git' ? preview.ref : null}
/>
)
: (
<SessionLens
ops={ops}
loadError={opsError && ops.length === 0}
onPreview={previewOp}
selectedCallId={preview !== null && preview.kind === 'op' ? preview.op.callId : null}
/>
)}
{preview !== null && (
<DiffPane
key={previewKey(preview)}
target={preview}
scope={scope}
height={paneHeight}
onHeightCommit={(height) => { setPaneHeight(height); patchMeta({ previewH: height }) }}
onClose={() => { setPreview(null) }}
onExpand={expandPreview}
/>
)}
</div>
)
}
+320
View File
@@ -0,0 +1,320 @@
/**
* The changes tab's shared bottom preview pane: one selected target — a git
* worktree change, a commit patch, or a session file op — rendered through
* the unified diff stack (the same one the diff tab uses). Git targets load
* on demand (refreshable, with the untracked full-addition fallback); op
* targets are pure snapshots (diff / read view / error text). The pane is
* resizable by drag (clamped; the height commits to the tab's persisted
* meta on release) and by keyboard; git targets can expand into a dedicated
* 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 type { SessionScope } from '../api.ts'
import { api } from '../api.ts'
import { t } from '../locales.ts'
import { baseName } from '../paths.ts'
import { resolveSidebarPath } from '../produced-files.ts'
import type { SidebarDiffRef, SidebarTab } from '../state.ts'
import { DiffRows, ReadRows } from '../diff/DiffRows.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 { 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. */
const HEIGHT_MIN = 140
const HEIGHT_STEP = 24
/** What the pane is showing right now. */
export type ChangesPreview =
| { kind: 'git'; ref: SidebarDiffRef }
| { kind: 'op'; path: string; op: FileOp; prior?: string }
/** Diff material for one op snapshot: an edit reconstructs the full file
* from the window's known prior content when possible (hunk-style context);
* a write with unknown prior content renders all-added. */
function diffOf(op: FileOp, prior: string | undefined): readonly DiffRow[] {
if (op.kind === 'read') return []
if (op.kind === 'edit' && op.edit !== undefined) {
const { oldString, newString } = op.edit
if (prior !== undefined && prior.includes(oldString)) {
const newFile = prior.replace(oldString, newString)
return diffLines(prior, newFile)
}
return diffLines(oldString, newString)
}
if (op.kind === 'write') {
const content = op.content ?? ''
const old = prior !== undefined && prior !== content ? prior : undefined
return diffLines(old ?? '', content)
}
return []
}
/** The diff tab a git preview expands into (the shell owns placement). */
export function diffTabOf(ref: SidebarDiffRef): SidebarTab {
if (ref.kind === 'worktree') {
return {
id: `diff:w:${encodeURIComponent(ref.worktree ?? '')}:${ref.staged ? 's' : 'u'}:${ref.path}`,
type: 'diff',
title: baseName(ref.path),
diff: ref,
}
}
return {
id: `diff:c:${encodeURIComponent(ref.worktree ?? '')}:${ref.hashFull}`,
type: 'diff',
title: `${ref.hash} ${ref.subject}`,
diff: ref,
}
}
export interface DiffPaneProps {
target: ChangesPreview
scope: SessionScope
/** The persisted pane height (px); drag commits a new one upwards. */
height: number
onHeightCommit: (height: number) => void
onClose: () => void
/** Expand the current git target into a dedicated diff tab. */
onExpand: () => void
}
export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExpand }: DiffPaneProps) {
// ── Git target loading (mirrors the diff tab: staged-side fallback, the
// untracked full-addition fallback, refresh by tick). ─────────────────
const [tick, setTick] = useState(0)
const [loading, setLoading] = useState(target.kind === 'git')
const [error, setError] = useState<string | null>(null)
const [diffText, setDiffText] = useState<string | null>(null)
const [untracked, setUntracked] = useState<string | undefined>(undefined)
const gitRef = target.kind === 'git' ? target.ref : null
useEffect(() => {
if (gitRef === null) return
let cancelled = false
const paneScope: SessionScope = {
sessionId: scope.sessionId,
cwd: scope.cwd,
...(gitRef.repoRoot !== undefined ? { repoRoot: gitRef.repoRoot } : {}),
}
setLoading(true)
setError(null)
setDiffText(null)
setUntracked(undefined)
const load = async (): Promise<void> => {
try {
if (gitRef.kind === 'commit') {
const result = await api.gitCommitDiff(paneScope, gitRef.hashFull, gitRef.worktree)
if (!cancelled) setDiffText(result.diff)
return
}
let result = await api.gitDiff(paneScope, gitRef.path, gitRef.staged, gitRef.worktree)
if (result.diff === '') {
// 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 (result.diff !== '') {
if (!cancelled) setDiffText(result.diff)
return
}
// Empty diff: an untracked file (git diff never lists it) falls back
// to a full-file addition from its content.
if (gitRef.untracked === true && !gitRef.staged) {
const text = await api.fsRead(paneScope, resolveSidebarPath(gitRef.repoRoot ?? gitRef.worktree ?? scope.cwd, gitRef.path))
if (!cancelled && text.kind === 'text') {
setDiffText('')
setUntracked(text.content)
}
return
}
if (!cancelled) setDiffText('')
} catch (reason) {
if (!cancelled) setError(reason instanceof Error ? reason.message : String(reason))
} finally {
if (!cancelled) setLoading(false)
}
}
void load()
return () => { cancelled = true }
}, [gitRef, scope.sessionId, scope.cwd, tick])
// ── 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 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])
const opStats = useMemo(() => diffStats(opSegments), [opSegments])
const opReadLines = useMemo(
() => (op?.kind === 'read' && op.read !== undefined ? parseReadLines(op.read) : []),
[op],
)
// Header stats for git targets come off the parsed patch text.
const gitStats = useMemo(() => {
if (target.kind !== 'git' || diffText === null || diffText === '') return null
let added = 0
let deleted = 0
for (const file of parseUnifiedDiff(diffText).files) {
const stats = diffStats(unifiedSegments(file))
added += stats.added
deleted += stats.deleted
}
return { added, deleted }
}, [target, diffText])
// ── Resize: drag the top handle; commit on release (persisted by the
// shell). Arrow keys resize by a step for keyboard users. ────────────
const [dragHeight, setDragHeight] = useState<number | null>(null)
const paneHeight = dragHeight ?? height
const clamp = (value: number): number => Math.min(Math.max(value, HEIGHT_MIN), Math.round(window.innerHeight * 0.7))
const dragOrigin = useRef<{ y: number; h: number } | null>(null)
// Pointer streams fire several times per frame; one setState per event
// re-rendered the whole pane at event cadence (see frame-batcher).
const dragBatcher = useRef(createFrameBatcher()).current
useEffect(() => () => dragBatcher.dispose(), [dragBatcher])
const onHandleDown = (event: ReactPointerEvent<HTMLDivElement>): void => {
event.preventDefault()
dragOrigin.current = { y: event.clientY, h: paneHeight }
const onMove = (ev: PointerEvent): void => {
if (dragOrigin.current === null) return
const next = clamp(dragOrigin.current.h + (dragOrigin.current.y - ev.clientY))
dragBatcher.schedule(() => { setDragHeight(next) })
}
const onUp = (): void => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
dragOrigin.current = null
dragBatcher.flushNow()
setDragHeight(current => {
if (current !== null) onHeightCommit(current)
return null
})
}
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
}
const title = target.kind === 'op'
? target.path
: target.ref.kind === 'worktree' ? target.ref.path : `${target.ref.hash} ${target.ref.subject}`
const stats = gitStats ?? (target.kind === 'op' && op !== null && op.kind !== 'read' && !op.isError ? opStats : null)
return (
<div className={css.diffPane} style={{ height: paneHeight }}>
<div
className={css.dragHandle}
role="separator"
aria-orientation="horizontal"
aria-label={t('changesResizePreview')}
tabIndex={0}
onPointerDown={onHandleDown}
onKeyDown={(event) => {
if (event.key === 'ArrowUp') { event.preventDefault(); onHeightCommit(clamp(paneHeight + HEIGHT_STEP)) }
if (event.key === 'ArrowDown') { event.preventDefault(); onHeightCommit(clamp(paneHeight - HEIGHT_STEP)) }
}}
/>
<div className={css.diffHead}>
{target.kind === 'op' && (
<span className={css.diffKind} data-kind={target.op.kind}>
{t(target.op.kind === 'read' ? 'changesRead' : target.op.kind === 'write' ? 'changesWrite' : 'changesEdit')}
</span>
)}
{target.kind === 'git' && target.ref.kind === 'worktree' && (
<span className={css.diffKind} data-kind="git">{target.ref.staged ? t('staged') : t('unstaged')}</span>
)}
{target.kind === 'git' && target.ref.kind === 'commit' && (
<span className={css.diffKind} data-kind="git">{target.ref.hash}</span>
)}
<span className={css.diffPath} title={title}>{title}</span>
{stats !== null && (stats.added > 0 || stats.deleted > 0) && (
<span className={css.diffStats}>
{stats.added > 0 && <span className={diffCss.statAdd}>+{String(stats.added)}</span>}
{stats.deleted > 0 && <span className={diffCss.statDel}>−{String(stats.deleted)}</span>}
</span>
)}
{target.kind === 'git' && (
<>
<button
type="button"
className={css.iconButton}
aria-label={t('refresh')}
title={t('refresh')}
disabled={loading}
onClick={() => { setTick(value => value + 1) }}
>
<IconRefreshOutline16 size={14} />
</button>
<button
type="button"
className={css.iconButton}
aria-label={t('changesOpenDiffTab')}
title={t('changesOpenDiffTab')}
onClick={onExpand}
>
<IconRightUpOutline16 size={14} />
</button>
</>
)}
<button
type="button"
className={css.iconButton}
aria-label={t('changesClosePreview')}
title={t('changesClosePreview')}
onClick={onClose}
>
<IconCloseOutline16 size={14} />
</button>
</div>
{target.kind === 'op' && op !== null && op.isError
? (
<div className={css.paneBody}>
<div className={css.readError} role="alert">
{op.errorText ?? t('changesError')}
</div>
</div>
)
: target.kind === 'op' && op !== null && op.kind === 'read'
? (
<div className={css.paneBody}>
<ReadRows lines={opReadLines} lang={opLang} />
</div>
)
: target.kind === 'op'
? (
<div className={css.paneBody}>
{op !== null && op.kind === 'write'
&& prior === undefined
&& <div className={css.priorUnknown}>{t('changesPriorUnknown')}</div>}
<DiffRows key={target.op.callId} segments={opSegments} lang={opLang} />
</div>
)
: loading
? <div className={css.paneBody}><div className={css.gitPlaceholder}>{t('loading')}</div></div>
: error !== null
? <div className={css.paneBody}><div className={css.gitError}>{t('diffLoadError')}: {error}</div></div>
: (
<div className={css.paneBody}>
{diffText !== null && diffText !== '' && (
<DiffFiles
diff={diffText}
untrackedPath={untracked !== undefined && target.ref.kind === 'worktree' ? target.ref.path : undefined}
untrackedContent={untracked}
/>
)}
{diffText === '' && untracked === undefined && (
<div className={css.gitEmpty}>{t('diffEmpty')}</div>
)}
</div>
)}
</div>
)
}
@@ -1,32 +1,33 @@
/**
* The source-control panel: status list (staged vs unstaged), stage/unstage,
* commit with a message box, branch switch, and a VSCode-like history — rows
* carry branch decorations, author and relative time. Clicking a changed
* file or a history row opens a dedicated diff TAB (see {@link DiffTab}),
* placed below the git pane on first use. File rows and history rows open a
* right-click context menu with advanced operations (open in editor, discard,
* revert, cherry-pick, copy paths/hashes). Refresh is manual + on mount/
* focus. While visible it polls lightweight porcelain state so model-authored
* file changes appear without a manual refresh.
* The Git lens of the changes tab: repository truth — status list (staged vs
* unstaged), stage/unstage, commit with a message box, branch switch, and a
* VSCode-like history with branch decorations, author and relative time.
* Clicking a changed file or a history row previews it in the tab's shared
* bottom pane (see {@link DiffPane}); rows carry right-click context menus
* with advanced operations (open in editor, discard, revert, cherry-pick,
* copy paths/hashes). Refresh is manual + on mount/focus. While visible it
* polls lightweight porcelain state so model-authored file changes appear
* without a manual refresh. Everything here is the former standalone git
* panel, re-homed as a lens.
*/
import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react'
import {
Button, IconBranchOutline16, IconCodeOutline16, IconCopyOutline16, IconRefreshOutline16,
IconTrashOutline16, Input, Menu, Modal, writeClipboard,
Button, IconCodeOutline16, IconCopyOutline16, IconPlusOutline16,
IconRefreshOutline16, IconTrashOutline16, Input, Menu, Modal, writeClipboard,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { GitLogEntry, GitStatusEntry, GitStatusResult, GitWorktree, SessionScope } from './api.ts'
import { api } from './api.ts'
import { isWithinWorkspace, relativeTo } from './paths.ts'
import { resolveSidebarPath } from './produced-files.ts'
import { relativeTime, t } from './locales.ts'
import type { SidebarStore, SidebarTab } from './state.ts'
import css from './sidebar.module.css'
import type { GitLogEntry, GitStatusEntry, GitStatusResult, GitWorktree, SessionScope } from '../api.ts'
import { api } from '../api.ts'
import { baseName, isWithinWorkspace, relativeTo } from '../paths.ts'
import { resolveSidebarPath } from '../produced-files.ts'
import { relativeTime, t } from '../locales.ts'
import type { SidebarDiffRef, SidebarStore } from '../state.ts'
import css from './changes.module.css'
/** The XY status letters a row badge shows (X = index, Y = worktree). */
function badgeOf(entry: GitStatusEntry): string {
const index = entry.xy[0]
const worktree = entry.xy[1]
if (index !== undefined && index !== ' ' && index !== '?') return index
const worktree = entry.xy[1]
if (worktree !== undefined && worktree !== ' ' && worktree !== '?') return worktree
return '?'
}
@@ -51,12 +52,6 @@ function isUntracked(entry: GitStatusEntry): boolean {
return badgeOf(entry) === '?'
}
/** The last path segment (tab title for a file's diff). */
function baseName(path: string): string {
const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
return at === -1 ? path : path.slice(at + 1)
}
/** The ref names of one log row's decorations (`HEAD -> main` → `main`), deduped. */
function refNames(refs: string): string[] {
return [...new Set(
@@ -69,6 +64,12 @@ function refNames(refs: string): string[] {
)]
}
/** One thrown value as display text (every error banner/row here normalizes
* through this so non-Error rejections never render as '[object Object]'). */
function errorMessage(reason: unknown): string {
return reason instanceof Error ? reason.message : String(reason)
}
/** The pending destructive action (discard / revert / cherry-pick), gated by a confirm modal. */
interface ConfirmState {
title: string
@@ -81,17 +82,28 @@ interface ConfirmState {
* floods the panel at once (the end of the log is reached by paging). */
const LOG_BATCH = 20
export function GitView(props: {
/** Every Nth silent poll re-lists worktrees (and re-runs auto-selection): the
* 2s tick only needs the selected checkout's STATUS, and re-listing spawned
* a second git process per tick for a list that almost never changes — a
* linked checkout the agent creates mid-session is picked up within ~30s
* instead of 2s. */
const WORKTREE_RECHECK_TICKS = 15
export interface GitLensProps {
scope: SessionScope
/** The sidebar store: reads the `workspaceFence` pref (see the open guard below). */
store: SidebarStore
onOpenFile: (path: string) => void
/** Open a diff tab (the shell places it below the git pane on first use). */
onOpenDiff: (tab: SidebarTab) => void
/** Preview one change in the shared bottom pane (worktree or commit ref). */
onPreview: (ref: SidebarDiffRef) => void
/** The ref currently previewed (row highlight); null when the pane is closed. */
selectedRef: SidebarDiffRef | null
/** Poll only while the tab is actually visible. */
visible: boolean
}) {
const { scope, store, onOpenFile, onOpenDiff, visible } = props
}
export function GitLens(props: GitLensProps) {
const { scope, store, onOpenFile, onPreview, selectedRef, visible } = props
const [status, setStatus] = useState<GitStatusResult | null>(null)
const [worktrees, setWorktrees] = useState<GitWorktree[]>([])
const [selectedWorktree, setSelectedWorktree] = useState<string | undefined>()
@@ -122,8 +134,10 @@ export function GitView(props: {
* avoids a spurious full refresh on every auto-select (the very state
* change refresh writes back via setSelectedWorktree would recreate the
* callback and re-trigger the mount effect — an N→N+1 fetch loop). */
const selectedRef = useRef<string | undefined>(undefined)
useEffect(() => { selectedRef.current = selectedWorktree }, [selectedWorktree])
const chosenPathRef = useRef<string | undefined>(undefined)
useEffect(() => { chosenPathRef.current = selectedWorktree }, [selectedWorktree])
/** Silent polls since the last worktree re-list (see WORKTREE_RECHECK_TICKS). */
const silentTickCount = useRef(0)
const gitScope: SessionScope = repoRoot === undefined ? scope : { ...scope, repoRoot }
@@ -149,7 +163,7 @@ export function GitView(props: {
setLogEnded(logResult.length < LOG_BATCH)
} catch (reason) {
if (options.generation === refreshGeneration.current) {
setError(reason instanceof Error ? reason.message : String(reason))
setError(errorMessage(reason))
}
} finally {
if (options.loading && options.generation === refreshGeneration.current) setLoading(false)
@@ -161,11 +175,20 @@ export function GitView(props: {
refreshInFlight.current = true
let generation = refreshGeneration.current
try {
// Fast path for the ordinary silent tick: the selected checkout's
// STATUS is all that changes between worktree re-lists (see
// WORKTREE_RECHECK_TICKS) — one git process instead of two.
if (silent && chosenPathRef.current !== undefined && (silentTickCount.current += 1) % WORKTREE_RECHECK_TICKS !== 0) {
const statusResult = await api.gitStatus(gitScope, chosenPathRef.current)
if (generation === refreshGeneration.current) setStatus(statusResult)
return
}
silentTickCount.current = 0
const listed = await api.gitWorktrees(scope)
if (generation !== refreshGeneration.current) return
setWorktrees(listed)
const selectedStillExists = listed.some(entry => entry.path === selectedRef.current)
let target = selectedStillExists ? selectedRef.current : listed.find(entry => entry.current)?.path
const selectedStillExists = listed.some(entry => entry.path === chosenPathRef.current)
let target = selectedStillExists ? chosenPathRef.current : listed.find(entry => entry.current)?.path
// DSH and other coding agents commonly create one linked checkout while
// the session remains rooted at the clean primary checkout. Select that
// checkout automatically only when the choice is unambiguous.
@@ -176,12 +199,12 @@ export function GitView(props: {
? dirtyLinked[0]!.path
: current?.path
}
const targetChanged = target !== selectedRef.current
const targetChanged = target !== chosenPathRef.current
if (targetChanged) {
// Changing the automatically selected checkout invalidates any direct
// target refresh that may still be resolving for the previous one.
generation = refreshGeneration.current += 1
selectedRef.current = target
chosenPathRef.current = target
setSelectedWorktree(target)
// Remove rows owned by the previous checkout immediately: keeping them
// interactive while the target changes could apply a destructive action
@@ -202,7 +225,7 @@ export function GitView(props: {
await refreshTarget(target, { loading: !silent, generation })
} catch (reason) {
if (generation === refreshGeneration.current) {
setError(reason instanceof Error ? reason.message : String(reason))
setError(errorMessage(reason))
if (!silent) setLoading(false)
}
} finally {
@@ -214,7 +237,8 @@ export function GitView(props: {
refreshGeneration.current += 1
refreshInFlight.current = false
worktreeChosenByUser.current = false
selectedRef.current = undefined
chosenPathRef.current = undefined
silentTickCount.current = 0
setSelectedWorktree(undefined)
}, [scope.sessionId, scope.cwd])
useEffect(() => { void refresh() }, [refresh])
@@ -223,7 +247,7 @@ export function GitView(props: {
* checkout-derived surface before destructive history actions can run. */
const chooseWorktree = (target: string): void => {
worktreeChosenByUser.current = true
selectedRef.current = target
chosenPathRef.current = target
setSelectedWorktree(target)
setStatus(null)
setBranchNames([])
@@ -248,7 +272,7 @@ export function GitView(props: {
// own worktree list is empty); keep the current linked-checkout choice
// unless it does not belong to the new repository.
const generation = refreshGeneration.current += 1
void refreshTarget(selectedRef.current ?? '', { loading: true, generation })
void refreshTarget(chosenPathRef.current ?? '', { loading: true, generation })
}
useEffect(() => {
if (!visible) return
@@ -260,42 +284,49 @@ export function GitView(props: {
const loadMoreLog = async (): Promise<void> => {
if (logLoadingMore || logEnded) return
const generation = refreshGeneration.current
const target = selectedRef.current
const target = chosenPathRef.current
setLogLoadingMore(true)
try {
const next = await api.gitLog(gitScope, LOG_BATCH, logEntries.length, target)
// A worktree switch clears the old history and increments generation.
// Never append a late page from that checkout into the new one.
if (generation !== refreshGeneration.current || target !== selectedRef.current) return
if (generation !== refreshGeneration.current || target !== chosenPathRef.current) return
setLogEntries(entries => [...entries, ...next])
if (next.length < LOG_BATCH) setLogEnded(true)
} catch (reason) {
if (generation === refreshGeneration.current && target === selectedRef.current) {
setCommitError(`${t('historyLoadError')}: ${reason instanceof Error ? reason.message : String(reason)}`)
if (generation === refreshGeneration.current && target === chosenPathRef.current) {
setCommitError(`${t('historyLoadError')}: ${errorMessage(reason)}`)
}
} finally {
if (generation === refreshGeneration.current && target === selectedRef.current) setLogLoadingMore(false)
if (generation === refreshGeneration.current && target === chosenPathRef.current) setLogLoadingMore(false)
}
}
/** The diff tab for one changed file (one tab per path+side; same id = focused). */
const openWorktreeDiff = (entry: GitStatusEntry, staged: boolean): void => {
onOpenDiff({
id: `diff:w:${encodeURIComponent(selectedWorktree ?? '')}:${staged ? 's' : 'u'}:${entry.path}`,
type: 'diff',
title: baseName(entry.path),
diff: { kind: 'worktree', path: entry.path, staged, untracked: isUntracked(entry), worktree: selectedWorktree, repoRoot },
})
}
/** The preview ref for one changed file (one ref per path+side). */
const worktreeRefOf = (entry: GitStatusEntry, staged: boolean): SidebarDiffRef => ({
kind: 'worktree',
path: entry.path,
staged,
untracked: isUntracked(entry),
worktree: selectedWorktree,
repoRoot,
})
/** The diff tab for one commit (one tab per commit). */
const openCommitDiff = (entry: GitLogEntry): void => {
onOpenDiff({
id: `diff:c:${encodeURIComponent(selectedWorktree ?? '')}:${entry.hashFull}`,
type: 'diff',
title: `${entry.hash} ${entry.subject}`,
diff: { kind: 'commit', hash: entry.hash, hashFull: entry.hashFull, subject: entry.subject, worktree: selectedWorktree, repoRoot },
})
/** The preview ref for one commit. */
const commitRefOf = (entry: GitLogEntry): SidebarDiffRef => ({
kind: 'commit',
hash: entry.hash,
hashFull: entry.hashFull,
subject: entry.subject,
worktree: selectedWorktree,
repoRoot,
})
/** Whether a worktree row is the one currently previewed. */
const isPreviewedWorktree = (entry: GitStatusEntry, staged: boolean): boolean => {
if (selectedRef === null || selectedRef.kind !== 'worktree') return false
return selectedRef.path === entry.path && selectedRef.staged === staged
&& (selectedRef.worktree ?? '') === (selectedWorktree ?? '')
}
const stageEntry = async (entry: GitStatusEntry, staged: boolean): Promise<void> => {
@@ -330,7 +361,7 @@ export function GitView(props: {
setCommitMsg('')
await refresh()
} catch (reason) {
setCommitError(reason instanceof Error ? reason.message : String(reason))
setCommitError(errorMessage(reason))
} finally {
setBusy(false)
}
@@ -344,7 +375,7 @@ export function GitView(props: {
await api.gitCheckout(gitScope, branch, selectedWorktree)
await refresh()
} catch (reason) {
setCommitError(`${t('checkoutError')}: ${reason instanceof Error ? reason.message : String(reason)}`)
setCommitError(`${t('checkoutError')}: ${errorMessage(reason)}`)
} finally {
setBusy(false)
}
@@ -359,7 +390,7 @@ export function GitView(props: {
await confirmState.onConfirm()
await refresh()
} catch (reason) {
setCommitError(reason instanceof Error ? reason.message : String(reason))
setCommitError(errorMessage(reason))
} finally {
setBusy(false)
}
@@ -387,16 +418,21 @@ export function GitView(props: {
const unstagedEntries = (status?.entries ?? []).filter(isUnstagedEntry)
const renderEntry = (entry: GitStatusEntry, staged: boolean): ReactNode => {
const selected = isPreviewedWorktree(entry, staged)
return (
<div key={`${staged ? 's' : 'u'}:${entry.path}`} className={css.gitRow}>
<div
key={`${staged ? 's' : 'u'}:${entry.path}`}
className={css.gitRow}
data-selected={selected ? 'true' : undefined}
>
<button
type="button"
className={css.gitRowMain}
title={entry.path}
onClick={() => { openWorktreeDiff(entry, staged) }}
onClick={() => { onPreview(worktreeRefOf(entry, staged)) }}
onContextMenu={(event) => { openFileMenu(event, entry, staged) }}
>
<span className={css.gitBadge}>{badgeOf(entry)}</span>
<span className={css.gitBadge} data-letter={badgeOf(entry)}>{badgeOf(entry)}</span>
<span className={css.gitName}>{entry.path}</span>
</button>
<button
@@ -407,7 +443,7 @@ export function GitView(props: {
disabled={busy}
onClick={() => { void stageEntry(entry, staged) }}
>
{staged ? <IconTrashOutline16 /> : <IconBranchOutline16 />}
{staged ? <IconTrashOutline16 /> : <IconPlusOutline16 />}
</button>
</div>
)
@@ -531,12 +567,13 @@ export function GitView(props: {
role="button"
tabIndex={0}
className={css.gitLogRow}
data-selected={selectedRef?.kind === 'commit' && selectedRef.hashFull === entry.hashFull ? 'true' : undefined}
title={`${entry.author} · ${entry.date}\n${entry.hashFull}`}
onClick={() => { openCommitDiff(entry) }}
onClick={() => { onPreview(commitRefOf(entry)) }}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
openCommitDiff(entry)
onPreview(commitRefOf(entry))
}
}}
onContextMenu={(event) => { openHistoryMenu(event, entry) }}
@@ -584,7 +621,7 @@ export function GitView(props: {
: []),
fileMenu?.staged === true
? { id: 'stage', label: t('unstage'), icon: <IconTrashOutline16 size={14} /> }
: { id: 'stage', label: t('stage'), icon: <IconBranchOutline16 size={14} /> },
: { id: 'stage', label: t('stage'), icon: <IconPlusOutline16 size={14} /> },
...(fileMenu !== null && !isUntracked(fileMenu.entry)
? [{ id: 'discard', label: t('discard'), icon: <IconTrashOutline16 size={14} />, danger: true }]
: []),
@@ -650,7 +687,7 @@ export function GitView(props: {
if (target === null) return
setHistoryMenu(null)
if (id === 'view') {
openCommitDiff(target.entry)
onPreview(commitRefOf(target.entry))
return
}
if (id === 'copyShort') {
+116
View File
@@ -0,0 +1,116 @@
/**
* The session lens of the changes tab: agent truth — every file the model
* read, wrote, or edited in this session, grouped by file, newest first,
* with kind filters. Clicking an op previews it in the tab's shared bottom
* pane (see {@link DiffPane}): writes and edits as line diffs, reads as a
* line-numbered content view, failures as their real error text. The ops
* arrive pre-folded from the tab (which owns the event poll); this
* component is purely presentational.
*/
import { useMemo, useState, type ReactNode } from 'react'
import { relativeTime, t } from '../locales.ts'
import { groupByFile, type FileOp, type FileOpKind } from './ops.ts'
import { formatBytes } from '../diff/rows.ts'
import css from './changes.module.css'
/** The op-kind filter chips: 'all' or one concrete kind. */
type OpFilter = 'all' | FileOpKind
export interface SessionLensProps {
/** The folded file operations, newest first (the tab's poll owns them). */
ops: readonly FileOp[]
/** Every poll failed and nothing loaded: an inline notice, not an empty
* state that would read as "no operations". */
loadError: boolean
/** Preview one op in the shared bottom pane. */
onPreview: (path: string, op: FileOp) => void
/** The op currently previewed (row highlight); null when the pane is closed. */
selectedCallId: string | null
}
export function SessionLens({ ops, loadError, onPreview, selectedCallId }: SessionLensProps) {
const [filter, setFilter] = useState<OpFilter>('all')
const filteredOps = useMemo(
() => (filter === 'all' ? ops : ops.filter(op => op.kind === filter)),
[ops, filter],
)
const groups = useMemo(() => groupByFile(filteredOps), [filteredOps])
const counts = useMemo(() => {
const map = new Map<FileOpKind, number>([['read', 0], ['write', 0], ['edit', 0]])
for (const op of ops) map.set(op.kind, (map.get(op.kind) ?? 0) + 1)
return map
}, [ops])
const chip = (value: OpFilter, label: string, count: number): ReactNode => (
<button
type="button"
key={value}
className={css.filterChip}
data-active={filter === value ? 'true' : undefined}
onClick={() => { setFilter(value) }}
aria-pressed={filter === value}
>
{label}{value !== 'all' ? ` ${String(count)}` : ''}
</button>
)
// Sizing a row's content constructs a Blob (a UTF-8 encode of the whole
// edit) — that was per row PER RENDER, re-encoding every field on every
// poll tick. ops keeps its identity between unchanged polls (see the
// tab's opsRef), so one pass per changed fold covers every render.
const opSizes = useMemo(() => {
const sizes = new Map<string, string>()
for (const op of ops) {
if (op.kind !== 'read' && !op.isError) {
sizes.set(op.callId, formatBytes(new Blob([op.edit?.newString ?? op.content ?? '']).size))
}
}
return sizes
}, [ops])
return (
<div className={css.session}>
<div className={css.filterRow} role="group" aria-label={t('changesSessionLens')}>
{chip('all', t('changesFilterAll'), ops.length)}
{chip('write', t('changesWrite'), counts.get('write') ?? 0)}
{chip('edit', t('changesEdit'), counts.get('edit') ?? 0)}
{chip('read', t('changesRead'), counts.get('read') ?? 0)}
</div>
<div className={css.sessionList}>
{loadError && <div className={css.loadError}>{t('changesLoadError')}</div>}
{ops.length === 0 && !loadError && <div className={css.empty}>{t('changesSessionEmpty')}</div>}
{ops.length > 0 && filteredOps.length === 0 && <div className={css.empty}>{t('changesFilterEmpty')}</div>}
{[...groups.entries()].map(([path, fileOps]) => (
<div key={path} className={css.fileGroup}>
<div className={css.filePath} title={path}>{path}</div>
{fileOps.map(op => (
<button
type="button"
key={op.callId}
className={css.opRow}
data-op-kind={op.kind}
data-op-error={op.isError ? 'true' : undefined}
data-selected={selectedCallId === op.callId ? 'true' : undefined}
onClick={() => { onPreview(path, op) }}
>
<span className={css.opKind} data-kind={op.kind}>
{t(op.kind === 'read' ? 'changesRead' : op.kind === 'write' ? 'changesWrite' : 'changesEdit')}
</span>
{op.running && <span className={css.opFlag}>{t('changesRunning')}</span>}
{op.isError && <span className={css.opFlagError}>{t('changesError')}</span>}
<span className={css.opMeta}>
{opSizes.get(op.callId) !== undefined && (
<span className={css.opSize}>
{opSizes.get(op.callId)}
</span>
)}
<span className={css.opTime}>{relativeTime(new Date(op.time).toISOString())}</span>
</span>
</button>
))}
</div>
))}
</div>
</div>
)
}
+399
View File
@@ -0,0 +1,399 @@
/* The unified changes tab: lens switcher + Git lens + session lens + the
shared preview pane. Semantic --dsw-alias tokens only (skin contract).
The git lens styles moved here from sidebar.module.css when the git panel
became a lens of this tab; the generic placeholder/empty/error trio stays
duplicated in both modules (the diff tab still owns its copy). */
.root { display: flex; flex-direction: column; min-height: 0; height: 100%; background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary); font-size: 12px; }
/* ── Lens switcher ─────────────────────────────────────────────────────── */
.lensBar { flex: none; display: flex; align-items: center; padding: 8px 8px 4px 12px; }
.lensSwitch { display: inline-flex; gap: 2px; padding: 2px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; }
.lensButton { border: 0; border-radius: 6px; padding: 2px 10px; background: transparent; color: var(--dsw-alias-label-secondary); font: var(--dsw-font-xxxs-strong-11); cursor: pointer; }
.lensButton:hover { color: var(--dsw-alias-label-primary); }
.lensButton[data-active='true'] { background: var(--dsw-alias-interactive-bg-active); color: var(--dsw-alias-label-primary); }
/* ── Git lens (the former git panel) ───────────────────────────────────── */
.git {
flex: 1;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
overflow-y: auto;
/* Tab pages never scroll horizontally: content wraps or truncates to the
panel width instead of panning sideways. */
overflow-x: hidden;
}
.gitHeader {
flex: none;
display: flex;
align-items: center;
gap: 8px;
height: 36px;
padding: 0 8px 0 12px;
}
.gitWorktreeRow {
flex: none;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px 0 12px;
}
.gitWorktreeLabel {
flex: none;
color: var(--dsw-alias-label-tertiary);
font: var(--dsw-font-xxxs-11);
}
.gitBranchSelect {
flex: 1;
min-width: 0;
height: 26px;
padding: 0 6px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 6px;
background: var(--dsw-alias-bg-base);
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xxs-12);
}
.gitSection {
border-top: 1px solid var(--dsw-alias-border-l1);
}
.gitSectionHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px 4px;
font: var(--dsw-font-xxxs-strong-11);
color: var(--dsw-alias-label-tertiary);
text-transform: uppercase;
}
.gitLink {
border: none;
background: transparent;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-brand-primary);
cursor: pointer;
padding: 0;
}
.gitLink:hover:not(:disabled) {
text-decoration: underline;
}
.gitLink:disabled {
opacity: 0.4;
cursor: default;
}
.gitRow {
display: flex;
align-items: center;
gap: 6px;
min-height: 34px;
margin: 0 6px;
padding: 0 8px;
border-radius: 8px;
animation: dsh-row-in 150ms var(--ds-ease-in-out);
}
.gitRow:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.gitRow[data-selected='true'] {
background: var(--dsw-alias-interactive-bg-active);
}
.gitRowMain {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
border: none;
background: transparent;
padding: 3px 0;
cursor: pointer;
text-align: left;
}
.gitBadge {
flex: none;
width: 20px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
font: var(--dsw-font-xxxs-strong-11);
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
}
/* Status-letter tint: A added / D deleted / M·R modified / C·U conflicts;
untracked '?' reads as a not-yet-added file, so it shares the added tint. */
.gitBadge[data-letter='A'], .gitBadge[data-letter='?'] { color: var(--dsw-alias-state-success-primary); }
.gitBadge[data-letter='D'] { color: var(--dsw-alias-state-error-primary); }
.gitBadge[data-letter='M'], .gitBadge[data-letter='R'] { color: var(--dsw-alias-state-business-primary); }
.gitBadge[data-letter='C'], .gitBadge[data-letter='U'] { color: var(--dsw-alias-state-warn-primary); }
.gitName {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-primary);
}
.gitEmpty {
padding: 4px 12px 8px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
}
.gitPlaceholder {
padding: 16px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
text-align: center;
}
.gitError {
padding: 8px 12px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-state-error-primary);
white-space: pre-wrap;
}
.gitConfirmDesc {
margin: 0;
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-primary);
white-space: pre-wrap;
}
.gitCommit {
display: flex;
gap: 6px;
padding: 8px 12px;
border-top: 1px solid var(--dsw-alias-border-l1);
align-items: center;
}
.gitCommitInput {
flex: 1;
min-width: 0;
}
.gitCommitButton {
flex: none;
height: 26px;
padding: 0 12px;
border: none;
border-radius: 6px;
background: var(--dsw-alias-button-primary-fill);
color: var(--dsw-alias-label-primary-inverted);
font: var(--dsw-font-xxs-strong-12);
cursor: pointer;
}
.gitCommitButton:hover:not(:disabled) {
background: var(--dsw-alias-button-primary-hover);
}
.gitCommitButton:disabled {
opacity: 0.45;
cursor: default;
}
/* A history row: line 1 = hash + subject, line 2 = ref badges + author/date
(badges live on the second line so they never crowd the subject). */
.gitLogRow {
display: flex;
flex-direction: column;
gap: 2px;
padding: 5px 12px;
cursor: pointer;
border-radius: 8px;
}
.gitLogRow:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.gitLogRow[data-selected='true'] {
background: var(--dsw-alias-interactive-bg-active);
}
.gitLogLine1 {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
}
.gitLogHash {
flex: none;
font: var(--dsw-font-markdown-code-block-small);
color: var(--dsw-alias-label-tertiary);
}
.gitLogLine2 {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex-wrap: wrap;
}
.gitLogRef {
flex: none;
padding: 0 5px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 999px;
font: var(--dsw-font-xxxs-strong-11);
color: var(--dsw-alias-brand-primary);
white-space: nowrap;
}
.gitLogSubject {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-primary);
}
.gitLogMeta {
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-tertiary);
}
.gitLogMore {
display: block;
width: calc(100% - 24px);
margin: 4px 12px 8px;
padding: 6px 0;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 6px;
background: transparent;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-secondary);
cursor: pointer;
}
.gitLogMore:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.gitLogMore:disabled {
opacity: 0.5;
cursor: default;
}
/* ── Session lens (this round's files) ─────────────────────────────────── */
.session { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.filterRow { flex: none; display: flex; gap: 4px; padding: 2px 8px 6px 12px; flex-wrap: wrap; }
.filterChip { border: 1px solid var(--dsw-alias-border-l2); border-radius: 999px; padding: 1px 8px; background: transparent; color: var(--dsw-alias-label-secondary); font: var(--dsw-font-xxxs-11); cursor: pointer; }
.filterChip:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
.filterChip[data-active='true'] { background: var(--dsw-alias-interactive-bg-active); color: var(--dsw-alias-label-primary); border-color: transparent; }
.sessionList { flex: 1; min-height: 0; overflow-y: auto; padding: 0 6px 10px; }
.empty { color: var(--dsw-alias-label-tertiary); padding: 24px 8px; text-align: center; }
.loadError { margin: 4px 8px; padding: 6px 10px; border-left: 3px solid var(--dsw-alias-state-warn-primary); border-radius: 0 6px 6px 0; color: var(--dsw-alias-label-secondary); font-size: 11px; }
.fileGroup { margin-bottom: 6px; }
/* The group label sits between op blocks: extra head padding separates it from
the previous file's rows, the first group starts flush. */
.filePath { display: block; color: var(--dsw-alias-label-tertiary); font-family: var(--ds-font-family-code, monospace); font-size: 11px; padding: 6px 4px 2px; word-break: break-all; }
.fileGroup:first-child .filePath { padding-top: 2px; }
.opRow { display: flex; align-items: center; gap: 6px; width: 100%; border: 0; border-radius: 6px; background: transparent; color: var(--dsw-alias-label-primary); font: inherit; font-size: 11px; padding: 3px 5px; cursor: pointer; text-align: left; }
.opRow:hover { background: var(--dsw-alias-interactive-bg-hover); }
.opRow[data-selected='true'] { background: var(--dsw-alias-interactive-bg-active); }
/* A failed op carries a quiet error inset bar (the flag chip names it). */
.opRow[data-op-error='true'] { box-shadow: inset 2px 0 0 var(--dsw-alias-state-error-primary); }
.opKind { border-radius: 4px; font-size: 10px; line-height: 16px; padding: 0 5px; background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-secondary); flex: none; }
.opKind[data-kind='write'] { color: var(--dsw-alias-state-success-primary); }
.opKind[data-kind='edit'] { color: var(--dsw-alias-state-business-primary); }
/* Size + relative time ride as one right-aligned cluster so the kind chips
form a scannable left rail and the meta column stays flush right. */
.opMeta { margin-left: auto; display: inline-flex; align-items: baseline; gap: 8px; flex: none; min-width: 0; }
.opTime { color: var(--dsw-alias-label-tertiary); font-size: 10px; flex: none; }
.opFlag { color: var(--dsw-alias-state-business-primary); font-size: 10px; flex: none; }
.opFlagError { color: var(--dsw-alias-state-error-primary); font-size: 10px; flex: none; }
.opSize { color: var(--dsw-alias-label-tertiary); font-size: 10px; flex: none; font-family: var(--ds-font-family-code, monospace); }
/* ── Shared preview pane ───────────────────────────────────────────────── */
.diffPane { border-top: 1px solid var(--dsw-alias-border-l1); display: flex; flex-direction: column; flex: none; min-height: 0; }
.dragHandle { flex: none; height: 8px; cursor: ns-resize; position: relative; background: var(--dsw-alias-bg-base); }
.dragHandle::after { content: ''; position: absolute; left: calc(50% - 18px); top: 3px; width: 36px; height: 2px; border-radius: 1px; background: var(--dsw-alias-border-l2); }
.dragHandle:hover::after, .dragHandle:focus-visible::after { background: var(--dsw-alias-state-business-primary); }
.diffHead { display: flex; align-items: center; gap: 6px; padding: 3px 8px 3px 10px; border-bottom: 1px solid var(--dsw-alias-border-l1); }
.diffKind { flex: none; border-radius: 4px; font-size: 10px; line-height: 16px; padding: 0 5px; background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-secondary); }
.diffKind[data-kind='git'] { color: var(--dsw-alias-brand-primary); }
.diffKind[data-kind='write'] { color: var(--dsw-alias-state-success-primary); }
.diffKind[data-kind='edit'] { color: var(--dsw-alias-state-business-primary); }
/* The path keeps its tail (the file name is the identity; directories are
context): an RTL paragraph renders an ASCII path in reading order while
clipping its HEAD under the ellipsis. */
.diffPath { flex: 1; direction: rtl; font-family: var(--ds-font-family-code, monospace); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--dsw-alias-label-primary); min-width: 0; }
.diffStats { flex: none; display: inline-flex; gap: 5px; font-size: 10px; font-family: var(--ds-font-family-code, monospace); }
.iconButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: none;
border-radius: 6px;
background: transparent;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
flex: none;
}
.iconButton:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
.iconButton:disabled { opacity: 0.4; cursor: default; }
.paneBody { flex: 1; min-height: 0; overflow-y: auto; }
.readError { color: var(--dsw-alias-state-error-primary); border-left: 3px solid var(--dsw-alias-state-error-primary); border-radius: 0 6px 6px 0; margin: 6px 8px; padding: 6px 10px; white-space: pre-wrap; word-break: break-word; }
.priorUnknown { color: var(--dsw-alias-label-tertiary); font-size: 10px; padding: 3px 10px; }
/* ── Shared bits ───────────────────────────────────────────────────────── */
/* Mount fade for rows (the app's session-row reveal). */
@keyframes dsh-row-in {
from { opacity: 0; }
}
/* Keyboard focus rings match the shared sidebar convention. */
.gitLink:focus-visible, .gitRowMain:focus-visible, .gitLogRow:focus-visible,
.gitCommitButton:focus-visible, .gitLogMore:focus-visible, .gitBranchSelect:focus-visible,
.lensButton:focus-visible, .filterChip:focus-visible, .iconButton:focus-visible,
.dragHandle:focus-visible {
outline: 2px solid var(--dsw-alias-interactive-bg-hover-accent);
outline-offset: -1px;
}
@media (prefers-reduced-motion: reduce) {
.gitRow { animation: none; }
}
+230
View File
@@ -0,0 +1,230 @@
/**
* File-operation extraction for the changes tab's session lens: folds the
* session's append-only event log (`tool/call` + `tool/result` pairs) into
* one op per file-touching call — the same semantics as the standalone
* dsh-file-trace plugin's Chat-view extraction, reading the authoritative
* event log instead (the repo's stated preference for cross-feature session
* data). Pure — no React, no DOM.
*/
import type { SidebarSessionEvent } from '../../context-types.ts'
/** The file-touching operation kinds the tracer distinguishes. */
export type FileOpKind = 'read' | 'write' | 'edit'
/** One extracted file operation. */
export interface FileOp {
/** Stable identity: the originating tool-call id. */
readonly callId: string
readonly kind: FileOpKind
/** Workspace-relative or absolute path exactly as the model spelled it. */
readonly path: string
/** Unix epoch ms of the call; the result time when only that is known. */
readonly time: number
/** True while the call has no result yet. */
readonly running: boolean
/** True when the result reported an error. */
readonly isError: boolean
/** The result's error text when isError; presented instead of the payload. */
readonly errorText?: string
/** For 'edit': the model's exact replacement payload. */
readonly edit?: { oldString: string; newString: string }
/** For 'write': the full new content. */
readonly content?: string
/** For 'read': the file content returned by the tool result. */
readonly read?: string
}
/** Tool names mapped to each op kind; unknown names are ignored. */
const READ_TOOLS = new Set(['read', 'view', 'see'])
const WRITE_TOOLS = new Set(['write', 'create'])
const EDIT_TOOLS = new Set(['edit', 'str_replace', 'str-replace-editor', 'multi-edit'])
/** Classify one tool name; undefined when the tool touches no file. */
function kindOf(name: string): FileOpKind | undefined {
if (READ_TOOLS.has(name)) return 'read'
if (WRITE_TOOLS.has(name)) return 'write'
if (EDIT_TOOLS.has(name)) return 'edit'
return undefined
}
/**
* Parse one tool-call arguments JSON body defensively: the payload is
* model-emitted wire data, so every field is checked before use.
*/
function parseArgs(argsRaw: string): Record<string, unknown> {
try {
const parsed: unknown = JSON.parse(argsRaw)
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
return parsed as Record<string, unknown>
} catch {
return {}
}
}
/** Extract the path field common to every file tool's arguments. */
function pathOf(args: Record<string, unknown>): string | undefined {
for (const key of ['file_path', 'path', 'filePath']) {
const value = args[key]
if (typeof value === 'string' && value.length > 0) return value
}
return undefined
}
/** The 'tool/result' message envelope inside a session event's data. */
interface ToolResultMessageLike {
source?: { kind?: unknown; callId?: unknown }
content?: unknown
}
/** One 'tool-result' content block (inner blocks carry the text). */
interface ToolResultBlockLike {
type?: unknown
content?: unknown
isError?: unknown
}
/** The finalized plain text of one tool result (inner text blocks joined). */
function resultText(message: ToolResultMessageLike): string | undefined {
if (!Array.isArray(message.content)) return undefined
const parts: string[] = []
for (const block of message.content) {
if (block === null || typeof block !== 'object') continue
const candidate = block as ToolResultBlockLike
if (candidate.type !== 'tool-result') continue
const inner = candidate.content
if (!Array.isArray(inner)) continue
for (const item of inner) {
if (item === null || typeof item !== 'object') continue
const textItem = item as { type?: unknown; text?: unknown }
if (textItem.type === 'text' && typeof textItem.text === 'string') parts.push(textItem.text)
}
}
return parts.length > 0 ? parts.join('\n') : undefined
}
/** Whether a tool result reported an error (the inner block's isError flag). */
function resultIsError(message: ToolResultMessageLike): boolean {
if (!Array.isArray(message.content)) return false
return message.content.some((block) => {
if (block === null || typeof block !== 'object') return false
return (block as ToolResultBlockLike).type === 'tool-result'
&& (block as ToolResultBlockLike).isError === true
})
}
/**
* Fold a session event log into the file operations it contains, newest
* first. A `tool/call` seeds a running op (payload from the model's
* arguments); its `tool/result` settles it (read content, error text).
* Calls dispatched through a host tool such as run_code appear as their own
* `tool/call` rows in the log, so nested file calls fold in naturally.
* @param events - the session's append-only event log (oldest → newest).
* @returns the ordered operation list.
*/
export function extractFileOps(events: readonly SidebarSessionEvent[]): FileOp[] {
const byCall = new Map<string, FileOp>()
for (const event of events) {
if (event.type === 'tool/call') {
const data = event.data as { name?: unknown; callId?: unknown; arguments?: unknown }
if (typeof data.name !== 'string' || typeof data.callId !== 'string') continue
const kind = kindOf(data.name)
if (kind === undefined) continue
const args = parseArgs(typeof data.arguments === 'string' ? data.arguments : '')
const path = pathOf(args)
if (path === undefined) continue
const base: FileOp = { callId: data.callId, kind, path, time: event.time, running: true, isError: false }
if (kind === 'edit') {
const oldString = args.old_string
const newString = args.new_string
byCall.set(data.callId, typeof oldString === 'string' && typeof newString === 'string'
? { ...base, edit: { oldString, newString } }
: base)
continue
}
if (kind === 'write') {
const content = args.content
byCall.set(data.callId, typeof content === 'string' && content.length > 0 ? { ...base, content } : base)
continue
}
byCall.set(data.callId, base)
} else if (event.type === 'tool/result') {
const message = (event.data as { message?: unknown }).message as ToolResultMessageLike | undefined
if (message === undefined) continue
const callId = message.source?.callId
if (typeof callId !== 'string') continue
const op = byCall.get(callId)
if (op === undefined) continue
const text = resultText(message)
const isError = resultIsError(message)
const patch: { running: boolean; isError: boolean; errorText?: string; read?: string; content?: string } = { running: false, isError }
if (text !== undefined && text.length > 0) {
if (isError) patch.errorText = text
else if (op.kind === 'read') patch.read = text
else if (op.kind === 'write' && op.content === undefined) patch.content = text
}
byCall.set(callId, { ...op, ...patch })
}
}
return [...byCall.values()].sort((a, b) => b.time - a.time)
}
/**
* Group operations by path, newest op first per file, files ordered by their
* most recent operation.
*/
export function groupByFile(ops: readonly FileOp[]): Map<string, FileOp[]> {
const groups = new Map<string, FileOp[]>()
for (const op of ops) {
const list = groups.get(op.path) ?? []
list.push(op)
groups.set(op.path, list)
}
const ordered = [...groups.entries()].sort((a, b) => b[1][0]!.time - a[1][0]!.time)
return new Map(ordered)
}
/**
* The last content known for a path before the given operation, synthesized
* from earlier ops: a write's payload is authoritative, an edit implies its
* old side. Best effort — a write with no known prior content diffs against
* nothing (all-added).
*/
export function knownContentBefore(ops: readonly FileOp[], path: string, before: FileOp): string | undefined {
const ofFile = ops.filter(op => op.path === path && op.time <= before.time && op !== before)
for (let i = ofFile.length - 1; i >= 0; i -= 1) {
const op = ofFile[i]!
if (op.kind === 'write' && op.content !== undefined) return op.content
if (op.kind === 'edit' && op.edit !== undefined && i === ofFile.length - 1) {
return op.edit.oldString
}
}
return undefined
}
/** One parsed read line: the file's own line number and its content text. */
export interface ReadLine { readonly line: number; readonly text: string }
/**
* Parse a DSH read result into file lines with their real line numbers:
* drops the <content> envelope and "(Showing lines ...)" note; recovers the
* "<n>: " prefix as the line number, falling back to sequential counting.
*/
export function parseReadLines(raw: string): ReadLine[] {
const contentMatch = raw.match(/<content>([\s\S]*?)<\/content>/)
const body = contentMatch ? contentMatch[1]! : raw
const result: ReadLine[] = []
let fallback = 1
for (const line of body.split('\n')) {
if (/^\s*\(Showing lines .*\)\s*$/.test(line)) continue
if (line.length === 0) continue
const match = line.match(/^\s*(\d+):\s?(.*)$/)
if (match !== null) {
result.push({ line: Number(match[1]), text: match[2] ?? '' })
fallback = Number(match[1]) + 1
} else {
result.push({ line: fallback, text: line })
fallback += 1
}
}
return result
}
+5 -5
View File
@@ -49,7 +49,7 @@
* client.js); an edit that does land while a core HMR happens is caught by
* the ETag comparison on the next activation.
*/
export type ChunkName = 'terminal' | 'editor' | 'mermaid'
export type ChunkName = 'terminal' | 'editor' | 'mermaid' | 'locale'
/** The module exports a chunk factory provides (namespace-ish record). */
export type ChunkExports = Record<string, unknown>
@@ -154,7 +154,7 @@ const defaultScriptLoader: ChunkScriptLoader = (src) => new Promise((resolve, re
}, { once: true })
el.addEventListener('error', () => {
el.remove()
reject(new Error(`[dsh-better-sidebar] chunk script ${src} failed to load`))
reject(new Error(`[dsh-apes-sidebar] chunk script ${src} failed to load`))
}, { once: true })
document.head.append(el)
})
@@ -192,7 +192,7 @@ async function buildExternalsRequire(modules: ChunkModuleSystem): Promise<(spec:
if (!table.has(spec)) {
// Single quotes: the bundle-consistency scan regexes for require("...")
// lexical calls — a double-quoted literal here would trip it.
throw new Error(`[dsh-better-sidebar] chunk require('${spec}') missed the module table`)
throw new Error(`[dsh-apes-sidebar] chunk require('${spec}') missed the module table`)
}
return table.get(spec)
}
@@ -250,12 +250,12 @@ export async function loadChunk(name: ChunkName): Promise<ChunkExports> {
if (test !== undefined) return test()
const modules = moduleSystem()
if (modules === undefined) {
throw new Error(`[dsh-better-sidebar] chunk "${name}": client module system unavailable`)
throw new Error(`[dsh-apes-sidebar] chunk "${name}": client module system unavailable`)
}
await scriptLoader(CHUNK_URL(name))
const factory = chunkRegistry()[name]
if (typeof factory !== 'function') {
throw new Error(`[dsh-better-sidebar] chunk "${name}" script did not register its factory`)
throw new Error(`[dsh-apes-sidebar] chunk "${name}" script did not register its factory`)
}
const require = await buildExternalsRequire(modules)
const exports = factory(require)
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* Lazy chunk entry: the code/markdown/html text editor (CodeMirror 6 + the
* language packages). Built as `lib/client-editor.js` and registered under
* `dsh-better-sidebar/editor` — fetched only when a text file is first
* `dsh-apes-sidebar/editor` — fetched only when a text file is first
* opened (see chunk-loader.ts and docs/plans/2026-08-12-lazy-chunks-design.md).
* Never import this module from the core bundle: it pulls CodeMirror into
* the startup path.
+60
View File
@@ -0,0 +1,60 @@
/**
* Lazy chunk entry: the 19 non-zh/en dictionaries (ja/de/fr/…/zh-HK/MO/TW —
* ~640KB of source). The sidebar's own `t()` only ever consults zh/en plus
* the better-locale override store, so these dicts are needed ONLY when
* @huanlin/dsh-plugin-better-locale is installed: they register into its
* override store from there (see the client apply's better-locale
* integration). Built as `lib/client-locale.js` and fetched from the
* plugin's /sidebar/bundle route on first need — never import this module
* from the core bundle: it would drag every language back into the startup
* path (see docs/plans/2026-08-31-perf-optimization.md).
*/
import { ja } from '../locales-ja.ts'
import { de } from '../locales-de.ts'
import { fr } from '../locales-fr.ts'
import { pt } from '../locales-pt.ts'
import { ko } from '../locales-ko.ts'
import { ar } from '../locales-ar.ts'
import { hi } from '../locales-hi.ts'
import { id } from '../locales-id.ts'
import { tr } from '../locales-tr.ts'
import { vi } from '../locales-vi.ts'
import { th } from '../locales-th.ts'
import { ru } from '../locales-ru.ts'
import { it } from '../locales-it.ts'
import { nl } from '../locales-nl.ts'
import { sv } from '../locales-sv.ts'
import { pl } from '../locales-pl.ts'
import { zhHK } from '../locales-zh-HK.ts'
import { zhTW } from '../locales-zh-TW.ts'
import { zhMO } from '../locales-zh-MO.ts'
import type { CopyKey } from '../locales.ts'
/** Key-set check against zh (type-only import — erased before bundling, so
* this adds no runtime tie to the core bundle): a dictionary missing or
* adding a key fails the build instead of silently falling back to en. */
const checked = (dict: Record<CopyKey, string>): Record<string, string> => dict
/** Every non-zh/en dictionary keyed by its override id, ready for the
* better-locale store's `register(ns, dicts)`. */
export const localeDicts: Record<string, Record<string, string>> = {
ja: checked(ja),
de: checked(de),
fr: checked(fr),
pt: checked(pt),
ko: checked(ko),
ar: checked(ar),
hi: checked(hi),
id: checked(id),
tr: checked(tr),
vi: checked(vi),
th: checked(th),
ru: checked(ru),
it: checked(it),
nl: checked(nl),
sv: checked(sv),
pl: checked(pl),
'zh-HK': checked(zhHK),
'zh-TW': checked(zhTW),
'zh-MO': checked(zhMO),
}
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* Lazy chunk entry: the interactive terminal (xterm + fit addon). Built as
* `lib/client-terminal.js` and registered under
* `dsh-better-sidebar/terminal` — fetched only when a terminal tab is first
* `dsh-apes-sidebar/terminal` — fetched only when a terminal tab is first
* opened (see chunk-loader.ts and docs/plans/2026-08-12-lazy-chunks-design.md).
* Never import this module from the core bundle: it pulls xterm (and its
* stylesheet) into the startup path.
+234 -8
View File
@@ -1,29 +1,255 @@
/**
* Append text to the current session's composer draft through the
* Insert text into the current session's composer draft through the
* conversation service — the shared path behind the explorer's @-reference
* button and the viewer selection popup. The service is resolved lazily
* through `ctx.get` (the inject-free read the app's own plugins use); a
* missing service or scope degrades to a logged no-op, never a crash.
*
* File references additionally use DSH's own structured insert event
* (`slash/input-insert-reference`, see `insertFileReference`) instead of
* plain draft text: the native `@file` picker emits this event, and the
* conversation input machine mints one occurrence whose chip covers the
* whole reference. Plain text `@folder/file.ts` only ever gets DSH's
* folder-ref decoration (`@folder/`) — the file name stays undecorated — so
* plain append is the fallback, not the primary path, for files.
*
* Insert position (fixes upstream issue #425): the draft store only exposes
* the whole string (`getSnapshot().draft` + `setDraft(text)`) — there is no
* caret API on the conversation service. The composer's `<textarea>` keeps
* its last selection even while unfocused, so the live caret is probed from
* the DOM (guarded by a value-sync check), and the text is spliced at that
* position, replacing any live selection — with whitespace-aware joins, an
* insert into the middle of a sentence keeps single-space separation like
* the append path. An unknown/stale caret falls back to appending at the
* end (the pre-fix behavior).
*
* The caret is also *restored* after the insert: committing a programmatic
* draft change resets the controlled textarea's caret (observed landing at
* the start of the value), which would make every later insert probe the
* reset position and drift the stack (A|B + C + D ended up as |DACB). The
* placement (`placeComposerCaretAfterInsert`) puts the caret right after the
* inserted text once the value commit lands — the index accounts for the
* separating space on the left, so stacked inserts stay at their running
* position (A|B + C + D → ACD|B).
*/
import type { Context, SidebarConversation } from '../context-types.ts'
/** A resolved composer caret/selection in draft coordinates. */
export interface DraftCaret {
start: number
end: number
}
/**
* Append `text` to the session's composer draft (space-separated, like the
* @-mentions). Returns false — and logs — when the conversation service or
* the session scope is unavailable.
* The spliced draft plus the caret index (in that draft) right after the
* inserted text — the left-side separating space shifts the caret by one,
* which naive `start + text.length` misses (it would land before the tail).
*/
interface SpliceResult {
draft: string
caretAfter: number
}
/**
* Splice `text` into `draft` at `caret` (replacing any live selection) with
* whitespace-aware joins and report the caret position right after the
* inserted text. `caret === null` (position unknown) appends at the end,
* exactly like the original behavior.
*/
function spliceInsert(draft: string, text: string, caret: DraftCaret | null): SpliceResult {
if (caret === null || draft === '') {
const next = draft.trim() === '' ? text : `${draft} ${text}`
return { draft: next, caretAfter: next.length }
}
const prefix = draft.slice(0, caret.start)
const suffix = draft.slice(caret.end)
if (prefix === '' && suffix === '') return { draft: text, caretAfter: text.length }
// One separating space, but never doubled against adjacent whitespace
// (or the string edges) — mirrors how typing in the middle of a sentence
// behaves.
const left = prefix === '' || /\s$/.test(prefix) ? '' : ' '
const right = suffix === '' || /^\s/.test(suffix) ? '' : ' '
return {
draft: `${prefix}${left}${text}${right}${suffix}`,
caretAfter: prefix.length + left.length + text.length,
}
}
/**
* The spliced draft string (see {@link spliceInsert}); pure string math —
* unit-tested directly.
*/
export function insertAtCaret(draft: string, text: string, caret: DraftCaret | null): string {
return spliceInsert(draft, text, caret).draft
}
/**
* Locate the composer `<textarea>` in the conversation column: prefer the
* `data-phase`-tagged textarea (the composer's marker), falling back to any
* textarea in the column, then to a bare data-phase textarea (older host
* layouts without the column attribute). Null in jsdom-less hosts.
*/
function findComposerTextarea(): HTMLTextAreaElement | null {
if (typeof document === 'undefined') return null
const column = document.querySelector('#root [data-slot="conversation"]')
const find = (scope: ParentNode): HTMLTextAreaElement | null =>
scope.querySelector('textarea[data-phase]') ?? scope.querySelector('textarea')
return column !== null
? find(column)
: document.querySelector<HTMLTextAreaElement>('textarea[data-phase]')
}
/**
* Resolve the composer's live caret from its DOM `<textarea>`. The draft
* store has no caret API, so the sidebar reads the composed input's selection
* directly; the value-sync check (`el.value === draft`) discards stale or
* wrong-composer reads — a caret must never be applied against a draft it
* was not measured on.
*
* Returns null when the composer is missing, disabled/read-only, out of
* sync with the store draft, or has no measurable selection (jsdom/odd
* hosts report null selectionStart/End).
*/
export function probeComposerCaret(draft: string): DraftCaret | null {
const el = findComposerTextarea()
if (el === null || el.disabled || el.readOnly) return null
if (el.value !== draft) return null
let start = el.selectionStart
let end = el.selectionEnd
if (typeof start !== 'number' || typeof end !== 'number') return null
if (!Number.isFinite(start) || !Number.isFinite(end)) return null
start = Math.max(0, Math.min(start, draft.length))
end = Math.max(start, Math.min(end, draft.length))
return { start, end }
}
/**
* Restore the composer caret to `caretIndex` after a programmatic
* `setDraft` commit. A controlled textarea update resets the caret (React
* commits the value asynchronously and the browser moves the caret to the
* start/end), so the placement is scheduled and retried across at most two
* animation frames (setTimeout fallback for jsdom), and only applied when
* the textarea still matches `expectedDraft` — a newer edit or a different
* composer wins the race untouched. The caret is clamped into the value
* bounds, mirroring how browsers clamp type-in positions.
*/
export function placeComposerCaretAfterInsert(expectedDraft: string, caretIndex: number): void {
let remaining = 2
let scheduled = false
const schedule = (fn: () => void): void => {
if (scheduled) return
scheduled = true
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(fn)
else setTimeout(fn, 0)
}
const place = (): void => {
scheduled = false
if (remaining <= 0) return
remaining -= 1
const el = findComposerTextarea()
if (el === null || el.disabled || el.readOnly) return
if (el.value !== expectedDraft) {
// The commit has not landed yet (or a competing edit won) — one more
// frame before giving up.
schedule(place)
return
}
const clamped = Math.max(0, Math.min(caretIndex, el.value.length))
el.setSelectionRange(clamped, clamped)
}
schedule(place)
}
/**
* Insert `text` into the session's composer draft at the composer's live
* caret (see {@link probeComposerCaret}), falling back to appending at the
* end when the caret cannot be resolved. Returns false — and logs — when the
* conversation service or the session scope is unavailable.
*/
export function appendToDraft(ctx: Context, sessionId: string, text: string): boolean {
try {
const actx = ctx.sessions.scope(sessionId)
if (actx === undefined) {
console.warn('[dsh-apes-sidebar] draft insert skipped: no session scope', sessionId)
return false
}
const conversation = ctx.get('conversation') as SidebarConversation | undefined
if (conversation === undefined) {
console.warn('[dsh-apes-sidebar] draft insert skipped: conversation service unavailable')
return false
}
const input = conversation.input.for(actx)
const draft = input.state.getSnapshot().draft
const caret = probeComposerCaret(draft)
const { draft: next, caretAfter } = spliceInsert(draft, text, caret)
input.setDraft(next)
// Put the caret right after the inserted text once the value commit
// lands — see the module doc for why this keeps stacked inserts at the
// running position.
placeComposerCaretAfterInsert(next, caretAfter)
return true
} catch (error) {
console.warn('[dsh-apes-sidebar] draft insert failed:', error)
return false
}
}
/**
* The DSH `@file` spelling for one relative path, mirroring the host grammar
* (`formatFileMention` in `@deepseek-ai/dsh-file-reference`): plain when
* there is no whitespace, quoted when there is, and `undefined` when the
* path contains a control character or an embedded quote the editor grammar
* cannot represent.
*/
export function fileMention(relativePath: string): { mention: string; label: string } | undefined {
const path = relativePath.replace(/[\\/]+$/, '')
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('\\'))
const label = at === -1 ? path : path.slice(at + 1)
return { mention, label }
}
/**
* Insert one FILE reference as a structured chip (like DSH's own `@` picker).
* The chip displays `@<basename>` but serializes to `@<relative path>` on
* send, so the reference stays a single link from trigger to basename.
*
* Directories are NOT handled here: DSH's folder grammar wants the trailing
* slash as plain text (`@dir/`) so completion can descend, which
* `appendToDraft` already covers.
*/
export function insertFileReference(ctx: Context, sessionId: string, relativePath: string): boolean {
const reference = fileMention(relativePath)
if (reference === undefined) return false
try {
const actx = ctx.sessions.scope(sessionId)
if (actx === undefined) return false
const conversation = ctx.get('conversation') as SidebarConversation | undefined
if (conversation === undefined) return false
const input = conversation.input.for(actx)
const draft = input.state.getSnapshot().draft
input.setDraft(draft.trim() === '' ? text : `${draft} ${text}`)
return true
const before = input.state.getSnapshot()
if (before.draftRev === undefined) return false
// The session-scope Context's typed `emit` is keyed to DSH's closed event
// map; this internal composer event is deliberately string-loose at runtime.
;(actx as unknown as { emit(name: string, payload: unknown): void }).emit('slash/input-insert-reference', {
reference: {
source: 'reference',
ref: reference.mention,
label: reference.label,
appearance: 'file',
clipboardText: reference.mention,
},
span: {
draftRev: before.draftRev,
start: before.draft.length,
end: before.draft.length,
},
})
const after = input.state.getSnapshot()
return after.draftRev !== before.draftRev
} catch (error) {
console.warn('[dsh-better-sidebar] draft insert failed:', error)
console.warn('[dsh-apes-sidebar] file-reference insert failed:', error)
return false
}
}
+122
View File
@@ -0,0 +1,122 @@
/**
* A full unified-diff document (one changed file, or every file of a commit
* patch): collapsible per-file headers — source files open by default, tests
* / docs / generated files stay folded — each expanded file rendering through
* the shared {@link DiffRows} (so git diffs get the same rewrite tinting,
* intra-line highlights, syntax colors and hunk folds as session-op diffs).
* 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 clsx from 'clsx'
import { t } from '../locales.ts'
import { diffStats, displayPath, parseUnifiedDiff, unifiedSegments, untrackedFile, type DiffFile } from './rows.ts'
import { langOfPath } from './highlight.ts'
import { DiffRows } from './DiffRows.tsx'
import css from './diff.module.css'
const TEST_PATH = /(^|\/)(?:__tests__|tests?|specs?|fixtures?|mocks?|snapshots?)(?:\/|$)|\.(?:test|spec)\.[^/]+$/i
const DOC_PATH = /(^|\/)(?:docs?|documentation)(?:\/|$)|(^|\/)(?:readme|changelog|contributing|license|authors|notice)(\.[^/]*)?$/i
const GENERATED_PATH = /(^|\/)(?:dist|build|coverage|generated|vendor|node_modules)(?:\/|$)|(^|\/)(?:package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?|composer\.lock|cargo\.lock|poetry\.lock)$/i
const SOURCE_PATH = /\.(?:js|jsx|mjs|cjs|ts|tsx|mts|cts|py|pyw|rb|php|java|kt|kts|scala|go|rs|swift|c|h|cc|cpp|cxx|hpp|hh|hxx|cs|fs|fsx|vb|dart|lua|r|ex|exs|erl|hrl|clj|cljs|cljc|groovy|sh|bash|zsh|fish|ps1|sql|vue|svelte|astro|html|htm|css|scss|sass|less)$/i
/** Source files open by default; tests, docs, generated files and unknown types stay folded. */
function defaultExpandedFiles(files: readonly DiffFile[]): Set<number> {
const expanded = new Set<number>()
files.forEach((file, index) => {
const path = displayPath(file.newPath === '/dev/null' ? file.oldPath : file.newPath)
if (!file.binary && file.hunks.length > 0
&& !TEST_PATH.test(path) && !DOC_PATH.test(path) && !GENERATED_PATH.test(path)
&& SOURCE_PATH.test(path)) {
expanded.add(index)
}
})
return expanded
}
/** The file header badge: added / deleted / renamed / binary ('' for a plain edit). */
function fileTag(file: DiffFile): string | null {
if (file.binary) return t('diffBinary')
if (file.oldPath === '/dev/null') return t('diffAdded')
if (file.newPath === '/dev/null') return t('diffDeleted')
const oldPath = displayPath(file.oldPath)
const newPath = displayPath(file.newPath)
if (oldPath !== newPath) return t('diffRenamed')
return null
}
export interface DiffFilesProps {
/** Unified diff text (`git.diff` or `git.commit-diff` payloads). */
diff: string
/** Untracked-file content: when present, renders as a full-file addition instead of parsing. */
untrackedPath?: string
untrackedContent?: string
}
export function DiffFiles({ diff, untrackedPath, untrackedContent }: DiffFilesProps) {
const parsed = useMemo(() => {
if (untrackedPath !== undefined) {
return { files: [untrackedFile(untrackedPath, untrackedContent ?? '')] }
}
return parseUnifiedDiff(diff)
}, [diff, untrackedPath, untrackedContent])
const [expandedFiles, setExpandedFiles] = useState<Set<number>>(() => defaultExpandedFiles(parsed.files))
useEffect(() => { setExpandedFiles(defaultExpandedFiles(parsed.files)) }, [parsed])
// Segments and header stats computed once per file.
const files = useMemo(
() => parsed.files.map((file) => {
const segments = unifiedSegments(file)
return { file, segments, stats: diffStats(segments) }
}),
[parsed],
)
const renderFile = (entry: (typeof files)[number], fileIndex: number): ReactNode => {
const { file, segments, stats } = entry
const tag = fileTag(file)
const from = displayPath(file.oldPath)
const to = displayPath(file.newPath)
const expandable = !file.binary && file.hunks.length > 0
const fileExpanded = expandedFiles.has(fileIndex)
return (
<div key={`file-${String(fileIndex)}`} className={css.fileBlock}>
<button
type="button"
className={css.file}
disabled={!expandable}
aria-expanded={expandable ? fileExpanded : undefined}
onClick={() => {
setExpandedFiles(current => {
const next = new Set(current)
if (next.has(fileIndex)) next.delete(fileIndex)
else next.add(fileIndex)
return next
})
}}
>
{expandable && <span aria-hidden="true" className={clsx(css.fileChevron, fileExpanded && css.fileChevronExpanded)}>›</span>}
<span className={css.filePath}>{to}</span>
{from !== to && <span className={css.fileOld}>← {from}</span>}
{tag !== null && <span className={css.fileTag}>{tag}</span>}
{expandable && (stats.added > 0 || stats.deleted > 0) && (
<span className={css.fileStats}>
{stats.added > 0 && <span className={css.statAdd}>+{String(stats.added)}</span>}
{stats.deleted > 0 && <span className={css.statDel}>−{String(stats.deleted)}</span>}
</span>
)}
</button>
{expandable && fileExpanded && (
<DiffRows segments={segments} lang={langOfPath(to)} />
)}
</div>
)
}
if (parsed.files.length === 0) return null
return (
<div className={css.files}>
{files.map(renderFile)}
</div>
)
}
+271
View File
@@ -0,0 +1,271 @@
/**
* The one diff renderer every file-change surface shares (the changes tab's
* inline preview pane and the diff tab): fold-capable hunk rows with old/new
* 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.
*/
import { useEffect, useMemo, 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 { hasBlockComment, isColored, scanLine, type CodeToken, type TokenType } from './highlight.ts'
import css from './diff.module.css'
/** Long diff lines fold to one ellipsized row; the threshold is the char count. */
const FOLD_THRESHOLD = 120
/** Rendered hunk rows per file capped at this count; expand reveals the rest. */
const MAX_ROWS = 600
/** Token class -> CSS color class ('' inherits the row's diff color). */
const TOKEN_CLASS: Readonly<Record<TokenType, string>> = {
plain: '',
comment: css.tokComment ?? '',
string: css.tokString ?? '',
keyword: css.tokKeyword ?? '',
number: css.tokNumber ?? '',
type: css.tokType ?? '',
function: css.tokFunction ?? '',
macro: css.tokMacro ?? '',
}
/** One token span's class list: its color class, plus the change tint. */
function tokenSpanClass(type: TokenType, changed: boolean): string {
const color = TOKEN_CLASS[type]
return changed ? `${color} ${css.inlineChange}` : color
}
/** Render scanned tokens as colored nodes; uncolored runs stay text. */
function tokensToNodes(tokens: readonly CodeToken[], changed = false): ReactNode[] {
const nodes: ReactNode[] = []
for (const token of tokens) {
if (!changed && !isColored(token)) nodes.push(token.text)
else nodes.push(<span key={String(nodes.length)} className={tokenSpanClass(token.type, changed)}>{token.text}</span>)
}
return nodes
}
/**
* Intra-line diffs for mod-row pairs, keyed by row identity: each mod-run's
* first half (old side) pairs with its second half (new side), each pair
* diffed by common prefix/suffix so both sides highlight the exact changed
* substring.
*/
function buildInlineMap(segments: readonly DiffSegment[]): Map<DiffRow, InlineDiff> {
const map = new Map<DiffRow, InlineDiff>()
for (const segment of segments) {
if (segment.kind !== 'hunk') continue
const rows = segment.rows
let i = 0
while (i < rows.length) {
if (rows[i]!.kind !== 'mod') { i += 1; continue }
let j = i
while (j < rows.length && rows[j]!.kind === 'mod') j += 1
const block = rows.slice(i, j)
const half = Math.floor(block.length / 2)
for (let p = 0; p < half; p += 1) {
const delRow = block[p]!
const addRow = block[p + half]!
const inline = diffInline(delRow.text, addRow.text)
map.set(delRow, inline)
map.set(addRow, inline)
}
i = j
}
}
return map
}
/**
* Per-row block-comment entry state for a diff: the old side threads along
* old-line order and the new side along new-line order (the row order
* preserves both), so multi-line comments color correctly on each side.
*/
function diffBlockEntries(segments: readonly DiffSegment[], lang: string | undefined): Map<DiffRow, boolean> {
const entries = new Map<DiffRow, boolean>()
if (!hasBlockComment(lang)) return entries
let oldIn = false
let newIn = false
for (const segment of segments) {
if (segment.kind !== 'hunk') continue
for (const row of segment.rows) {
const isOld = row.oldLine !== undefined
const isNew = row.newLine !== undefined
entries.set(row, isOld ? oldIn : newIn)
if (isOld) oldIn = scanLine(row.text, lang, oldIn).inBlock
if (isNew) newIn = scanLine(row.text, lang, newIn).inBlock
}
}
return entries
}
export interface DiffRowsProps {
/** Precomputed segments (hunks and folds) for one file's diff. */
segments: readonly DiffSegment[]
/** Syntax language id (langOfPath); undefined renders plain text. */
lang?: string
}
/** One file's diff rows: fold chips between hunks, highlighted code rows. */
export function DiffRows({ segments, lang }: 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)
// Reset all folding when the segments identity changes (new target).
useEffect(() => {
setExpandedLines(new Set())
setExpandedFolds(new Set())
setExpandedAll(false)
}, [segments])
const inlineMap = useMemo(() => buildInlineMap(segments), [segments])
const blockEntries = useMemo(() => diffBlockEntries(segments, lang), [segments, lang])
/** One diff row: colored sign + syntax-colored text, long-line fold toggle. */
const renderDiffRow = (row: DiffRow, rowKey: string): ReactElement => {
if (row.kind === 'meta') {
return (
<div key={rowKey} className={css.row} data-kind="meta">
<span className={css.metaText}>{row.text}</span>
</div>
)
}
const isLong = row.text.length > FOLD_THRESHOLD
const isFolded = isLong && !expandedLines.has(rowKey)
const blockEntry = blockEntries.get(row) ?? false
return (
<div
key={rowKey}
className={css.row}
data-kind={row.kind}
data-folded={isFolded ? 'true' : undefined}
onClick={isLong ? () => {
setExpandedLines(prev => {
const next = new Set(prev)
if (next.has(rowKey)) next.delete(rowKey)
else next.add(rowKey)
return next
})
} : undefined}
title={isFolded ? row.text : undefined}
>
<span className={css.lineNo}>{row.oldLine !== undefined ? String(row.oldLine) : ''}</span>
<span className={css.lineNo}>{row.newLine !== undefined ? String(row.newLine) : ''}</span>
<span className={css.sign}>{row.kind === 'del' ? '-' : row.kind === 'add' ? '+' : row.kind === 'mod' ? '~' : ' '}</span>
<span className={css.text} data-folded={isFolded ? 'true' : undefined}>
{row.kind === 'mod' && (() => {
const inline = inlineMap.get(row)
if (inline === undefined) return tokensToNodes(scanLine(row.text, lang, blockEntry).tokens)
// Coalesced change runs, each split into syntax tokens with
// block-comment state threaded across the runs of this line.
const side = coalesceInline(row.oldLine !== undefined ? inline.old : inline.next)
const nodes: ReactNode[] = []
let state = blockEntry
for (const seg of side) {
const scan = scanLine(seg.text, lang, state)
state = scan.inBlock
nodes.push(...tokensToNodes(scan.tokens, seg.changed))
}
return nodes
})()}
{row.kind !== 'mod' && tokensToNodes(scanLine(row.text, lang, blockEntry).tokens)}
</span>
</div>
)
}
let renderedRows = 0
const renderSegment = (segment: DiffSegment, segIndex: number): ReactNode => {
if (segment.kind === 'hunk') {
const capped = !expandedAll && renderedRows + segment.rows.length > MAX_ROWS
const rows = capped ? segment.rows.slice(0, Math.max(MAX_ROWS - renderedRows, 0)) : segment.rows
renderedRows += rows.length
return (
<div key={`hunk-${String(segIndex)}`}>
{rows.map((row, index) => renderDiffRow(row, `${segIndex}-${String(index)}`))}
</div>
)
}
const expandable = segment.rows !== undefined && segment.count >= MIN_FOLD
if (!expandable) {
// A tiny fold (or a git gap with no rows to reveal): a quiet marker.
return (
<div key={`fold-${String(segIndex)}`} className={css.foldRow}>
<span className={css.foldMarker} title={t('changesContext')}>
{t('changesFold', { count: segment.count })}
</span>
</div>
)
}
const isExpanded = expandedFolds.has(segIndex)
return (
<div
key={`fold-${String(segIndex)}`}
className={css.foldRow}
data-expandable="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
})
}}
>
{isExpanded
? segment.rows!.map((row, index) => renderDiffRow(row, `${segIndex}-${String(index)}`))
: (
<span className={css.foldMarker} title={t('changesContext')}>
{t('changesFold', { count: segment.count })}
</span>
)}
</div>
)
}
const parts = segments.map(renderSegment)
return (
<div className={css.rows}>
{parts}
{renderedRows >= MAX_ROWS && !expandedAll && (
<button type="button" className={css.expand} onClick={() => { setExpandedAll(true) }}>
{t('diffExpand', { count: segments.reduce((sum, s) => sum + (s.kind === 'hunk' ? s.rows.length : 0), 0) - renderedRows })}
</button>
)}
</div>
)
}
export interface ReadRowsProps {
/** The file lines with their real line numbers (parseReadLines output). */
lines: ReadonlyArray<{ line: number; text: string }>
/** Syntax language id (langOfPath); undefined renders plain text. */
lang?: string
}
/** The read view: a line-numbered, syntax-colored slice of a read file. */
export function ReadRows({ lines, lang }: ReadRowsProps) {
const rows = useMemo(() => {
let state = false
return lines.map((line) => {
const scan = scanLine(line.text, lang, state)
state = scan.inBlock
return { line: line.line, nodes: tokensToNodes(scan.tokens) }
})
}, [lines, lang])
return (
<div className={css.rows}>
{rows.map((row) => (
<div key={String(row.line)} className={css.row} data-kind="read">
<span className={css.lineNo}>{String(row.line)}</span>
<span className={css.text}>{row.nodes}</span>
</div>
))}
</div>
)
}
+86
View File
@@ -0,0 +1,86 @@
/* The shared diff renderer (DiffRows / DiffFiles): one visual language for
every file-change surface — the changes tab's preview pane, the diff tab,
session-op diffs and git diffs alike. Semantic --dsw-alias tokens only
(skin contract); the syntax-token hues derive from the alias state tokens
via color-mix so they sit BETWEEN the diff colors (violet keywords, teal
strings, brand-tinted types, warn-family numbers/functions, magenta
macros) instead of colliding with red del / green add / blue mod. */
.rows { padding: 4px 0 8px; font-family: var(--ds-font-family-code, monospace); font-size: 11px; }
.row { display: flex; align-items: flex-start; gap: 5px; padding: 0 10px; line-height: 18px; cursor: default; }
.row[data-folded='true'] { cursor: pointer; }
.lineNo { flex: none; width: 2.5em; color: var(--dsw-alias-label-tertiary); text-align: right; user-select: none; font-size: 10px; }
.sign { flex: none; width: 1em; text-align: center; user-select: none; font-weight: 700; }
.row[data-kind='del'] .sign, .row[data-kind='del'] .text { color: var(--dsw-alias-state-error-primary); }
.row[data-kind='add'] .sign, .row[data-kind='add'] .text { color: var(--dsw-alias-state-success-primary); }
.row[data-kind='mod'] .sign, .row[data-kind='mod'] .text { color: var(--dsw-alias-state-business-primary); }
.row[data-kind='context'] .sign, .row[data-kind='context'] .text { color: var(--dsw-alias-label-secondary); }
.row[data-kind='read'] .text { color: var(--dsw-alias-label-primary); }
/* Faint row tints under changed lines (GitHub-style, lighter than the text
hue so the syntax colors stay readable on top). */
.row[data-kind='del'] { background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent); }
.row[data-kind='add'] { background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 8%, transparent); }
.row[data-kind='mod'] { background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 8%, transparent); }
.row .text { flex: 1; min-width: 0; white-space: pre-wrap; word-break: break-word; }
.row[data-folded='true'] .text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* The `\ No newline at end of file` marker row attached to a hunk's tail. */
.row[data-kind='meta'] .metaText { color: var(--dsw-alias-label-tertiary); font-style: italic; }
/* Intra-line changed substring inside a rewritten (mod) line. */
.inlineChange { background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 26%, transparent); border-radius: 3px; padding: 0 1px; }
/* Syntax-token colors: alias-token mixes between the diff hues. */
.tokComment { color: var(--dsw-alias-label-tertiary); font-style: italic; }
.tokKeyword { color: color-mix(in oklab, var(--dsw-alias-state-business-primary) 55%, var(--dsw-alias-state-error-primary)); }
.tokString { color: color-mix(in oklab, var(--dsw-alias-state-success-primary) 45%, var(--dsw-alias-state-business-primary)); }
.tokType { color: color-mix(in oklab, var(--dsw-alias-brand-primary) 60%, var(--dsw-alias-state-success-primary)); }
.tokNumber { color: var(--dsw-alias-state-warn-primary); }
.tokFunction { color: color-mix(in oklab, var(--dsw-alias-state-warn-primary) 55%, var(--dsw-alias-state-error-primary)); }
.tokMacro { color: color-mix(in oklab, var(--dsw-alias-state-error-primary) 45%, var(--dsw-alias-state-business-primary)); }
/* Hunk-fold row: collapsed marker chip; expanded padding drops to 0 so the
revealed rows keep the x-alignment of hunk rows (they carry their own).
Only an expandable fold (enough hidden rows to be worth a click) reads as
interactive — the › chevron matches the multi-file document's disclosure. */
.foldRow { padding: 0 10px; }
.foldRow[data-expandable='true'] { cursor: pointer; }
.foldRow[data-expandable='true']:hover { background: var(--dsw-alias-interactive-bg-hover); }
.foldRow[data-expanded='true'] { padding: 0; }
.foldRow[data-expanded='true'] { cursor: default; }
.foldRow[data-expanded='true'] .foldMarker { display: none; }
.foldMarker { display: inline-flex; align-items: center; gap: 5px; padding: 2px 7px; border-radius: 4px; margin: 2px 0; color: var(--dsw-alias-label-tertiary); background: var(--dsw-alias-interactive-bg-hover); }
.foldRow[data-expandable='true'] .foldMarker { color: var(--dsw-alias-label-secondary); }
.foldRow[data-expandable='true'] .foldMarker::before { content: '›'; color: var(--dsw-alias-label-tertiary); transition: transform var(--ds-transition-duration-slow) var(--ds-ease-in-out); }
/* The huge-file overflow reveal under a capped hunk. */
.expand { display: block; width: calc(100% - 20px); margin: 4px 10px; padding: 4px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 6px; background: transparent; color: var(--dsw-alias-label-secondary); font: inherit; font-size: 11px; cursor: pointer; }
.expand:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
/* Multi-file documents (commit patches): collapsible file headers. */
.files { display: flex; flex-direction: column; }
.fileBlock { display: flex; flex-direction: column; }
.file { display: flex; align-items: center; gap: 6px; width: 100%; padding: 6px 10px; border: 0; background: transparent; font: inherit; font-size: 11px; text-align: left; cursor: pointer; }
.file:disabled { cursor: default; }
.file:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); }
.fileChevron { flex: none; width: 12px; color: var(--dsw-alias-label-tertiary); transition: transform var(--ds-transition-duration-slow) var(--ds-ease-in-out); }
.fileChevronExpanded { transform: rotate(90deg); }
.filePath { font-family: var(--ds-font-family-code, monospace); color: var(--dsw-alias-label-primary); word-break: break-all; min-width: 0; }
.fileOld { flex: none; max-width: 40%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-tertiary); font-family: var(--ds-font-family-code, monospace); }
.fileTag { flex: none; border-radius: 4px; font-size: 10px; line-height: 16px; padding: 0 5px; background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-secondary); }
.fileStats { margin-left: auto; flex: none; display: inline-flex; gap: 5px; font-size: 10px; font-family: var(--ds-font-family-code, monospace); }
.statAdd { color: var(--dsw-alias-state-success-primary); }
.statDel { color: var(--dsw-alias-state-error-primary); }
/* Keyboard focus rings match the shared sidebar convention. */
.row:focus-visible, .foldRow:focus-visible, .file:focus-visible, .expand:focus-visible {
outline: 2px solid var(--dsw-alias-interactive-bg-hover-accent);
outline-offset: -1px;
}
@media (prefers-reduced-motion: reduce) {
.fileChevron { transition: none; }
}
+383
View File
@@ -0,0 +1,383 @@
/**
* Lightweight syntax highlighting for the shared diff renderer: extension-
* based language detection plus a single-pass line tokenizer that colors
* comments, strings, numbers, keywords, types, functions, and preprocessor
* directives for common languages (C/C++, Java, C#, JS/TS, Python, Go, Rust,
* shell, batch, PowerShell, and config formats). Pure functions only — no
* React, no DOM. Multi-line block-comment state threads across lines via
* scanLine's inBlock parameter, so interior lines of a block comment color
* correctly.
*/
/** Token classes mapped 1:1 to panel CSS color classes. */
export type TokenType =
| 'plain'
| 'comment'
| 'string'
| 'keyword'
| 'number'
| 'type'
| 'function'
| 'macro'
/** One token span: a run of one line sharing one token class. */
export interface CodeToken {
readonly text: string
readonly type: TokenType
}
/** Token classes that imply their own color; `plain` inherits the row color. */
const COLORED: ReadonlySet<TokenType> = new Set(['comment', 'string', 'keyword', 'number', 'type', 'function', 'macro'])
/** Per-language scanner configuration. */
interface LangConfig {
/** Start-of-line (after blanks) or after-token comment prefixes. */
readonly lineComments: readonly string[]
/** Multi-line comment delimiters; the opener may appear anywhere. */
readonly blockComment?: readonly [open: string, close: string]
/** String delimiter characters; the escape character precedes any literal. */
readonly strings?: readonly string[]
/** Reserved words colored as keywords. */
readonly keywords: ReadonlySet<string>
/** Constant literals colored as keywords (true/false/null). */
readonly constants: ReadonlySet<string>
/** '#' starts a preprocessor directive / directive-like annotation. */
readonly macro: boolean
/** Identifier chars (word body). */
readonly wordStart: RegExp
readonly wordBody: RegExp
}
const LETTER = /[A-Za-z_$]/u
const WORD = /[A-Za-z0-9_$]/u
const ANYWORD = { wordStart: LETTER, wordBody: WORD }
const kw = (words: string): ReadonlySet<string> => new Set(words.split(/\s+/))
const C_FAMILY = (words: string): LangConfig => ({
lineComments: ['//'],
blockComment: ['/*', '*/'],
strings: ['"', "'", '`'],
keywords: kw(words),
constants: kw('true false null NULL nullptr TRUE FALSE'),
macro: true,
...ANYWORD,
})
const HASH_FAMILY = (words: string, constants = ''): LangConfig => ({
lineComments: ['#'],
strings: ['"', "'"],
keywords: kw(words),
constants: kw(constants.length > 0 ? constants : 'True False None true false null'),
macro: false,
...ANYWORD,
})
const CONFIG_LANG: LangConfig = {
lineComments: ['#'],
strings: ['"', "'"],
keywords: new Set(),
constants: new Set(),
macro: false,
wordStart: LETTER,
wordBody: WORD,
}
/** SQL: '--' line comments plus '#', quoting with single quotes. */
const SQL_LANG: LangConfig = {
lineComments: ['--', '#'],
strings: ["'"],
keywords: kw(`select from where insert into values update set delete create table drop alter add column
primary key foreign references index view join inner left right outer on as order by group having
limit offset distinct union all and or not in exists between like is null asc desc count sum avg
min max case when then else end begin commit rollback transaction default constraint unique`),
constants: kw('true false null'),
macro: false,
...ANYWORD,
}
/** Windows batch: 'REM'/'::' comments, '%' variable quoting. */
const CMD_LANG: LangConfig = {
lineComments: ['::'],
strings: ['"'],
keywords: kw(`rem if else for in do goto call exit echo set setlocal endlocal shift
exist defined errorlevel not equ neq lss leq gtr geq nul con defined enabledelayedexpansion`),
constants: new Set(),
macro: false,
wordStart: LETTER,
wordBody: WORD,
}
/** PowerShell: '#' comments, quoted strings including here-string quotes. */
const PS_LANG: LangConfig = {
lineComments: ['#'],
blockComment: ['<#', '#>'],
strings: ['"', "'"],
keywords: kw(`function param begin process end if elseif else foreach for while do until switch
try catch finally throw return break continue filter in workflow class enum interface
dynamicparam data checkpoint systemlanguage default expand`),
constants: kw('true false null'),
macro: false,
...ANYWORD,
}
/** Markdown: no tokenizer; the whole line stays plain. */
const MD_LANG: LangConfig = {
lineComments: [],
strings: [],
keywords: new Set(),
constants: new Set(),
macro: false,
wordStart: LETTER,
wordBody: WORD,
}
/** Extension → language id, mirroring the read tool's hint table. */
const LANGS: Readonly<Record<string, LangConfig>> = {
ts: C_FAMILY(`abstract any as asserts async await boolean break case catch class const constructor
continue debugger declare default delete do else enum export extends false finally for from
function get if implements import in infer instanceof interface is keyof let module namespace
never new null number object of override private protected public readonly return satisfies set
static string super switch symbol this throw true try type typeof undefined union unknown var
void while with yield`),
tsx: C_FAMILY(`abstract any as asserts async await boolean break case catch class const constructor
continue declare default delete do else enum export extends false finally for from function get
if implements import in infer instanceof interface is keyof let module namespace never new null
number object of override private protected public readonly return satisfies set static string
super switch symbol this throw true try type typeof undefined union unknown var void while with
yield`),
js: C_FAMILY(`async await break case catch class const continue debugger default delete do else
export extends false finally for from function get if implements import in instanceof interface
let new null of return set static super switch this throw true try typeof undefined var void
while with yield`),
jsx: C_FAMILY(`async await break case catch class const continue debugger default delete do else
export extends false finally for from function get if implements import in instanceof interface
let new null of return set static super switch this throw true try typeof undefined var void
while with yield`),
json: CONFIG_LANG,
py: HASH_FAMILY(`and as assert async await break class continue def del elif else except finally
for from global if import in is lambda nonlocal not or pass raise return try while with yield
match case`, 'True False None self cls NotImplemented __name__ __main__'),
go: C_FAMILY(`break case chan const continue default defer else fallthrough for func go goto if
import interface map package range return select struct switch type var nil iota make new len
cap append copy close delete panic print println recover`),
rs: C_FAMILY(`as async await break const continue crate dyn else enum extern false fn for if impl
in let loop match mod move mut pub ref return self Self static struct super trait true type
unsafe use where while`),
java: C_FAMILY(`abstract assert boolean break byte case catch char class const continue default do
double else enum extends final finally float for goto if implements import instanceof int
interface long native new package private protected public return short static strictfp super
switch synchronized this throw throws transient try void volatile while var record sealed
permits yield`),
c: C_FAMILY(`auto break case char const continue default do double else enum extern float for
goto if inline int long register restrict return short signed sizeof static struct switch
typedef union unsigned void volatile while _Bool _Complex _Atomic`),
cpp: C_FAMILY(`alignas alignof and auto break case catch char class co_await co_return co_yield
concept const consteval constexpr constinit const_cast continue decltype default delete
do double dynamic_cast else enum explicit export extern false final float for friend goto if
inline int long mutable namespace new noexcept not nullptr operator or override private
protected public register reinterpret_cast requires return short signed sizeof static
static_assert static_cast struct switch template this thread_local throw true try typedef
typeid typename union unsigned using virtual void volatile wchar_t while`),
cs: C_FAMILY(`abstract as async await base bool break byte case catch char checked class const
continue decimal default delegate do double dynamic else enum event explicit extern false
finally fixed float for foreach get goto if implicit in init int interface internal is lock
long namespace new null not null forgiving object operator out override params partial
private protected public readonly record ref return sbyte sealed set short sizeof stackalloc
static string struct switch this throw true try typeof uint ulong unchecked unsafe ushort
using var virtual void volatile when where while with yield`),
kt: C_FAMILY(`as break by catch class companion const constructor continue crossinline data do
dynamic else enum external false final finally for fun get if import in infix init inline
interface internal is lateinit lazy null object open operator out override package private
protected public reified return sealed set super suspend tailrec this throw true try typealias
val var vararg when where while`),
swift: C_FAMILY(`actor as associatedtype async await break case catch class continue
convenience default defer deinit didSet do dynamic else enum extension fallthrough false
final for func get guard if import in indirect infix init inout internal is lazy let nil
nonmutating open operator optional override postfix precedencegroup prefix private protocol
public repeat required rethrows return self set some static struct subscript super switch
throw throws true try typealias unowned var weak where while willSet`),
php: C_FAMILY(`abstract and array as break callable case catch class clone const continue declare
default do echo else elseif empty enddeclare endfor endforeach endif endswitch endwhile enum
extends final finally fn for foreach function global goto if implements include
include_once instanceof insteadof interface isset list match namespace new or print private
protected public readonly require require_once return static switch throw trait try unset use
var while xor yield true false null int string bool float void mixed never self parent`),
sh: HASH_FAMILY(`if then else elif fi for while until do done case esac function in select time
coproc return break continue local export readonly declare typeset unset shift eval exec trap
exit source alias set`, 'true false'),
bash: HASH_FAMILY(`if then else elif fi for while until do done case esac function in select time
coproc return break continue local export readonly declare typeset unset shift eval exec trap
exit source alias set`, 'true false'),
zsh: HASH_FAMILY(`if then else elif fi for while until do done case esac function in select time
coproc return break continue local export readonly declare typeset unset shift eval exec trap
exit source alias set`, 'true false'),
yaml: CONFIG_LANG,
yml: CONFIG_LANG,
toml: CONFIG_LANG,
ini: CONFIG_LANG,
sql: SQL_LANG,
cmd: CMD_LANG,
bat: CMD_LANG,
ps1: PS_LANG,
psm1: PS_LANG,
psd1: PS_LANG,
md: MD_LANG,
markdown: MD_LANG,
mdx: MD_LANG,
html: CONFIG_LANG,
htm: CONFIG_LANG,
css: HASH_FAMILY(''),
scss: HASH_FAMILY(''),
less: HASH_FAMILY(''),
lua: HASH_FAMILY(`and break do else elseif end false for function goto if in local nil not or
repeat return then true until while`),
}
/**
* Language id for a file path's extension (the read tool's mapping): the
* lowercase extension without its dot; dotfiles and unknown extensions map to
* undefined (plain text).
* @param path - the op's file path exactly as recorded.
* @returns the language id, or undefined for plain text.
*/
export function langOfPath(path: string): string | undefined {
const base = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1)
const dot = base.lastIndexOf('.')
if (dot <= 0) return undefined
const ext = base.slice(dot + 1).toLowerCase()
return Object.hasOwn(LANGS, ext) ? ext : undefined
}
/** One line's scan output: its tokens plus whether a block comment stays open. */
export interface ScanResult {
readonly tokens: CodeToken[]
readonly inBlock: boolean
}
/** Scan one line, entering from (and reporting) block-comment state. */
export function scanLine(line: string, lang: string | undefined, inBlock = false): ScanResult {
if (line.length === 0) return { tokens: [], inBlock }
const cfg = lang !== undefined ? LANGS[lang] : undefined
if (cfg === undefined || (cfg.lineComments.length === 0 && cfg.keywords.size === 0)) {
return { tokens: [{ text: line, type: 'plain' }], inBlock: false }
}
// A language without block delimiters never carries block state forward.
let inComment = inBlock && cfg.blockComment !== undefined
const tokens: CodeToken[] = []
const push = (text: string, type: TokenType): void => {
if (text.length === 0) return
const last = tokens[tokens.length - 1]
if (last !== undefined && last.type === type) tokens[tokens.length - 1] = { text: last.text + text, type }
else tokens.push({ text, type })
}
let i = 0
const atLineComment = (): string | undefined => {
for (const lead of cfg.lineComments) {
if (line.startsWith(lead, i)) return lead
}
return undefined
}
const atBlockOpen = (): string | undefined => cfg.blockComment !== undefined && line.startsWith(cfg.blockComment[0], i) ? cfg.blockComment[0] : undefined
const readString = (): void => {
const quote = line[i]!
i += 1
while (i < line.length && line[i] !== quote) {
if (line[i] === '\\') i += 1
i += 1
}
i = Math.min(i + 1, line.length)
}
while (i < line.length) {
if (inComment) {
const closeIdx = cfg.blockComment !== undefined ? line.indexOf(cfg.blockComment[1], i) : -1
if (closeIdx === -1) {
push(line.slice(i), 'comment')
return { tokens, inBlock: true }
}
const end = closeIdx + (cfg.blockComment?.[1].length ?? 0)
push(line.slice(i, end), 'comment')
i = end
inComment = false
continue
}
const ch = line[i]!
const wsMatch = /\s/u.exec(line.slice(i))
if (wsMatch !== null && wsMatch.index === 0) {
push(ch, 'plain')
i += 1
continue
}
const commentLead = atLineComment()
if (commentLead !== undefined) {
push(line.slice(i), 'comment')
break
}
const blockOpen = atBlockOpen()
if (blockOpen !== undefined && cfg.blockComment !== undefined) {
const close = line.indexOf(cfg.blockComment[1], i + blockOpen.length)
const end = close === -1 ? line.length : close + cfg.blockComment[1].length
push(line.slice(i, end), 'comment')
i = end
inComment = close === -1
continue
}
if (cfg.strings !== undefined && cfg.strings.includes(ch)) {
const start = i
readString()
push(line.slice(start, i), 'string')
continue
}
if (/[0-9]/u.test(ch)) {
const m = /^(?:0[xXbo][0-9a-fA-F_]+|[0-9][0-9_]*(?:\.[0-9_]+)?(?:[eE][+-]?[0-9_]+)?)/u.exec(line.slice(i))
const len = m !== null ? m[0].length : 1
push(line.slice(i, i + len), 'number')
i += len
continue
}
if (cfg.wordStart.test(ch)) {
let j = i + 1
while (j < line.length && cfg.wordBody.test(line[j]!)) j += 1
const word = line.slice(i, j)
// Batch 'REM whatever' runs to end of line as a comment.
if ((lang === 'cmd' || lang === 'bat') && word.toLowerCase() === 'rem') {
push(line.slice(i), 'comment')
break
}
let k = j
while (k < line.length && (line[k] === ' ' || line[k] === '\t')) k += 1
if (cfg.constants.has(word)) push(word, 'keyword')
else if (cfg.keywords.has(word)) push(word, 'keyword')
else if (line[k] === '(') push(word, 'function')
else if (/^[A-Z]/u.test(word) && word.length > 1) push(word, 'type')
else push(word, 'plain')
i = j
continue
}
if (cfg.macro && ch === '#' && (i === 0 || /\s/u.test(line[i - 1]!))) {
let j = i + 1
while (j < line.length && cfg.wordBody.test(line[j]!)) j += 1
push(line.slice(i, j), 'macro')
i = j
continue
}
push(ch, 'plain')
i += 1
}
return { tokens, inBlock: inComment }
}
/** Tokenize one line with no incoming block state (standalone lines). */
export function tokenizeLine(line: string, lang: string | undefined): CodeToken[] {
return scanLine(line, lang).tokens
}
/** True when the language has multi-line block-comment delimiters. */
export function hasBlockComment(lang: string | undefined): boolean {
return lang !== undefined && LANGS[lang]?.blockComment !== undefined
}
/** Token classes worth wrapping in a span; plain runs join the parent text node. */
export function isColored(token: CodeToken): boolean {
return COLORED.has(token.type)
}
+473
View File
@@ -0,0 +1,473 @@
/**
* The one diff engine every file-change surface shares (the changes tab's
* git and session lenses, the diff tab): a unified DiffRow model fed by two
* producers — a line LCS over known contents (session file ops) and git's
* own unified-diff text (worktree/commit diffs, via `parseUnifiedDiff` +
* `unifiedSegments`) — collapsed into hunk/fold segments for the shared
* renderer. Pure functions only — no React, no DOM.
*/
/** One rendered diff row. */
export interface DiffRow {
/** Change class: 'context' shared, 'del' removed, 'add' added, 'mod'
* rewritten (paired del+add), 'meta' an attached marker row. */
readonly kind: 'context' | 'del' | 'add' | 'mod' | 'meta'
/** Old-side 1-based line number; absent on pure additions. */
readonly oldLine?: number
/** New-side 1-based line number; absent on pure deletions. */
readonly newLine?: number
/** The line's text without its terminator. */
readonly text: string
}
/**
* Longest-common-subsequence table over line equality.
* @param oldLines - old side lines.
* @param newLines - new side lines.
* @returns the LCS length matrix (rows index oldLines, columns newLines).
*/
function lcsTable(oldLines: readonly string[], newLines: readonly string[]): number[][] {
const table: number[][] = Array.from({ length: oldLines.length + 1 }, () => new Array<number>(newLines.length + 1).fill(0))
for (let i = oldLines.length - 1; i >= 0; i -= 1) {
for (let j = newLines.length - 1; j >= 0; j -= 1) {
table[i]![j] = oldLines[i] === newLines[j]
? table[i + 1]![j + 1]! + 1
: Math.max(table[i + 1]![j]!, table[i]![j + 1]!)
}
}
return table
}
/**
* Pair each del-run with the add-run that follows it: the overlapping
* `min(len)` rows on both sides become 'mod' so rewrites tint distinctly and
* receive intra-line highlighting. Pure — returns a new array when anything
* changed. Shared by the LCS walk and the unified-diff converter so git
* hunks rewrite-pair exactly like session ops do.
*/
export function pairMods(rows: readonly DiffRow[]): DiffRow[] {
const out = rows.slice()
let k = 0
while (k < out.length) {
if (out[k]!.kind !== 'del') { k += 1; continue }
const delStart = k
while (k < out.length && out[k]!.kind === 'del') k += 1
const addStart = k
while (k < out.length && out[k]!.kind === 'add') k += 1
const pairs = Math.min(addStart - delStart, k - addStart)
for (let p = 0; p < pairs; p += 1) {
out[delStart + p] = { ...out[delStart + p]!, kind: 'mod' }
out[addStart + p] = { ...out[addStart + p]!, kind: 'mod' }
}
}
return out
}
/**
* Line diff by LCS walk with rewrite pairing: the raw walk emits context/del/
* add rows; `pairMods` marks rewritten pairs as 'mod'.
* @param oldText - the previous content; empty string diffs against nothing.
* @param newText - the next content.
* @returns ordered diff rows, old-side deletions before new-side additions.
*/
export function diffLines(oldText: string, newText: string): DiffRow[] {
const oldLines = oldText.length === 0 ? [] : oldText.split('\n')
const newLines = newText.length === 0 ? [] : newText.split('\n')
const table = lcsTable(oldLines, newLines)
const raw: DiffRow[] = []
let i = 0
let j = 0
while (i < oldLines.length && j < newLines.length) {
if (oldLines[i] === newLines[j]) {
raw.push({ kind: 'context', oldLine: i + 1, newLine: j + 1, text: oldLines[i]! })
i += 1
j += 1
} else if (table[i + 1]![j]! >= table[i]![j + 1]!) {
raw.push({ kind: 'del', oldLine: i + 1, text: oldLines[i]! })
i += 1
} else {
raw.push({ kind: 'add', newLine: j + 1, text: newLines[j]! })
j += 1
}
}
while (i < oldLines.length) {
raw.push({ kind: 'del', oldLine: i + 1, text: oldLines[i]! })
i += 1
}
while (j < newLines.length) {
raw.push({ kind: 'add', newLine: j + 1, text: newLines[j]! })
j += 1
}
return pairMods(raw)
}
/** One rendered hunk: change rows plus the surrounding context window. */
export interface HunkSegment { readonly kind: 'hunk'; readonly rows: readonly DiffRow[] }
/** One collapsed run of unchanged rows (the "… n 行" fold). */
export interface FoldSegment {
readonly kind: 'fold'
/** The rows this fold hides, when the producer knows them (session diffs
* re-derive them from full contents; git gaps never emitted the text, so
* those folds carry only their line ranges and cannot expand). */
readonly rows?: readonly DiffRow[]
/** How many rows the fold hides. */
readonly count: number
readonly oldStart: number
readonly oldEnd: number
readonly newStart: number
readonly newEnd: number
}
export type DiffSegment = HunkSegment | FoldSegment
/** Context window (rows around a change) kept visible in a hunk. */
export const HUNK_CONTEXT = 3
/** Folded context runs shorter than this are shown directly, not collapsed. */
export const MIN_FOLD = 3
/**
* Group a line diff into hunks and folded context runs. Consecutive changes
* whose gap fits within the context window merge into one hunk; unchanged
* regions between hunks (and any surrounding the whole diff) become fold
* segments that default collapsed. This yields the file-hunk presentation
* familiar from terminal diffs (Claude Code / git hunk headers).
* @param rows - the flat diff rows.
* @param context - how many unchanged rows around a change stay visible.
* @returns ordered segments (hunks and folds).
*/
export function buildDiffSegments(rows: readonly DiffRow[], context = HUNK_CONTEXT): DiffSegment[] {
if (rows.length === 0) return []
const changeIndexes = rows.flatMap((row, index) => (row.kind === 'context' || row.kind === 'meta' ? [] : [index]))
if (changeIndexes.length === 0) {
// Whole diff is unchanged (or a pure no-op): hide it all behind one fold.
return [{
kind: 'fold', rows: [...rows], count: rows.length,
oldStart: 1, oldEnd: rows.length, newStart: 1, newEnd: rows.length,
}]
}
// Cluster changes into runs whose gaps fit within 2*context+1 rows.
const hunks: { start: number; end: number }[] = []
for (const ci of changeIndexes) {
const start = Math.max(0, ci - context)
const end = Math.min(rows.length - 1, ci + context)
const last = hunks[hunks.length - 1]
if (last !== undefined && start <= last.end + 1) {
last.end = Math.max(last.end, end)
} else {
hunks.push({ start, end })
}
}
const segments: DiffSegment[] = []
let cursor = 0
for (const hunk of hunks) {
if (hunk.start > cursor) segments.push(foldOf(rows.slice(cursor, hunk.start)))
segments.push({ kind: 'hunk', rows: rows.slice(hunk.start, hunk.end + 1) })
cursor = hunk.end + 1
}
if (cursor < rows.length) segments.push(foldOf(rows.slice(cursor)))
return segments
}
function foldOf(rows: readonly DiffRow[]): FoldSegment {
return {
kind: 'fold',
rows,
count: rows.length,
oldStart: firstOldLine(rows) ?? 1,
oldEnd: lastOldLine(rows) ?? 0,
newStart: firstNewLine(rows) ?? 1,
newEnd: lastNewLine(rows) ?? 0,
}
}
function firstOldLine(rows: readonly DiffRow[]): number | undefined {
for (const row of rows) if (row.oldLine !== undefined) return row.oldLine
return undefined
}
function lastOldLine(rows: readonly DiffRow[]): number | undefined {
for (let i = rows.length - 1; i >= 0; i -= 1) if (rows[i]!.oldLine !== undefined) return rows[i]!.oldLine
return undefined
}
function firstNewLine(rows: readonly DiffRow[]): number | undefined {
for (const row of rows) if (row.newLine !== undefined) return row.newLine
return undefined
}
function lastNewLine(rows: readonly DiffRow[]): number | undefined {
for (let i = rows.length - 1; i >= 0; i -= 1) if (rows[i]!.newLine !== undefined) return rows[i]!.newLine
return undefined
}
/** One intra-line segment: a run of chars with a changed flag. */
export interface InlineSegment { readonly text: string; readonly changed: boolean }
/** Each side's intra-line segments. */
export interface InlineDiff { readonly old: readonly InlineSegment[]; readonly next: readonly InlineSegment[] }
/**
* Intra-line diff by common prefix/suffix: the shared leading and trailing
* characters stay unchanged, and only the differing middle is marked changed
* on both sides. This never highlights identical characters and keeps a small
* edit inside a long line immediately visible. Pure, no React/DOM.
* @param oldText - the old line.
* @param newText - the new line.
* @returns per-side segments marking changed runs.
*/
export function diffInline(oldText: string, newText: string): InlineDiff {
const minLen = Math.min(oldText.length, newText.length)
let prefix = 0
while (prefix < minLen && oldText[prefix] === newText[prefix]) prefix += 1
let suffix = 0
while (suffix < minLen - prefix && oldText[oldText.length - 1 - suffix] === newText[newText.length - 1 - suffix]) suffix += 1
const oldMidStart = prefix
const oldMidEnd = oldText.length - suffix
const newMidStart = prefix
const newMidEnd = newText.length - suffix
const segments = (text: string, midStart: number, midEnd: number): InlineSegment[] => {
const out: InlineSegment[] = []
if (midStart > 0) out.push({ text: text.slice(0, midStart), changed: false })
const mid = text.slice(midStart, midEnd)
if (mid.length > 0) out.push({ text: mid, changed: true })
if (midEnd < text.length) out.push({ text: text.slice(midEnd), changed: false })
return out
}
return { old: segments(oldText, oldMidStart, oldMidEnd), next: segments(newText, newMidStart, newMidEnd) }
}
/**
* Merge adjacent inline segments sharing the same changed flag, so rendering
* wraps each run — not each character — in one span.
* @param segments - the raw per-character-heavy inline segments.
* @returns coalesced segments; identical text joined into runs.
*/
export function coalesceInline(segments: readonly InlineSegment[]): InlineSegment[] {
const out: InlineSegment[] = []
for (const seg of segments) {
const last = out[out.length - 1]
if (last !== undefined && last.changed === seg.changed) out[out.length - 1] = { text: last.text + seg.text, changed: seg.changed }
else out.push(seg)
}
return out
}
/** Human byte count for the panel meta row. */
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${String(bytes)} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
// ─────────────────────────────────────────────────────────────────────────────
// Unified-diff text (git) → DiffRow segments
// ─────────────────────────────────────────────────────────────────────────────
/** One parsed diff line of a unified diff (git's own hunk structure). */
export interface DiffLine {
kind: 'ctx' | 'del' | 'add' | 'meta'
/** The line content without its diff marker ('' for the no-newline marker). */
text: string
/** Old-side line number (null for pure additions / metadata). */
oldNum: number | null
/** New-side line number (null for pure deletions / metadata). */
newNum: number | null
}
/** One parsed hunk. */
export interface DiffHunk {
/** The old-side start line (`-a[,b]`). */
oldStart: number
/** The new-side start line (`+c[,d]`). */
newStart: number
/** The section text after the trailing `@@` (may be empty). */
header: string
lines: DiffLine[]
}
/** One parsed file section of a unified diff. */
export interface DiffFile {
/** The `---` path verbatim ('/dev/null' for a new file). */
oldPath: string
/** The `+++` path verbatim ('/dev/null' for a deleted file). */
newPath: string
/** The file changed with binary content: no hunks to draw. */
binary: boolean
hunks: DiffHunk[]
}
/** The parsed unified diff. */
export interface ParsedDiff {
files: DiffFile[]
}
/** Parse the hunk header `@@ -a[,b] +c[,d] @@ section` (section may contain '@@'). */
function parseHunkHeader(line: string): { oldStart: number; newStart: number; header: string } | null {
const match = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/.exec(line)
if (match === null) return null
return { oldStart: Number(match[1]), newStart: Number(match[3]), header: match[5] ?? '' }
}
/**
* Parse `git diff --no-color` output into file sections and hunks. Rows
* outside a file section (leading noise) and metadata rows between the
* `diff --git`/`---`/`+++` headers and the first hunk (index lines, mode
* changes, rename/similarity lines) are skipped; a section that never
* reaches a hunk (a mode/rename-only change) stays hunkless so the caller
* can still draw its path.
*/
export function parseUnifiedDiff(text: string): ParsedDiff {
const files: DiffFile[] = []
let current: DiffFile | null = null
let inHunk = false
let hunk: DiffHunk | null = null
let oldNum = 0
let newNum = 0
const flushHunk = (): void => {
if (current !== null && hunk !== null) current.hunks.push(hunk)
hunk = null
inHunk = false
}
for (const raw of text.split('\n')) {
if (raw.startsWith('diff --git ')) {
flushHunk()
current = { oldPath: '', newPath: '', binary: false, hunks: [] }
files.push(current)
continue
}
if (current === null) continue
if (raw.startsWith('Binary files ') || raw === 'GIT binary patch') {
flushHunk()
current.binary = true
continue
}
if (raw.startsWith('--- ')) {
flushHunk()
current.oldPath = raw.slice(4)
continue
}
if (raw.startsWith('+++ ')) {
current.newPath = raw.slice(4)
continue
}
const header = parseHunkHeader(raw)
if (header !== null) {
flushHunk()
hunk = { oldStart: header.oldStart, newStart: header.newStart, header: header.header, lines: [] }
oldNum = header.oldStart
newNum = header.newStart
inHunk = true
continue
}
if (!inHunk || hunk === null) continue
const marker = raw[0]
if (marker === '\\') {
// `\ No newline at end of file`: metadata attached to the previous row.
hunk.lines.push({ kind: 'meta', text: raw.slice(1), oldNum: null, newNum: null })
continue
}
if (marker === ' ') {
hunk.lines.push({ kind: 'ctx', text: raw.slice(1), oldNum, newNum })
oldNum += 1
newNum += 1
} else if (marker === '-') {
hunk.lines.push({ kind: 'del', text: raw.slice(1), oldNum, newNum: null })
oldNum += 1
} else if (marker === '+') {
hunk.lines.push({ kind: 'add', text: raw.slice(1), oldNum: null, newNum })
newNum += 1
} else {
// Not a diff line (a hunk can never contain one): stop the hunk.
flushHunk()
}
}
flushHunk()
return { files }
}
/** One DiffLine row mapped onto the shared DiffRow model. */
function rowOfLine(line: DiffLine): DiffRow {
if (line.kind === 'meta') return { kind: 'meta', text: line.text }
const kind: DiffRow['kind'] = line.kind === 'ctx' ? 'context' : line.kind
if (line.kind === 'del') return { kind, oldLine: line.oldNum ?? undefined, text: line.text }
if (line.kind === 'add') return { kind, newLine: line.newNum ?? undefined, text: line.text }
return { kind, oldLine: line.oldNum ?? undefined, newLine: line.newNum ?? undefined, text: line.text }
}
/**
* Convert one parsed unified-diff file into renderer segments: each hunk's
* lines become a paired-mod hunk segment, and the unemitted context gaps
* between hunks (git already trimmed them) become non-expandable folds that
* still carry their old/new line ranges. The rewrite pairing applies within
* each hunk, exactly like session-op diffs.
*/
export function unifiedSegments(file: DiffFile): DiffSegment[] {
const segments: DiffSegment[] = []
file.hunks.forEach((hunk, index) => {
const rows = pairMods(hunk.lines.map(rowOfLine))
const prev = file.hunks[index - 1]
if (prev !== undefined) {
// The gap git never emitted: rows are unknown (non-expandable), but
// the ranges are derivable from the surrounding hunk headers/counts.
const prevOldEnd = prev.oldStart + prev.lines.filter(line => line.oldNum !== null).length - 1
const prevNewEnd = prev.newStart + prev.lines.filter(line => line.newNum !== null).length - 1
const oldGap = hunk.oldStart - prevOldEnd - 1
const newGap = hunk.newStart - prevNewEnd - 1
if (oldGap > 0 || newGap > 0) {
segments.push({
kind: 'fold',
count: Math.max(oldGap, newGap, 0),
oldStart: prevOldEnd + 1,
oldEnd: Math.max(hunk.oldStart - 1, prevOldEnd),
newStart: prevNewEnd + 1,
newEnd: Math.max(hunk.newStart - 1, prevNewEnd),
})
}
} else if (hunk.oldStart > 1 || hunk.newStart > 1) {
// Leading context git trimmed (e.g. -U0 diffs).
segments.push({
kind: 'fold',
count: Math.max(hunk.oldStart - 1, hunk.newStart - 1, 0),
oldStart: 1,
oldEnd: hunk.oldStart - 1,
newStart: 1,
newEnd: hunk.newStart - 1,
})
}
segments.push({ kind: 'hunk', rows })
})
return segments
}
/** Build the untracked-file shape: one file, one hunk of pure additions. */
export function untrackedFile(path: string, content: string): DiffFile {
const lines: DiffLine[] = []
const body = content.endsWith('\n') ? content.slice(0, -1) : content
if (body !== '') {
let num = 1
for (const line of body.split('\n')) {
lines.push({ kind: 'add', text: line, oldNum: null, newNum: num })
num += 1
}
}
return { oldPath: '/dev/null', newPath: `b/${path}`, binary: false, hunks: [{ oldStart: 0, newStart: 1, header: '', lines }] }
}
/** 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
if (path.startsWith('a/') || path.startsWith('b/')) return path.slice(2)
return path
}
/** The diff's add/del/mod row counts (the "+n −m" header chips). */
export function diffStats(segments: readonly DiffSegment[]): { added: number; deleted: number } {
let added = 0
let deleted = 0
for (const segment of segments) {
if (segment.kind !== 'hunk') continue
for (const row of segment.rows) {
if (row.kind === 'add' || row.kind === 'mod') added += 1
if (row.kind === 'del' || row.kind === 'mod') deleted += 1
}
}
return { added, deleted }
}
+12
View File
@@ -161,3 +161,15 @@ export const IconVscode16 = ({ size = 16, className }: IconProps) => (
<path d="M23.15 2.587L18.21.21a1.494 1.494 0 0 0-1.705.29l-9.46 8.63-4.12-3.128a.999.999 0 0 0-1.276.057L.327 7.261A1 1 0 0 0 .326 8.74L3.899 12 .326 15.26a1 1 0 0 0 .001 1.479L1.65 17.94a.999.999 0 0 0 1.276.057l4.12-3.128 9.46 8.63a1.492 1.492 0 0 0 1.704.29l4.942-2.377A1.5 1.5 0 0 0 24 20.06V3.939a1.5 1.5 0 0 0-.85-1.352zm-5.146 14.861L10.826 12l7.178-5.448v10.896z" />
</svg>
)
/**
* Free-window glyph in the app's outline style (1.5px stroke, currentColor):
* a background frame with a detached rounded mini-window floating over its
* top-right — the changes tab's "diff opens as a free window" setting.
*/
export const IconFloatWindowOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1.5" y="4.5" width="10" height="10" rx="2" stroke="currentColor" strokeWidth="1.5" />
<rect x="9.5" y="1.5" width="5" height="5" rx="1.5" stroke="currentColor" strokeWidth="1.5" fill="none" />
</svg>
)
+85 -41
View File
@@ -1,5 +1,5 @@
/**
* Client half of dsh-better-sidebar: resolves the user's "Side card"
* Client half of dsh-apes-sidebar: resolves the user's "Side card"
* preferences through the plugin's own fenced settings route, mounts the
* right sidebar portal (inside an error boundary so a rendering failure
* shows an error strip instead of a blank panel), registers the turn-tail
@@ -15,27 +15,31 @@ import { allLeaves, createSidebarStore, isAgentTabId } from './state.ts'
import { createBetterSidebarService, matchUrlTarget } from './service.ts'
import { revalidateChunksOnReactivate, setChunkModuleSystem } from './chunk-loader.ts'
import { registerBuiltins } from './builtins/index.ts'
import { registerApesTabs, APES_LOCALE_NS, apesZh, apesEn, attachApesLocale } from './apes/index.ts'
import { registerApesNativeTabs } from './apes/native-tabs.tsx'
import { Sidebar } from './Sidebar.tsx'
import { RenderBoundary } from './RenderBoundary.tsx'
import { registerOpenPathInterception, registerTurnTailInterception } from './intercept.tsx'
import { registerLinkInterception } from './link-intercept.ts'
import { registerImeGuard } from './ime-guard.ts'
import { registerSettingsNavIcon } from './settings-nav-icon.ts'
import { loadExternalDisable, loadPrefs } from './prefs.ts'
import { loadBootDecision } from './prefs.ts'
import { SideCardSection } from './SideCardSection.tsx'
import { api } from './api.ts'
import { LOCALE_NS, attachLocale, attachBetterLocale, t, zh, en,
ja, de, fr, pt, ko, ar, hi, id, tr, vi, th, ru, it, nl, sv, pl,
zhHK, zhTW, zhMO,
} from './locales.ts'
import { LOCALE_NS, attachLocale, attachBetterLocale, t, zh, en } from './locales.ts'
import { loadChunk } from './chunk-loader.ts'
import css from './sidebar.module.css'
import './layout.css'
/** Services required before mounting (provided by the client runtime; the
* locale service backs the sidebar's copy — see locales.ts). `modules`
* (rc.8+) is the client module system the chunk loader resolves its
* externals through — Cordis guards service access without inject. */
export const inject = ['slots', 'sessions', 'workspaces', 'locale', 'modules']
* externals through; `connection` (0.1.2-alpha.2+) is the Remote transport's
* recovery lifecycle the side chat's disconnect banner reads — Cordis guards
* service access without inject. The `remote.session` namespace is NOT here:
* it mounts asynchronously, so the open-path interception reaches it through
* `ctx.inject` (see intercept.tsx). */
export const inject = ['slots', 'sessions', 'locale', 'modules', 'connection']
/**
* Error boundary over the sidebar tree (root scope): a render error in the
@@ -55,11 +59,17 @@ export function apply(ctx: Context): void {
// plugin's dictionaries into the shared locale registry. The disposers
// run on fiber disposal, so re-activation (HMR) re-registers cleanly.
attachLocale(ctx.locale)
// The apes module has its own locale namespace (isolated from the upstream
// betterSidebar dicts per D1=B); attach it so apes t() tracks the DSH
// active-locale preference, and register its zh/en dicts under `apes`.
attachApesLocale(ctx.locale)
ctx.effect(() => {
const offZh = ctx.locale.register(LOCALE_NS, 'zh', zh)
const offEn = ctx.locale.register(LOCALE_NS, 'en', en)
return () => { offZh(); offEn() }
}, 'dsh-better-sidebar: dictionaries')
const offApesZh = ctx.locale.register(APES_LOCALE_NS, 'zh', apesZh as Record<string, string>)
const offApesEn = ctx.locale.register(APES_LOCALE_NS, 'en', apesEn as Record<string, string>)
return () => { offZh(); offEn(); offApesZh(); offApesEn() }
}, 'dsh-apes-sidebar: dictionaries')
// Opt-in third-language support through @huanlin/dsh-plugin-better-locale.
// When that plugin is installed, it publishes `ctx.betterLocale` (the
@@ -80,7 +90,12 @@ export function apply(ctx: Context): void {
// the ja dict once the store becomes available.
ctx.effect(() => {
let dispose: (() => void) | undefined
// Guards the async chunk registration below: a sync() re-run (or fiber
// disposal) that lands while the chunk is still in flight must render
// that registration moot.
let generation = 0
const sync = (): void => {
generation += 1
dispose?.()
dispose = undefined
const store = ctx.get('betterLocale') as
@@ -94,10 +109,17 @@ export function apply(ctx: Context): void {
| undefined
attachBetterLocale(store)
if (store !== undefined) {
dispose = store.register(LOCALE_NS, {
ja, de, fr, pt, ko, ar, hi, id, tr, vi, th, ru, it, nl, sv, pl,
'zh-HK': zhHK, 'zh-TW': zhTW, 'zh-MO': zhMO,
})
// The 19 override dictionaries ride the lazy `locale` chunk: until
// it lands, the store has no betterSidebar entries and t() keeps
// the zh/en chain; the store's own revision bump on register
// re-renders the chrome once the dicts arrive.
const myGeneration = generation
void loadChunk('locale')
.then(mod => {
if (myGeneration !== generation) return
dispose = store.register(LOCALE_NS, mod.localeDicts as Record<string, Record<string, string>>)
})
.catch(() => { /* the dicts stay unregistered; the zh/en chain runs */ })
}
}
// Initial check (picks up the store if better-locale activated first).
@@ -106,11 +128,12 @@ export function apply(ctx: Context): void {
// activates with a persisted override, and when the user switches).
const unsubscribe = ctx.locale.subscribe(sync)
return () => {
generation += 1
unsubscribe()
dispose?.()
attachBetterLocale(undefined)
}
}, 'dsh-better-sidebar: better-locale lazy integration')
}, 'dsh-apes-sidebar: better-locale lazy integration')
// One store instance per activation: production code creates it only here,
// then hands it to the mounted panel and closes over it in the slot
// registrations (the official createXXXStore() factory rule — no
@@ -147,19 +170,38 @@ export function apply(ctx: Context): void {
// fiber disposal (HMR-safe).
ctx.effect(
() => registerBuiltins(ctx, service, { terminalTitle: () => terminalTitle }),
'dsh-better-sidebar: register built-in tabs and viewers',
'dsh-apes-sidebar: register built-in tabs and viewers',
)
// The apes:* information-layer tabs (the fork's own contribution, isolated
// under src/client/apes per D1=B). Registered through the same service as
// the builtins so they get the full sidebar lifecycle; the disposer runs on
// fiber disposal (HMR-safe). A throw here is caught by the outer try and
// routed to fail() — a bad apes registration must never blank the sidebar.
ctx.effect(
() => registerApesTabs(ctx, service),
'dsh-apes-sidebar: register apes tabs',
)
// Phase A spike: also publish apes:profile as an OFFICIAL right-sidebar tab
// type (ctx.sidebarRightTabs + the keyed `sidebar.right.pane.tab` seat), so
// the tab renders inside DSH's own panel instead of only in the fork's
// shell. Both surfaces coexist during the spike; the disposer reverses the
// two-stage registration on fiber disposal (HMR-safe). A throw is caught
// inside the bridge and never reaches this effect.
ctx.effect(
() => registerApesNativeTabs(ctx),
'dsh-apes-sidebar: apes native right-sidebar tabs',
)
// 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.
const fail = (phase: string, error: unknown): void => {
console.error(`[dsh-better-sidebar] ${phase} error:`, error)
console.error(`[dsh-apes-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'
bar.textContent = `[dsh-better-sidebar] ${phase} error: ${error instanceof Error ? error.message : String(error)}`
bar.textContent = `[dsh-apes-sidebar] ${phase} error: ${error instanceof Error ? error.message : String(error)}`
document.body.appendChild(bar)
} catch {
// Nothing left to report with.
@@ -229,7 +271,7 @@ export function apply(ctx: Context): void {
return
}
layer.setAttribute('data-dsh-panel-host-degraded', '')
console.warn('[dsh-better-sidebar] panel host geometry mismatch — a page-level transform was detected; using degraded viewport sync')
console.warn('[dsh-apes-sidebar] panel host geometry mismatch — a page-level transform was detected; using degraded viewport sync')
// Track our own compensating translation so the loop judges the
// UNCORRECTED geometry: clearing degraded mode must wait for the
// ancestor transform to actually disappear — the frame right after
@@ -260,7 +302,7 @@ export function apply(ctx: Context): void {
if (mounted || disposed) return
try {
host = document.createElement('div')
host.setAttribute('data-dsh-better-sidebar', '')
host.setAttribute('data-dsh-apes-sidebar', '')
document.body.appendChild(host)
root = createRoot(host)
root.render(createElement(RenderBoundary, { className: css.boundaryError }, createElement(Sidebar, { ctx, store: sidebarStore })))
@@ -273,24 +315,26 @@ export function apply(ctx: Context): void {
}
const sync = async (): Promise<void> => {
if (disposed) return
// Resolve the user's side card prefs BEFORE the first session seeds,
// so a brand-new conversation opens (or stays closed) at the chosen
// width from first paint. A settings route failure falls back to the
// schema defaults; the sidebar still mounts (a stalled wire gives up
// after the timeout and mounts on the defaults).
const prefs = await Promise.race([
loadPrefs(api),
// Resolve the user's side card prefs and the external-disable flag
// from ONE settings fetch BEFORE the first session seeds, so a
// brand-new conversation opens (or stays closed) at the chosen width
// from first paint. A settings route failure falls back to the schema
// defaults; the sidebar still mounts (a stalled wire gives up after
// the timeout and mounts on the defaults — the external-disable check
// 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) }),
])
if (prefs !== null) sidebarStore.setPrefs(prefs)
if (disposed) return
// Mutual exclusion with the dsh-web-ui family right panel: while the
// aionui-panel provider is selected, the sidebar must not mount at
// all. Re-evaluated on every settings-document update (live switch).
const suspended = await loadExternalDisable(api)
if (disposed) return
sidebarStore.setSuspended(suspended)
if (suspended) unmount()
if (decision !== null) {
sidebarStore.setPrefs(decision.prefs)
// Mutual exclusion with the dsh-web-ui family right panel: while the
// aionui-panel provider is selected, the sidebar must not mount at
// all. Re-evaluated on every settings-document update (live switch).
sidebarStore.setSuspended(decision.suspended)
}
if (decision?.suspended) unmount()
else mount()
}
void sync()
@@ -305,7 +349,7 @@ export function apply(ctx: Context): void {
offRemote?.()
unmount()
}
}, 'dsh-better-sidebar: sidebar mount')
}, 'dsh-apes-sidebar: sidebar mount')
ctx.effect(
() => {
@@ -316,7 +360,7 @@ export function apply(ctx: Context): void {
return () => {}
}
},
'dsh-better-sidebar: turn-tail interception',
'dsh-apes-sidebar: turn-tail interception',
)
ctx.effect(
@@ -328,7 +372,7 @@ export function apply(ctx: Context): void {
return () => {}
}
},
'dsh-better-sidebar: open-path interception',
'dsh-apes-sidebar: open-path interception',
)
ctx.effect(
@@ -372,7 +416,7 @@ export function apply(ctx: Context): void {
return () => {}
}
},
'dsh-better-sidebar: link interception',
'dsh-apes-sidebar: link interception',
)
// The IME guard: composition keys (candidate arrows, confirm, cancel)
@@ -392,7 +436,7 @@ export function apply(ctx: Context): void {
return () => {}
}
},
'dsh-better-sidebar: IME composition guard',
'dsh-apes-sidebar: IME composition guard',
)
// DSH 0.1.x does not yet carry an icon through the settings.section
@@ -402,7 +446,7 @@ export function apply(ctx: Context): void {
// the marker for HMR / plugin disable.
ctx.effect(
() => registerSettingsNavIcon(() => t('settingsNav')),
'dsh-better-sidebar: settings navigation icon',
'dsh-apes-sidebar: settings navigation icon',
)
// The "Side card" settings section: appears in the DSH Settings shell
+31 -17
View File
@@ -11,7 +11,7 @@ import type { Context } from '../context-types.ts'
import { firstLeaf, revealPaths, togglePanel, type SidebarStore } from './state.ts'
import { t } from './locales.ts'
import { resolveSidebarPath, selectProducedFiles } from './produced-files.ts'
import { wrapOpenPath } from './openpath-intercept.ts'
import { wrapOpenWorkspacePath, type OpenWorkspacePathService } from './openpath-intercept.ts'
import css from './sidebar.module.css'
/** Open a file in the sidebar's editor (used by the intercepted row and the explorer). */
@@ -142,7 +142,7 @@ export function registerTurnTailInterception(ctx: Context, store: SidebarStore):
return matched
},
priority: -1,
registrant: 'dsh-better-sidebar',
registrant: 'dsh-apes-sidebar',
inject: (sessionId: string) => ({
openInSidebar: (path: string) => { openSidebarFile(ctx, store, sessionId, path) },
onShowInFolder: (files: readonly string[]) => { revealInExplorer(ctx, store, sessionId, files) },
@@ -151,22 +151,36 @@ export function registerTurnTailInterception(ctx: Context, store: SidebarStore):
}
/**
* Register the chat file-open interception: wraps `ctx.workspaces.openPath`
* — the single funnel every chat-side file open goes through (tool-row path
* links, the produced-files row, prose mentions) — so opens land in the
* sidebar editor instead of the Host OS. The folder-reveal gesture ("Show in
* folder" passes `'.'`) is the one exception: it is routed to the explorer.
* Gated by BOTH the `interceptOpenPath` pref and the editor tab's enable
* switch; declined opens fall through to the original method. Returns the
* disposer restoring the original (HMR-safe).
* Register the chat file-open interception: shadows
* `remote.session.openWorkspacePath` — the single funnel every chat-side
* file open goes through on alpha hosts (tool-row path links, the
* produced-files row, prose mentions, inline-code paths) — so opens land in
* the sidebar editor instead of the Host OS. The folder-reveal gesture
* ("Show in folder" passes `'.'`) is the one exception: it is routed to the
* explorer. Gated by BOTH the `interceptOpenPath` pref and the editor tab's
* enable switch; declined opens fall through to the original remote call.
*
* The `remote.session` namespace service mounts asynchronously (the gateway
* client creates it when the session-controller contribution arrives) and
* is recreated on contribution remounts, so the wrapper installs through
* `ctx.inject`: the callback runs once the service exists and re-runs after
* every remount, re-applying the shadow on the fresh instance. Returns the
* disposer (disposes the inject fiber, which restores the original method
* descriptor — HMR-safe).
*/
export function registerOpenPathInterception(ctx: Context, store: SidebarStore): () => void {
return wrapOpenPath(ctx.workspaces, {
takeoverEnabled: () => !store.getSuspended()
&& store.getPrefs().interceptOpenPath !== false
&& store.getPrefs().tabsEnabled['editor'] !== false,
currentSessionId: () => ctx.sessions.list.getSnapshot().current,
openInSidebar: (path, sessionId) => { openSidebarFile(ctx, store, sessionId, path) },
revealInExplorer: (_path, sessionId) => { revealInExplorer(ctx, store, sessionId, lastProduced) },
const fiber = ctx.inject(['remote.session'], (fctx) => {
fctx.effect(() => {
const service = fctx.get('remote.session') as OpenWorkspacePathService
return wrapOpenWorkspacePath(service, {
takeoverEnabled: () => !store.getSuspended()
&& store.getPrefs().interceptOpenPath !== false
&& store.getPrefs().tabsEnabled['editor'] !== false,
currentSessionId: () => ctx.sessions.list.getSnapshot().current,
openInSidebar: (path, sessionId) => { openSidebarFile(ctx, store, sessionId, path) },
revealInExplorer: (_path, sessionId) => { revealInExplorer(ctx, store, sessionId, lastProduced) },
})
}, 'dsh-apes-sidebar: open-path interception wrap')
})
return () => { void fiber.dispose() }
}
+6 -8
View File
@@ -48,14 +48,12 @@ import { pascal } from '@codemirror/legacy-modes/mode/pascal'
import { vb } from '@codemirror/legacy-modes/mode/vb'
import { vhdl } from '@codemirror/legacy-modes/mode/vhdl'
import { stex } from '@codemirror/legacy-modes/mode/stex'
import { extOf } from './paths.ts'
/** The lowercased file extension of a path ('' when none). */
export function extOf(path: string): string {
const at = path.lastIndexOf('.')
if (at === -1) return ''
const base = path.slice(at + 1).toLowerCase()
return base.includes('/') || base.includes('\\') ? '' : base
}
// Re-exported for the established import surface (tests import extOf from
// this module); the implementation lives in the dependency-free paths.ts so
// core-bundle modules can share it without pulling the CodeMirror packages.
export { extOf }
/** Language key for an extension, or null for plain text. Pure (tested). */
export function languageKeyForExt(ext: string): string | null {
@@ -219,7 +217,7 @@ export function languageForPath(path: string): Language | LanguageSupport | null
return FACTORIES[key]!()
} catch (error) {
// A broken factory degrades to plain text, never crashes the editor.
console.warn(`[dsh-better-sidebar] language factory "${key}" failed:`, error)
console.warn(`[dsh-apes-sidebar] language factory "${key}" failed:`, error)
return null
}
}
+56 -44
View File
@@ -1,48 +1,54 @@
/**
* Layout push: when a panel is open it OCCUPIES the layout instead of
* floating over it — the app shell (#root, the AppFrame three-column grid)
* gives up space. Only the center column is flexible (1fr), so the right
* panel's width squeeze (margin-right on #root) lands exactly on the
* conversation output and the input bar, like a VSCode sidebar.
* Layout push: an open panel occupies layout instead of floating over it.
* The right panel reserves padding inside the AppFrame three-column grid,
* so the app sidebar, conversation, and details column all remain to its
* left while the frame's border-box stays viewport-wide. Harness measures
* that border-box to choose desktop versus narrow presentation; preserving
* it prevents a desktop window from entering narrow mode merely because the
* plugin panel opened.
*
* The width rides `calc(100% - var(...))` instead of a bare margin on a
* full-width box: some desktop shells (DSH Desktop, #208) set #root to
* width:100%, where a margin would overflow the viewport additively —
* the calc keeps the box at exactly 100% minus the push in every shell.
* Width and margin transition in lockstep (same variable, same duration
* and easing), so expand/collapse animates the content width exactly as
* the bare-margin version did.
* AppFrame positions its details drag handle from the measured border-box.
* Move that handle left by the same reserved width so it stays on the details
* column edge. The ordinary sidebar handle remains at its native coordinate.
*
* The bottom panel squeezes ONLY the center column — it must not cover the
* app's own left sidebar or the right panel. The anchor is the frame's
* center grid item via its stable data attribute ([data-pane="conversation"]
* on the centerCol inside [data-dsh-frame]), not a positional path: the
* frame's child order also contains an overlay layer and drag handles
* (verified against a live DSH 0.1.x page), so nth-child is brittle. A
* stretched grid item shrinks by its margins, so the conversation content
* (output + input bar) lifts without touching the sidebars.
* The bottom panel squeezes only the conversation column. The anchor is the
* [data-dsh-center-col] tag the Sidebar shell's locator writes on the
* frame's measured center grid item (Sidebar.tsx locate — not a positional
* path: the frame also contains an overlay layer and drag handles). A
* stretched grid item shrinks by its bottom margin, so the conversation
* output and composer lift without touching either sidebar.
*
* The sizes ride CSS variables updated by the Sidebar shell (0 while
* collapsed); expand/collapse animates both the margins and the panel
* slides on the same theme duration. Drags disable the transition so the
* layout tracks the pointer.
* Sizes ride CSS variables updated by the Sidebar shell (0 while collapsed).
* Expand/collapse uses the host theme duration; drags disable transitions so
* every reserved edge tracks the pointer.
*/
#root {
margin-right: var(--dsh-sidebar-width, 0px);
width: calc(100% - var(--dsh-sidebar-width, 0px));
transition:
margin-right var(--ds-transition-duration-slow) var(--ds-ease-in-out),
width var(--ds-transition-duration-slow) var(--ds-ease-in-out);
/* Current shells expose [data-dsh-frame]; rc.8-era shells expose the same
AppFrame only as the root slot's direct child. */
#root [data-dsh-frame],
#root > [data-slot="root"] > div {
box-sizing: border-box;
padding-right: var(--dsh-sidebar-width, 0px);
transition: padding-right var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
/* The AppFrame's center column, anchored by data attributes (see above).
Composite selector: DSH 0.1.x versions name the center grid item
`[data-pane="conversation"]`, while rc.8-era shells put a
`[data-slot="conversation"]` child inside it — both selectors resolve to
the SAME element on live pages (verified), and keeping both future-proofs
the rule against a host rename without touching shell-specific markup. */
#root [data-dsh-frame] > [data-pane="conversation"],
#root :has(> [data-slot="conversation"]) {
#root [data-dsh-frame] > [data-side="details"],
#root > [data-slot="root"] > div > [data-side="details"] {
transform: translateX(calc(0px - var(--dsh-sidebar-width, 0px)));
transition:
left var(--ds-transition-duration-slow) var(--ds-ease-in-out),
transform var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
/* The AppFrame's center column. The Sidebar shell's locator (Sidebar.tsx
locate/measureCenter) tags the measured column node with
[data-dsh-center-col] — one attribute write per node lifetime. The rule
used to anchor on a structural has()-selector over the conversation slot
wrapper, whose match cache invalidates on every #root subtree mutation
(the streaming chat mutates it constantly), re-evaluating the selector
against the whole document; the tag is evaluated once per node instead.
A stretched grid item shrinks by its margins, so the conversation
content (output + input bar) lifts without touching the sidebars. */
#root [data-dsh-center-col] {
margin-bottom: var(--dsh-sidebar-height, 0px);
/* Grid/flex items default to min-height:auto. A long unbreakable token
(OAuth URL) then grows this column past the viewport and clips the
@@ -67,9 +73,13 @@ body[data-dsh-sidebar-collapsed] [data-slot="conversation.session.header"] > hea
padding-right: 78px;
}
body[data-dsh-sidebar-dragging] #root,
body[data-dsh-sidebar-dragging] #root [data-dsh-frame] > [data-pane="conversation"],
body[data-dsh-sidebar-dragging] #root :has(> [data-slot="conversation"]) {
body[data-dsh-sidebar-dragging] #root [data-dsh-frame],
body[data-dsh-sidebar-dragging] #root > [data-slot="root"] > div,
body[data-dsh-sidebar-dragging] #root [data-dsh-frame] > [data-side="details"],
body[data-dsh-sidebar-dragging] #root > [data-slot="root"] > div > [data-side="details"],
body[data-dsh-sidebar-dragging] #root [data-dsh-center-col],
#root [data-dsh-frame][data-dragging] > [data-side="details"],
#root > [data-slot="root"] > div[data-dragging] > [data-side="details"] {
transition: none;
}
@@ -93,9 +103,11 @@ body[data-dsh-sidebar-dragging] #root :has(> [data-slot="conversation"]) {
}
@media (prefers-reduced-motion: reduce) {
#root,
#root [data-dsh-frame] > [data-pane="conversation"],
#root :has(> [data-slot="conversation"]) {
#root [data-dsh-frame],
#root > [data-slot="root"] > div,
#root [data-dsh-frame] > [data-side="details"],
#root > [data-slot="root"] > div > [data-side="details"],
#root [data-dsh-center-col] {
transition: none;
}
}
+1 -1
View File
@@ -37,7 +37,7 @@ function LazyChunkView<P>({ chunk, pick, props }: LazyChunkViewProps<P>): ReactN
if (cancelled) return
const Comp = pick(mod)
if (Comp === undefined) {
setState({ status: 'error', message: `[dsh-better-sidebar] chunk "${chunk}" is missing its component` })
setState({ status: 'error', message: `[dsh-apes-sidebar] chunk "${chunk}" is missing its component` })
return
}
setState({ status: 'ready', Comp })
+49 -8
View File
@@ -18,8 +18,16 @@
/** The ar dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const ar: Record<string, string> = {
files: 'الملفات',
changesSessionEmpty: 'لا توجد عمليات ملفات في هذه الجلسة بعد',
changesRead: 'قراءة',
changesWrite: 'كتابة',
changesEdit: 'تحرير',
changesRunning: 'قيد التنفيذ',
changesError: 'خطأ',
changesFold: '{count} أسطر… انقر للتوسيع',
changesContext: 'سياق',
changesPriorUnknown: 'المحتوى السابق خارج النافذة؛ يُعرض كما لو أُضيف بالكامل',
explorer: 'المستكشف',
git: 'إدارة المصدر',
terminal: 'الطرفية',
editor: 'المحرّر',
editorExplorer: 'سلوك فتح الملفات',
@@ -129,7 +137,21 @@ export const ar: Record<string, string> = {
worktree: 'شجرة العمل',
checkoutError: 'فشل تبديل الفرع',
history: 'السجل',
changes: 'التغييرات',
changes: 'تغييرات الملفات',
changesGitLens: 'Git',
changesSessionLens: 'الجلسة',
changesFilterAll: 'الكل',
changesFilterEmpty: 'لا توجد عمليات من هذا النوع',
changesOpenDiffTab: 'فتح في تبويب diff منفصل',
changesClosePreview: 'إغلاق المعاينة',
changesResizePreview: 'تغيير حجم المعاينة',
changesDiffOpenTitle: 'يفتح الفرق كـ',
changesDiffOpenDesc: 'أين يضع إجراء «التوسيع إلى تبويب diff» الفرق',
changesDiffOpenFloat: 'نافذة عائمة',
changesDiffOpenFloatDesc: 'نافذة عائمة في وسط الشاشة — سحب وتحجيم وتثبيت فوق',
changesDiffOpenPane: 'لوحة',
changesDiffOpenPaneDesc: 'مثبتة أسفل اللوحة المصدر (تقسيم diff بنمط VSCode)',
changesLoadError: 'سجلات ملفات الجلسة غير متاحة الآن',
staged: 'مُدرج',
unstaged: 'غير مُدرج',
cancel: 'إلغاء',
@@ -222,10 +244,10 @@ export const ar: Record<string, string> = {
settingsConflict: 'تغيّر الإعداد في نافذة أخرى — يُرجى إعادة المحاولة',
binaryNoPreview: 'لا يمكن معاينة هذا النوع من الملفات',
downloadToView: 'تنزيل للعرض',
settingsSubagentTitle: 'فتح صفحة المهام تلقائياً عند ظهور وكيل فرعي',
settingsSubagentDesc: 'توسيع البطاقة الجانبية وفتح صفحة المهام عند توليد المحادثة الحالية لوكيل فرعي جديد؛ أوقفها للفتح يدوياً',
settingsJobsTitle: 'فتح صفحة المهام الخلفية تلقائياً عند مهمة خلفية جديدة',
settingsJobsDesc: 'توسيع البطاقة الجانبية وفتح صفحة المهام الخلفية عند ظهور مهمة خلفية جديدة للمحادثة الحالية (كل مهمة جديدة تُطلق ذلك)؛ أوقفها للفتح يدوياً',
settingsSubagentTitle: 'تفعيل صفحة المهام تلقائياً عند ظهور وكيل فرعي',
settingsSubagentDesc: 'تُفعَّل صفحة المهام عندما تُنشئ المحادثة الحالية وكيلاً فرعياً جديداً؛ في الشاشات العريضة تُوسَّع البطاقة الجانبية أيضاً، أما في الشاشات الضيقة فلا يُفرض فتح الدرج بملء الشاشة؛ عند إيقاف الخيار يصبح الفتح يدوياً',
settingsJobsTitle: 'تفعيل صفحة المهام تلقائياً عند ظهور مهمة خلفية جديدة',
settingsJobsDesc: 'تُفعَّل صفحة المهام كلما ظهرت مهمة خلفية جديدة للمحادثة الحالية (كل مهمة جديدة تُطلق التفعيل)؛ في الشاشات العريضة تُوسَّع البطاقة الجانبية أيضاً، أما في الشاشات الضيقة فلا يُفرض فتح الدرج بملء الشاشة؛ عند إيقاف الخيار يصبح الفتح يدوياً',
settingsToolsTitle: 'حقن أدوات الطرفية للنموذج',
settingsToolsDesc: 'عند التفعيل، يمكن للنموذج إنشاء وتشغيل طرفيات الشريط الجانبي عبر أدوات terminal_* الثمانية (معطّل افتراضياً)',
settingsFenceTitle: 'فحص مسارات مساحة العمل',
@@ -329,6 +351,25 @@ export const ar: Record<string, string> = {
sideChatClose: 'إغلاق الخيط',
sideChatCloseTitle: 'تحرير وكيل الخيط (يُحتفظ بالسجل)',
sideChatError: 'خطأ في المحادثة الجانبية: {message}',
sideChatTurnUsage: 'إدخال {input} توكن · إخراج {output} توكن',
sideChatBlockCollapse: 'طي',
sideChatBlockCollapseAria: 'طي',
sideChatBlockExpand: 'توسيع {hidden} أسطر',
sideChatBlockExpandAria: 'توسيع {hidden} أسطر إضافية',
sideChatBlockSignal: 'أُنهي بالإشارة: {signal}',
sideChatBlockExitCode: 'رمز الخروج {code}',
sideChatBlockRunning: 'قيد التشغيل',
sideChatBlockFailed: 'فشل',
sideChatBlockDone: 'اكتمل',
sideChatBlockNoOutput: '(لا يوجد إخراج)',
sideChatBlockFiles: '{count} ملفات',
sideChatBlockWindow: '{shown} من {total} سطرًا',
sideChatConnDisconnected: 'انقطع الاتصال',
sideChatConnReconnect: 'إعادة الاتصال',
sideChatConnConnecting: 'جارٍ إعادة الاتصال…',
sideChatConnRecovered: 'تم استعادة الاتصال',
sideChatConnReconnectAction: 'إعادة الاتصال الآن',
sideChatConnRestartAction: 'بدء الاتصال من جديد',
jobs: 'المهام الخلفية',
jobsCount: '{count} مهمة خلفية',
jobsCountRunning: '{count} مهمة خلفية · {running} قيد التشغيل',
@@ -355,7 +396,7 @@ export const ar: Record<string, string> = {
addPluginsViewerCardDesc: 'تسجيل معاينة نوع ملف',
addPluginsTabDesc: 'يمكن توسيع صفحات الشريط الجانبي (التبويبات) بواسطة الإضافات. تُسجَّل الإضافات عبر خدمة ctx.betterSidebar؛ النقر على «تثبيت» ينسخ أمر التثبيت — الصقه في طرفية حيث يقيم ملف DSH الشخصي وشغّله.',
addPluginsViewerDesc: 'يمكن توسيع عارضات الملفات بواسطة الإضافات. تُسجَّل الإضافات عبر خدمة ctx.betterSidebar؛ النقر على «تثبيت» ينسخ أمر التثبيت — الصقه في طرفية حيث يقيم ملف DSH الشخصي وشغّله.',
addPluginsBrowseMore: 'تصفّح المزيد من الإضافات على GitHub (topic: dsh-better-sidebar)',
addPluginsBrowseMore: 'تصفّح المزيد من الإضافات على GitHub (topic: dsh-apes-sidebar)',
addPluginsSearch: 'البحث باسم الإضافة / الوصف…',
addPluginsNoMatch: 'لا إضافات مطابقة',
addPluginsRecommended: 'إضافات موصى بها',
@@ -368,7 +409,7 @@ export const ar: Record<string, string> = {
pluginGithubWorkbenchDesc: 'تبويب GitHub Workbench: شجرة المستودع البعيد + Issues / Pull requests / Actions مع دعم كامل للكتابة — إنشاء Issue/PR، والتعليق، والتعديل، والإغلاق/إعادة الفتح، والدمج عبر squash·merge·rebase (بتأكيد قوي)، وإعادة تشغيل CI أو إلغائها؛ يعرض مبدّل المستودعات تلقائيًا المستودعات المتاحة ويتيح البحث في المستودعات العامة؛ وبدون better-sidebar يتحول تلقائيًا إلى لوحة جانبية مستقلة على اليمين',
pluginMdExportDesc: 'يضيف زر «تصدير» إلى شريط أدوات Markdown في better-sidebar: يعرض ملف .md الحالي بنقرة واحدة كملف HTML مستقل (الجداول/كتل الأكواد/مخططات Mermaid مدمجة، والتخطيط يتبع سمة المعاينة) ويحفظه بجوار الملف، أو يصدّره PDF عبر نافذة الطباعة',
pluginCodeNavDesc: 'تنقل معاينة الكود: يكتشف اللغة تلقائياً من نوع الملف ويبرز الصياغة، مخطط الرموز (تصفية الفئات/الطرق/المتغيرات + قفزة بنقرة واحدة)، والبحث داخل الملف (إبراز كل التطابقات، السابق/التالي، مطابقة حالة الأحرف) — يتولى معاينة ملفات الكود في محرر better-sidebar',
pluginSuhuangScrollDesc: 'يربط بيئة Suhuang Scroll المحلية بإعدادات DSH وbetter-sidebar لإعداد النموذج واختبار الاتصال والتحكم في التصحيح المتواصل؛ يتطلب Suhuang Scroll Runtime وdsh-better-sidebar',
pluginSuhuangScrollDesc: 'يربط بيئة Suhuang Scroll المحلية بإعدادات DSH وbetter-sidebar لإعداد النموذج واختبار الاتصال والتحكم في التصحيح المتواصل؛ يتطلب Suhuang Scroll Runtime وdsh-apes-sidebar',
pluginBetterOverleafDesc: 'تبويب Overleaf لـ better-sidebar: تسجيل دخول مباشر عبر متصفح CDP (يدعم Chromium من جهات أخرى)، قائمة المشاريع/التبديل، نسخ git محلية ضمن <workspace>/overleaf/، مزامنة git ثنائية الاتجاه مع تراجع إلى API للقراءة فقط، ومعاينة الملفات عبر مساحة عمل الشريط الجانبي',
pluginGitRemotesDesc: 'تبويب Git Remotes: فرع/أصل/أمام-خلف، fetch (مع prune اختياري)، ff-only pull، والدفع فقط بعد تأكيد داخل التبويب. لا يستبدل تبويب Git المدمج للإدراج/التثبيت، ولا يقدّم force-push أو أداة دفع آلي للنموذج',
pluginSentinelDesc: 'نظام إيقاظ وكيل يحرّكه تحقّق الشروط: مستشعرات ملف/عملية/منفذ/HTTP/أمر/webhook توقظ الجلسات الخاملة عند تحقّق الشرط؛ يُسجِّل تبويب «Sentinel» مع جدول مراقبة شامل للخادم',
+49 -8
View File
@@ -3,8 +3,16 @@
*/
export const de: Record<string, string> = {
files: 'Dateien',
changesSessionEmpty: 'Noch keine Dateioperationen in dieser Sitzung',
changesRead: 'Lesen',
changesWrite: 'Schreiben',
changesEdit: 'Bearbeiten',
changesRunning: 'läuft',
changesError: 'Fehler',
changesFold: '{count} Zeilen… zum Aufklappen klicken',
changesContext: 'Kontext',
changesPriorUnknown: 'Inhalt vor der Änderung liegt außerhalb des Fensters; als vollständig neu angezeigt',
explorer: 'Explorer',
git: 'Quellcodeverwaltung',
terminal: 'Terminal',
editor: 'Editor',
editorExplorer: 'Dateiöffnungsverhalten',
@@ -114,7 +122,21 @@ export const de: Record<string, string> = {
worktree: 'Worktree',
checkoutError: 'Branchenwechsel fehlgeschlagen',
history: 'Verlauf',
changes: 'Änderungen',
changes: 'Dateiänderungen',
changesGitLens: 'Git',
changesSessionLens: 'Sitzung',
changesFilterAll: 'Alle',
changesFilterEmpty: 'Keine Vorgänge dieser Art',
changesOpenDiffTab: 'In eigenem Tab öffnen',
changesClosePreview: 'Vorschau schließen',
changesResizePreview: 'Vorschauhöhe anpassen',
changesDiffOpenTitle: 'Diff öffnet als',
changesDiffOpenDesc: 'Wo die Aktion „In eigenem Tab öffnen“ den Diff platziert',
changesDiffOpenFloat: 'Freies Fenster',
changesDiffOpenFloatDesc: 'Ein schwebendes Fenster mittig im Viewport — verschieben, skalieren, oben halten',
changesDiffOpenPane: 'Bereich',
changesDiffOpenPaneDesc: 'Unter dem Quellbereich angedockt (VSCode-artiger Diff-Split)',
changesLoadError: 'Sitzungsdatei-Aufzeichnungen sind derzeit nicht verfügbar',
staged: 'Gestaged',
unstaged: 'Nicht gestaged',
cancel: 'Abbrechen',
@@ -207,10 +229,10 @@ export const de: Record<string, string> = {
settingsConflict: 'Die Einstellung wurde in einem anderen Fenster geändert – bitte erneut versuchen',
binaryNoPreview: 'Dieser Dateityp kann nicht in der Vorschau angezeigt werden',
downloadToView: 'Zum Ansehen herunterladen',
settingsSubagentTitle: 'Aufgaben-Seite bei einem Subagenten automatisch öffnen',
settingsSubagentDesc: 'Die Seitenkarte wird ausgeklappt und die Aufgaben-Seite geöffnet, wenn die aktuelle Unterhaltung einen neuen Subagenten erzeugt; ausschalten, um sie manuell zu öffnen',
settingsJobsTitle: 'Aufgaben-Seite bei einer neuen Hintergrundaufgabe automatisch öffnen',
settingsJobsDesc: 'Die Seitenkarte wird ausgeklappt und die Aufgaben-Seite geöffnet, sobald für die aktuelle Unterhaltung eine neue Hintergrundaufgabe erscheint (jede neue Aufgabe löst dies aus); ausschalten, um sie manuell zu öffnen',
settingsSubagentTitle: 'Aufgaben-Seite bei einem neuen Subagenten automatisch aktivieren',
settingsSubagentDesc: 'Die Aufgaben-Seite aktivieren, wenn die aktuelle Unterhaltung einen neuen Subagenten erzeugt; auf breiten Ansichten wird zusätzlich die Seitenkarte ausgeklappt, auf schmalen Ansichten wird das Öffnen der Vollbild-Schublade nicht erzwungen; ausschalten, um sie manuell zu öffnen',
settingsJobsTitle: 'Aufgaben-Seite bei einer neuen Hintergrundaufgabe automatisch aktivieren',
settingsJobsDesc: 'Die Aufgaben-Seite aktivieren, sobald für die aktuelle Unterhaltung eine neue Hintergrundaufgabe erscheint (jede neue Aufgabe löst dies aus); auf breiten Ansichten wird zusätzlich die Seitenkarte ausgeklappt, auf schmalen Ansichten wird das Öffnen der Vollbild-Schublade nicht erzwungen; ausschalten, um sie manuell zu öffnen',
settingsToolsTitle: 'Terminal-Werkzeuge für das Modell bereitstellen',
settingsToolsDesc: 'Wenn aktiviert, kann das Modell über die 8 terminal_*-Werkzeuge Terminale in der Seitenleiste erstellen und steuern (standardmäßig deaktiviert)',
settingsFenceTitle: 'Workspace-Pfadkontrolle',
@@ -314,6 +336,25 @@ export const de: Record<string, string> = {
sideChatClose: 'Thread schließen',
sideChatCloseTitle: 'Den Agenten des Threads freigeben (Verlauf bleibt erhalten)',
sideChatError: 'Seitenchat-Fehler: {message}',
sideChatTurnUsage: 'Eingabe {input} tok · Ausgabe {output} tok',
sideChatBlockCollapse: 'Einklappen',
sideChatBlockCollapseAria: 'Einklappen',
sideChatBlockExpand: '{hidden} Zeilen ausklappen',
sideChatBlockExpandAria: '{hidden} weitere Zeilen ausklappen',
sideChatBlockSignal: 'Durch Signal beendet: {signal}',
sideChatBlockExitCode: 'Exit-Code {code}',
sideChatBlockRunning: 'Läuft',
sideChatBlockFailed: 'Fehlgeschlagen',
sideChatBlockDone: 'Fertig',
sideChatBlockNoOutput: '(keine Ausgabe)',
sideChatBlockFiles: '{count} Dateien',
sideChatBlockWindow: '{shown} von {total} Zeilen',
sideChatConnDisconnected: 'Verbindung verloren',
sideChatConnReconnect: 'Neu verbinden',
sideChatConnConnecting: 'Verbinde neu…',
sideChatConnRecovered: 'Verbindung wiederhergestellt',
sideChatConnReconnectAction: 'Jetzt neu verbinden',
sideChatConnRestartAction: 'Verbindung neu aufbauen',
jobs: 'Hintergrundaufgaben',
jobsCount: '{count} Hintergrundaufgaben',
jobsCountRunning: '{count} Hintergrundaufgaben · {running} läuft',
@@ -340,7 +381,7 @@ export const de: Record<string, string> = {
addPluginsViewerCardDesc: 'Eine Dateityp-Vorschau registrieren',
addPluginsTabDesc: 'Seitenleisten-Seiten (Tabs) können durch Plugins erweitert werden. Plugins registrieren sich über den ctx.betterSidebar-Dienst; ein Klick auf „Installieren“ kopiert den Installationsbefehl – fügen Sie ihn in einem Terminal ein, in dem Ihr DSH-Profil liegt, und führen Sie ihn aus.',
addPluginsViewerDesc: 'Datei-Viewer können durch Plugins erweitert werden. Plugins registrieren sich über den ctx.betterSidebar-Dienst; ein Klick auf „Installieren“ kopiert den Installationsbefehl – fügen Sie ihn in einem Terminal ein, in dem Ihr DSH-Profil liegt, und führen Sie ihn aus.',
addPluginsBrowseMore: 'Weitere Plugins auf GitHub durchsuchen (Topic: dsh-better-sidebar)',
addPluginsBrowseMore: 'Weitere Plugins auf GitHub durchsuchen (Topic: dsh-apes-sidebar)',
addPluginsSearch: 'Nach Plugin-Name oder -Beschreibung suchen…',
addPluginsNoMatch: 'Keine passenden Plugins',
addPluginsRecommended: 'Empfohlene Plugins',
@@ -353,7 +394,7 @@ export const de: Record<string, string> = {
pluginGithubWorkbenchDesc: '„GitHub Workbench“-Tab: Repository-Baum + Issues / Pull requests / Actions mit vollen Schreibrechten — Issue/PR erstellen, kommentieren, bearbeiten, schließen/wieder öffnen, squash·merge·rebase-Merge (mit starker Bestätigung), CI erneut ausführen/abbrechen; der Repo-Umschalter listet automatisch zugreifbare Repos auf und durchsucht öffentliche; ohne better-sidebar fällt er auf ein eigenständiges rechtes Panel zurück',
pluginMdExportDesc: 'Fügt einen „Export"-Button zur Markdown-Symbolleiste in better-sidebar hinzu: rendert die aktuelle .md per Klick in eigenständiges HTML (Tabellen/Codeblöcke/Mermaid-Diagramme inline, Layout folgt dem Vorschau-Theme) und speichert es neben der .md, oder exportiert über den Druckdialog als PDF',
pluginCodeNavDesc: 'Code-Vorschau-Navigation: erkennt die Sprache am Dateityp und hebt die Syntax hervor, Symbol-Gliederung (Filter für Klassen/Methoden/Variablen + Sprung mit einem Klick) und Suche in der Datei (alle Treffer hervorgehoben, vor/zurück, Groß-/Kleinschreibung) — übernimmt die Code-Dateivorschau im better-sidebar-Editor',
pluginSuhuangScrollDesc: 'Verbindet die lokale Suhuang Scroll Runtime mit den DSH-Einstellungen und better-sidebar für Modellkonfiguration, Verbindungstests und fortlaufende Bewertung; benötigt Suhuang Scroll Runtime und dsh-better-sidebar',
pluginSuhuangScrollDesc: 'Verbindet die lokale Suhuang Scroll Runtime mit den DSH-Einstellungen und better-sidebar für Modellkonfiguration, Verbindungstests und fortlaufende Bewertung; benötigt Suhuang Scroll Runtime und dsh-apes-sidebar',
pluginBetterOverleafDesc: 'Overleaf-Tab für better-sidebar: Login direkt über CDP-Browser (Chromium von Drittanbietern unterstützt), Projekliste/-wechsel, lokale git-Spiegel unter <workspace>/overleaf/, bidirektionaler git-Abgleich mit Read-only-API-Fallback und Dateivorschau über die Sidebar-Workbench',
pluginGitRemotesDesc: '„Git-Remotes“-Tab: Branches/Upstream/ahead-behind ansehen, fetch (optional prune), ff-only pull und push erst nach Bestätigung im Tab. Ersetzt nicht den eingebauten Stagen/Commit-Tab und bietet weder force-push noch automatisches Push-Werkzeug für das Modell',
pluginSentinelDesc: 'Bedingungsgesteuertes Wecksystem für Agenten: Datei-/Prozess-/Port-/HTTP-/Befehls-/Webhook-Sensoren wecken ruhende Sitzungen, wenn Bedingungen zutreffen; registriert einen „Sentinel“-Tab mit der serverweiten Überwachungstabelle',
+48 -7
View File
@@ -10,8 +10,16 @@
/** fr dictionary for the `betterSidebar` namespace. */
export const fr: Record<string, string> = {
files: 'Fichiers',
changesSessionEmpty: 'Aucune opération de fichier dans cette session',
changesRead: 'Lecture',
changesWrite: 'Écriture',
changesEdit: 'Modification',
changesRunning: 'en cours',
changesError: 'erreur',
changesFold: '{count} lignes… cliquer pour déplier',
changesContext: 'contexte',
changesPriorUnknown: 'Le contenu antérieur est hors de la fenêtre ; affiché comme entièrement ajouté',
explorer: 'Explorateur',
git: 'Gestion de code source',
terminal: 'Terminal',
editor: 'Éditeur',
editorExplorer: 'Comportement d’ouverture des fichiers',
@@ -122,6 +130,20 @@ export const fr: Record<string, string> = {
checkoutError: 'Échec du changement de branche',
history: 'Historique',
changes: 'Modifications',
changesGitLens: 'Git',
changesSessionLens: 'Session',
changesFilterAll: 'Tout',
changesFilterEmpty: 'Aucune opération de ce type',
changesOpenDiffTab: 'Ouvrir dans un onglet diff',
changesClosePreview: 'Fermer l\'aperçu',
changesResizePreview: 'Redimensionner l\'aperçu',
changesDiffOpenTitle: 'Le diff s\'ouvre en',
changesDiffOpenDesc: 'Où l\'action « Ouvrir dans un onglet diff » place le diff',
changesDiffOpenFloat: 'Fenêtre flottante',
changesDiffOpenFloatDesc: 'Une fenêtre flottante centrée sur la fenêtre — déplacer, redimensionner, garder au premier plan',
changesDiffOpenPane: 'Panneau',
changesDiffOpenPaneDesc: 'Ancré sous le panneau source (division de diff style VSCode)',
changesLoadError: 'Les enregistrements de fichiers de la session sont indisponibles',
staged: 'En scène',
unstaged: 'Hors scène',
cancel: 'Annuler',
@@ -214,10 +236,10 @@ export const fr: Record<string, string> = {
settingsConflict: 'Les réglages ont été modifiés par une autre fenêtre, veuillez réessayer',
binaryNoPreview: 'Ce type de fichier ne prend pas en charge l’aperçu',
downloadToView: 'Télécharger pour consulter',
settingsSubagentTitle: 'Déployer automatiquement la page de gestion des tâches à la détection d’un sous-agent',
settingsSubagentDesc: 'Lorsque la session actuelle produit un nouveau sous-agent, déployer automatiquement la barre latérale et ouvrir la page de gestion des tâches ; une fois désactivé, l’ouverture est manuelle',
settingsJobsTitle: 'Déployer automatiquement la page des tâches d’arrière-plan sur nouvelle tâche',
settingsJobsDesc: 'Lorsque la session actuelle produit une nouvelle tâche d’arrière-plan, déployer automatiquement la barre latérale et ouvrir la page des tâches (déclenché à chaque nouvelle tâche) ; une fois désactivé, l’ouverture est manuelle',
settingsSubagentTitle: 'Activer automatiquement la page Gestion des tâches lorsqu’un sous-agent apparaît',
settingsSubagentDesc: 'Lorsqu’un nouveau sous-agent est créé dans la session actuelle, activer la page Gestion des tâches ; sur les écrans larges, déployer également la carte latérale ; sur les écrans étroits, ne pas forcer l’ouverture du tiroir plein écran ; une fois l’option désactivée, l’ouverture est manuelle',
settingsJobsTitle: 'Activer automatiquement la page Gestion des tâches à l’arrivée d’une tâche d’arrière-plan',
settingsJobsDesc: 'À chaque nouvelle tâche d’arrière-plan dans la session actuelle, activer la page Gestion des tâches (chaque nouvelle tâche déclenche l’activation) ; sur les écrans larges, déployer également la carte latérale ; sur les écrans étroits, ne pas forcer l’ouverture du tiroir plein écran ; une fois l’option désactivée, l’ouverture est manuelle',
settingsToolsTitle: 'Injecter des outils de terminal au modèle',
settingsToolsDesc: 'Une fois activé, le modèle peut créer et piloter des terminaux de la barre latérale via les 8 outils terminal_* (désactivé par défaut)',
settingsFenceTitle: 'Contrôle des chemins de l\'espace de travail',
@@ -321,6 +343,25 @@ export const fr: Record<string, string> = {
sideChatClose: 'Fermer le fil',
sideChatCloseTitle: 'Libérer l’agent du fil (l’historique est conservé)',
sideChatError: 'Erreur de la discussion latérale : {message}',
sideChatTurnUsage: 'Entrée {input} tok · Sortie {output} tok',
sideChatBlockCollapse: 'Réduire',
sideChatBlockCollapseAria: 'Réduire',
sideChatBlockExpand: 'Déplier {hidden} lignes',
sideChatBlockExpandAria: 'Déplier {hidden} lignes de plus',
sideChatBlockSignal: 'Terminé par le signal : {signal}',
sideChatBlockExitCode: 'Code de sortie {code}',
sideChatBlockRunning: 'En cours',
sideChatBlockFailed: 'Échec',
sideChatBlockDone: 'Terminé',
sideChatBlockNoOutput: '(aucune sortie)',
sideChatBlockFiles: '{count} fichiers',
sideChatBlockWindow: '{shown} sur {total} lignes',
sideChatConnDisconnected: 'Connexion perdue',
sideChatConnReconnect: 'Reconnecter',
sideChatConnConnecting: 'Reconnexion…',
sideChatConnRecovered: 'Connexion rétablie',
sideChatConnReconnectAction: 'Reconnecter maintenant',
sideChatConnRestartAction: 'Relancer la connexion',
jobs: 'Tâches d’arrière-plan',
jobsCount: '{count} tâche(s) d’arrière-plan',
jobsCountRunning: '{count} tâche(s) d’arrière-plan · {running} en cours',
@@ -347,7 +388,7 @@ export const fr: Record<string, string> = {
addPluginsViewerCardDesc: 'Enregistrer un aperçu d’un type de fichier',
addPluginsTabDesc: 'Les pages de la barre latérale (onglets) peuvent être étendues par des plugins. Les plugins s’enregistrent via le service ctx.betterSidebar ; cliquez sur « Installer » pour copier la commande d’installation, collez-la dans un terminal de l’environnement DSH et exécutez-la.',
addPluginsViewerDesc: 'Les aperçus de fichiers peuvent être étendus par des plugins. Les plugins s’enregistrent via le service ctx.betterSidebar ; cliquez sur « Installer » pour copier la commande d’installation, collez-la dans un terminal de l’environnement DSH et exécutez-la.',
addPluginsBrowseMore: 'Parcourir plus de plugins sur GitHub (topic : dsh-better-sidebar)',
addPluginsBrowseMore: 'Parcourir plus de plugins sur GitHub (topic : dsh-apes-sidebar)',
addPluginsSearch: 'Rechercher par nom / description de plugin…',
addPluginsNoMatch: 'Aucun plugin correspondant',
addPluginsRecommended: 'Plugins recommandés',
@@ -360,7 +401,7 @@ export const fr: Record<string, string> = {
pluginGithubWorkbenchDesc: 'Onglet « GitHub Workbench » de better-sidebar : arborescence du dépôt distant + Issues / Pull requests / Actions avec prise en charge complète en écriture — création d’issue/PR, commentaire, édition, fermeture/réouverture, merge squash·merge·rebase (confirmation renforcée), relance/annulation de la CI ; le sélecteur de dépôt liste automatiquement les dépôts accessibles et recherche les dépôts publics ; sans better-sidebar, il bascule sur un panneau latéral droit autonome',
pluginMdExportDesc: 'Ajoute un bouton « Exporter » à la barre d’outils Markdown de better-sidebar : rend en un clic le .md courant en HTML autonome (tableaux/blocs de code/diagrammes Mermaid en ligne, mise en page fidèle au thème d’aperçu) et l’enregistre à côté du .md, ou exporte en PDF via la boîte de dialogue d’impression',
pluginCodeNavDesc: 'Navigation de prévisualisation de code : détecte le langage selon le type de fichier et colore la syntaxe, plan des symboles (filtres classes/méthodes/variables + saut en un clic) et recherche dans le fichier (toutes les correspondances surlignées, précédent/suivant, respect de la casse) — prend en charge l’aperçu des fichiers de code dans l’éditeur better-sidebar',
pluginSuhuangScrollDesc: 'Connecte le Runtime Suhuang Scroll local aux réglages DSH et à better-sidebar pour configurer le modèle, tester la connexion et contrôler la correction continue ; nécessite Suhuang Scroll Runtime et dsh-better-sidebar',
pluginSuhuangScrollDesc: 'Connecte le Runtime Suhuang Scroll local aux réglages DSH et à better-sidebar pour configurer le modèle, tester la connexion et contrôler la correction continue ; nécessite Suhuang Scroll Runtime et dsh-apes-sidebar',
pluginBetterOverleafDesc: 'Onglet Overleaf pour better-sidebar : connexion directe via navigateur CDP (Chromium tiers pris en charge), liste/sélection de projets, miroirs git locaux sous <workspace>/overleaf/, synchronisation git bidirectionnelle avec repli API en lecture seule, et aperçu des fichiers via l’atelier de la barre latérale',
pluginGitRemotesDesc: 'Onglet Git Remotes de better-sidebar : branches/amonts/ahead-behind, fetch (avec prune possible), pull en ff-only, push seulement après confirmation. Ne remplace pas la mise en scène/les commits du Git intégré et ne fournit pas de force-push ni de push automatique par le modèle',
pluginSentinelDesc: 'Système de réveil d’agent piloté par conditions : capteurs fichier/processus/port/HTTP/commande/webhook, les sessions en veille se réveillent automatiquement lorsque les conditions sont remplies ; enregistre l’onglet « Sentinel » affichant le tableau de surveillance global du serveur',
+49 -8
View File
@@ -17,8 +17,16 @@
/** The hi dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const hi: Record<string, string> = {
files: 'फ़ाइलें',
changesSessionEmpty: 'इस सत्र में अभी कोई फ़ाइल ऑपरेशन नहीं',
changesRead: 'पढ़ना',
changesWrite: 'लिखना',
changesEdit: 'संपादन',
changesRunning: 'चल रहा है',
changesError: 'त्रुटि',
changesFold: '{count} पंक्तियाँ… विस्तार के लिए क्लिक करें',
changesContext: 'संदर्भ',
changesPriorUnknown: 'बदलाव से पहले की सामग्री विंडो में नहीं है; पूरी नई जोड़ी गई दिखाई जाती है',
explorer: 'एक्सप्लोरर',
git: 'सोर्स कंट्रोल',
terminal: 'टर्मिनल',
editor: 'एडिटर',
editorExplorer: 'फ़ाइल खोलने का तरीका',
@@ -128,7 +136,21 @@ export const hi: Record<string, string> = {
worktree: 'वर्कट्री',
checkoutError: 'ब्रांच स्विच विफल',
history: 'इतिहास',
changes: 'बदलाव',
changes: 'फ़ाइल परिवर्तन',
changesGitLens: 'Git',
changesSessionLens: 'सेशन',
changesFilterAll: 'सभी',
changesFilterEmpty: 'इस प्रकार का कोई ऑपरेशन नहीं',
changesOpenDiffTab: 'अलग diff टैब में खोलें',
changesClosePreview: 'पूर्वावलोकन बंद करें',
changesResizePreview: 'पूर्वावलोकन का आकार बदलें',
changesDiffOpenTitle: 'diff खुलता है',
changesDiffOpenDesc: '«अलग diff टैब में खोलें» क्रिया diff को कहाँ रखती है',
changesDiffOpenFloat: 'मुक्त विंडो',
changesDiffOpenFloatDesc: 'व्यूपोर्ट के केंद्र में एक तैरती विंडो — खींचें, आकार बदलें, ऊपर रखें',
changesDiffOpenPane: 'पैनल',
changesDiffOpenPaneDesc: 'सोर्स पैनल के नीचे डॉक किया (VSCode-शैली diff स्प्लिट)',
changesLoadError: 'सेशन फ़ाइल रिकॉर्ड अभी अनुपलब्ध हैं',
staged: 'स्टेज किया',
unstaged: 'अनस्टेज',
cancel: 'रद्द करें',
@@ -221,10 +243,10 @@ export const hi: Record<string, string> = {
settingsConflict: 'सेटिंग दूसरी विंडो में बदली — कृपया पुनः प्रयास करें',
binaryNoPreview: 'इस फ़ाइल प्रकार का पूर्वावलोकन नहीं हो सकता',
downloadToView: 'देखने के लिए डाउनलोड करें',
settingsSubagentTitle: 'सबएजेंट दिखने पर स्वतः कार्य पृष्ठ खोलें',
settingsSubagentDesc: 'वर्तमान वार्तालाप में नया सबएजेंट बनने पर साइडबार विस्तृत करें और कार्य पृष्ठ खोलें; बंद करने पर मैन्युअल रूप से खोलें',
settingsJobsTitle: 'नया बैकग्राउंड कार्य आने पर स्वतः कार्य पृष्ठ खोलें',
settingsJobsDesc: 'वर्तमान वार्तालाप में नया बैकग्राउंड कार्य दिखने पर साइडबार विस्तृत करें और कार्य पृष्ठ खोलें (हर नया कार्य ट्रिगर करता है); बंद करने पर मैन्युअल रूप से खोलें',
settingsSubagentTitle: 'सबएजेंट दिखने पर कार्य पृष्ठ स्वतः सक्रिय करें',
settingsSubagentDesc: 'वर्तमान वार्तालाप में नया सबएजेंट बनने पर कार्य पृष्ठ सक्रिय करें; चौड़ी स्क्रीन पर साइड कार्ड भी विस्तृत होता है, जबकि संकीर्ण स्क्रीन पर पूर्ण-स्क्रीन दराज को जबरन नहीं खोला जाता; विकल्प बंद होने पर इसे मैन्युअल रूप से खोलना होगा',
settingsJobsTitle: 'नया बैकग्राउंड कार्य आने पर कार्य पृष्ठ स्वतः सक्रिय करें',
settingsJobsDesc: 'वर्तमान वार्तालाप में हर नया बैकग्राउंड कार्य आने पर कार्य पृष्ठ सक्रिय करें (हर नया कार्य ट्रिगर करता है); चौड़ी स्क्रीन पर साइड कार्ड भी विस्तृत होता है, जबकि संकीर्ण स्क्रीन पर पूर्ण-स्क्रीन दराज को जबरन नहीं खोला जाता; विकल्प बंद होने पर इसे मैन्युअल रूप से खोलना होगा',
settingsToolsTitle: 'मॉडल के लिए टर्मिनल टूल इंजेक्ट करें',
settingsToolsDesc: 'सक्षम होने पर, मॉडल 8 terminal_* टूल्स से साइडबार टर्मिनल बना और चला सकता है (डिफ़ॉल्ट रूप से बंद)',
settingsFenceTitle: 'वर्कस्पेस पाथ जाँच',
@@ -328,6 +350,25 @@ export const hi: Record<string, string> = {
sideChatClose: 'थ्रेड बंद करें',
sideChatCloseTitle: 'थ्रेड का एजेंट रिलीज़ करें (इतिहास रखा जाता है)',
sideChatError: 'साइड चैट त्रुटि: {message}',
sideChatTurnUsage: 'इनपुट {input} टोकन · आउटपुट {output} टोकन',
sideChatBlockCollapse: 'सिकोड़ें',
sideChatBlockCollapseAria: 'सिकोड़ें',
sideChatBlockExpand: '{hidden} पंक्तियाँ विस्तृत करें',
sideChatBlockExpandAria: 'आगे की {hidden} पंक्तियाँ विस्तृत करें',
sideChatBlockSignal: 'सिग्नल से समाप्त: {signal}',
sideChatBlockExitCode: 'एग्जिट कोड {code}',
sideChatBlockRunning: 'चल रहा है',
sideChatBlockFailed: 'विफल',
sideChatBlockDone: 'पूर्ण',
sideChatBlockNoOutput: '(कोई आउटपुट नहीं)',
sideChatBlockFiles: '{count} फ़ाइलें',
sideChatBlockWindow: 'कुल {total} में से {shown} पंक्तियाँ',
sideChatConnDisconnected: 'कनेक्शन टूट गया',
sideChatConnReconnect: 'फिर से कनेक्ट करें',
sideChatConnConnecting: 'फिर से कनेक्ट हो रहा है…',
sideChatConnRecovered: 'कनेक्शन बहाल हुआ',
sideChatConnReconnectAction: 'अभी फिर से कनेक्ट करें',
sideChatConnRestartAction: 'कनेक्शन फिर से शुरू करें',
jobs: 'बैकग्राउंड कार्य',
jobsCount: '{count} बैकग्राउंड कार्य',
jobsCountRunning: '{count} बैकग्राउंड कार्य · {running} चल रहे',
@@ -354,7 +395,7 @@ export const hi: Record<string, string> = {
addPluginsViewerCardDesc: 'फ़ाइल-प्रकार पूर्वावलोकन पंजीकृत करें',
addPluginsTabDesc: 'साइडबार पृष्ठ (टैब) प्लगइन्स द्वारा विस्तारित हो सकते हैं। प्लगइन्स ctx.betterSidebar सेवा के माध्यम से पंजीकृत होते हैं; इंस्टॉल क्लिक करने पर इंस्टॉल कमांड कॉपी होता है — इसे उस टर्मिनल में पेस्ट करें जहाँ आपका DSH प्रोफ़ाइल है और चलाएँ।',
addPluginsViewerDesc: 'फ़ाइल पूर्वावलोकक प्लगइन्स द्वारा विस्तारित हो सकते हैं। प्लगइन्स ctx.betterSidebar सेवा के माध्यम से पंजीकृत होते हैं; इंस्टॉल क्लिक करने पर इंस्टॉल कमांड कॉपी होता है — इसे उस टर्मिनल में पेस्ट करें जहाँ आपका DSH प्रोफ़ाइल है और चलाएँ।',
addPluginsBrowseMore: 'GitHub पर और प्लगइन्स देखें (topic: dsh-better-sidebar)',
addPluginsBrowseMore: 'GitHub पर और प्लगइन्स देखें (topic: dsh-apes-sidebar)',
addPluginsSearch: 'प्लगइन नाम या विवरण से खोजें…',
addPluginsNoMatch: 'कोई प्लगइन मेल नहीं खाता',
addPluginsRecommended: 'अनुशंसित प्लगइन',
@@ -367,7 +408,7 @@ export const hi: Record<string, string> = {
pluginGithubWorkbenchDesc: 'GitHub Workbench टैब: रिमोट रिपॉज़िटरी ट्री + Issues / Pull requests / Actions, पढ़ने के साथ-साथ पूर्ण लेखन समर्थन — Issue/PR बनाना, टिप्पणी करना, संपादन, बंद करना/पुनः खोलना, squash·merge·rebase मर्ज (पुष्टि अनिवार्य), CI पुनः चलाना/रद्द करना; रिपॉज़िटरी स्विचर स्वतः सुलभ रिपॉज़िटरी सूचीबद्ध करता है और सार्वजनिक रिपॉज़िटरी खोजता है; better-sidebar न होने पर स्वतंत्र दाईं ओर पैनल में फ़ॉलबैक होता है',
pluginMdExportDesc: 'better-sidebar के Markdown टूलबार में "एक्सपोर्ट" बटन जोड़ता है: एक क्लिक में वर्तमान .md को स्टैंडअलोन HTML (टेबल/कोड ब्लॉक/Mermaid आरेख इनलाइन, लेआउट प्रीव्यू थीम का अनुसरण करता है) में रेंडर करके उसी फ़ोल्डर में सहेजता है, या प्रिंट डायलॉग से PDF निर्यात करता है',
pluginCodeNavDesc: 'कोड प्रीव्यू नेविगेशन: फ़ाइल प्रकार से भाषा पहचानकर सिंटैक्स हाइलाइट, सिंबल आउटलाइन (क्लास/मेथड/वेरिएबल फ़िल्टर + एक-क्लिक जंप), फ़ाइल में खोज (सभी मैच हाइलाइट, पिछला/अगला, केस मैच) — better-sidebar एडिटर में कोड फ़ाइल प्रीव्यू संभालता है',
pluginSuhuangScrollDesc: 'स्थानीय Suhuang Scroll Runtime को DSH सेटिंग्स और better-sidebar से जोड़ता है, ताकि मॉडल कॉन्फ़िगरेशन, कनेक्शन परीक्षण और निरंतर ग्रेडिंग नियंत्रण उपलब्ध हों; Suhuang Scroll Runtime और dsh-better-sidebar आवश्यक हैं',
pluginSuhuangScrollDesc: 'स्थानीय Suhuang Scroll Runtime को DSH सेटिंग्स और better-sidebar से जोड़ता है, ताकि मॉडल कॉन्फ़िगरेशन, कनेक्शन परीक्षण और निरंतर ग्रेडिंग नियंत्रण उपलब्ध हों; Suhuang Scroll Runtime और dsh-apes-sidebar आवश्यक हैं',
pluginBetterOverleafDesc: 'better-sidebar के लिए Overleaf टैब: सीधे CDP ब्राउज़र से लॉगिन (तीसरे पक्ष का Chromium समर्थित), प्रोजेक्ट सूची/स्विच, <workspace>/overleaf/ में स्थानीय git मिरर, रीड-ओनली API फ़ॉलबैक के साथ द्विदिश git सिंक, और फ़ाइल प्रीव्यू साइडबार वर्कबेंच से',
pluginGitRemotesDesc: 'Git Remotes टैब: ब्रांच/अपस्ट्रीम/ahead-behind, fetch (वैकल्पिक prune), ff-only pull, और केवल टैब-में पुष्टि के बाद push। अंतर्निहित Git स्टेज/कमिट टैब को प्रतिस्थापित नहीं करता, और force-push या मॉडल ऑटो-push टूल नहीं देता',
pluginSentinelDesc: 'स्थिति-संचालित एजेंट जागरूकता: फ़ाइल/प्रक्रिया/पोर्ट/http/कमांड/webhook सेंसर स्थितियाँ पूरी होने पर निष्क्रिय सत्र जगाते हैं; सर्वर-व्यापी वॉच टेबल के साथ "Sentinel" टैब पंजीकृत करता है',
+49 -8
View File
@@ -15,8 +15,16 @@
export const id: Record<string, string> = {
files: 'Berkas',
changesSessionEmpty: 'Belum ada operasi file di sesi ini',
changesRead: 'Baca',
changesWrite: 'Tulis',
changesEdit: 'Edit',
changesRunning: 'berjalan',
changesError: 'error',
changesFold: '{count} baris… klik untuk meluaskan',
changesContext: 'konteks',
changesPriorUnknown: 'Konten sebelum perubahan berada di luar jendela; ditampilkan sebagai sepenuhnya baru',
explorer: 'Explorer',
git: 'Source Control',
terminal: 'Terminal',
editor: 'Editor',
editorExplorer: 'Perilaku buka berkas',
@@ -126,7 +134,21 @@ export const id: Record<string, string> = {
worktree: 'Worktree',
checkoutError: 'Beralih branch gagal',
history: 'Riwayat',
changes: 'Perubahan',
changes: 'Perubahan berkas',
changesGitLens: 'Git',
changesSessionLens: 'Sesi',
changesFilterAll: 'Semua',
changesFilterEmpty: 'Tidak ada operasi jenis ini',
changesOpenDiffTab: 'Buka di tab diff terpisah',
changesClosePreview: 'Tutup pratinjau',
changesResizePreview: 'Ubah ukuran pratinjau',
changesDiffOpenTitle: 'Diff terbuka sebagai',
changesDiffOpenDesc: 'Di mana aksi "buka di tab diff terpisah" menempatkan diff',
changesDiffOpenFloat: 'Jendela bebas',
changesDiffOpenFloatDesc: 'Jendela mengambang di tengah viewport — geser, ubah ukuran, selalu di atas',
changesDiffOpenPane: 'Panel',
changesDiffOpenPaneDesc: 'Ditambat di bawah panel sumber (split diff gaya VSCode)',
changesLoadError: 'Catatan berkas sesi sedang tidak tersedia',
staged: 'Staged',
unstaged: 'Unstaged',
cancel: 'Batal',
@@ -219,10 +241,10 @@ export const id: Record<string, string> = {
settingsConflict: 'Pengaturan diubah di jendela lain — silakan coba lagi',
binaryNoPreview: 'Tipe berkas ini tidak dapat dipratinjau',
downloadToView: 'Unduh untuk melihat',
settingsSubagentTitle: 'Buka otomatis halaman Tasks saat subagen muncul',
settingsSubagentDesc: 'Bentangkan kartu samping dan buka halaman Tasks saat obrolan saat ini menelurkan subagen baru; matikan untuk membukanya secara manual',
settingsJobsTitle: 'Buka otomatis halaman Jobs saat ada tugas latar baru',
settingsJobsDesc: 'Bentangkan kartu samping dan buka halaman Jobs setiap kali tugas latar baru muncul untuk obrolan saat ini (setiap tugas baru memicu); matikan untuk membukanya secara manual',
settingsSubagentTitle: 'Aktifkan otomatis halaman Tasks saat subagen muncul',
settingsSubagentDesc: 'Aktifkan halaman Tasks saat obrolan saat ini membuat subagen baru; pada layar lebar, kartu samping juga dibentangkan; pada layar sempit, laci layar penuh tidak dipaksa terbuka; matikan untuk membukanya secara manual',
settingsJobsTitle: 'Aktifkan otomatis halaman Tasks saat ada tugas latar baru',
settingsJobsDesc: 'Aktifkan halaman Tasks setiap kali tugas latar baru muncul untuk obrolan saat ini (setiap tugas baru memicu aktivasi); pada layar lebar, kartu samping juga dibentangkan; pada layar sempit, laci layar penuh tidak dipaksa terbuka; matikan untuk membukanya secara manual',
settingsToolsTitle: 'Suntik alat terminal untuk model',
settingsToolsDesc: 'Saat diaktifkan, model dapat membuat dan mengendalikan terminal sidebar melalui 8 alat terminal_* (nonaktif secara default)',
settingsFenceTitle: 'Pemeriksaan path workspace',
@@ -326,6 +348,25 @@ export const id: Record<string, string> = {
sideChatClose: 'Tutup thread',
sideChatCloseTitle: 'Lepaskan agen thread (riwayat dipertahankan)',
sideChatError: 'Kesalahan Side Chat: {message}',
sideChatTurnUsage: 'Input {input} tok · Output {output} tok',
sideChatBlockCollapse: 'Lipat',
sideChatBlockCollapseAria: 'Lipat',
sideChatBlockExpand: 'Perlihatkan {hidden} baris',
sideChatBlockExpandAria: 'Perlihatkan {hidden} baris lagi',
sideChatBlockSignal: 'Diakhiri sinyal: {signal}',
sideChatBlockExitCode: 'Kode keluar {code}',
sideChatBlockRunning: 'Berjalan',
sideChatBlockFailed: 'Gagal',
sideChatBlockDone: 'Selesai',
sideChatBlockNoOutput: '(tanpa output)',
sideChatBlockFiles: '{count} file',
sideChatBlockWindow: '{shown} dari {total} baris',
sideChatConnDisconnected: 'Koneksi terputus',
sideChatConnReconnect: 'Sambungkan ulang',
sideChatConnConnecting: 'Menyambung ulang…',
sideChatConnRecovered: 'Koneksi dipulihkan',
sideChatConnReconnectAction: 'Sambungkan ulang sekarang',
sideChatConnRestartAction: 'Mulai ulang koneksi',
jobs: 'Tugas latar',
jobsCount: '{count} tugas latar',
jobsCountRunning: '{count} tugas latar · {running} berjalan',
@@ -352,7 +393,7 @@ export const id: Record<string, string> = {
addPluginsViewerCardDesc: 'Daftarkan pratinjau tipe berkas',
addPluginsTabDesc: 'Halaman sidebar (tab) dapat diperluas oleh plugin. Plugin mendaftar melalui layanan ctx.betterSidebar; klik Install menyalin perintah instalasi — tempel ke terminal tempat profil DSH Anda berada dan jalankan.',
addPluginsViewerDesc: 'Pratinjau berkas dapat diperluas oleh plugin. Plugin mendaftar melalui layanan ctx.betterSidebar; klik Install menyalin perintah instalasi — tempel ke terminal tempat profil DSH Anda berada dan jalankan.',
addPluginsBrowseMore: 'Jelajahi lebih banyak plugin di GitHub (topic: dsh-better-sidebar)',
addPluginsBrowseMore: 'Jelajahi lebih banyak plugin di GitHub (topic: dsh-apes-sidebar)',
addPluginsSearch: 'Cari berdasarkan nama / deskripsi plugin…',
addPluginsNoMatch: 'Tidak ada plugin yang cocok',
addPluginsRecommended: 'Plugin yang direkomendasikan',
@@ -365,7 +406,7 @@ export const id: Record<string, string> = {
pluginGithubWorkbenchDesc: 'Tab GitHub Workbench: pohon repo remote + Issues / Pull requests / Actions dengan dukungan tulis penuh — buat Issue/PR, komentar, edit, tutup/buka ulang, merge squash·merge·rebase (konfirmasi kuat), jalankan ulang/batalkan CI; pengalih repo otomatis mencantumkan repo yang dapat diakses dan mencari repo publik; tanpa better-sidebar otomatis beralih ke panel kanan mandiri',
pluginMdExportDesc: 'Menambahkan tombol "Ekspor" ke bilah alat Markdown di better-sidebar: merender .md saat ini menjadi HTML mandiri dalam satu klik (tabel/blok kode/diagram Mermaid inline, tata letak mengikuti tema pratinjau) dan menyimpannya di folder yang sama, atau mengekspor ke PDF lewat dialog cetak',
pluginCodeNavDesc: 'Navigasi pratinjau kode: mengenali bahasa dari jenis berkas dan menyorot sintaks, kerangka simbol (filter kelas/metode/variabel + lompat sekali klik), serta pencarian dalam berkas (sorot semua kecocokan, sebelum/berikutnya, peka huruf besar-kecil) — mengambil alih pratinjau berkas kode di editor better-sidebar',
pluginSuhuangScrollDesc: 'Menghubungkan Suhuang Scroll Runtime lokal ke pengaturan DSH dan better-sidebar untuk konfigurasi model, uji koneksi, dan kontrol penilaian berkelanjutan; memerlukan Suhuang Scroll Runtime dan dsh-better-sidebar',
pluginSuhuangScrollDesc: 'Menghubungkan Suhuang Scroll Runtime lokal ke pengaturan DSH dan better-sidebar untuk konfigurasi model, uji koneksi, dan kontrol penilaian berkelanjutan; memerlukan Suhuang Scroll Runtime dan dsh-apes-sidebar',
pluginBetterOverleafDesc: 'Tab Overleaf untuk better-sidebar: login langsung lewat peramban CDP (mendukung Chromium pihak ketiga), daftar/pergantian proyek, mirror git lokal di <workspace>/overleaf/, sinkronisasi git dua arah dengan fallback API baca-saja, dan pratinjau berkas melalui workbench sidebar',
pluginGitRemotesDesc: 'Tab Git Remotes: branch/upstream/ahead-behind, fetch (prune opsional), ff-only pull, dan push hanya setelah konfirmasi dalam tab. Tidak menggantikan tab stage/commit Git bawaan, dan tidak menawarkan force-push atau alat auto-push model',
pluginSentinelDesc: 'Sistem bangun agen berbasis kondisi: sensor berkas/proses/port/http/perintah/webhook membangunkan sesi yang tidur saat kondisi terpicu; mendaftarkan tab "Sentinel" dengan tabel pengawasan server-wide',
+49 -8
View File
@@ -3,13 +3,21 @@
* consumed by the better-locale override store (see `locales.ts` for the
* zh/en/ja lookup chain and the `attachBetterLocale` contract).
*
* @module dsh-better-sidebar/client/locales-it
* @module dsh-apes-sidebar/client/locales-it
*/
export const it: Record<string, string> = {
files: 'File',
changesSessionEmpty: 'Ancora nessuna operazione sui file in questa sessione',
changesRead: 'Lettura',
changesWrite: 'Scrittura',
changesEdit: 'Modifica',
changesRunning: 'in esecuzione',
changesError: 'errore',
changesFold: '{count} righe… clicca per espandere',
changesContext: 'contesto',
changesPriorUnknown: 'Il contenuto precedente è fuori dalla finestra; mostrato come interamente aggiunto',
explorer: 'Esplora risorse',
git: 'Controllo del codice sorgente',
terminal: 'Terminale',
editor: 'Editor',
editorExplorer: 'Modalità di apertura dei file',
@@ -120,6 +128,20 @@ export const it: Record<string, string> = {
checkoutError: 'Cambio di ramo non riuscito',
history: 'Cronologia',
changes: 'Modifiche',
changesGitLens: 'Git',
changesSessionLens: 'Sessione',
changesFilterAll: 'Tutti',
changesFilterEmpty: 'Nessuna operazione di questo tipo',
changesOpenDiffTab: 'Apri in una scheda diff',
changesClosePreview: 'Ridimensiona l\'anteprima',
changesResizePreview: 'Ridimensiona anteprima',
changesDiffOpenTitle: 'Il diff si apre come',
changesDiffOpenDesc: 'Dove l\'azione "apri in una scheda diff" colloca il diff',
changesDiffOpenFloat: 'Finestra libera',
changesDiffOpenFloatDesc: 'Una finestra flottante centrata sul viewport — trascina, ridimensiona, sempre in primo piano',
changesDiffOpenPane: 'Pannello',
changesDiffOpenPaneDesc: 'Ancorato sotto il pannello sorgente (divisione diff in stile VSCode)',
changesLoadError: 'I record dei file della sessione non sono disponibili',
staged: 'In stage',
unstaged: 'Non in stage',
cancel: 'Annulla',
@@ -212,10 +234,10 @@ export const it: Record<string, string> = {
settingsConflict: 'L’impostazione è stata modificata in un’altra finestra — riprovi',
binaryNoPreview: 'Questo tipo di file non può essere visualizzato in anteprima',
downloadToView: 'Scarica per visualizzare',
settingsSubagentTitle: 'Apri automaticamente la pagina Attività quando compare un sottoagente',
settingsSubagentDesc: 'Espande la barra laterale e apre la pagina Attività quando la conversazione attuale genera un nuovo sottoagente; disattivato va aperta manualmente',
settingsJobsTitle: 'Apri automaticamente la pagina Attività in background in caso di nuova attività',
settingsJobsDesc: 'Espande la barra laterale e apre la pagina Attività in background ogni volta che compare una nuova attività in background per la conversazione attuale (ogni nuova attività la attiva); disattivato va aperta manualmente',
settingsSubagentTitle: 'Attiva automaticamente la pagina Attività quando compare un sottoagente',
settingsSubagentDesc: 'Quando nella conversazione attuale compare un nuovo sottoagente, attiva automaticamente la pagina Attività; sugli schermi ampi espande anche la barra laterale, mentre sugli schermi stretti non forza l’apertura del pannello a schermo intero; disattiva per selezionare la pagina manualmente',
settingsJobsTitle: 'Attiva automaticamente la pagina Attività quando compare una nuova attività in background',
settingsJobsDesc: 'Quando nella conversazione attuale compare una nuova attività in background, attiva automaticamente la pagina Attività (l’attivazione avviene per ogni nuova attività); sugli schermi ampi espande anche la barra laterale, mentre sugli schermi stretti non forza l’apertura del pannello a schermo intero; disattiva per selezionare la pagina manualmente',
settingsToolsTitle: 'Inietta strumenti del terminale per il modello',
settingsToolsDesc: 'Se attivato, il modello può creare e gestire terminali della barra laterale attraverso gli 8 strumenti terminal_* (disattivato per impostazione predefinita)',
settingsFenceTitle: 'Controllo dei percorsi del workspace',
@@ -319,6 +341,25 @@ export const it: Record<string, string> = {
sideChatClose: 'Chiudi conversazione',
sideChatCloseTitle: 'Rilascia l’agente della conversazione (la cronologia è conservata)',
sideChatError: 'Errore della chat laterale: {message}',
sideChatTurnUsage: 'Input {input} tok · Output {output} tok',
sideChatBlockCollapse: 'Comprimi',
sideChatBlockCollapseAria: 'Comprimi',
sideChatBlockExpand: 'Espandi {hidden} righe',
sideChatBlockExpandAria: 'Espandi altre {hidden} righe',
sideChatBlockSignal: 'Terminato dal segnale: {signal}',
sideChatBlockExitCode: 'Codice di uscita {code}',
sideChatBlockRunning: 'In esecuzione',
sideChatBlockFailed: 'Non riuscito',
sideChatBlockDone: 'Completato',
sideChatBlockNoOutput: '(nessun output)',
sideChatBlockFiles: '{count} file',
sideChatBlockWindow: '{shown} di {total} righe',
sideChatConnDisconnected: 'Connessione persa',
sideChatConnReconnect: 'Riconnetti',
sideChatConnConnecting: 'Riconnessione…',
sideChatConnRecovered: 'Connessione ripristinata',
sideChatConnReconnectAction: 'Riconnetti ora',
sideChatConnRestartAction: 'Riavvia la connessione',
jobs: 'Attività in background',
jobsCount: '{count} attività in background',
jobsCountRunning: '{count} attività in background · {running} in esecuzione',
@@ -345,7 +386,7 @@ export const it: Record<string, string> = {
addPluginsViewerCardDesc: 'Registri un’anteprima di tipo di file',
addPluginsTabDesc: 'Le pagine della barra laterale (schede) possono essere estese dai plugin. I plugin si registrano tramite il servizio ctx.betterSidebar; cliccando Installa viene copiato il comando di installazione — lo incolli in un terminale dove risiede il profilo DSH e lo esegua.',
addPluginsViewerDesc: 'I visualizzatori di file possono essere estesi dai plugin. I plugin si registrano tramite il servizio ctx.betterSidebar; cliccando Installa viene copiato il comando di installazione — lo incolli in un terminale dove risiede il profilo DSH e lo esegua.',
addPluginsBrowseMore: 'Sfogli altri plugin su GitHub (topic: dsh-better-sidebar)',
addPluginsBrowseMore: 'Sfogli altri plugin su GitHub (topic: dsh-apes-sidebar)',
addPluginsSearch: 'Cerca per nome / descrizione del plugin…',
addPluginsNoMatch: 'Nessun plugin corrispondente',
addPluginsRecommended: 'Plugin consigliati',
@@ -358,7 +399,7 @@ export const it: Record<string, string> = {
pluginGithubWorkbenchDesc: 'Scheda GitHub Workbench: albero del repository remoto + Issues / Pull requests / Actions con pieno supporto in scrittura — creazione di issue/PR, commenti, modifica, chiusura/riapertura, merge squash·merge·rebase (conferma rafforzata), riavvio/annullamento della CI; il selettore dei repository elenca automaticamente quelli accessibili e cerca tra quelli pubblici; senza better-sidebar esegue il fallback a un pannello laterale destro indipendente',
pluginMdExportDesc: 'Aggiunge un pulsante «Esporta» alla barra degli strumenti Markdown di better-sidebar: con un clic esegue il rendering del .md corrente in HTML autonomo (tabelle/blocchi di codice/diagrammi Mermaid in linea, layout fedele al tema di anteprima) e lo salva accanto al .md, oppure esporta in PDF tramite la finestra di stampa',
pluginCodeNavDesc: 'Navigazione dell’anteprima del codice: rileva il linguaggio dal tipo di file ed evidenzia la sintassi, struttura dei simboli (filtri classi/metodi/variabili + salto con un clic) e ricerca nel file (tutte le corrispondenze evidenziate, precedente/successiva, maiuscole/minuscole) — gestisce l’anteprima dei file di codice nell’editor better-sidebar',
pluginSuhuangScrollDesc: 'Collega il Runtime Suhuang Scroll locale alle impostazioni DSH e a better-sidebar per configurare il modello, testare la connessione e controllare la correzione continua; richiede Suhuang Scroll Runtime e dsh-better-sidebar',
pluginSuhuangScrollDesc: 'Collega il Runtime Suhuang Scroll locale alle impostazioni DSH e a better-sidebar per configurare il modello, testare la connessione e controllare la correzione continua; richiede Suhuang Scroll Runtime e dsh-apes-sidebar',
pluginBetterOverleafDesc: 'Tab Overleaf per better-sidebar: accesso diretto tramite browser CDP (supporta Chromium di terze parti), elenco/cambio progetti, mirror git locali in <workspace>/overleaf/, sincronizzazione git bidirezionale con fallback API di sola lettura e anteprima dei file tramite la workbench della barra laterale',
pluginGitRemotesDesc: 'Scheda Git Remotes: rami/upstream/ahead-behind, fetch (prune opzionale), pull ff-only e push solo dopo una conferma nella scheda. Non sostituisce la scheda stage/commit integrata di Git, e non offre force-push o uno strumento di push automatico del modello',
pluginSentinelDesc: 'Sistema di risveglio dell’agente guidato da condizioni: sensori file/processo/porta/HTTP/comando/webhook risvegliano le sessioni dormienti quando le condizioni si verificano; registra una scheda «Sentinella» con la tabella di monitoraggio globale del server',
+49 -8
View File
@@ -17,8 +17,16 @@
/** The ja dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const ja: Record<string, string> = {
files: 'ファイル',
changesSessionEmpty: 'このセッションにはファイル操作がまだありません',
changesRead: '読み取り',
changesWrite: '書き込み',
changesEdit: '編集',
changesRunning: '実行中',
changesError: 'エラー',
changesFold: '{count} 行…クリックで展開',
changesContext: 'コンテキスト',
changesPriorUnknown: '変更前の内容がウィンドウ内になく、すべて新規追加として表示されます',
explorer: 'エクスプローラー',
git: 'ソース管理',
terminal: 'ターミナル',
editor: 'エディター',
editorExplorer: 'ファイルの開き方',
@@ -128,7 +136,21 @@ export const ja: Record<string, string> = {
worktree: 'ワークツリー',
checkoutError: 'ブランチ切り替えに失敗',
history: '履歴',
changes: '変更',
changes: 'ファイル変更',
changesGitLens: 'Git',
changesSessionLens: 'セッション',
changesFilterAll: 'すべて',
changesFilterEmpty: 'この種類の操作はありません',
changesOpenDiffTab: '独立タブで開く',
changesClosePreview: 'プレビューを閉じる',
changesResizePreview: 'プレビューの高さを変更',
changesDiffOpenTitle: 'diff の展開先',
changesDiffOpenDesc: '「独立タブで開く」操作が diff をどこに配置するか',
changesDiffOpenFloat: 'フリーウィンドウ',
changesDiffOpenFloatDesc: 'ビューポート中央に浮かぶウィンドウ — ドラッグ・リサイズ・最前面固定',
changesDiffOpenPane: 'ペイン',
changesDiffOpenPaneDesc: 'ソースペインの下にドッキング(VSCode 式 diff 分割)',
changesLoadError: 'セッションのファイル記録は現在利用できません',
staged: 'ステージ済み',
unstaged: '未ステージ',
cancel: 'キャンセル',
@@ -221,10 +243,10 @@ export const ja: Record<string, string> = {
settingsConflict: '設定が別のウィンドウで変更されました、再試行してください',
binaryNoPreview: 'このファイル形式はプレビューできません',
downloadToView: 'ダウンロードして表示',
settingsSubagentTitle: 'サブエージェント検出時に自動展開',
settingsSubagentDesc: '現在の会話で新しいサブエージェントが発生した際、サイドバーを自動展開してタスク管理ページを開く。オフ時は手動で開く必要あり',
settingsJobsTitle: '新規バックグラウンドタスク時に自動展開',
settingsJobsDesc: '現在の会話で新しいバックグラウンドタスクが発生するたびにサイドバーを展開してタスクページを開く(新規タスクごとに発火)。オフ時は手動で開く必要あり',
settingsSubagentTitle: 'サブエージェント検出時にタスク管理ページを自動的にアクティブ化',
settingsSubagentDesc: '現在の会話で新しいサブエージェントが発生した際にタスク管理ページをアクティブ化する。広い画面ではサイドカードも展開するが、狭い画面では全画面ドロワーを強制的に開かない。オフにすると手動で開く必要がある',
settingsJobsTitle: '新規バックグラウンドタスク時にタスク管理ページを自動的にアクティブ化',
settingsJobsDesc: '現在の会話で新しいバックグラウンドタスクが発生するたびにタスク管理ページをアクティブ化する(新規タスクごとに発火)。広い画面ではサイドカードも展開するが、狭い画面では全画面ドロワーを強制的に開かない。オフにすると手動で開く必要がある',
settingsToolsTitle: 'モデルにターミナルツールを注入',
settingsToolsDesc: 'オンにすると、モデルが terminal_create 等 8 個のツールでサイドバーターミナルを作成・操作可能(デフォルトオフ)',
settingsFenceTitle: 'ワークスペースパスガード',
@@ -328,6 +350,25 @@ export const ja: Record<string, string> = {
sideChatClose: 'スレッドを閉じる',
sideChatCloseTitle: 'スレッドの agent を解放(履歴は保持)',
sideChatError: 'サイド会話エラー:{message}',
sideChatTurnUsage: '入力 {input} tok · 出力 {output} tok',
sideChatBlockCollapse: '折りたたむ',
sideChatBlockCollapseAria: '折りたたむ',
sideChatBlockExpand: '{hidden} 行を展開',
sideChatBlockExpandAria: '残り {hidden} 行を展開',
sideChatBlockSignal: 'シグナルで終了:{signal}',
sideChatBlockExitCode: '終了コード {code}',
sideChatBlockRunning: '実行中',
sideChatBlockFailed: '失敗',
sideChatBlockDone: '完了',
sideChatBlockNoOutput: '(出力なし)',
sideChatBlockFiles: '{count} 個のファイル',
sideChatBlockWindow: '全 {total} 行中 {shown} 行を表示',
sideChatConnDisconnected: '接続が切断されました',
sideChatConnReconnect: '再接続',
sideChatConnConnecting: '再接続中…',
sideChatConnRecovered: '接続が復旧しました',
sideChatConnReconnectAction: '今すぐ再接続',
sideChatConnRestartAction: '接続をやり直す',
jobs: 'バックグラウンドタスク',
jobsCount: '{count} 個のバックグラウンドタスク',
jobsCountRunning: '{count} 個のバックグラウンドタスク ・ {running} 個実行中',
@@ -354,7 +395,7 @@ export const ja: Record<string, string> = {
addPluginsViewerCardDesc: '新しいファイル種別プレビューを登録',
addPluginsTabDesc: 'サイドバーページ(Tab)はプラグインで拡張可能。プラグインは ctx.betterSidebar サービス経由で登録。「インストール」をクリックしてインストールコマンドをコピー、DSH 環境のターミナルに貼り付けて実行。',
addPluginsViewerDesc: 'ファイルプレビューアーはプラグインで拡張可能。プラグインは ctx.betterSidebar サービス経由で登録。「インストール」をクリックしてインストールコマンドをコピー、DSH 環境のターミナルに貼り付けて実行。',
addPluginsBrowseMore: 'GitHub でプラグинをさらに閲覧(topic: dsh-better-sidebar)',
addPluginsBrowseMore: 'GitHub でプラグинをさらに閲覧(topic: dsh-apes-sidebar)',
addPluginsSearch: 'プラグイン名 / 説明で検索…',
addPluginsNoMatch: '一致するプラグインなし',
addPluginsRecommended: '推奨プラグイン',
@@ -366,7 +407,7 @@ export const ja: Record<string, string> = {
pluginFlowglassDesc: 'ライブセッションフローグラフ:ユーザー、アシスタント、ツール呼び出しの 3 レーン泳道、並列グループ、サブエージェント支線、ドリルダウン、ライブ状態をサポート。better-sidebar インストール時にネイティブ「Flowglass」Tab を登録、未インストール時は独立ドロワーを保持',
pluginGitForgeDesc: 'better-sidebar「Git Forge」Tab:GitHub/Gitea 等 Forge アカウントライブラリ + プロジェクト単位の認可 + push ポリシー硬核拒絶。token はローカル secrets のみ、モデルコンテキストに入らない。読み取り専用 GitForge ツールと agent HTTPS credential helper を提供',
pluginGithubWorkbenchDesc: 'better-sidebar「GitHub ワークベンチ」タブ:リモートリポジトリのツリー + Issues / Pull requests / Actions タブ。読み取りに加え、Issue・PR の作成、コメント、編集、クローズ/再オープン、squash・merge・rebase マージ(強確認)、CI の再実行/キャンセルに対応。リポジトリ切替ポップアップはアクセス可能なリポジトリを自動取得し、公開リポジトリの検索にも対応。better-sidebar 未導入時は独立右パネルへ自動フォールバック',
pluginSuhuangScrollDesc: 'ローカルの Suhuang Scroll Runtime を DSH 設定と better-sidebar に接続し、モデル設定、接続テスト、連続採点の操作を提供。Suhuang Scroll Runtime と dsh-better-sidebar が必要',
pluginSuhuangScrollDesc: 'ローカルの Suhuang Scroll Runtime を DSH 設定と better-sidebar に接続し、モデル設定、接続テスト、連続採点の操作を提供。Suhuang Scroll Runtime と dsh-apes-sidebar が必要',
pluginBetterOverleafDesc: 'better-sidebar の Overleaf タブ:CDP ブラウザーに直接接続してログイン(サードパーティー Chromium 対応)、プロジェクト一覧/切替、<workspace>/overleaf/ のローカル git ミラー、双方向 git 同期(API 読み取り専用フォールバック)、ファイルプレビューはサイドバーのワークベンチを利用',
pluginGitRemotesDesc: 'better-sidebar Git リモート Tab:ブランチ/アップストリーム/ahead-behind 表示、fetch(prune 可)、ff-only pull、確認後のみ push。内蔵 Git のステージ/コミットは置き換えず、force-push やモデル自動 push ツールは提供しない',
pluginSentinelDesc: '条件駆動の agent 起動システム:ファイル/プロセス/ポート/HTTP/コマンド/webhook センサーが条件達成時に休眠会話を自動起動。「Sentinel」Tab を登録しサーバー全体の監視テーブルを表示',
+49 -8
View File
@@ -9,8 +9,16 @@
export const ko: Record<string, string> = {
files: '파일',
changesSessionEmpty: '이 세션에 아직 파일 작업이 없습니다',
changesRead: '읽기',
changesWrite: '쓰기',
changesEdit: '편집',
changesRunning: '실행 중',
changesError: '오류',
changesFold: '{count}줄… 클릭하여 펼치기',
changesContext: '컨텍스트',
changesPriorUnknown: '변경 전 내용이 창에 없어 모두 새로 추가된 것으로 표시됩니다',
explorer: '탐색기',
git: '소스 제어',
terminal: '터미널',
editor: '편집기',
editorExplorer: '파일 열기 방식',
@@ -120,7 +128,21 @@ export const ko: Record<string, string> = {
worktree: '워크트리',
checkoutError: '브랜치 전환 실패',
history: '기록',
changes: '변경 사항',
changes: '파일 변경',
changesGitLens: 'Git',
changesSessionLens: '세션',
changesFilterAll: '모두',
changesFilterEmpty: '이 종류의 작업이 없습니다',
changesOpenDiffTab: '별도 탭에서 열기',
changesClosePreview: '미리보기 닫기',
changesResizePreview: '미리보기 높이 조정',
changesDiffOpenTitle: 'diff 열기 위치',
changesDiffOpenDesc: '「별도 탭에서 열기」 작업이 diff를 배치하는 곳',
changesDiffOpenFloat: '자유 창',
changesDiffOpenFloatDesc: '뷰포트 중앙의 떠 있는 창 — 드래그, 크기 조절, 항상 위',
changesDiffOpenPane: '패널',
changesDiffOpenPaneDesc: '소스 패널 아래에 도킹(VSCode 스타일 diff 분할)',
changesLoadError: '세션 파일 기록을 지금 사용할 수 없습니다',
staged: '스테이징됨',
unstaged: '스테이징 안 됨',
cancel: '취소',
@@ -213,10 +235,10 @@ export const ko: Record<string, string> = {
settingsConflict: '설정이 다른 창에서 수정되었습니다. 다시 시도하세요',
binaryNoPreview: '이 파일 형식은 미리보기를 지원하지 않습니다',
downloadToView: '다운로드하여 보기',
settingsSubagentTitle: '서브 에이전트를 감지하면 작업 관리 페이지를 자동으로 펼치기',
settingsSubagentDesc: '현재 대화에 새 서브 에이전트가 생기면 사이드바를 자동으로 펼치고 작업 관리 페이지를 엽니다. 끄면 수동으로 열어야 합니다',
settingsJobsTitle: '새 백그라운드 작업이 있으면 백그라운드 작업 페이지를 자동으로 펼치기',
settingsJobsDesc: '현재 대화에 새 백그라운드 작업이 생기면 사이드바를 자동으로 펼치고 백그라운드 작업 페이지를 엽니다(새 작업마다 트리거됨). 끄면 수동으로 열어야 합니다',
settingsSubagentTitle: '서브 에이전트를 감지하면 작업 관리 페이지 자동 활성화',
settingsSubagentDesc: '현재 대화에 새 서브 에이전트가 생기면 작업 관리 페이지를 활성화합니다. 넓은 화면에서는 사이드 카드도 펼치지만, 좁은 화면에서는 전체 화면 드로어를 강제로 열지 않습니다. 끄면 수동으로 열어야 합니다',
settingsJobsTitle: '새 백그라운드 작업이 생기면 작업 관리 페이지 자동 활성화',
settingsJobsDesc: '현재 대화에 새 백그라운드 작업이 생길 때마다 작업 관리 페이지를 활성화합니다(새 작업마다 트리거됨). 넓은 화면에서는 사이드 카드도 펼치지만, 좁은 화면에서는 전체 화면 드로어를 강제로 열지 않습니다. 끄면 수동으로 열어야 합니다',
settingsToolsTitle: '모델에 터미널 도구 주입',
settingsToolsDesc: '켜면 모델이 terminal_create 등 8개 도구로 사이드바 터미널을 만들고 조작할 수 있습니다(기본 꺼짐)',
settingsFenceTitle: '워크스페이스 경로 검사',
@@ -320,6 +342,25 @@ export const ko: Record<string, string> = {
sideChatClose: '스레드 닫기',
sideChatCloseTitle: '스레드의 agent를 해제합니다(기록은 유지)',
sideChatError: '사이드 대화 오류: {message}',
sideChatTurnUsage: '입력 {input} tok · 출력 {output} tok',
sideChatBlockCollapse: '접기',
sideChatBlockCollapseAria: '접기',
sideChatBlockExpand: '{hidden}줄 펼치기',
sideChatBlockExpandAria: '나머지 {hidden}줄 펼치기',
sideChatBlockSignal: '시그널로 종료됨: {signal}',
sideChatBlockExitCode: '종료 코드 {code}',
sideChatBlockRunning: '실행 중',
sideChatBlockFailed: '실패',
sideChatBlockDone: '완료',
sideChatBlockNoOutput: '(출력 없음)',
sideChatBlockFiles: '파일 {count}개',
sideChatBlockWindow: '전체 {total}줄 중 {shown}줄 표시',
sideChatConnDisconnected: '연결이 끊겼습니다',
sideChatConnReconnect: '다시 연결',
sideChatConnConnecting: '다시 연결 중…',
sideChatConnRecovered: '연결이 복원되었습니다',
sideChatConnReconnectAction: '지금 다시 연결',
sideChatConnRestartAction: '연결 다시 시작',
jobs: '백그라운드 작업',
jobsCount: '백그라운드 작업 {count}개',
jobsCountRunning: '백그라운드 작업 {count}개 · {running} 실행 중',
@@ -346,7 +387,7 @@ export const ko: Record<string, string> = {
addPluginsViewerCardDesc: '새 파일 형식 미리보기 등록',
addPluginsTabDesc: '사이드바 페이지(Tab)는 플러그인으로 확장할 수 있습니다. 플러그인은 ctx.betterSidebar 서비스를 통해 등록합니다. "설치"를 클릭하면 설치 명령이 복사되며, DSH가 설치된 환경의 터미널에 붙여넣어 실행하세요.',
addPluginsViewerDesc: '파일 미리보기는 플러그인으로 확장할 수 있습니다. 플러그인은 ctx.betterSidebar 서비스를 통해 등록합니다. "설치"를 클릭하면 설치 명령이 복사되며, DSH가 설치된 환경의 터미널에 붙여넣어 실행하세요.',
addPluginsBrowseMore: 'GitHub에서 더 많은 플러그인 보기(topic: dsh-better-sidebar)',
addPluginsBrowseMore: 'GitHub에서 더 많은 플러그인 보기(topic: dsh-apes-sidebar)',
addPluginsSearch: '플러그인 이름 / 설명 검색…',
addPluginsNoMatch: '일치하는 플러그인 없음',
addPluginsRecommended: '추천 플러그인',
@@ -359,7 +400,7 @@ export const ko: Record<string, string> = {
pluginGithubWorkbenchDesc: 'better-sidebar "GitHub Workbench" Tab: 원격 저장소 트리 + Issues / Pull requests / Actions 탭. 읽기 외에 Issue·PR 생성, 댓글, 편집, 닫기/다시 열기, squash·merge·rebase 병합(강한 확인), CI 재실행/취소를 지원합니다. 저장소 전환 팝업은 접근 가능한 저장소를 자동으로 가져오고 공개 저장소 검색도 지원합니다. better-sidebar 미설치 시 독립 우측 패널로 자동 폴백합니다',
pluginMdExportDesc: 'better-sidebar의 Markdown 툴바에 "내보내기" 버튼을 추가: 클릭 한 번으로 현재 .md를 독립 실행형 HTML(표/코드 블록/Mermaid 다이어그램 인라인, 레이아웃은 미리보기 테마 따름)로 렌더링해 같은 폴더에 저장하거나, 인쇄 대화상자로 PDF를 내보냅니다',
pluginCodeNavDesc: '코드 미리보기 내비게이션: 파일 형식으로 언어를 자동 인식해 구문 강조, 심볼 아웃라인(클래스/메서드/변수 필터 + 원클릭 이동), 파일 내 검색(모든 일치 강조, 이전/다음, 대소문자 구분) — better-sidebar 편집기의 코드 파일 미리보기를 대신 맡습니다',
pluginSuhuangScrollDesc: '로컬 Suhuang Scroll Runtime을 DSH 설정과 better-sidebar에 연결하여 모델 구성, 연결 테스트, 연속 채점 제어를 제공합니다. Suhuang Scroll Runtime과 dsh-better-sidebar가 필요합니다',
pluginSuhuangScrollDesc: '로컬 Suhuang Scroll Runtime을 DSH 설정과 better-sidebar에 연결하여 모델 구성, 연결 테스트, 연속 채점 제어를 제공합니다. Suhuang Scroll Runtime과 dsh-apes-sidebar가 필요합니다',
pluginBetterOverleafDesc: 'better-sidebar의 Overleaf 탭: CDP 브라우저 직접 로그인(서드파티 Chromium 지원), 프로젝트 목록/전환, <workspace>/overleaf/ 로컬 git 미러, 읽기 전용 API 폴백이 있는 양방향 git 동기화, 파일 미리보기는 사이드바 워크벤치 사용',
pluginGitRemotesDesc: 'better-sidebar Git 원격 Tab: 브랜치/업스트림/ahead-behind 확인, fetch(prune 가능), ff-only pull, 확인 후에만 push. 내장 Git의 스테이징/커밋을 대체하지 않으며, force-push나 모델 자동 push도 제공하지 않습니다',
pluginSentinelDesc: '조건 기반 agent 웨이크업 시스템: 파일/프로세스/포트/HTTP/명령/webhook 센서가 조건을 충족하면 휴면 세션을 자동으로 깨웁니다. "센티널(哨兵)" Tab을 등록하여 서버 전역 모니터링 표를 보여줍니다',
+49 -8
View File
@@ -15,8 +15,16 @@
*/
export const nl: Record<string, string> = {
files: 'Bestanden',
changesSessionEmpty: 'Nog geen bestandsbewerkingen in deze sessie',
changesRead: 'Lezen',
changesWrite: 'Schrijven',
changesEdit: 'Bewerken',
changesRunning: 'actief',
changesError: 'fout',
changesFold: '{count} regels… klik om uit te klappen',
changesContext: 'context',
changesPriorUnknown: 'Eerdere inhoud valt buiten het venster; getoond als volledig nieuw',
explorer: 'Verkenner',
git: 'Bronbeheer',
terminal: 'Terminal',
editor: 'Editor',
editorExplorer: 'Bestand openingsgedrag',
@@ -126,7 +134,21 @@ export const nl: Record<string, string> = {
worktree: 'Worktree',
checkoutError: 'Wisselen van branch mislukt',
history: 'Geschiedenis',
changes: 'Wijzigingen',
changes: 'Bestandswijzigingen',
changesGitLens: 'Git',
changesSessionLens: 'Sessie',
changesFilterAll: 'Alles',
changesFilterEmpty: 'Geen bewerkingen van dit soort',
changesOpenDiffTab: 'In eigen diff-tabblad openen',
changesClosePreview: 'Voorbeeld sluiten',
changesResizePreview: 'Voorbeeld vergroten/verkleinen',
changesDiffOpenTitle: 'Diff opent als',
changesDiffOpenDesc: 'Waar de actie „in eigen diff-tabblad openen” de diff plaatst',
changesDiffOpenFloat: 'Vrij venster',
changesDiffOpenFloatDesc: 'Een zwevend venster in het midden — slepen, schalen, bovenop houden',
changesDiffOpenPane: 'Paneel',
changesDiffOpenPaneDesc: 'Vastgemaakt onder het bronpaneel (VSCode-achtige diff-split)',
changesLoadError: 'Sessiebestandsregistraties zijn nu niet beschikbaar',
staged: 'Gestaged',
unstaged: 'Niet gestaged',
cancel: 'Annuleren',
@@ -219,10 +241,10 @@ export const nl: Record<string, string> = {
settingsConflict: 'De instelling is in een ander venster gewijzigd — probeer het opnieuw',
binaryNoPreview: 'Dit bestandstype kan niet worden gepreviewed',
downloadToView: 'Downloaden om te bekijken',
settingsSubagentTitle: 'Taken-pagina automatisch openen wanneer een subagent verschijnt',
settingsSubagentDesc: 'De zijkaart uitklappen en de Taken-pagina openen wanneer de huidige conversatie een nieuwe subagent voortbrengt; schakel uit om het handmatig te openen',
settingsJobsTitle: 'Jobs-pagina automatisch openen bij een nieuwe achtergrondtaak',
settingsJobsDesc: 'De zijkaart uitklappen en de Jobs-pagina openen telkens een nieuwe achtergrondtaak verschijnt voor de huidige conversatie (elke nieuwe taak triggert); schakel uit om het handmatig te openen',
settingsSubagentTitle: 'Activeer de Taken-pagina automatisch wanneer een subagent verschijnt',
settingsSubagentDesc: 'Wanneer de huidige conversatie een nieuwe subagent start, wordt de Taken-pagina automatisch geactiveerd; op brede schermen wordt ook de zijkaart uitgeklapt, terwijl op smalle schermen het schuifpaneel op volledig scherm niet geforceerd wordt geopend; schakel uit om de pagina handmatig te selecteren',
settingsJobsTitle: 'Activeer de Taken-pagina automatisch bij een nieuwe achtergrondtaak',
settingsJobsDesc: 'Wanneer er voor de huidige conversatie een nieuwe achtergrondtaak verschijnt, wordt de Taken-pagina automatisch geactiveerd (dit gebeurt bij elke nieuwe taak); op brede schermen wordt ook de zijkaart uitgeklapt, terwijl op smalle schermen het schuifpaneel op volledig scherm niet geforceerd wordt geopend; schakel uit om de pagina handmatig te selecteren',
settingsToolsTitle: 'Terminaltools voor het model injecteren',
settingsToolsDesc: 'Indien ingeschakeld kan het model zijbalk-terminals aanmaken en besturen via de 8 terminal_*-tools (standaard uit)',
settingsFenceTitle: 'Workspace-padcontrole',
@@ -326,6 +348,25 @@ export const nl: Record<string, string> = {
sideChatClose: 'Thread sluiten',
sideChatCloseTitle: 'De agent van de thread vrijgeven (geschiedenis blijft behouden)',
sideChatError: 'Side Chat-fout: {message}',
sideChatTurnUsage: 'Invoer {input} tok · Uitvoer {output} tok',
sideChatBlockCollapse: 'Invouwen',
sideChatBlockCollapseAria: 'Invouwen',
sideChatBlockExpand: '{hidden} regels uitvouwen',
sideChatBlockExpandAria: '{hidden} regels meer uitvouwen',
sideChatBlockSignal: 'Beëindigd door signaal: {signal}',
sideChatBlockExitCode: 'Exitcode {code}',
sideChatBlockRunning: 'Wordt uitgevoerd',
sideChatBlockFailed: 'Mislukt',
sideChatBlockDone: 'Voltooid',
sideChatBlockNoOutput: '(geen uitvoer)',
sideChatBlockFiles: '{count} bestanden',
sideChatBlockWindow: '{shown} van {total} regels',
sideChatConnDisconnected: 'Verbinding verbroken',
sideChatConnReconnect: 'Opnieuw verbinden',
sideChatConnConnecting: 'Opnieuw verbinden…',
sideChatConnRecovered: 'Verbinding hersteld',
sideChatConnReconnectAction: 'Nu opnieuw verbinden',
sideChatConnRestartAction: 'Verbinding herstarten',
jobs: 'Achtergrondtaken',
jobsCount: '{count} achtergrondtaken',
jobsCountRunning: '{count} achtergrondtaken · {running} actief',
@@ -352,7 +393,7 @@ export const nl: Record<string, string> = {
addPluginsViewerCardDesc: 'Een bestandstype-preview registreren',
addPluginsTabDesc: 'Zijbalkpagina\'s (tabbladen) kunnen door plugins worden uitgebreid. Plugins registreren via de ctx.betterSidebar-service; op Installeren klikken kopieert het installatiecommando — plak het in een terminal waar uw DSH-profiel zich bevindt en voer het uit.',
addPluginsViewerDesc: 'Bestandsviewers kunnen door plugins worden uitgebreid. Plugins registreren via de ctx.betterSidebar-service; op Installeren klikken kopieert het installatiecommando — plak het in een terminal waar uw DSH-profiel zich bevindt en voer het uit.',
addPluginsBrowseMore: 'Meer plugins bekijken op GitHub (topic: dsh-better-sidebar)',
addPluginsBrowseMore: 'Meer plugins bekijken op GitHub (topic: dsh-apes-sidebar)',
addPluginsSearch: 'Zoek op plugin-naam of -beschrijving…',
addPluginsNoMatch: 'Geen plugins komen overeen',
addPluginsRecommended: 'Aanbevolen plugins',
@@ -365,7 +406,7 @@ export const nl: Record<string, string> = {
pluginGithubWorkbenchDesc: 'GitHub Workbench-tab: externe repositoryboom + Issues / Pull requests / Actions met volledige schrijfontsteuning — issue/PR aanmaken, reageren, bewerken, sluiten/heropenen, squash·merge·rebase-merge (sterke bevestiging), CI opnieuw uitvoeren/annuleren; de repo-wisselaar toont automatisch toegankelijke repo’s en doorzoekt openbare; zonder better-sidebar valt het terug op een zelfstandig rechterpaneel',
pluginMdExportDesc: 'Voegt een knop «Exporteren» toe aan de Markdown-werkbalk van better-sidebar: rendert de huidige .md in één klik naar zelfstandige HTML (tabellen/codeblokken/Mermaid-diagrammen inline, lay-out volgt het voorbeeldthema) en slaat die naast het .md op, of exporteert via het afdrukvenster naar PDF',
pluginCodeNavDesc: 'Codevoorbeeldnavigatie: detecteert de taal op bestandstype en kleurt de syntaxis, symbooloverzicht (filters voor klassen/methoden/variabelen + sprong met één klik) en zoeken in het bestand (alle treffers gemarkeerd, vorige/volgende, hoofdlettergevoelig) — neemt het codevoorbeeld in de better-sidebar-editor over',
pluginSuhuangScrollDesc: 'Verbindt de lokale Suhuang Scroll Runtime met DSH-instellingen en better-sidebar voor modelconfiguratie, verbindingstests en doorlopende beoordelingsbediening; vereist Suhuang Scroll Runtime en dsh-better-sidebar',
pluginSuhuangScrollDesc: 'Verbindt de lokale Suhuang Scroll Runtime met DSH-instellingen en better-sidebar voor modelconfiguratie, verbindingstests en doorlopende beoordelingsbediening; vereist Suhuang Scroll Runtime en dsh-apes-sidebar',
pluginBetterOverleafDesc: 'Overleaf-tabblad voor better-sidebar: directe browserlogin via CDP (ondersteunt Chromium van derden), projectlijst/wisselen, lokale git-spiegels onder <workspace>/overleaf/, tweerichtingssynchronisatie met read-only-API-terugval en bestandsvoorbeeld via de sidebar-werkbank',
pluginGitRemotesDesc: 'Git Remotes-tabblad: branch/upstream/ahead-behind, fetch (optioneel prune), ff-only pull, en push pas na een bevestiging in het tabblad. Vervangt het ingebouwde Git stage/commit-tabblad niet, en biedt geen force-push of een model-auto-push-tool',
pluginSentinelDesc: 'Conditie-gedreven agent-wakeupsysteem: bestands/proces/poort/http/command/webhook-sensoren wekken slapende sessies wanneer condities af gaan; registreert een "Sentinel"-tabblad met de serverbrede watch-tabel',
+49 -8
View File
@@ -19,8 +19,16 @@
/** The pl dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const pl: Record<string, string> = {
files: 'Pliki',
changesSessionEmpty: 'Brak operacji na plikach w tej sesji',
changesRead: 'Odczyt',
changesWrite: 'Zapis',
changesEdit: 'Edycja',
changesRunning: 'w toku',
changesError: 'błąd',
changesFold: '{count} wierszy… kliknij, aby rozwinąć',
changesContext: 'kontekst',
changesPriorUnknown: 'Zawartość sprzed zmiany jest poza oknem; pokazana jako w całości nowa',
explorer: 'Eksplorator',
git: 'Kontrola źródła',
terminal: 'Terminal',
editor: 'Edytor',
editorExplorer: 'Sposób otwierania plików',
@@ -130,7 +138,21 @@ export const pl: Record<string, string> = {
worktree: 'Drzewo robocze',
checkoutError: 'Przełączenie gałęzi nie powiodło się',
history: 'Historia',
changes: 'Zmiany',
changes: 'Zmiany plików',
changesGitLens: 'Git',
changesSessionLens: 'Sesja',
changesFilterAll: 'Wszystko',
changesFilterEmpty: 'Brak operacji tego rodzaju',
changesOpenDiffTab: 'Otwórz w osobnej karcie',
changesClosePreview: 'Zamknij podgląd',
changesResizePreview: 'Zmień wysokość podglądu',
changesDiffOpenTitle: 'Diff otwiera się jako',
changesDiffOpenDesc: 'Gdzie akcja „otwórz w osobnej karcie” umieszcza diff',
changesDiffOpenFloat: 'Odrębne okno',
changesDiffOpenFloatDesc: 'Pływające okno wyśrodkowane na ekranie — przeciągaj, skaluj, trzymaj na wierzchu',
changesDiffOpenPane: 'Panel',
changesDiffOpenPaneDesc: 'Zadokowane pod panelem źródłowym (podział diff w stylu VSCode)',
changesLoadError: 'Zapisy plików sesji są teraz niedostępne',
staged: 'W indeksie',
unstaged: 'Poza indeksem',
cancel: 'Anuluj',
@@ -223,10 +245,10 @@ export const pl: Record<string, string> = {
settingsConflict: 'Ustawienie zmienione w innym oknie — spróbuj ponownie',
binaryNoPreview: 'Tego typu pliku nie można wyświetlić w podglądzie',
downloadToView: 'Pobierz, aby zobaczyć',
settingsSubagentTitle: 'Automatycznie otwieraj stronę Zadań przy pojawieniu się podagenta',
settingsSubagentDesc: 'Gdy bieżąca rozmowa powoła nowego podagenta, rozwiń panel boczny i otwórz stronę Zadań; wyłącz, aby otwierać ręcznie',
settingsJobsTitle: 'Automatycznie otwieraj stronę zadań w tle przy nowym zadaniu',
settingsJobsDesc: 'Gdy w bieżącej rozmowie pojawi się nowe zadanie w tle, rozwiń panel boczny i otwórz stronę zadań w tle (każde nowe zadanie wyzwala); wyłącz, aby otwierać ręcznie',
settingsSubagentTitle: 'Automatycznie aktywuj stronę „Zadania” po pojawieniu się podagenta',
settingsSubagentDesc: 'Gdy w bieżącej rozmowie pojawi się nowy podagent, automatycznie aktywuj stronę „Zadania”; na szerokich ekranach rozwiń także panel boczny, a na wąskich nie wymuszaj otwarcia pełnoekranowego panelu; wyłącz, aby wybierać stronę ręcznie',
settingsJobsTitle: 'Automatycznie aktywuj stronę „Zadania” po pojawieniu się nowego zadania w tle',
settingsJobsDesc: 'Gdy w bieżącej rozmowie pojawi się nowe zadanie w tle, automatycznie aktywuj stronę „Zadania” (akcja jest wyzwalana przez każde nowe zadanie); na szerokich ekranach rozwiń także panel boczny, a na wąskich nie wymuszaj otwarcia pełnoekranowego panelu; wyłącz, aby wybierać stronę ręcznie',
settingsToolsTitle: 'Wstrzyknij narzędzia terminala dla modelu',
settingsToolsDesc: 'Po włączeniu model może tworzyć i sterować terminalami panelu bocznego przez 8 narzędzi terminal_* (domyślnie wyłączone)',
settingsFenceTitle: 'Kontrola ścieżek obszaru roboczego',
@@ -330,6 +352,25 @@ export const pl: Record<string, string> = {
sideChatClose: 'Zamknij wątek',
sideChatCloseTitle: 'Zwolnij agenta wątku (historia jest zachowana)',
sideChatError: 'Błąd czatu bocznego: {message}',
sideChatTurnUsage: 'Wejście {input} tok · Wyjście {output} tok',
sideChatBlockCollapse: 'Zwiń',
sideChatBlockCollapseAria: 'Zwiń',
sideChatBlockExpand: 'Rozwiń {hidden} wierszy',
sideChatBlockExpandAria: 'Rozwiń kolejne {hidden} wierszy',
sideChatBlockSignal: 'Zakończono sygnałem: {signal}',
sideChatBlockExitCode: 'Kod zakończenia {code}',
sideChatBlockRunning: 'Uruchomione',
sideChatBlockFailed: 'Niepowodzenie',
sideChatBlockDone: 'Zakończono',
sideChatBlockNoOutput: '(brak wyniku)',
sideChatBlockFiles: 'Pliki: {count}',
sideChatBlockWindow: '{shown} z {total} wierszy',
sideChatConnDisconnected: 'Połączenie utracone',
sideChatConnReconnect: 'Połącz ponownie',
sideChatConnConnecting: 'Łączenie ponownie…',
sideChatConnRecovered: 'Połączenie przywrócone',
sideChatConnReconnectAction: 'Połącz ponownie teraz',
sideChatConnRestartAction: 'Uruchom połączenie ponownie',
jobs: 'Zadania w tle',
jobsCount: '{count} zadań w tle',
jobsCountRunning: '{count} zadań w tle · {running} działa',
@@ -356,7 +397,7 @@ export const pl: Record<string, string> = {
addPluginsViewerCardDesc: 'Zarejestruj podgląd typu pliku',
addPluginsTabDesc: 'Strony panelu bocznego (karty) mogą być rozszerzane przez wtyczki. Wtyczki rejestrują się przez usługę ctx.betterSidebar; kliknięcie „Zainstaluj” kopiuje polecenie instalacji — wklej je do terminala tam, gdzie żyje Twój profil DSH, i uruchom.',
addPluginsViewerDesc: 'Podglądy plików mogą być rozszerzane przez wtyczki. Wtyczki rejestrują się przez usługę ctx.betterSidebar; kliknięcie „Zainstaluj” kopiuje polecenie instalacji — wklej je do terminala tam, gdzie żyje Twój profil DSH, i uruchom.',
addPluginsBrowseMore: 'Przeglądaj więcej wtyczek na GitHub (topic: dsh-better-sidebar)',
addPluginsBrowseMore: 'Przeglądaj więcej wtyczek na GitHub (topic: dsh-apes-sidebar)',
addPluginsSearch: 'Szukaj po nazwie lub opisie wtyczki…',
addPluginsNoMatch: 'Brak pasujących wtyczek',
addPluginsRecommended: 'Zalecane wtyczki',
@@ -369,7 +410,7 @@ export const pl: Record<string, string> = {
pluginGithubWorkbenchDesc: 'Karta GitHub Workbench: drzewo zdalnego repozytorium + Issues / Pull requests / Actions z pełną obsługą zapisu — tworzenie issue/PR, komentowanie, edycja, zamykanie/ponowne otwieranie, scalanie squash·merge·rebase (mocne potwierdzenie), ponowne uruchomienie/anulowanie CI; przełącznik repozytoriów automatycznie wyświetla dostępne repozytoria i wyszukuje publiczne; bez better-sidebar następuje przejście na samodzielny panel po prawej stronie',
pluginMdExportDesc: 'Dodaje przycisk „Eksportuj" do paska narzędzi Markdown w better-sidebar: jednym kliknięciem renderuje bieżący .md do samodzielnego HTML (tabele/bloki kodu/diagramy Mermaid w treści, układ zgodny z motywem podglądu) i zapisuje obok pliku .md albo eksportuje do PDF przez okno drukowania',
pluginCodeNavDesc: 'Nawigacja podglądu kodu: rozpoznaje język po typie pliku i koloruje składnię, konspekt symboli (filtry klas/metod/zmiennych + skok jednym kliknięciem) oraz wyszukiwanie w pliku (podświetlanie wszystkich trafień, poprzedni/następny, rozróżnianie wielkości liter) — przejmuje podgląd plików kodu w edytorze better-sidebar',
pluginSuhuangScrollDesc: 'Łączy lokalny Suhuang Scroll Runtime z ustawieniami DSH i better-sidebar, zapewniając konfigurację modelu, test połączenia i sterowanie ciągłym ocenianiem; wymaga Suhuang Scroll Runtime i dsh-better-sidebar',
pluginSuhuangScrollDesc: 'Łączy lokalny Suhuang Scroll Runtime z ustawieniami DSH i better-sidebar, zapewniając konfigurację modelu, test połączenia i sterowanie ciągłym ocenianiem; wymaga Suhuang Scroll Runtime i dsh-apes-sidebar',
pluginBetterOverleafDesc: 'Karta Overleaf dla better-sidebar: logowanie bezpośrednio przez przeglądarkę CDP (obsługa Chromium innych firm), lista/przełączanie projektów, lokalne kopie lustrzane git w <workspace>/overleaf/, dwukierunkowa synchronizacja git z awaryjnym API tylko do odczytu oraz podgląd plików przez warsztat panelu bocznego',
pluginGitRemotesDesc: 'Karta Git Remotes: gałąź/nadrzędne/ahead-behind, fetch (z opcjonalnym prune), ff-only pull i push dopiero po potwierdzeniu w karcie. Nie zastępuje wbudowanej karty Git stage/commit i nie oferuje force-push ani narzędzia auto-push modelu',
pluginSentinelDesc: 'Budzenie agenta sterowane warunkami: czujniki plików/procesów/portów/HTTP/poleceń/webhooków wybudzają uśpione sesje, gdy warunek zajdzie; rejestruje kartę „Sentinel” z ogólnoserwerową tabelą obserwacji',
+48 -7
View File
@@ -1,7 +1,15 @@
export const pt: Record<string, string> = {
files: 'Arquivos',
changesSessionEmpty: 'Ainda sem operações de arquivo nesta sessão',
changesRead: 'Leitura',
changesWrite: 'Escrita',
changesEdit: 'Edição',
changesRunning: 'em execução',
changesError: 'erro',
changesFold: '{count} linhas… clique para expandir',
changesContext: 'contexto',
changesPriorUnknown: 'O conteúdo anterior está fora da janela; mostrado como totalmente adicionado',
explorer: 'Explorador',
git: 'Controle de código-fonte',
terminal: 'Terminal',
editor: 'Editor',
editorExplorer: 'Comportamento de abertura de arquivos',
@@ -112,6 +120,20 @@ export const pt: Record<string, string> = {
checkoutError: 'Falha ao alternar de branch',
history: 'Histórico',
changes: 'Alterações',
changesGitLens: 'Git',
changesSessionLens: 'Sessão',
changesFilterAll: 'Tudo',
changesFilterEmpty: 'Nenhuma operação deste tipo',
changesOpenDiffTab: 'Abrir em uma aba de diff',
changesClosePreview: 'Fechar prévia',
changesResizePreview: 'Ajustar altura da prévia',
changesDiffOpenTitle: 'O diff abre como',
changesDiffOpenDesc: 'Onde a ação "abrir em uma aba de diff" coloca o diff',
changesDiffOpenFloat: 'Janela livre',
changesDiffOpenFloatDesc: 'Uma janela flutuante centralizada — arraste, redimensione, mantenha no topo',
changesDiffOpenPane: 'Painel',
changesDiffOpenPaneDesc: 'Ancorado abaixo do painel de origem (divisão de diff estilo VSCode)',
changesLoadError: 'Os registros de arquivos da sessão estão indisponíveis no momento',
staged: 'Preparadas',
unstaged: 'Não preparadas',
cancel: 'Cancelar',
@@ -204,10 +226,10 @@ export const pt: Record<string, string> = {
settingsConflict: 'A configuração foi alterada em outra janela — tente novamente',
binaryNoPreview: 'Este tipo de arquivo não pode ser pré-visualizado',
downloadToView: 'Baixar para ver',
settingsSubagentTitle: 'Abrir automaticamente a página de Tarefas quando um subagente aparecer',
settingsSubagentDesc: 'Expandir o cartão lateral e abrir a página de Tarefas quando a conversa atual gerar um novo subagente; desative para abrir manualmente',
settingsJobsTitle: 'Abrir automaticamente a página de Tarefas em segundo plano quando houver uma nova tarefa',
settingsJobsDesc: 'Expandir o cartão lateral e abrir a página de Tarefas sempre que uma nova tarefa em segundo plano aparecer para a conversa atual (cada nova tarefa dispara); desative para abrir manualmente',
settingsSubagentTitle: 'Ativar automaticamente a página de Tarefas quando um subagente aparecer',
settingsSubagentDesc: 'Ativar a página de Tarefas quando a conversa atual gerar um novo subagente; em telas largas, expandir também o cartão lateral; em telas estreitas, não forçar a abertura da gaveta em tela cheia; desative para abri-la manualmente',
settingsJobsTitle: 'Ativar automaticamente a página de Tarefas quando houver uma nova tarefa em segundo plano',
settingsJobsDesc: 'Ativar a página de Tarefas sempre que uma nova tarefa em segundo plano aparecer na conversa atual (cada nova tarefa aciona a ativação); em telas largas, expandir também o cartão lateral; em telas estreitas, não forçar a abertura da gaveta em tela cheia; desative para abri-la manualmente',
settingsToolsTitle: 'Injetar ferramentas de terminal para o modelo',
settingsToolsDesc: 'Quando ativado, o modelo pode criar e operar terminais da barra lateral por meio das 8 ferramentas terminal_* (desativado por padrão)',
settingsFenceTitle: 'Controle de caminhos do workspace',
@@ -311,6 +333,25 @@ export const pt: Record<string, string> = {
sideChatClose: 'Fechar tópico',
sideChatCloseTitle: 'Liberar o agente do tópico (o histórico é mantido)',
sideChatError: 'Erro no chat lateral: {message}',
sideChatTurnUsage: 'Entrada {input} tok · Saída {output} tok',
sideChatBlockCollapse: 'Recolher',
sideChatBlockCollapseAria: 'Recolher',
sideChatBlockExpand: 'Expandir {hidden} linhas',
sideChatBlockExpandAria: 'Expandir mais {hidden} linhas',
sideChatBlockSignal: 'Encerrado por sinal: {signal}',
sideChatBlockExitCode: 'Código de saída {code}',
sideChatBlockRunning: 'Em execução',
sideChatBlockFailed: 'Falhou',
sideChatBlockDone: 'Concluído',
sideChatBlockNoOutput: '(sem saída)',
sideChatBlockFiles: '{count} arquivos',
sideChatBlockWindow: '{shown} de {total} linhas',
sideChatConnDisconnected: 'Conexão perdida',
sideChatConnReconnect: 'Reconectar',
sideChatConnConnecting: 'Reconectando…',
sideChatConnRecovered: 'Conexão restabelecida',
sideChatConnReconnectAction: 'Reconectar agora',
sideChatConnRestartAction: 'Reiniciar conexão',
jobs: 'Tarefas em segundo plano',
jobsCount: '{count} tarefas em segundo plano',
jobsCountRunning: '{count} tarefas em segundo plano · {running} executando',
@@ -337,7 +378,7 @@ export const pt: Record<string, string> = {
addPluginsViewerCardDesc: 'Registrar uma pré-visualização de tipo de arquivo',
addPluginsTabDesc: 'As páginas da barra lateral (abas) podem ser estendidas por plugins. Os plugins registram pelo serviço ctx.betterSidebar; clicar em Instalar copia o comando de instalação — cole-o em um terminal onde seu perfil do DSH vive e execute.',
addPluginsViewerDesc: 'Os visualizadores de arquivos podem ser estendidos por plugins. Os plugins registram pelo serviço ctx.betterSidebar; clicar em Instalar copia o comando de instalação — cole-o em um terminal onde seu perfil do DSH vive e execute.',
addPluginsBrowseMore: 'Navegue por mais plugins no GitHub (topic: dsh-better-sidebar)',
addPluginsBrowseMore: 'Navegue por mais plugins no GitHub (topic: dsh-apes-sidebar)',
addPluginsSearch: 'Pesquisar por nome ou descrição do plugin…',
addPluginsNoMatch: 'Nenhum plugin corresponde',
addPluginsRecommended: 'Plugins recomendados',
@@ -350,7 +391,7 @@ export const pt: Record<string, string> = {
pluginGithubWorkbenchDesc: 'Aba GitHub Workbench: árvore do repositório remoto + Issues / Pull requests / Actions com suporte completo de escrita — criar Issue/PR, comentar, editar, fechar/reabrir, merge squash·merge·rebase (confirmação reforçada), reexecutar/cancelar CI; o seletor de repositórios lista automaticamente os repositórios acessíveis e busca nos públicos; sem better-sidebar, faz fallback para um painel lateral direito independente',
pluginMdExportDesc: 'Adiciona um botão «Exportar» à barra de ferramentas do Markdown no better-sidebar: com um clique, renderiza o .md atual em HTML autônomo (tabelas/blocos de código/diagramas Mermaid embutidos, layout seguindo o tema de pré-visualização) e salva na mesma pasta, ou exporta para PDF pela caixa de impressão',
pluginCodeNavDesc: 'Navegação de pré-visualização de código: detecta a linguagem pelo tipo de arquivo e destaca a sintaxe, contorno de símbolos (filtros de classes/métodos/variáveis + salto em um clique) e busca no arquivo (todas as ocorrências destacadas, anterior/próxima, diferenciando maiúsculas) — assume a pré-visualização de arquivos de código no editor do better-sidebar',
pluginSuhuangScrollDesc: 'Conecta o Suhuang Scroll Runtime local às configurações do DSH e ao better-sidebar para configurar o modelo, testar a conexão e controlar a correção contínua; requer Suhuang Scroll Runtime e dsh-better-sidebar',
pluginSuhuangScrollDesc: 'Conecta o Suhuang Scroll Runtime local às configurações do DSH e ao better-sidebar para configurar o modelo, testar a conexão e controlar a correção contínua; requer Suhuang Scroll Runtime e dsh-apes-sidebar',
pluginBetterOverleafDesc: 'Aba Overleaf para better-sidebar: login direto pelo navegador via CDP (compatível com Chromium de terceiros), lista/troca de projetos, espelhos git locais em <workspace>/overleaf/, sincronização git bidirecional com fallback de API somente leitura e pré-visualização de arquivos pela bancada da barra lateral',
pluginGitRemotesDesc: 'Aba de remotos Git: branch/upstream/ahead-behind, fetch (prune opcional), pull ff-only e push apenas após confirmar na aba. Não substitui a aba interna de stage/commit do Git e não oferece force-push nem push automático pelo modelo',
pluginSentinelDesc: 'Despertar de agentes dirigido por condições: sensores de arquivo/processo/porta/HTTP/comando/webhook acordam sessões adormecidas quando as condições disparam; registra uma aba "Sentinelas" com a tabela de monitoramento global do servidor',
+49 -8
View File
@@ -13,8 +13,16 @@
export const ru: Record<string, string> = {
files: 'Файлы',
changesSessionEmpty: 'В этой сессии пока нет файловых операций',
changesRead: 'Чтение',
changesWrite: 'Запись',
changesEdit: 'Изменение',
changesRunning: 'выполняется',
changesError: 'ошибка',
changesFold: '{count} строк… нажмите, чтобы развернуть',
changesContext: 'контекст',
changesPriorUnknown: 'Содержимое до изменения вне окна; показано как полностью добавленное',
explorer: 'Проводник',
git: 'Контроль версий',
terminal: 'Терминал',
editor: 'Редактор',
editorExplorer: 'Способ открытия файлов',
@@ -124,7 +132,21 @@ export const ru: Record<string, string> = {
worktree: 'Рабочее дерево',
checkoutError: 'Сбой переключения ветки',
history: 'История',
changes: 'Изменения',
changes: 'Изменения файлов',
changesGitLens: 'Git',
changesSessionLens: 'Сессия',
changesFilterAll: 'Все',
changesFilterEmpty: 'Нет операций этого типа',
changesOpenDiffTab: 'Открыть в отдельной вкладке',
changesClosePreview: 'Закрыть просмотр',
changesResizePreview: 'Изменить высоту просмотра',
changesDiffOpenTitle: 'Diff открывается как',
changesDiffOpenDesc: 'Куда действие «открыть в отдельной вкладке» помещает diff',
changesDiffOpenFloat: 'Свободное окно',
changesDiffOpenFloatDesc: 'Плавающее окно по центру экрана — перетаскивание, масштаб, поверх всех',
changesDiffOpenPane: 'Панель',
changesDiffOpenPaneDesc: 'Прикреплено под исходной панелью (разделение diff в стиле VSCode)',
changesLoadError: 'Записи файлов сессии сейчас недоступны',
staged: 'В индексе',
unstaged: 'Не в индексе',
cancel: 'Отмена',
@@ -218,10 +240,10 @@ export const ru: Record<string, string> = {
settingsConflict: 'Настройки были изменены в другом окне — повторите',
binaryNoPreview: 'Этот тип файла не поддерживает предпросмотр',
downloadToView: 'Скачать для просмотра',
settingsSubagentTitle: 'Автоматически разворачивать страницу «Задачи» при появлении субагента',
settingsSubagentDesc: 'При появлении нового субагента в текущей сессии боковая панель автоматически разворачивается и открывается страница «Задачи»; при отключении нужно открывать вручную',
settingsJobsTitle: 'Автоматически разворачивать страницу «Фоновые задачи» при новой задаче',
settingsJobsDesc: 'При появлении новой фоновой задачи в текущей сессии боковая панель автоматически разворачивается и открывается страница «Фоновые задачи» (срабатывает на каждую новую задачу); при отключении нужно открывать вручную',
settingsSubagentTitle: 'Автоматически активировать страницу «Задачи» при появлении субагента',
settingsSubagentDesc: 'При появлении нового субагента в текущей сессии автоматически активируется страница «Задачи»; на широких экранах также разворачивается боковая панель, а на узких экранах полноэкранная выдвижная панель не открывается принудительно; отключите, чтобы выбирать страницу вручную',
settingsJobsTitle: 'Автоматически активировать страницу «Задачи» при появлении новой фоновой задачи',
settingsJobsDesc: 'При появлении новой фоновой задачи в текущей сессии автоматически активируется страница «Задачи» (срабатывает для каждой новой задачи); на широких экранах также разворачивается боковая панель, а на узких экранах полноэкранная выдвижная панель не открывается принудительно; отключите, чтобы выбирать страницу вручную',
settingsToolsTitle: 'Внедрить терминальные инструменты для модели',
settingsToolsDesc: 'Если включено, модель может через 8 инструментов (terminal_create и др.) создавать терминалы боковой панели и управлять ими (по умолчанию выключено)',
settingsFenceTitle: 'Контроль путей рабочей области',
@@ -326,6 +348,25 @@ export const ru: Record<string, string> = {
sideChatClose: 'Закрыть поток',
sideChatCloseTitle: 'Освободить агента потока (история сохраняется)',
sideChatError: 'Ошибка бокового чата: {message}',
sideChatTurnUsage: 'Вход {input} tok · Выход {output} tok',
sideChatBlockCollapse: 'Свернуть',
sideChatBlockCollapseAria: 'Свернуть',
sideChatBlockExpand: 'Развернуть {hidden} строк',
sideChatBlockExpandAria: 'Развернуть ещё {hidden} строк',
sideChatBlockSignal: 'Завершён сигналом: {signal}',
sideChatBlockExitCode: 'Код выхода {code}',
sideChatBlockRunning: 'Выполняется',
sideChatBlockFailed: 'Ошибка',
sideChatBlockDone: 'Готово',
sideChatBlockNoOutput: '(нет вывода)',
sideChatBlockFiles: 'Файлов: {count}',
sideChatBlockWindow: '{shown} из {total} строк',
sideChatConnDisconnected: 'Соединение потеряно',
sideChatConnReconnect: 'Переподключиться',
sideChatConnConnecting: 'Переподключение…',
sideChatConnRecovered: 'Соединение восстановлено',
sideChatConnReconnectAction: 'Переподключиться сейчас',
sideChatConnRestartAction: 'Перезапустить соединение',
jobs: 'Фоновые задачи',
jobsCount: '{count} фоновых задач',
jobsCountRunning: '{count} фоновых задач · {running} выполняется',
@@ -352,7 +393,7 @@ export const ru: Record<string, string> = {
addPluginsViewerCardDesc: 'Зарегистрировать предпросмотр типа файлов',
addPluginsTabDesc: 'Страницы боковой панели (вкладки) расширяются плагинами. Плагины регистрируются через службу ctx.betterSidebar; щелчок по «Установить» копирует команду — вставьте её в терминал, где находится профиль DSH, и выполните.',
addPluginsViewerDesc: 'Просмотрщики файлов расширяются плагинами. Плагины регистрируются через службу ctx.betterSidebar; щелчок по «Установить» копирует команду — вставьте её в терминал, где находится профиль DSH, и выполните.',
addPluginsBrowseMore: 'Найти больше плагинов на GitHub (topic: dsh-better-sidebar)',
addPluginsBrowseMore: 'Найти больше плагинов на GitHub (topic: dsh-apes-sidebar)',
addPluginsSearch: 'Поиск по имени или описанию плагина…',
addPluginsNoMatch: 'Нет совпадающих плагинов',
addPluginsRecommended: 'Рекомендуемые плагины',
@@ -365,7 +406,7 @@ export const ru: Record<string, string> = {
pluginGithubWorkbenchDesc: 'Вкладка «GitHub Workbench»: дерево удалённого репозитория + Issues / Pull requests / Actions с полной поддержкой записи — создание Issue/PR, комментарии, редактирование, закрытие/повторное открытие, merge squash·merge·rebase (с сильным подтверждением), перезапуск/отмена CI; переключатель репозиториев автоматически показывает доступные репозитории и ищет по публичным; без better-sidebar используется отдельная правая панель',
pluginMdExportDesc: 'Добавляет кнопку «Экспорт» на панель инструментов Markdown в better-sidebar: одним кликом рендерит текущий .md в самостоятельный HTML (таблицы/блоки кода/Mermaid-диаграммы встроены, вёрстка следует теме предпросмотра) и сохраняет рядом с .md либо экспортирует в PDF через диалог печати',
pluginCodeNavDesc: 'Навигация по предпросмотру кода: определяет язык по типу файла и подсвечивает синтаксис, схема символов (фильтры классов/методов/переменных + переход в один клик) и поиск по файлу (подсветка всех совпадений, назад/вперёд, учёт регистра) — берёт на себя предпросмотр файлов кода в редакторе better-sidebar',
pluginSuhuangScrollDesc: 'Подключает локальный Suhuang Scroll Runtime к настройкам DSH и better-sidebar для настройки модели, проверки соединения и управления непрерывной проверкой; требуются Suhuang Scroll Runtime и dsh-better-sidebar',
pluginSuhuangScrollDesc: 'Подключает локальный Suhuang Scroll Runtime к настройкам DSH и better-sidebar для настройки модели, проверки соединения и управления непрерывной проверкой; требуются Suhuang Scroll Runtime и dsh-apes-sidebar',
pluginBetterOverleafDesc: 'Вкладка Overleaf для better-sidebar: вход напрямую через CDP-браузер (поддержка стороннего Chromium), список/переключение проектов, локальные git-зеркала в <workspace>/overleaf/, двунаправленная синхронизация git с откатом к API только для чтения, просмотр файлов через рабочую панель сайдбара',
pluginGitRemotesDesc: 'Вкладка Git Remotes: просмотр веток/апстрима/ahead-behind, fetch (с возможностью prune), ff-only pull и push только после подтверждения во вкладке. Не заменяет встроенную вкладку Git (индексирование/коммит), не предоставляет force-push и инструмент авто-push для модели',
pluginSentinelDesc: 'Система пробуждения агента по условиям: сенсоры файлов/процессов/портов/HTTP/команд/webhook будят спящие сессии при срабатывании; регистрирует вкладку «Sentinel» с глобальной таблицей наблюдений сервера',
+49 -8
View File
@@ -1,7 +1,15 @@
export const sv: Record<string, string> = {
files: 'Filer',
changesSessionEmpty: 'Inga filoperationer i den här sessionen ännu',
changesRead: 'Läs',
changesWrite: 'Skriv',
changesEdit: 'Redigera',
changesRunning: 'körs',
changesError: 'fel',
changesFold: '{count} rader… klicka för att expandera',
changesContext: 'kontext',
changesPriorUnknown: 'Innehållet före ändringen ligger utanför fönstret; visas som helt nytt',
explorer: 'Utforskare',
git: 'Källkodshantering',
terminal: 'Terminal',
editor: 'Editor',
editorExplorer: 'Filers öppningsbeteende',
@@ -111,7 +119,21 @@ export const sv: Record<string, string> = {
worktree: 'Arbetsträd',
checkoutError: 'Grenbyte misslyckades',
history: 'Historik',
changes: 'Ändringar',
changes: 'Filändringar',
changesGitLens: 'Git',
changesSessionLens: 'Session',
changesFilterAll: 'Alla',
changesFilterEmpty: 'Inga åtgärder av denna typ',
changesOpenDiffTab: 'Öppna i egen diff-flik',
changesClosePreview: 'Stäng förhandsvisning',
changesResizePreview: 'Ändra storlek på förhandsvisningen',
changesDiffOpenTitle: 'Diff öppnas som',
changesDiffOpenDesc: 'Var åtgärden "öppna i egen diff-flik" placerar diffen',
changesDiffOpenFloat: 'Fritt fönster',
changesDiffOpenFloatDesc: 'Ett flytande fönster centrerat i vyn — dra, ändra storlek, håll överst',
changesDiffOpenPane: 'Panel',
changesDiffOpenPaneDesc: 'Dockad under källpanelen (VSCode-liknande diff-delning)',
changesLoadError: 'Sessionens filposter är inte tillgängliga just nu',
staged: 'Köade',
unstaged: 'Avköade',
cancel: 'Avbryt',
@@ -204,10 +226,10 @@ export const sv: Record<string, string> = {
settingsConflict: 'Inställningen ändrades i ett annat fönster — vänligen försök igen',
binaryNoPreview: 'Denna filtyp kan inte förhandsgranskas',
downloadToView: 'Ladda ner för att visa',
settingsSubagentTitle: 'Öppna automatiskt Uppgiftssidan när en subagent dyker upp',
settingsSubagentDesc: 'Expandera sidokortet och öppna Uppgiftssidan när aktuell konversation skapar en ny subagent; stäng av för att öppna manuellt',
settingsJobsTitle: 'Öppna automatiskt Jobbsidan vid nytt bakgrundsjobb',
settingsJobsDesc: 'Expandera sidokortet och öppna Jobbsidan när ett nytt bakgrundsjobb dyker upp för aktuell konversation (varje nytt jobb utlöser); stäng av för att öppna manuellt',
settingsSubagentTitle: 'Aktivera sidan Uppgifter automatiskt när en subagent dyker upp',
settingsSubagentDesc: 'När den aktuella konversationen skapar en ny subagent aktiveras sidan Uppgifter automatiskt; på breda skärmar expanderas även sidokortet, medan helskärmspanelen inte tvingas öppnas på smala skärmar; stäng av för att välja sidan manuellt',
settingsJobsTitle: 'Aktivera sidan Uppgifter automatiskt vid ett nytt bakgrundsjobb',
settingsJobsDesc: 'När ett nytt bakgrundsjobb dyker upp för den aktuella konversationen aktiveras sidan Uppgifter automatiskt (detta sker för varje nytt jobb); på breda skärmar expanderas även sidokortet, medan helskärmspanelen inte tvingas öppnas på smala skärmar; stäng av för att välja sidan manuellt',
settingsToolsTitle: 'Injicera terminalverktyg för modellen',
settingsToolsDesc: 'När aktiverat kan modellen skapa och styra sidopanelsterminaler via de 8 terminal_*-verktygen (av som standard)',
settingsFenceTitle: 'Kontroll av workspace-sökvägar',
@@ -311,6 +333,25 @@ export const sv: Record<string, string> = {
sideChatClose: 'Stäng tråd',
sideChatCloseTitle: 'Frigör trådens agent (historik behålls)',
sideChatError: 'Sidochattsfel: {message}',
sideChatTurnUsage: 'In {input} tok · Ut {output} tok',
sideChatBlockCollapse: 'Fäll ihop',
sideChatBlockCollapseAria: 'Fäll ihop',
sideChatBlockExpand: 'Visa {hidden} rader',
sideChatBlockExpandAria: 'Visa {hidden} rader till',
sideChatBlockSignal: 'Avslutad av signal: {signal}',
sideChatBlockExitCode: 'Slutkod {code}',
sideChatBlockRunning: 'Körs',
sideChatBlockFailed: 'Misslyckades',
sideChatBlockDone: 'Klar',
sideChatBlockNoOutput: '(ingen utdata)',
sideChatBlockFiles: '{count} filer',
sideChatBlockWindow: '{shown} av {total} rader',
sideChatConnDisconnected: 'Anslutningen bröts',
sideChatConnReconnect: 'Anslut igen',
sideChatConnConnecting: 'Ansluter igen…',
sideChatConnRecovered: 'Anslutningen återställd',
sideChatConnReconnectAction: 'Anslut igen nu',
sideChatConnRestartAction: 'Starta om anslutningen',
jobs: 'Bakgrundsjobb',
jobsCount: '{count} bakgrundsjobb',
jobsCountRunning: '{count} bakgrundsjobb · {running} kör',
@@ -337,7 +378,7 @@ export const sv: Record<string, string> = {
addPluginsViewerCardDesc: 'Registrera en filtypsförhandsgranskning',
addPluginsTabDesc: 'Sidopanelens sidor (flikar) kan utökas av plugins. Plugins registreras via ctx.betterSidebar-tjänsten; att klicka på Installera kopierar installationskommandot — klistra in det i en terminal där er DSH-profil lever och kör det.',
addPluginsViewerDesc: 'Filförhandsgranskare kan utökas av plugins. Plugins registreras via ctx.betterSidebar-tjänsten; att klicka på Installera kopierar installationskommandot — klistra in det i en terminal där er DSH-profil lever och kör det.',
addPluginsBrowseMore: 'Bläddra bland fler plugins på GitHub (topic: dsh-better-sidebar)',
addPluginsBrowseMore: 'Bläddra bland fler plugins på GitHub (topic: dsh-apes-sidebar)',
addPluginsSearch: 'Sök på plugin-namn eller beskrivning…',
addPluginsNoMatch: 'Inga plugins matchar',
addPluginsRecommended: 'Rekommenderade plugins',
@@ -350,7 +391,7 @@ export const sv: Record<string, string> = {
pluginGithubWorkbenchDesc: 'GitHub Workbench-flik: fjärrrepo-träd + Issues / Pull requests / Actions med fullt skrivstöd — skapa issue/PR, kommentera, redigera, stäng/öppna igen, squash·merge·rebase-merge (stark bekräftelse), kör om/avbryt CI; repoväxlaren listar automatiskt tillgängliga repon och söker i publika; utan better-sidebar växlar den till en fristående höger panel',
pluginMdExportDesc: 'Lägger till en «Exportera»-knapp i Markdown-verktygsfältet i better-sidebar: renderar nuvarande .md till fristående HTML i ett klick (tabeller/kodblock/Mermaid-diagram inline, layout följer förhandsvisningstemat) och sparar bredvid .md-filen, eller exporterar till PDF via utskriftsdialogen',
pluginCodeNavDesc: 'Kodförhandsvisningsnavigering: känner automatiskt av språket från filtypen och färglägger syntaxen, symbolöversikt (filter för klasser/metoder/variabler + hopp med ett klick) och filsökning (markera alla träffar, föregående/nästa, skiftlägeskänsligt) — tar över kodfilförhandsvisning i better-sidebar-redigeraren',
pluginSuhuangScrollDesc: 'Ansluter den lokala Suhuang Scroll Runtime till DSH-inställningar och better-sidebar för modellkonfiguration, anslutningstest och styrning av kontinuerlig rättning; kräver Suhuang Scroll Runtime och dsh-better-sidebar',
pluginSuhuangScrollDesc: 'Ansluter den lokala Suhuang Scroll Runtime till DSH-inställningar och better-sidebar för modellkonfiguration, anslutningstest och styrning av kontinuerlig rättning; kräver Suhuang Scroll Runtime och dsh-apes-sidebar',
pluginBetterOverleafDesc: 'Overleaf-flik för better-sidebar: direktinloggning via CDP-webbläsare (stöd för tredjeparts-Chromium), projektlista/byte, lokala git-speglar under <workspace>/overleaf/, dubbelriktad git-synk med read-only-API-fallback och förhandsvisning av filer via sidofältets workbench',
pluginGitRemotesDesc: 'Git Remotes-flik: gren/upstream/ahead-behind, fetch (valbar prune), ff-only pull och push endast efter bekräftelse i fliken. Ersätter inte den inbyggda Git-köa/commit-fliken, och erbjuder inte force-push eller ett model-auto-push-verktyg',
pluginSentinelDesc: 'Villkorsdriven agentväckning: fil-/process-/port-/http-/kommando-/webhook-sensorer väcker vilande sessioner när villkor utlöses; registrerar en "Sentinel"-flik med serverns globala bevakningstabell',
+49 -8
View File
@@ -17,8 +17,16 @@
/** The th dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const th: Record<string, string> = {
files: 'ไฟล์',
changesSessionEmpty: 'ยังไม่มีการดำเนินการไฟล์ในเซสชันนี้',
changesRead: 'อ่าน',
changesWrite: 'เขียน',
changesEdit: 'แก้ไข',
changesRunning: 'กำลังทำงาน',
changesError: 'ข้อผิดพลาด',
changesFold: '{count} บรรทัด… คลิกเพื่อขยาย',
changesContext: 'บริบท',
changesPriorUnknown: 'เนื้อหาก่อนแก้ไขอยู่นอกหน้าต่าง แสดงเป็นเพิ่มใหม่ทั้งหมด',
explorer: 'ตัวสำรวจ',
git: 'การควบคุมซอร์ส',
terminal: 'เทอร์มินัล',
editor: 'ตัวแก้ไข',
editorExplorer: 'พฤติกรรมการเปิดไฟล์',
@@ -128,7 +136,21 @@ export const th: Record<string, string> = {
worktree: 'เวิร์กทรี',
checkoutError: 'สลับ branch ล้มเหลว',
history: 'ประวัติ',
changes: 'การเปลี่ยนแปลง',
changes: 'การเปลี่ยนแปลงไฟล์',
changesGitLens: 'Git',
changesSessionLens: 'เซสชัน',
changesFilterAll: 'ทั้งหมด',
changesFilterEmpty: 'ไม่มีการดำเนินการประเภทนี้',
changesOpenDiffTab: 'เปิดในแท็บ diff แยก',
changesClosePreview: 'ปิดตัวอย่าง',
changesResizePreview: 'ปรับขนาดตัวอย่าง',
changesDiffOpenTitle: 'diff เปิดเป็น',
changesDiffOpenDesc: 'การกระทำ «เปิดในแท็บ diff แยก» วาง diff ไว้ที่ใด',
changesDiffOpenFloat: 'หน้าต่างลอย',
changesDiffOpenFloatDesc: 'หน้าต่างลอยกลางจอ — ลาก ย่อขยาย ปักหมุดไว้บนสุด',
changesDiffOpenPane: 'แพเนล',
changesDiffOpenPaneDesc: 'จอดใต้แพเนลต้นทาง (แยก diff สไตล์ VSCode)',
changesLoadError: 'บันทึกไฟล์ของเซสชันยังใช้ไม่ได้ตอนนี้',
staged: 'Staged',
unstaged: 'Unstaged',
cancel: 'ยกเลิก',
@@ -221,10 +243,10 @@ export const th: Record<string, string> = {
settingsConflict: 'การตั้งค่าถูกเปลี่ยนในหน้าต่างอื่น — โปรดลองอีกครั้ง',
binaryNoPreview: 'ไม่สามารถพรีวิวประเภทไฟล์นี้ได้',
downloadToView: 'ดาวน์โหลดเพื่อดู',
settingsSubagentTitle: 'เปิดหน้า Tasks อัตโนมัติเมื่อมีตัวแทนย่อยปรากฏ',
settingsSubagentDesc: 'ขยายการ์ดด้านข้างและเปิดหน้า Tasks เมื่อแชทปัจจุบันสร้างตัวแทนย่อยใหม่; ปิดเพื่อเปิดเอง',
settingsJobsTitle: 'เปิดหน้า Jobs อัตโนมัติเมื่อมีงานเบื้องหลังใหม่',
settingsJobsDesc: 'ขยายการ์ดด้านข้างและเปิดหน้า Jobs เมื่อใดก็ตามที่มีงานเบื้องหลังใหม่ปรากฏสำหรับแชทปัจจุบัน (ทุกงานใหม่จะทริกเกอร์); ปิดเพื่อเปิดเอง',
settingsSubagentTitle: 'เลือกหน้า Tasks โดยอัตโนมัติเมื่อมีตัวแทนย่อยปรากฏ',
settingsSubagentDesc: 'เมื่อแชทปัจจุบันสร้างตัวแทนย่อยใหม่ ระบบจะเลือกหน้า Tasks โดยอัตโนมัติ; ในมุมมองแบบกว้าง ระบบจะขยายการ์ดด้านข้างด้วย ส่วนในมุมมองแบบแคบ ระบบจะไม่บังคับเปิดลิ้นชักเต็มหน้าจอ; ปิดเพื่อเลือกหน้าด้วยตนเอง',
settingsJobsTitle: 'เลือกหน้า Tasks โดยอัตโนมัติเมื่อมีงานเบื้องหลังใหม่',
settingsJobsDesc: 'เมื่อมีงานเบื้องหลังใหม่ปรากฏในแชทปัจจุบัน ระบบจะเลือกหน้า Tasks โดยอัตโนมัติ (ทุกงานใหม่จะทริกเกอร์); ในมุมมองแบบกว้าง ระบบจะขยายการ์ดด้านข้างด้วย ส่วนในมุมมองแบบแคบ ระบบจะไม่บังคับเปิดลิ้นชักเต็มหน้าจอ; ปิดเพื่อเลือกหน้าด้วยตนเอง',
settingsToolsTitle: 'ฉีดเครื่องมือเทอร์มินัลสำหรับโมเดล',
settingsToolsDesc: 'เมื่อเปิดใช้ โมเดลสามารถสร้างและควบคุมเทอร์มินัลแถบด้านข้างผ่านเครื่องมือ terminal_* 8 ตัว (ปิดเป็นค่าเริ่มต้น)',
settingsFenceTitle: 'การตรวจสอบเส้นทางเวิร์กสเปซ',
@@ -328,6 +350,25 @@ export const th: Record<string, string> = {
sideChatClose: 'ปิดเธรด',
sideChatCloseTitle: 'ปล่อยตัวแทนของเธรด (ประวัติยังคงอยู่)',
sideChatError: 'ข้อผิดพลาดแชทด้านข้าง: {message}',
sideChatTurnUsage: 'อินพุต {input} tok · เอาต์พุต {output} tok',
sideChatBlockCollapse: 'ย่อ',
sideChatBlockCollapseAria: 'ย่อ',
sideChatBlockExpand: 'ขยาย {hidden} บรรทัด',
sideChatBlockExpandAria: 'ขยายอีก {hidden} บรรทัด',
sideChatBlockSignal: 'ถูกยุตด้วยสัญญาณ: {signal}',
sideChatBlockExitCode: 'รหัสออก {code}',
sideChatBlockRunning: 'กำลังทำงาน',
sideChatBlockFailed: 'ล้มเหลว',
sideChatBlockDone: 'เสร็จสิ้น',
sideChatBlockNoOutput: '(ไม่มีเอาต์พุต)',
sideChatBlockFiles: '{count} ไฟล์',
sideChatBlockWindow: '{shown} จาก {total} บรรทัด',
sideChatConnDisconnected: 'การเชื่อมต่อขาดหาย',
sideChatConnReconnect: 'เชื่อมต่อใหม่',
sideChatConnConnecting: 'กำลังเชื่อมต่อใหม่…',
sideChatConnRecovered: 'กู้คืนการเชื่อมต่อแล้ว',
sideChatConnReconnectAction: 'เชื่อมต่อใหม่ตอนนี้',
sideChatConnRestartAction: 'เริ่มการเชื่อมต่อใหม่',
jobs: 'งานเบื้องหลัง',
jobsCount: '{count} งานเบื้องหลัง',
jobsCountRunning: '{count} งานเบื้องหลัง · {running} กำลังทำงาน',
@@ -354,7 +395,7 @@ export const th: Record<string, string> = {
addPluginsViewerCardDesc: 'ลงทะเบียนพรีวิวประเภทไฟล์',
addPluginsTabDesc: 'หน้าแถบด้านข้าง (tab) สามารถขยายได้ด้วยปลั๊กอิน ปลั๊กอินลงทะเบียนผ่านบริการ ctx.betterSidebar; การคลิก Install จะคัดลอกคำสั่งติดตั้ง — วางลงในเทอร์มินัลที่ DSH profile ของคุณอยู่แล้วเรียกใช้',
addPluginsViewerDesc: 'ตัวพรีวิวไฟล์สามารถขยายได้ด้วยปลั๊กอิน ปลั๊กอินลงทะเบียนผ่านบริการ ctx.betterSidebar; การคลิก Install จะคัดลอกคำสั่งติดตั้ง — วางลงในเทอร์มินัลที่ DSH profile ของคุณอยู่แล้วเรียกใช้',
addPluginsBrowseMore: 'เรียกดูปลั๊กอินเพิ่มเติมบน GitHub (topic: dsh-better-sidebar)',
addPluginsBrowseMore: 'เรียกดูปลั๊กอินเพิ่มเติมบน GitHub (topic: dsh-apes-sidebar)',
addPluginsSearch: 'ค้นหาตามชื่อหรือคำอธิบายปลั๊กอิน…',
addPluginsNoMatch: 'ไม่มีปลั๊กอินที่ตรง',
addPluginsRecommended: 'ปลั๊กอินแนะนำ',
@@ -367,7 +408,7 @@ export const th: Record<string, string> = {
pluginGithubWorkbenchDesc: 'แท็บ GitHub Workbench: โครงสร้าง repo รีโมต + Issues / Pull requests / Actions พร้อมรองรับการเขียนครบวงจร — สร้าง Issue/PR, แสดงความคิดเห็น, แก้ไข, ปิด/เปิดใหม่, รวมแบบ squash·merge·rebase (ยืนยันก่อนเสมอ), รัน/ยกเลิก CI ใหม่; ตัวสลับ repo จะดึงรายการ repo ที่มีสิทธิ์เข้าถึงโดยอัตโนมัติและค้นหา repo สาธารณะได้; หากไม่ได้ติดตั้ง better-sidebar จะสลับไปใช้แผงด้านขวาแบบสแตนด์อโลนอัตโนมัติ',
pluginMdExportDesc: 'เพิ่มปุ่ม "ส่งออก" ในแถบเครื่องมือ Markdown ของ better-sidebar: เรนเดอร์ .md ปัจจุบันเป็น HTML แบบสแตนด์อโลนในคลิกเดียว (ตาราง/บล็อกโค้ด/ไดอะแกรม Mermaid แบบอินไลน์ เลย์เอาต์ตามธีมพรีวิว) แล้วบันทึกไว้โฟลเดอร์เดียวกัน หรือส่งออกเป็น PDF ผ่านกล่องโต้ตอบการพิมพ์',
pluginCodeNavDesc: 'การนำทางพรีวิวโค้ด: ระบุภาษาอัตโนมัติจากประเภทไฟล์และไฮไลต์ไวยากรณ์ โครงร่างสัญลักษณ์ (กรองคลาส/เมธอด/ตัวแปร + กระโดดคลิกเดียว) และค้นหาในไฟล์ (ไฮไลต์ทุกจุดที่ตรง ก่อนหน้า/ถัดไป แยกตัวพิมพ์ใหญ่เล็ก) — รับช่วงพรีวิวไฟล์โค้ดในตัวแก้ไข better-sidebar',
pluginSuhuangScrollDesc: 'เชื่อมต่อ Suhuang Scroll Runtime ในเครื่องกับการตั้งค่า DSH และ better-sidebar สำหรับการกำหนดค่าโมเดล การทดสอบการเชื่อมต่อ และการควบคุมการตรวจงานต่อเนื่อง โดยต้องติดตั้ง Suhuang Scroll Runtime และ dsh-better-sidebar',
pluginSuhuangScrollDesc: 'เชื่อมต่อ Suhuang Scroll Runtime ในเครื่องกับการตั้งค่า DSH และ better-sidebar สำหรับการกำหนดค่าโมเดล การทดสอบการเชื่อมต่อ และการควบคุมการตรวจงานต่อเนื่อง โดยต้องติดตั้ง Suhuang Scroll Runtime และ dsh-apes-sidebar',
pluginBetterOverleafDesc: 'แท็บ Overleaf สำหรับ better-sidebar: เข้าสู่ระบบผ่านเบราว์เซอร์ CDP โดยตรง (รองรับ Chromium ของบุคคลที่สาม) รายการ/สลับโปรเจกต์ มิเรอร์ git ภายในเครื่องที่ <workspace>/overleaf/ ซิงก์ git สองทางพร้อมสำรองแบบ API อ่านอย่างเดียว และดูตัวอย่างไฟล์ผ่านเวิร์กเบนช์ของแถบข้าง',
pluginGitRemotesDesc: 'tab Git Remotes: branch/upstream/ahead-behind, fetch (optional prune), ff-only pull และ push หลังการยืนยันใน tab เท่านั้น ไม่แทนที่ tab stage/commit ของ Git แบบ built-in และไม่เสนอ force-push หรือเครื่องมือ auto-push ของโมเดล',
pluginSentinelDesc: 'การปลุกตัวแทนที่ขับเคลื่อนด้วยเงื่อนไข: เซนเซอร์ file/process/port/http/command/webhook ปลุกเซสชันที่หลับเมื่อเงื่อนไขเกิดขึ้น; ลงทะเบียน tab "Sentinel" พร้อมตารางเฝ้าระวังทั่วทั้งเซิร์ฟเวอร์',
+49 -8
View File
@@ -17,8 +17,16 @@
/** The tr dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const tr: Record<string, string> = {
files: 'Dosyalar',
changesSessionEmpty: 'Bu oturumda henüz dosya işlemi yok',
changesRead: 'Okuma',
changesWrite: 'Yazma',
changesEdit: 'Düzenleme',
changesRunning: 'çalışıyor',
changesError: 'hata',
changesFold: '{count} satır… genişletmek için tıklayın',
changesContext: 'bağlam',
changesPriorUnknown: 'Değişiklik öncesi içerik pencere dışında; tamamen yeni eklenmiş olarak gösteriliyor',
explorer: 'Gezgin',
git: 'Kaynak denetimi',
terminal: 'Terminal',
editor: 'Düzenleyici',
editorExplorer: 'Dosya açma davranışı',
@@ -128,7 +136,21 @@ export const tr: Record<string, string> = {
worktree: 'Çalışma ağacı',
checkoutError: 'Dal değiştirme başarısız',
history: 'Geçmiş',
changes: 'Değişiklikler',
changes: 'Dosya değişiklikleri',
changesGitLens: 'Git',
changesSessionLens: 'Oturum',
changesFilterAll: 'Tümü',
changesFilterEmpty: 'Bu türde işlem yok',
changesOpenDiffTab: 'Ayrı bir diff sekmesinde aç',
changesClosePreview: 'Önizlemeyi kapat',
changesResizePreview: 'Önizlemeyi boyutlandır',
changesDiffOpenTitle: 'diff açılır',
changesDiffOpenDesc: '"Ayrı bir diff sekmesinde aç" eylemi diff\'i nereye koyar',
changesDiffOpenFloat: 'Serbest pencere',
changesDiffOpenFloatDesc: 'Görünümün ortasında yüzen pencere — sürükle, boyutlandır, üstte tut',
changesDiffOpenPane: 'Panel',
changesDiffOpenPaneDesc: 'Kaynak panelin altına sabitlenmiş (VSCode tarzı diff bölmesi)',
changesLoadError: 'Oturum dosya kayıtları şu anda kullanılamıyor',
staged: 'Sahnelendi',
unstaged: 'Sahnelenmedi',
cancel: 'İptal',
@@ -221,10 +243,10 @@ export const tr: Record<string, string> = {
settingsConflict: 'Ayar başka bir pencerede değiştirildi — lütfen yeniden deneyin',
binaryNoPreview: 'Bu dosya türü önizlenemiyor',
downloadToView: 'Görüntülemek için indir',
settingsSubagentTitle: 'Bir alt aracı belirdiğinde Görevler sayfasını otomatik aç',
settingsSubagentDesc: 'Geçerli sohbet yeni bir alt aracı doğurduğunda yan kartı genişlet ve Görevler sayfasını aç; elle açmak için kapatın',
settingsJobsTitle: 'Yeni arka plan işinde İşler sayfasını otomatik aç',
settingsJobsDesc: 'Geçerli sohbet için yeni bir arka plan işi belirdiğinde yan kartı genişlet ve İşler sayfasını aç (her yeni iş tetikler); elle açmak için kapatın',
settingsSubagentTitle: 'Bir alt aracı belirdiğinde Görevler sayfasını otomatik etkinleştir',
settingsSubagentDesc: 'Geçerli sohbet yeni bir alt aracı oluşturduğunda Görevler sayfasını etkinleştir; geniş ekranlarda yan kart da genişletilir, dar ekranlarda tam ekran çekmecesi zorla açılmaz; kapalıyken elle açılması gerekir',
settingsJobsTitle: 'Yeni bir arka plan işi belirdiğinde Görevler sayfasını otomatik etkinleştir',
settingsJobsDesc: 'Geçerli sohbette her yeni arka plan işi belirdiğinde Görevler sayfasını etkinleştir (her yeni iş tetikler); geniş ekranlarda yan kart da genişletilir, dar ekranlarda tam ekran çekmecesi zorla açılmaz; kapalıyken elle açılması gerekir',
settingsToolsTitle: 'Model için terminal araçları enjekte et',
settingsToolsDesc: 'Etkinleştirildiğinde model, 8 terminal_* aracı üzerinden kenar çubuğu terminalleri oluşturup kullanabilir (varsayılan kapalı)',
settingsFenceTitle: 'Çalışma alanı yol denetimi',
@@ -328,6 +350,25 @@ export const tr: Record<string, string> = {
sideChatClose: 'İş parçacığını kapat',
sideChatCloseTitle: 'İş parçacığının aracısını serbest bırak (geçmiş korunur)',
sideChatError: 'Yan sohbet hatası: {message}',
sideChatTurnUsage: 'Giriş {input} tok · Çıkış {output} tok',
sideChatBlockCollapse: 'Daralt',
sideChatBlockCollapseAria: 'Daralt',
sideChatBlockExpand: '{hidden} satırı genişlet',
sideChatBlockExpandAria: '{hidden} satır daha genişlet',
sideChatBlockSignal: 'Sinyalle sonlandırıldı: {signal}',
sideChatBlockExitCode: 'Çıkış kodu {code}',
sideChatBlockRunning: 'Çalışıyor',
sideChatBlockFailed: 'Başarısız',
sideChatBlockDone: 'Tamamlandı',
sideChatBlockNoOutput: '(çıktı yok)',
sideChatBlockFiles: '{count} dosya',
sideChatBlockWindow: '{total} satırdan {shown} satır',
sideChatConnDisconnected: 'Bağlantı koptu',
sideChatConnReconnect: 'Yeniden bağlan',
sideChatConnConnecting: 'Yeniden bağlanıyor…',
sideChatConnRecovered: 'Bağlantı geri yüklendi',
sideChatConnReconnectAction: 'Şimdi yeniden bağlan',
sideChatConnRestartAction: 'Bağlantıyı yeniden başlat',
jobs: 'Arka plan işleri',
jobsCount: '{count} arka plan işi',
jobsCountRunning: '{count} arka plan işi · {running} çalışıyor',
@@ -354,7 +395,7 @@ export const tr: Record<string, string> = {
addPluginsViewerCardDesc: 'Yeni bir dosya türü önizlemesi kaydet',
addPluginsTabDesc: 'Kenar çubuğu sayfaları (sekmeler) eklentilerce genişletilebilir. Eklentiler ctx.betterSidebar hizmeti aracılığıyla kaydolur; Yükle’ye tıklamak kurulum komutunu kopyalar — DSH profilinizin yaşadığı bir terminale yapıştırın ve çalıştırın.',
addPluginsViewerDesc: 'Dosya önizleyicileri eklentilerce genişletilebilir. Eklentiler ctx.betterSidebar hizmeti aracılığıyla kaydolur; Yükle’ye tıklamak kurulum komutunu kopyalar — DSH profilinizin yaşadığı bir terminale yapıştırın ve çalıştırın.',
addPluginsBrowseMore: 'GitHub’da daha fazla eklentiye göz atın (topic: dsh-better-sidebar)',
addPluginsBrowseMore: 'GitHub’da daha fazla eklentiye göz atın (topic: dsh-apes-sidebar)',
addPluginsSearch: 'Eklenti adı / açıklamasına göre ara…',
addPluginsNoMatch: 'Eşleşen eklenti yok',
addPluginsRecommended: 'Önerilen eklentiler',
@@ -367,7 +408,7 @@ export const tr: Record<string, string> = {
pluginGithubWorkbenchDesc: 'GitHub Workbench sekmesi: uzak depo ağacı + Issues / Pull requests / Actions tam yazma desteğiyle — Issue/PR oluşturma, yorum yapma, düzenleme, kapatma/yeniden açma, squash·merge·rebase birleştirme (güçlü onay), CI’yi yeniden çalıştırma/iptal etme; repo değiştirici erişilebilir depoları otomatik listeler ve genel depolarda arama yapar; better-sidebar yoksa bağımsız sağ panele geri düşer',
pluginMdExportDesc: 'better-sidebar Markdown araç çubuğuna «Dışa aktar» düğmesi ekler: geçerli .md dosyasını tek tıkla bağımsız HTML biçimine dönüştürür (tablolar/kod blokları/Mermaid diyagramları gömülü, düzen önizleme temasını izler) ve .md dosyasının yanına kaydeder ya da yazdırma penceresinden PDF dışa aktarır',
pluginCodeNavDesc: 'Kod önizleme gezinmesi: dosya türünden dili otomatik algılayıp söz dizimini vurgular, simge şeması (sınıf/yöntem/değişken filtreleri + tek tıkla atlama) ve dosya içi arama (tüm eşleşmeleri vurgula, önceki/sonraki, büyük/küçük harf duyarlı) — better-sidebar düzenleyicisinde kod dosyası önizlemesini devralır',
pluginSuhuangScrollDesc: 'Yerel Suhuang Scroll Runtime ortamını model yapılandırması, bağlantı testi ve kesintisiz değerlendirme denetimi için DSH ayarlarına ve better-sidebar öğesine bağlar; Suhuang Scroll Runtime ve dsh-better-sidebar gerektirir',
pluginSuhuangScrollDesc: 'Yerel Suhuang Scroll Runtime ortamını model yapılandırması, bağlantı testi ve kesintisiz değerlendirme denetimi için DSH ayarlarına ve better-sidebar öğesine bağlar; Suhuang Scroll Runtime ve dsh-apes-sidebar gerektirir',
pluginBetterOverleafDesc: 'better-sidebar için Overleaf sekmesi: doğrudan CDP tarayıcı girişi (üçüncü taraf Chromium destekli), proje listesi/geçiş, <workspace>/overleaf/ altında yerel git aynaları, salt okunur API yedekli çift yönlü git senkronizasyonu ve dosya önizlemesi kenar çubuğu workbench üzerinden',
pluginGitRemotesDesc: 'Git Remotes sekmesi: dal/yukarı akış/ahead-behind, fetch (isteğe bağlı prune), ff-only pull ve yalnızca sekme içi onaydan sonra push. Yerleşik Git sahnele/işle sekmesini değiştirmez; force-push veya model otomatik push aracı sunmaz',
pluginSentinelDesc: 'Koşul odaklı aracı uyandırma: dosya/süreç/port/HTTP/komut/web algılayıcılar koşullar tetiklendiğinde uyuyan oturumları uyandırır; sunucu geneli izleme tablosuyla «Sentinel» sekmesi kaydeder',
+49 -8
View File
@@ -17,8 +17,16 @@
/** The vi dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const vi: Record<string, string> = {
files: 'Tệp',
changesSessionEmpty: 'Chưa có thao tác tệp nào trong phiên này',
changesRead: 'Đọc',
changesWrite: 'Ghi',
changesEdit: 'Chỉnh sửa',
changesRunning: 'đang chạy',
changesError: 'lỗi',
changesFold: '{count} dòng… bấm để mở rộng',
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á',
git: 'Quản lý mã nguồn',
terminal: 'Terminal',
editor: 'Trình soạn thảo',
editorExplorer: 'Cách mở tệp',
@@ -128,7 +136,21 @@ export const vi: Record<string, string> = {
worktree: 'Worktree',
checkoutError: 'Chuyển branch thất bại',
history: 'Lịch sử',
changes: 'Thay đổi',
changes: 'Thay đổi tệp',
changesGitLens: 'Git',
changesSessionLens: 'Phiên',
changesFilterAll: 'Tất cả',
changesFilterEmpty: 'Không có thao tác thuộc loại này',
changesOpenDiffTab: 'Mở trong tab diff riêng',
changesClosePreview: 'Đóng xem trước',
changesResizePreview: 'Điều chỉnh kích thước xem trước',
changesDiffOpenTitle: 'diff mở thành',
changesDiffOpenDesc: 'Hành động «mở trong tab diff riêng» đặt diff ở đâu',
changesDiffOpenFloat: 'Cửa sổ tự do',
changesDiffOpenFloatDesc: 'Cửa sổ nổi giữa khung nhìn — kéo, đổi kích thước, ghim trên cùng',
changesDiffOpenPane: 'Bảng',
changesDiffOpenPaneDesc: 'Gắn dưới bảng nguồn (chia diff kiểu VSCode)',
changesLoadError: 'Bản ghi tệp phiên hiện không khả dụng',
staged: 'Đã stage',
unstaged: 'Chưa stage',
cancel: 'Hủy',
@@ -221,10 +243,10 @@ export const vi: Record<string, string> = {
settingsConflict: 'Cài đặt đã bị sửa bởi cửa sổ khác, vui lòng thử lại',
binaryNoPreview: 'Loại tệp này không hỗ trợ xem trước',
downloadToView: 'Tải xuống để xem',
settingsSubagentTitle: 'Tự động mở trang Quản lý tác vụ khi phát hiện tác nhân con',
settingsSubagentDesc: 'Khi phiên hiện tại tạo tác nhân con mới, tự động mở rộng thanh bên và mở trang Quản lý tác vụ; tắt thì phải mở thủ công',
settingsJobsTitle: 'Tự động mở trang Tác vụ nền khi có tác vụ nền mới',
settingsJobsDesc: 'Khi phiên hiện tại có tác vụ nền mới, tự động mở rộng thanh bên và mở trang Tác vụ nền (mỗi tác vụ mới đều kích hoạt); tắt thì phải mở thủ công',
settingsSubagentTitle: 'Tự động kích hoạt trang Quản lý tác vụ khi phát hiện tác nhân con',
settingsSubagentDesc: 'Khi phiên hiện tại tạo tác nhân con mới, kích hoạt trang Quản lý tác vụ; trên màn hình rộng, thẻ bên cũng được mở rộng; trên màn hình hẹp, ngăn kéo toàn màn hình không bị ép mở; tắt để mở thủ công',
settingsJobsTitle: 'Tự động kích hoạt trang Quản lý tác vụ khi có tác vụ nền mới',
settingsJobsDesc: 'Mỗi khi phiên hiện tại có tác vụ nền mới, kích hoạt trang Quản lý tác vụ (mỗi tác vụ mới đều kích hoạt); trên màn hình rộng, thẻ bên cũng được mở rộng; trên màn hình hẹp, ngăn kéo toàn màn hình không bị ép mở; tắt để mở thủ công',
settingsToolsTitle: 'Tiêm công cụ terminal cho mô hình',
settingsToolsDesc: 'Khi bật, mô hình có thể tạo và điều khiển terminal thanh bên qua 8 công cụ terminal_* (mặc định tắt)',
settingsFenceTitle: 'Kiểm tra đường dẫn workspace',
@@ -328,6 +350,25 @@ export const vi: Record<string, string> = {
sideChatClose: 'Đóng luồng',
sideChatCloseTitle: 'Giải phóng agent của luồng (lịch sử được giữ)',
sideChatError: 'Lỗi chat bên: {message}',
sideChatTurnUsage: 'Vào {input} tok · Ra {output} tok',
sideChatBlockCollapse: 'Thu gọn',
sideChatBlockCollapseAria: 'Thu gọn',
sideChatBlockExpand: 'Mở rộng {hidden} dòng',
sideChatBlockExpandAria: 'Mở rộng thêm {hidden} dòng',
sideChatBlockSignal: 'Kết thúc bởi tín hiệu: {signal}',
sideChatBlockExitCode: 'Mã thoát {code}',
sideChatBlockRunning: 'Đang chạy',
sideChatBlockFailed: 'Thất bại',
sideChatBlockDone: 'Hoàn tất',
sideChatBlockNoOutput: '(không có đầu ra)',
sideChatBlockFiles: '{count} tệp',
sideChatBlockWindow: '{shown} trong {total} dòng',
sideChatConnDisconnected: 'Mất kết nối',
sideChatConnReconnect: 'Kết nối lại',
sideChatConnConnecting: 'Đang kết nối lại…',
sideChatConnRecovered: 'Đã khôi phục kết nối',
sideChatConnReconnectAction: 'Kết nối lại ngay',
sideChatConnRestartAction: 'Khởi động lại kết nối',
jobs: 'Tác vụ nền',
jobsCount: '{count} tác vụ nền',
jobsCountRunning: '{count} tác vụ nền · {running} đang chạy',
@@ -354,7 +395,7 @@ export const vi: Record<string, string> = {
addPluginsViewerCardDesc: 'Đăng ký xem trước loại tệp mới',
addPluginsTabDesc: 'Trang thanh bên (tab) có thể mở rộng qua plugin. Plugin đăng ký qua service ctx.betterSidebar; nhấp «Cài đặt» để sao chép lệnh cài, dán vào terminal nơi DSH chạy rồi thực thi.',
addPluginsViewerDesc: 'Trình xem tệp có thể mở rộng qua plugin. Plugin đăng ký qua service ctx.betterSidebar; nhấp «Cài đặt» để sao chép lệnh cài, dán vào terminal nơi DSH chạy rồi thực thi.',
addPluginsBrowseMore: 'Duyệt thêm plugin trên GitHub (topic: dsh-better-sidebar)',
addPluginsBrowseMore: 'Duyệt thêm plugin trên GitHub (topic: dsh-apes-sidebar)',
addPluginsSearch: 'Tìm theo tên / mô tả plugin…',
addPluginsNoMatch: 'Không có plugin phù hợp',
addPluginsRecommended: 'Plugin đề xuất',
@@ -367,7 +408,7 @@ export const vi: Record<string, string> = {
pluginGithubWorkbenchDesc: 'Tab GitHub Workbench: cây repo từ xa + Issues / Pull requests / Actions với hỗ trợ ghi đầy đủ — tạo Issue/PR, bình luận, chỉnh sửa, đóng/mở lại, merge squash·merge·rebase (xác nhận mạnh), chạy lại/hủy CI; bộ chuyển repo tự liệt kê các repo có quyền truy cập và tìm kiếm repo công khai; khi chưa cài better-sidebar sẽ tự động chuyển sang panel bên phải độc lập',
pluginMdExportDesc: 'Thêm nút «Xuất» vào thanh công cụ Markdown của better-sidebar: một cú nhấp chuyển .md hiện tại thành HTML độc lập (bảng/khối mã/sơ đồ Mermaid nhúng, bố cục theo theme xem trước) và lưu cạnh tệp .md, hoặc xuất PDF qua hộp thoại in',
pluginCodeNavDesc: 'Điều hướng xem trước mã: tự nhận diện ngôn ngữ theo loại tệp và tô sáng cú pháp, phác thảo ký hiệu (lọc lớp/phương thức/biến + nhảy một cú nhấp), tìm kiếm trong tệp (tô sáng mọi kết quả, trước/sau, phân biệt hoa thường) — đảm nhận xem trước tệp mã trong trình soạn thảo better-sidebar',
pluginSuhuangScrollDesc: 'Kết nối Suhuang Scroll Runtime cục bộ với phần cài đặt DSH và better-sidebar để cấu hình mô hình, kiểm tra kết nối và điều khiển chấm bài liên tục; yêu cầu Suhuang Scroll Runtime và dsh-better-sidebar',
pluginSuhuangScrollDesc: 'Kết nối Suhuang Scroll Runtime cục bộ với phần cài đặt DSH và better-sidebar để cấu hình mô hình, kiểm tra kết nối và điều khiển chấm bài liên tục; yêu cầu Suhuang Scroll Runtime và dsh-apes-sidebar',
pluginBetterOverleafDesc: 'Tab Overleaf cho better-sidebar: đăng nhập trực tiếp qua trình duyệt CDP (hỗ trợ Chromium bên thứ ba), danh sách/chuyển dự án, mirror git cục bộ trong <workspace>/overleaf/, đồng bộ git hai chiều với dự phòng API chỉ đọc, xem trước tệp qua workbench của thanh bên',
pluginGitRemotesDesc: 'Tab Git Remotes của better-sidebar: xem branch/upstream/ahead-behind, fetch (có thể prune), pull ff-only, push chỉ sau khi xác nhận trong tab. Không thay thế tab stage/commit Git built-in, cũng không cung cấp force-push hay công cụ auto-push cho mô hình',
pluginSentinelDesc: 'Hệ thống đánh thức agent theo điều kiện: cảm biến file/process/port/HTTP/command/webhook, khi điều kiện đạt sẽ tự đánh thức phiên ngủ; đăng ký tab «Sentinel» hiển thị bảng giám sát toàn server',
+49 -8
View File
@@ -32,8 +32,16 @@
/** The zh-HK dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const zhHK: Record<string, string> = {
files: '檔案',
changesSessionEmpty: '本工作階段還沒有檔案操作',
changesRead: '讀取',
changesWrite: '寫入',
changesEdit: '編輯',
changesRunning: '執行中',
changesError: '出錯',
changesFold: '{count} 行…點擊展開',
changesContext: '上下文',
changesPriorUnknown: '變更前的內容不在視窗內,顯示為全部新增',
explorer: '檔案總管',
git: '原始碼管理',
terminal: '終端',
editor: '編輯器',
editorExplorer: '檔案開啟方式',
@@ -143,7 +151,21 @@ export const zhHK: Record<string, string> = {
worktree: '工作樹',
checkoutError: '切換分支失敗',
history: '歷史',
changes: '變更',
changes: '檔案變動',
changesGitLens: 'Git',
changesSessionLens: '本輪檔案',
changesFilterAll: '全部',
changesFilterEmpty: '沒有此類操作',
changesOpenDiffTab: '在獨立分頁中開啟',
changesClosePreview: '關閉預覽',
changesResizePreview: '調整預覽高度',
changesDiffOpenTitle: '差異展開方式',
changesDiffOpenDesc: '「展開為獨立分頁」把 diff 放到哪裡',
changesDiffOpenFloat: '浮動視窗',
changesDiffOpenFloatDesc: '作為自由視窗置中開啟,可拖拽、縮放、置頂',
changesDiffOpenPane: '面板',
changesDiffOpenPaneDesc: '停駐在來源面板下方(VSCode 式 diff 分欄)',
changesLoadError: '工作階段檔案記錄暫不可用',
staged: '已暫存',
unstaged: '未暫存',
cancel: '取消',
@@ -236,10 +258,10 @@ export const zhHK: Record<string, string> = {
settingsConflict: '設定已被其他視窗修改,請重試',
binaryNoPreview: '此檔案類型不支援預覽',
downloadToView: '下載檢視',
settingsSubagentTitle: '偵測到子代理時自動展開任務管理頁',
settingsSubagentDesc: '目前工作階段產生新的子代理時,自動展開側邊欄並開啟任務管理頁;關閉後需手動開啟',
settingsJobsTitle: '有新背景任務時自動展開背景任務頁',
settingsJobsDesc: '目前工作階段出現新的背景任務時,自動展開側邊欄並開啟背景任務頁(每個新任務都會觸發);關閉後需手動開啟',
settingsSubagentTitle: '偵測到子代理時自動選取任務管理頁',
settingsSubagentDesc: '目前工作階段產生新的子代理時,自動選取任務管理頁;寬螢幕時亦會展開側邊欄,窄螢幕時不會強制開啟全螢幕抽屜;關閉後需手動選取',
settingsJobsTitle: '有新背景任務時自動選取任務管理頁',
settingsJobsDesc: '目前工作階段出現新的背景任務時,自動選取任務管理頁(每個新任務都會觸發);寬螢幕時亦會展開側邊欄,窄螢幕時不會強制開啟全螢幕抽屜;關閉後需手動選取',
settingsToolsTitle: '為模型注入終端工具',
settingsToolsDesc: '開啟後,模型可透過 terminal_create 等 8 個工具建立並操作側邊欄終端(預設關閉)',
settingsFenceTitle: '工作區路徑檢測',
@@ -343,6 +365,25 @@ export const zhHK: Record<string, string> = {
sideChatClose: '關閉執行緒',
sideChatCloseTitle: '釋放執行緒的 agent(歷史保留)',
sideChatError: '側邊對話出錯:{message}',
sideChatTurnUsage: '輸入 {input} tok · 輸出 {output} tok',
sideChatBlockCollapse: '收起',
sideChatBlockCollapseAria: '收起',
sideChatBlockExpand: '展開 {hidden} 行',
sideChatBlockExpandAria: '展開其餘 {hidden} 行',
sideChatBlockSignal: '訊號終止:{signal}',
sideChatBlockExitCode: '結束碼 {code}',
sideChatBlockRunning: '執行中',
sideChatBlockFailed: '失敗',
sideChatBlockDone: '完成',
sideChatBlockNoOutput: '(無輸出)',
sideChatBlockFiles: '{count} 個檔案',
sideChatBlockWindow: '共 {total} 行 · 顯示 {shown} 行',
sideChatConnDisconnected: '連線已中斷',
sideChatConnReconnect: '重新連線',
sideChatConnConnecting: '正在重新連線…',
sideChatConnRecovered: '連線已恢復',
sideChatConnReconnectAction: '立即重新連線',
sideChatConnRestartAction: '重新發起連線',
jobs: '背景任務',
jobsCount: '{count} 個背景任務',
jobsCountRunning: '{count} 個背景任務 · {running} 執行中',
@@ -369,7 +410,7 @@ export const zhHK: Record<string, string> = {
addPluginsViewerCardDesc: '註冊新的檔案類型預覽',
addPluginsTabDesc: '側邊欄頁面(Tab)可以由插件擴展。插件透過 ctx.betterSidebar 服務註冊;點擊「安裝」複製安裝命令,貼到 DSH 所在環境的終端執行。',
addPluginsViewerDesc: '檔案預覽器可以由插件擴展。插件透過 ctx.betterSidebar 服務註冊;點擊「安裝」複製安裝命令,貼到 DSH 所在環境的終端執行。',
addPluginsBrowseMore: '在 GitHub 上瀏覽更多插件(topic: dsh-better-sidebar)',
addPluginsBrowseMore: '在 GitHub 上瀏覽更多插件(topic: dsh-apes-sidebar)',
addPluginsSearch: '搜尋插件名稱 / 描述…',
addPluginsNoMatch: '沒有匹配的插件',
addPluginsRecommended: '推薦插件',
@@ -382,7 +423,7 @@ export const zhHK: Record<string, string> = {
pluginGithubWorkbenchDesc: 'better-sidebar「GitHub 工作台」Tab:遠端儲存庫目錄樹 + Issues / Pull requests / Actions 頁簽,讀取之外支援新建 Issue/PR、評論、編輯、關閉重開、squash·merge·rebase 合併(強確認)與重跑/取消 CI;儲存庫彈出層自動拉取有權限列表並支援公開儲存庫搜尋;未安裝 better-sidebar 時自動降級為獨立右側面板',
pluginMdExportDesc: '在 better-sidebar 的 Markdown 工具列上新增「匯出」按鈕:一鍵把目前 .md 渲染為獨立 HTML(表格/程式碼區塊/Mermaid 圖表內嵌,版面跟隨預覽主題)直接儲存到同目錄,或透過列印對話框匯出為 PDF',
pluginCodeNavDesc: '程式碼預覽導覽:按檔案類型自動識別語言並高亮語法,符號大綱(類別/方法/變數篩選 + 一鍵跳轉),檔案內搜尋(全部符合高亮、上/下一處、區分大小寫),接管 better-sidebar 的程式碼檔案預覽',
pluginSuhuangScrollDesc: '把本機蘇黃共閱 Runtime 接入 DSH 設定與 better-sidebar,支援模型設定、連線測試及連續閱卷控制;使用前須安裝 Suhuang Scroll Runtime 與 dsh-better-sidebar',
pluginSuhuangScrollDesc: '把本機蘇黃共閱 Runtime 接入 DSH 設定與 better-sidebar,支援模型設定、連線測試及連續閱卷控制;使用前須安裝 Suhuang Scroll Runtime 與 dsh-apes-sidebar',
pluginBetterOverleafDesc: 'better-sidebar 的 Overleaf 分頁:直連 CDP 瀏覽器登入(支援第三方 Chromium),專案列表/切換,<workspace>/overleaf/ 本地 git 映像,git 雙向同步(API 唯讀兜底),檔案預覽走側邊欄工作台',
pluginGitRemotesDesc: 'better-sidebar Git 遠端 Tab:看分支/上游/ahead-behind,fetch(可 prune)、ff-only pull、確認後才 push。不替換內建 Git 的暫存/提交,也不提供 force-push 或模型自動推送',
pluginSentinelDesc: '條件驅動的 agent 喚醒系統:檔案/程序/連接埠/HTTP/命令/webhook 感測器,條件達成自動喚醒休眠工作階段;註冊「哨兵」Tab 展示伺服器全域監控表',
+49 -8
View File
@@ -32,8 +32,16 @@
/** The zh-HK dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const zhMO: Record<string, string> = {
files: '檔案',
changesSessionEmpty: '本工作階段還沒有檔案操作',
changesRead: '讀取',
changesWrite: '寫入',
changesEdit: '編輯',
changesRunning: '執行中',
changesError: '出錯',
changesFold: '{count} 行…點擊展開',
changesContext: '上下文',
changesPriorUnknown: '變更前的內容不在視窗內,顯示為全部新增',
explorer: '檔案總管',
git: '原始碼管理',
terminal: '終端',
editor: '編輯器',
editorExplorer: '檔案開啟方式',
@@ -143,7 +151,21 @@ export const zhMO: Record<string, string> = {
worktree: '工作樹',
checkoutError: '切換分支失敗',
history: '歷史',
changes: '變更',
changes: '檔案變動',
changesGitLens: 'Git',
changesSessionLens: '本輪檔案',
changesFilterAll: '全部',
changesFilterEmpty: '沒有此類操作',
changesOpenDiffTab: '在獨立分頁中開啟',
changesClosePreview: '關閉預覽',
changesResizePreview: '調整預覽高度',
changesDiffOpenTitle: '差異展開方式',
changesDiffOpenDesc: '「展開為獨立分頁」把 diff 放到哪裡',
changesDiffOpenFloat: '浮動視窗',
changesDiffOpenFloatDesc: '作為自由視窗置中開啟,可拖拽、縮放、置頂',
changesDiffOpenPane: '面板',
changesDiffOpenPaneDesc: '停駐在來源面板下方(VSCode 式 diff 分欄)',
changesLoadError: '工作階段檔案記錄暫不可用',
staged: '已暫存',
unstaged: '未暫存',
cancel: '取消',
@@ -236,10 +258,10 @@ export const zhMO: Record<string, string> = {
settingsConflict: '設定已被其他視窗修改,請重試',
binaryNoPreview: '此檔案類型不支援預覽',
downloadToView: '下載檢視',
settingsSubagentTitle: '偵測到子代理時自動展開任務管理頁',
settingsSubagentDesc: '目前工作階段產生新的子代理時,自動展開側邊欄並開啟任務管理頁;關閉後需手動開啟',
settingsJobsTitle: '有新背景任務時自動展開背景任務頁',
settingsJobsDesc: '目前工作階段出現新的背景任務時,自動展開側邊欄並開啟背景任務頁(每個新任務都會觸發);關閉後需手動開啟',
settingsSubagentTitle: '偵測到子代理時自動選取任務管理頁',
settingsSubagentDesc: '目前工作階段產生新的子代理時,自動選取任務管理頁;寬螢幕時亦會展開側邊欄,窄螢幕時不會強制開啟全螢幕抽屜;關閉後需手動選取',
settingsJobsTitle: '有新背景任務時自動選取任務管理頁',
settingsJobsDesc: '目前工作階段出現新的背景任務時,自動選取任務管理頁(每個新任務都會觸發);寬螢幕時亦會展開側邊欄,窄螢幕時不會強制開啟全螢幕抽屜;關閉後需手動選取',
settingsToolsTitle: '為模型注入終端工具',
settingsToolsDesc: '開啟後,模型可透過 terminal_create 等 8 個工具建立並操作側邊欄終端(預設關閉)',
settingsFenceTitle: '工作區路徑檢測',
@@ -343,6 +365,25 @@ export const zhMO: Record<string, string> = {
sideChatClose: '關閉執行緒',
sideChatCloseTitle: '釋放執行緒的 agent(歷史保留)',
sideChatError: '側邊對話出錯:{message}',
sideChatTurnUsage: '輸入 {input} tok · 輸出 {output} tok',
sideChatBlockCollapse: '收起',
sideChatBlockCollapseAria: '收起',
sideChatBlockExpand: '展開 {hidden} 行',
sideChatBlockExpandAria: '展開其餘 {hidden} 行',
sideChatBlockSignal: '訊號終止:{signal}',
sideChatBlockExitCode: '結束碼 {code}',
sideChatBlockRunning: '執行中',
sideChatBlockFailed: '失敗',
sideChatBlockDone: '完成',
sideChatBlockNoOutput: '(無輸出)',
sideChatBlockFiles: '{count} 個檔案',
sideChatBlockWindow: '共 {total} 行 · 顯示 {shown} 行',
sideChatConnDisconnected: '連線已中斷',
sideChatConnReconnect: '重新連線',
sideChatConnConnecting: '正在重新連線…',
sideChatConnRecovered: '連線已恢復',
sideChatConnReconnectAction: '立即重新連線',
sideChatConnRestartAction: '重新發起連線',
jobs: '背景任務',
jobsCount: '{count} 個背景任務',
jobsCountRunning: '{count} 個背景任務 · {running} 執行中',
@@ -369,7 +410,7 @@ export const zhMO: Record<string, string> = {
addPluginsViewerCardDesc: '註冊新的檔案類型預覽',
addPluginsTabDesc: '側邊欄頁面(Tab)可以由插件擴展。插件透過 ctx.betterSidebar 服務註冊;點擊「安裝」複製安裝命令,貼到 DSH 所在環境的終端執行。',
addPluginsViewerDesc: '檔案預覽器可以由插件擴展。插件透過 ctx.betterSidebar 服務註冊;點擊「安裝」複製安裝命令,貼到 DSH 所在環境的終端執行。',
addPluginsBrowseMore: '在 GitHub 上瀏覽更多插件(topic: dsh-better-sidebar)',
addPluginsBrowseMore: '在 GitHub 上瀏覽更多插件(topic: dsh-apes-sidebar)',
addPluginsSearch: '搜尋插件名稱 / 描述…',
addPluginsNoMatch: '沒有匹配的插件',
addPluginsRecommended: '推薦插件',
@@ -382,7 +423,7 @@ export const zhMO: Record<string, string> = {
pluginGithubWorkbenchDesc: 'better-sidebar「GitHub 工作台」Tab:遠端儲存庫目錄樹 + Issues / Pull requests / Actions 頁簽,讀取之外支援新建 Issue/PR、評論、編輯、關閉重開、squash·merge·rebase 合併(強確認)與重跑/取消 CI;儲存庫彈層自動拉取有權限清單並支援公開儲存庫搜尋;未安裝 better-sidebar 時自動降級為獨立右側面板',
pluginMdExportDesc: '在 better-sidebar 的 Markdown 工具列上新增「匯出」按鈕:一鍵把目前 .md 渲染為獨立 HTML(表格/程式碼區塊/Mermaid 圖表內嵌,版面跟隨預覽主題)直接儲存到同目錄,或透過列印對話框匯出為 PDF',
pluginCodeNavDesc: '程式碼預覽導覽:按檔案類型自動識別語言並高亮語法,符號大綱(類別/方法/變數篩選 + 一鍵跳轉),檔案內搜尋(全部符合高亮、上/下一處、區分大小寫),接管 better-sidebar 的程式碼檔案預覽',
pluginSuhuangScrollDesc: '把本機蘇黃共閱 Runtime 接入 DSH 設定與 better-sidebar,支援模型設定、連線測試及連續閱卷控制;使用前須安裝 Suhuang Scroll Runtime 與 dsh-better-sidebar',
pluginSuhuangScrollDesc: '把本機蘇黃共閱 Runtime 接入 DSH 設定與 better-sidebar,支援模型設定、連線測試及連續閱卷控制;使用前須安裝 Suhuang Scroll Runtime 與 dsh-apes-sidebar',
pluginBetterOverleafDesc: 'better-sidebar 的 Overleaf 分頁:直連 CDP 瀏覽器登入(支援第三方 Chromium),專案列表/切換,<workspace>/overleaf/ 本地 git 映像,git 雙向同步(API 唯讀兜底),檔案預覽走側邊欄工作台',
pluginGitRemotesDesc: 'better-sidebar Git 遠端 Tab:看分支/上游/ahead-behind,fetch(可 prune)、ff-only pull、確認後才 push。不替換內建 Git 的暫存/提交,也不提供 force-push 或模型自動推送',
pluginSentinelDesc: '條件驅動的 agent 喚醒系統:檔案/程序/連接埠/HTTP/命令/webhook 感測器,條件達成自動喚醒休眠工作階段;註冊「哨兵」Tab 展示伺服器全域監控表',
+49 -8
View File
@@ -32,8 +32,16 @@
/** The zh-HK dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const zhTW: Record<string, string> = {
files: '檔案',
changesSessionEmpty: '本作業階段還沒有檔案操作',
changesRead: '讀取',
changesWrite: '寫入',
changesEdit: '編輯',
changesRunning: '執行中',
changesError: '出錯',
changesFold: '{count} 行…點擊展開',
changesContext: '上下文',
changesPriorUnknown: '變更前的內容不在視窗內,顯示為全部新增',
explorer: '檔案總管',
git: '原始碼管理',
terminal: '終端',
editor: '編輯器',
editorExplorer: '檔案開啟方式',
@@ -143,7 +151,21 @@ export const zhTW: Record<string, string> = {
worktree: '工作樹',
checkoutError: '切換分支失敗',
history: '歷史',
changes: '變更',
changes: '檔案變動',
changesGitLens: 'Git',
changesSessionLens: '本輪檔案',
changesFilterAll: '全部',
changesFilterEmpty: '沒有此類操作',
changesOpenDiffTab: '在獨立頁籤中開啟',
changesClosePreview: '關閉預覽',
changesResizePreview: '調整預覽高度',
changesDiffOpenTitle: '差異展開方式',
changesDiffOpenDesc: '「展開為獨立頁籤」把 diff 放到哪裡',
changesDiffOpenFloat: '浮動視窗',
changesDiffOpenFloatDesc: '作為自由視窗置中開啟,可拖拽、縮放、置頂',
changesDiffOpenPane: '面板',
changesDiffOpenPaneDesc: '停駐在來源面板下方(VSCode 式 diff 分欄)',
changesLoadError: '工作階段檔案記錄暫不可用',
staged: '已暫存',
unstaged: '未暫存',
cancel: '取消',
@@ -236,10 +258,10 @@ export const zhTW: Record<string, string> = {
settingsConflict: '設定已被其他視窗修改,請重試',
binaryNoPreview: '此檔案類型不支援預覽',
downloadToView: '下載檢視',
settingsSubagentTitle: '偵測到子代理時自動展開任務管理頁',
settingsSubagentDesc: '目前工作階段產生新的子代理時,自動展開側邊欄並開啟任務管理頁;關閉後需手動開啟',
settingsJobsTitle: '有新背景任務時自動展開背景任務頁',
settingsJobsDesc: '目前工作階段出現新的背景任務時,自動展開側邊欄並開啟背景任務頁(每個新任務都會觸發);關閉後需手動開啟',
settingsSubagentTitle: '偵測到子代理時自動選取任務管理頁',
settingsSubagentDesc: '目前工作階段產生新的子代理時,自動選取任務管理頁;寬螢幕時亦會展開側邊欄,窄螢幕時不會強制開啟全螢幕抽屜;關閉後需手動選取',
settingsJobsTitle: '有新背景任務時自動選取任務管理頁',
settingsJobsDesc: '目前工作階段出現新的背景任務時,自動選取任務管理頁(每個新任務都會觸發);寬螢幕時亦會展開側邊欄,窄螢幕時不會強制開啟全螢幕抽屜;關閉後需手動選取',
settingsToolsTitle: '為模型注入終端工具',
settingsToolsDesc: '開啟後,模型可透過 terminal_create 等 8 個工具建立並操作側邊欄終端(預設關閉)',
settingsFenceTitle: '工作區路徑檢測',
@@ -343,6 +365,25 @@ export const zhTW: Record<string, string> = {
sideChatClose: '關閉執行緒',
sideChatCloseTitle: '釋放執行緒的 agent(歷史保留)',
sideChatError: '側邊對話出錯:{message}',
sideChatTurnUsage: '輸入 {input} tok · 輸出 {output} tok',
sideChatBlockCollapse: '收起',
sideChatBlockCollapseAria: '收起',
sideChatBlockExpand: '展開 {hidden} 行',
sideChatBlockExpandAria: '展開其餘 {hidden} 行',
sideChatBlockSignal: '訊號終止:{signal}',
sideChatBlockExitCode: '結束碼 {code}',
sideChatBlockRunning: '執行中',
sideChatBlockFailed: '失敗',
sideChatBlockDone: '完成',
sideChatBlockNoOutput: '(無輸出)',
sideChatBlockFiles: '{count} 個檔案',
sideChatBlockWindow: '共 {total} 行 · 顯示 {shown} 行',
sideChatConnDisconnected: '連線已中斷',
sideChatConnReconnect: '重新連線',
sideChatConnConnecting: '正在重新連線…',
sideChatConnRecovered: '連線已恢復',
sideChatConnReconnectAction: '立即重新連線',
sideChatConnRestartAction: '重新發起連線',
jobs: '背景任務',
jobsCount: '{count} 個背景任務',
jobsCountRunning: '{count} 個背景任務 · {running} 執行中',
@@ -369,7 +410,7 @@ export const zhTW: Record<string, string> = {
addPluginsViewerCardDesc: '註冊新的檔案類型預覽',
addPluginsTabDesc: '側邊欄頁面(Tab)可以由插件擴展。插件透過 ctx.betterSidebar 服務註冊;點擊「安裝」複製安裝命令,貼到 DSH 所在環境的終端執行。',
addPluginsViewerDesc: '檔案預覽器可以由插件擴展。插件透過 ctx.betterSidebar 服務註冊;點擊「安裝」複製安裝命令,貼到 DSH 所在環境的終端執行。',
addPluginsBrowseMore: '在 GitHub 上瀏覽更多插件(topic: dsh-better-sidebar)',
addPluginsBrowseMore: '在 GitHub 上瀏覽更多插件(topic: dsh-apes-sidebar)',
addPluginsSearch: '搜尋插件名稱 / 描述…',
addPluginsNoMatch: '沒有匹配的插件',
addPluginsRecommended: '推薦插件',
@@ -382,7 +423,7 @@ export const zhTW: Record<string, string> = {
pluginGithubWorkbenchDesc: 'better-sidebar「GitHub 工作台」Tab:遠端儲存庫目錄樹 + Issues / Pull requests / Actions 頁籤,讀取之外支援建立 Issue/PR、留言、編輯、關閉重開、squash·merge·rebase 合併(強確認)與重跑/取消 CI;儲存庫彈出視窗自動載入有權限清單並支援公開儲存庫搜尋;未安裝 better-sidebar 時自動降級為獨立右側面板',
pluginMdExportDesc: '在 better-sidebar 的 Markdown 工具列上新增「匯出」按鈕:一鍵把目前 .md 渲染為獨立 HTML(表格/程式碼區塊/Mermaid 圖表內嵌,版面跟隨預覽主題)直接儲存到同目錄,或透過列印對話框匯出為 PDF',
pluginCodeNavDesc: '程式碼預覽導覽:按檔案類型自動識別語言並高亮語法,符號大綱(類別/方法/變數篩選 + 一鍵跳轉),檔案內搜尋(全部符合高亮、上/下一處、區分大小寫),接管 better-sidebar 的程式碼檔案預覽',
pluginSuhuangScrollDesc: '把本機蘇黃共閱 Runtime 接入 DSH 設定與 better-sidebar,支援模型設定、連線測試及連續閱卷控制;使用前須安裝 Suhuang Scroll Runtime 與 dsh-better-sidebar',
pluginSuhuangScrollDesc: '把本機蘇黃共閱 Runtime 接入 DSH 設定與 better-sidebar,支援模型設定、連線測試及連續閱卷控制;使用前須安裝 Suhuang Scroll Runtime 與 dsh-apes-sidebar',
pluginBetterOverleafDesc: 'better-sidebar 的 Overleaf 分頁:直連 CDP 瀏覽器登入(支援第三方 Chromium),專案列表/切換,<workspace>/overleaf/ 本地 git 映像,git 雙向同步(API 唯讀兜底),檔案預覽走側邊欄工作台',
pluginGitRemotesDesc: 'better-sidebar Git 遠端 Tab:看分支/上游/ahead-behind,fetch(可 prune)、ff-only pull、確認後才 push。不替換內建 Git 的暫存/提交,也不提供 force-push 或模型自動推送',
pluginSentinelDesc: '條件驅動的 agent 喚醒系統:檔案/程序/連接埠/HTTP/命令/webhook 感測器,條件達成自動喚醒休眠工作階段;註冊「哨兵」Tab 展示伺服器全域監控表',
+104 -58
View File
@@ -24,8 +24,16 @@
/** The zh dictionary (also registered into the DSH locale registry under {@link LOCALE_NS}). */
export const zh = {
files: '文件',
changesSessionEmpty: '本会话还没有文件操作',
changesRead: '读取',
changesWrite: '写入',
changesEdit: '编辑',
changesRunning: '执行中',
changesError: '出错',
changesFold: '{count} 行…点击展开',
changesContext: '上下文',
changesPriorUnknown: '变更前的内容不在窗口内,显示为全新增',
explorer: '资源管理器',
git: '源代码管理',
terminal: '终端',
editor: '编辑器',
editorExplorer: '文件打开方式',
@@ -134,7 +142,21 @@ export const zh = {
worktree: '工作树',
checkoutError: '切换分支失败',
history: '历史',
changes: '变更',
changes: '文件变动',
changesGitLens: 'Git',
changesSessionLens: '本轮文件',
changesFilterAll: '全部',
changesFilterEmpty: '没有此类操作',
changesOpenDiffTab: '在独立页签中打开',
changesClosePreview: '关闭预览',
changesResizePreview: '调整预览高度',
changesDiffOpenTitle: '差异展开方式',
changesDiffOpenDesc: '「展开为独立页签」把 diff 放到哪里',
changesDiffOpenFloat: '浮窗',
changesDiffOpenFloatDesc: '作为自由窗口居中打开,可拖拽、缩放、置顶',
changesDiffOpenPane: '面板',
changesDiffOpenPaneDesc: '停靠在源面板下方(VSCode 式 diff 分栏)',
changesLoadError: '会话文件记录暂不可用',
staged: '已暂存',
unstaged: '未暂存',
cancel: '取消',
@@ -228,10 +250,10 @@ export const zh = {
settingsConflict: '设置已被其他窗口修改,请重试',
binaryNoPreview: '此文件类型不支持预览',
downloadToView: '下载查看',
settingsSubagentTitle: '检测到子代理时自动展开任务管理页',
settingsSubagentDesc: '当前会话产生新的子代理时,自动展开侧边栏并打开任务管理页;关闭后需手动打开',
settingsJobsTitle: '有新后台任务时自动展开后台任务页',
settingsJobsDesc: '当前会话出现新的后台任务时,自动展开侧边栏并打开后台任务页(每个新任务都会触发);关闭后需手动打开',
settingsSubagentTitle: '检测到子代理时自动激活任务管理页',
settingsSubagentDesc: '当前会话产生新的子代理时,自动激活任务管理页;宽屏同时展开侧边栏,窄屏不强制展开全屏抽屉;关闭后需手动打开',
settingsJobsTitle: '有新后台任务时自动激活任务管理页',
settingsJobsDesc: '当前会话出现新的后台任务时,自动激活任务管理页(每个新任务都会触发);宽屏同时展开侧边栏,窄屏不强制展开全屏抽屉;关闭后需手动打开',
settingsToolsTitle: '为模型注入终端工具',
settingsToolsDesc: '开启后,模型可通过 terminal_create 等 8 个工具创建并操作侧边栏终端(默认关闭)',
settingsFenceTitle: '工作区路径检测',
@@ -335,6 +357,25 @@ export const zh = {
sideChatClose: '关闭线程',
sideChatCloseTitle: '释放线程的 agent(历史保留)',
sideChatError: '侧边对话出错:{message}',
sideChatTurnUsage: '输入 {input} tok · 输出 {output} tok',
sideChatBlockCollapse: '收起',
sideChatBlockCollapseAria: '收起',
sideChatBlockExpand: '展开 {hidden} 行',
sideChatBlockExpandAria: '展开其余 {hidden} 行',
sideChatBlockSignal: '信号终止:{signal}',
sideChatBlockExitCode: '退出码 {code}',
sideChatBlockRunning: '运行中',
sideChatBlockFailed: '失败',
sideChatBlockDone: '完成',
sideChatBlockNoOutput: '(无输出)',
sideChatBlockFiles: '{count} 个文件',
sideChatBlockWindow: '共 {total} 行 · 显示 {shown} 行',
sideChatConnDisconnected: '连接已断开',
sideChatConnReconnect: '重新连接',
sideChatConnConnecting: '正在重连…',
sideChatConnRecovered: '连接已恢复',
sideChatConnReconnectAction: '立即重连',
sideChatConnRestartAction: '重新发起连接',
jobs: '后台任务',
jobsCount: '{count} 个后台任务',
jobsCountRunning: '{count} 个后台任务 · {running} 运行中',
@@ -361,7 +402,7 @@ export const zh = {
addPluginsViewerCardDesc: '注册新的文件类型预览',
addPluginsTabDesc: '侧边栏页面(Tab)可以由插件扩展。插件通过 ctx.betterSidebar 服务注册;点击「安装」复制安装命令,粘贴到 DSH 所在环境的终端执行。',
addPluginsViewerDesc: '文件预览器可以由插件扩展。插件通过 ctx.betterSidebar 服务注册;点击「安装」复制安装命令,粘贴到 DSH 所在环境的终端执行。',
addPluginsBrowseMore: '在 GitHub 上浏览更多插件(topic: dsh-better-sidebar)',
addPluginsBrowseMore: '在 GitHub 上浏览更多插件(topic: dsh-apes-sidebar)',
addPluginsSearch: '搜索插件名称 / 描述…',
addPluginsNoMatch: '没有匹配的插件',
addPluginsRecommended: '推荐插件',
@@ -373,7 +414,7 @@ export const zh = {
pluginFlowglassDesc: '实时会话流程图:三列泳道展示用户、助手与工具调用,支持并行分组、子代理支线、逐层钻取和实时状态;安装 better-sidebar 后注册原生「流镜」Tab,未安装时保留独立抽屉',
pluginGitForgeDesc: 'better-sidebar「Git 凭据」Tab:GitHub/Gitea 等 Forge 账号库 + 按项目授权 + push 策略硬拦;token 仅存本地 secrets,不进模型上下文;提供只读 GitForge 工具与 agent HTTPS credential helper',
pluginGithubWorkbenchDesc: 'better-sidebar「GitHub 工作台」Tab:远端仓库目录树 + Issues / Pull requests / Actions 页签,读之外支持新建 Issue/PR、评论、编辑、关闭重开、squash·merge·rebase 合并(强确认)与重跑/取消 CI;仓库弹层自动拉取有权限列表并支持公开仓搜索;未装 better-sidebar 时自动降级为独立右侧面板',
pluginSuhuangScrollDesc: '把本地苏黄共阅 Runtime 接入 DSH 设置与 better-sidebar,支持模型配置、连接测试和连续阅卷控制;使用前需安装 Suhuang Scroll Runtime 与 dsh-better-sidebar',
pluginSuhuangScrollDesc: '把本地苏黄共阅 Runtime 接入 DSH 设置与 better-sidebar,支持模型配置、连接测试和连续阅卷控制;使用前需安装 Suhuang Scroll Runtime 与 dsh-apes-sidebar',
pluginBetterOverleafDesc: 'better-sidebar 的 Overleaf 标签页:直连 CDP 浏览器登录(支持第三方 Chromium),项目列表/切换,<workspace>/overleaf/ 本地 git 镜像,git 双向同步(API 只读兜底),文件预览走侧边栏工作台',
pluginGitRemotesDesc: 'better-sidebar Git 远程 Tab:看分支/上游/ahead-behind,fetch(可 prune)、ff-only pull、确认后才 push。不替换内置 Git 的暂存/提交,也不提供 force-push 或模型自动推送',
pluginSentinelDesc: '条件驱动的 agent 唤醒系统:文件/进程/端口/HTTP/命令/webhook 传感器,条件达成自动唤醒休眠会话;注册「哨兵」Tab 展示服务器全局监控表',
@@ -392,8 +433,16 @@ export const zh = {
/** The en dictionary (key-set-equal to zh, enforced by the type annotation). */
export const en: Record<keyof typeof zh, string> = {
files: 'Files',
changesSessionEmpty: 'No file operations in this session yet',
changesRead: 'Read',
changesWrite: 'Write',
changesEdit: 'Edit',
changesRunning: 'running',
changesError: 'error',
changesFold: '{count} lines…click to expand',
changesContext: 'context',
changesPriorUnknown: 'Prior content is outside the loaded window; shown as all-added',
explorer: 'Explorer',
git: 'Source Control',
terminal: 'Terminal',
editor: 'Editor',
editorExplorer: 'File open behavior',
@@ -503,6 +552,20 @@ export const en: Record<keyof typeof zh, string> = {
checkoutError: 'Branch switch failed',
history: 'History',
changes: 'Changes',
changesGitLens: 'Git',
changesSessionLens: 'Session',
changesFilterAll: 'All',
changesFilterEmpty: 'No operations of this kind',
changesOpenDiffTab: 'Open in a diff tab',
changesClosePreview: 'Close preview',
changesResizePreview: 'Resize preview',
changesDiffOpenTitle: 'Diff opens as',
changesDiffOpenDesc: 'Where the "expand to a diff tab" action lands the diff',
changesDiffOpenFloat: 'Free window',
changesDiffOpenFloatDesc: 'A floating window centered on the viewport — drag, resize, keep on top',
changesDiffOpenPane: 'Pane',
changesDiffOpenPaneDesc: 'Docked below the source panel (VSCode-style diff split)',
changesLoadError: 'Session file records are unavailable right now',
staged: 'Staged',
unstaged: 'Unstaged',
cancel: 'Cancel',
@@ -596,10 +659,10 @@ export const en: Record<keyof typeof zh, string> = {
settingsConflict: 'The setting changed in another window — please retry',
binaryNoPreview: 'This file type cannot be previewed',
downloadToView: 'Download to view',
settingsSubagentTitle: 'Auto-open the Tasks page when a subagent appears',
settingsSubagentDesc: 'Expand the side card and open the Tasks page when the current conversation spawns a new subagent; turn off to open it manually',
settingsJobsTitle: 'Auto-open the Jobs page on a new background job',
settingsJobsDesc: 'Expand the side card and open the Jobs page whenever a new background job appears for the current conversation (every new job triggers); turn off to open it manually',
settingsSubagentTitle: 'Auto-activate the Tasks page when a subagent appears',
settingsSubagentDesc: 'Activate the Tasks page when the current conversation spawns a new subagent; wide viewports also expand the side card, while narrow full-screen drawers are not forced open; turn off to open it manually',
settingsJobsTitle: 'Auto-activate the Tasks page on a new background job',
settingsJobsDesc: 'Activate the Tasks page whenever a new background job appears for the current conversation (every new job triggers); wide viewports also expand the side card, while narrow full-screen drawers are not forced open; turn off to open it manually',
settingsToolsTitle: 'Inject terminal tools for the model',
settingsToolsDesc: 'When enabled, the model can create and drive sidebar terminals through the 8 terminal_* tools (off by default)',
settingsFenceTitle: 'Workspace path fence',
@@ -703,6 +766,25 @@ export const en: Record<keyof typeof zh, string> = {
sideChatClose: 'Close thread',
sideChatCloseTitle: 'Release the thread\'s agent (history is kept)',
sideChatError: 'Side Chat error: {message}',
sideChatTurnUsage: 'Input {input} tok · Output {output} tok',
sideChatBlockCollapse: 'Collapse',
sideChatBlockCollapseAria: 'Collapse',
sideChatBlockExpand: 'Expand {hidden} lines',
sideChatBlockExpandAria: 'Expand {hidden} more lines',
sideChatBlockSignal: 'Killed by signal: {signal}',
sideChatBlockExitCode: 'Exit code {code}',
sideChatBlockRunning: 'Running',
sideChatBlockFailed: 'Failed',
sideChatBlockDone: 'Done',
sideChatBlockNoOutput: '(no output)',
sideChatBlockFiles: '{count} files',
sideChatBlockWindow: '{shown} of {total} lines',
sideChatConnDisconnected: 'Connection lost',
sideChatConnReconnect: 'Reconnect',
sideChatConnConnecting: 'Reconnecting…',
sideChatConnRecovered: 'Connection restored',
sideChatConnReconnectAction: 'Reconnect now',
sideChatConnRestartAction: 'Restart connection',
jobs: 'Background jobs',
jobsCount: '{count} background jobs',
jobsCountRunning: '{count} background jobs · {running} running',
@@ -729,7 +811,7 @@ export const en: Record<keyof typeof zh, string> = {
addPluginsViewerCardDesc: 'Register a file-type preview',
addPluginsTabDesc: 'Sidebar pages (tabs) can be extended by plugins. Plugins register through the ctx.betterSidebar service; clicking Install copies the install command — paste it into a terminal where your DSH profile lives and run it.',
addPluginsViewerDesc: 'File previewers can be extended by plugins. Plugins register through the ctx.betterSidebar service; clicking Install copies the install command — paste it into a terminal where your DSH profile lives and run it.',
addPluginsBrowseMore: 'Browse more plugins on GitHub (topic: dsh-better-sidebar)',
addPluginsBrowseMore: 'Browse more plugins on GitHub (topic: dsh-apes-sidebar)',
addPluginsSearch: 'Search by plugin name or description…',
addPluginsNoMatch: 'No plugins match',
addPluginsRecommended: 'Recommended plugins',
@@ -747,7 +829,7 @@ export const en: Record<keyof typeof zh, string> = {
pluginSidebarQaDesc: 'Select-and-ask: Select conversation text → ask in the right-side panel → a dedicated follow-up session (❓追问) in the same workspace; a fast no-thinking model compresses the main context and injects it with the quote, without interrupting the main conversation. Follow-ups nest, continue, and archive',
pluginSidenoteDesc: 'Codex-style side chat + selection annotations: fork the current session into a persistent side panel (archived out of the session list, multi-instance, /side command, survives reload, model follows the main session); select assistant text → numbered badge + note editor → an "N annotations" chip that rides your next message, or ask straight into a side chat',
pluginSshTunnelDesc: 'SSH Tunnel tab: multi-host inventory + per-project grants + local secrets; SSHManager tool (exec/SFTP/session strategies); center interactive terminal and dual-pane SFTP',
pluginSuhuangScrollDesc: 'Connect the local Suhuang Scroll Runtime to DSH settings and better-sidebar for model configuration, connection tests, and continuous grading controls; requires Suhuang Scroll Runtime and dsh-better-sidebar',
pluginSuhuangScrollDesc: 'Connect the local Suhuang Scroll Runtime to DSH settings and better-sidebar for model configuration, connection tests, and continuous grading controls; requires Suhuang Scroll Runtime and dsh-apes-sidebar',
pluginTurnReviewDesc: 'A human gate on the just-finished turn: Approve / Request changes per path with an optional comment; paths grouped by main session / subagent / unattributed; inline snapshot-vs-now diff before you decide. No fork, no /rewind',
pluginVideoPreviewDesc: 'Inline video preview (.mp4/.webm/.mov/.mkv/.avi etc.) for the better-sidebar editor, backed by a dedicated /video host route with HTTP Range (206) support — scrubbing works and files are not capped by the 20MB mediaLimit',
pluginCodeNavDesc: 'Code preview navigator: detects the language by file type and highlights syntax, symbol outline (class / method / variable filters + one-click jump), and in-file search (highlight all matches, prev/next, match case) — takes over code file preview in the better-sidebar editor',
@@ -763,49 +845,13 @@ export const en: Record<keyof typeof zh, string> = {
*/
export const LOCALE_NS = 'betterSidebar'
// The ja dictionary lives in a sibling file (326 keys) so this module
// stays readable. Type-checked against the zh key set: a missing or extra
// ja key is a compile error.
import { ja as jaDict } from './locales-ja.ts'
import { de as deDict } from './locales-de.ts'
import { fr as frDict } from './locales-fr.ts'
import { pt as ptDict } from './locales-pt.ts'
import { ko as koDict } from './locales-ko.ts'
import { ar as arDict } from './locales-ar.ts'
import { hi as hiDict } from './locales-hi.ts'
import { id as idDict } from './locales-id.ts'
import { tr as trDict } from './locales-tr.ts'
import { vi as viDict } from './locales-vi.ts'
import { th as thDict } from './locales-th.ts'
import { ru as ruDict } from './locales-ru.ts'
import { it as itDict } from './locales-it.ts'
import { nl as nlDict } from './locales-nl.ts'
import { sv as svDict } from './locales-sv.ts'
import { pl as plDict } from './locales-pl.ts'
import { zhHK as zhHKDict } from './locales-zh-HK.ts'
import { zhTW as zhTWDict } from './locales-zh-TW.ts'
import { zhMO as zhMODict } from './locales-zh-MO.ts'
/** The ja dictionary (key-set-equal to zh, enforced by the type annotation). */
export const ja: Record<keyof typeof zh, string> = jaDict as Record<keyof typeof zh, string>
export const de: Record<keyof typeof zh, string> = deDict as Record<keyof typeof zh, string>
export const fr: Record<keyof typeof zh, string> = frDict as Record<keyof typeof zh, string>
export const pt: Record<keyof typeof zh, string> = ptDict as Record<keyof typeof zh, string>
export const ko: Record<keyof typeof zh, string> = koDict as Record<keyof typeof zh, string>
export const ar: Record<keyof typeof zh, string> = arDict as Record<keyof typeof zh, string>
export const hi: Record<keyof typeof zh, string> = hiDict as Record<keyof typeof zh, string>
export const id: Record<keyof typeof zh, string> = idDict as Record<keyof typeof zh, string>
export const tr: Record<keyof typeof zh, string> = trDict as Record<keyof typeof zh, string>
export const vi: Record<keyof typeof zh, string> = viDict as Record<keyof typeof zh, string>
export const th: Record<keyof typeof zh, string> = thDict as Record<keyof typeof zh, string>
export const ru: Record<keyof typeof zh, string> = ruDict as Record<keyof typeof zh, string>
export const it: Record<keyof typeof zh, string> = itDict as Record<keyof typeof zh, string>
export const nl: Record<keyof typeof zh, string> = nlDict as Record<keyof typeof zh, string>
export const sv: Record<keyof typeof zh, string> = svDict as Record<keyof typeof zh, string>
export const pl: Record<keyof typeof zh, string> = plDict as Record<keyof typeof zh, string>
export const zhHK: Record<keyof typeof zh, string> = zhHKDict as Record<keyof typeof zh, string>
export const zhTW: Record<keyof typeof zh, string> = zhTWDict as Record<keyof typeof zh, string>
export const zhMO: Record<keyof typeof zh, string> = zhMODict as Record<keyof typeof zh, string>
// The 19 non-zh/en dictionaries live in the LAZY `locale` chunk
// (src/client/chunks/locale.tsx → lib/client-locale.js): `t()` only ever
// consults zh/en plus the better-locale override store, so those dicts are
// needed only when better-locale is installed — the client apply fetches the
// chunk there and registers its `localeDicts` into the override store. They
// used to sit statically in the core bundle (≈ half its weight) for a code
// path most sessions never enter.
/** The DSH locale service attached by the client apply (absent → browser detection). */
let localeService: { getSnapshot(): { active: string } } | undefined
+29
View File
@@ -0,0 +1,29 @@
/**
* Return the Markdown source used by preview renderers.
*
* A closed YAML frontmatter block is metadata, but the shared MarkdownText
* parser treats its delimiters as a thematic break and a setext heading. Hide
* only a block that starts on the first line and has its own closing `---`
* line. Unclosed or non-leading delimiters remain byte-for-byte unchanged so
* ordinary Markdown is never truncated. The editor and save path keep using
* the original source; this helper is preview-only.
*/
export function markdownPreviewSource(source: string): string {
const firstLineEnd = source.indexOf('\n')
if (firstLineEnd === -1) return source
const firstLineStart = source.charCodeAt(0) === 0xFEFF ? 1 : 0
const firstLine = source.slice(firstLineStart, firstLineEnd).replace(/\r$/u, '')
if (firstLine !== '---') return source
let lineStart = firstLineEnd + 1
while (lineStart <= source.length) {
const newline = source.indexOf('\n', lineStart)
const lineEnd = newline === -1 ? source.length : newline
const line = source.slice(lineStart, lineEnd).replace(/\r$/u, '')
if (line === '---') return newline === -1 ? '' : source.slice(newline + 1)
if (newline === -1) return source
lineStart = newline + 1
}
return source
}
+18 -1
View File
@@ -68,13 +68,30 @@ export function MdToc(): ReactNode {
// Esc closes the popover (the panel is a sibling of the button, so focus
// stays wherever the user came from — a document listener is the simplest).
// A document pointerdown also closes it when the click lands outside the
// button and the panel, so clicking blank preview space dismisses the
// outline instead of requiring a second click on the toggle. The check runs
// at pointerdown (before the button's own click toggles), and the button and
// panel are exempt so a re-click on the toggle keeps working as a toggle
// rather than being eaten by the dismiss.
useLayoutEffect(() => {
if (!open) return
const onKey = (event: KeyboardEvent): void => {
if (event.key === 'Escape') setOpen(false)
}
const onPointerDown = (event: Event): void => {
const target = event.target
if (!(target instanceof Node)) return
const bar = barRef.current
if (bar !== null && bar.contains(target)) return
setOpen(false)
}
document.addEventListener('keydown', onKey)
return () => { document.removeEventListener('keydown', onKey) }
document.addEventListener('pointerdown', onPointerDown)
return () => {
document.removeEventListener('keydown', onKey)
document.removeEventListener('pointerdown', onPointerDown)
}
}, [open])
const jump = (entry: TocEntry): void => {
+96 -31
View File
@@ -1,21 +1,57 @@
/**
* Interception of the chat's file-open funnel. The client runtime's
* `ctx.workspaces.openPath` is the SINGLE door every chat-side file open goes
* through — ui-conversation's apply.ts resolves the path against the session
* cwd and calls it for tool-row path links, the produced-files row, and
* prose file mentions alike (verified against the DSH source:
* `packages/client/ui-conversation/src/client/apply.ts` is the only
* production caller). Wrapping that one method reroutes those opens into the
* sidebar editor instead of the Host OS — no DSH modification needed.
* Interception of the chat's file-open funnel (DSH 0.1.2-alpha hosts).
*
* The alpha funnel is the Typert remote namespace `remote.session`: every
* chat-side file open (tool-row path links, the produced-files row, prose
* file mentions, inline-code paths) funnels through ChatView's injected
* `openFile`, which calls `ctx.remote.session.openWorkspacePath({ path })`
* with the path ALREADY resolved against the session cwd (verified against
* the DSH source: `packages/client/ui-chat/src/client/apply.ts` is the only
* production caller). The host side (`api/session-controller`) hands the
* path to the OS's default application (`openNativePath` → xdg-open on
* Linux). The pre-alpha funnel `ctx.workspaces.openPath` is gone — the
* alpha `IWorkspaces` has no openPath at all.
*
* `remote.session` is a cordis service (the gateway client's
* RemoteNamespaceService, key `remote.session`) whose methods are installed
* as ACCESSOR properties (`Object.defineProperty(service, method, {
* configurable: true, get })`) — the getter reads the method table at
* ACCESS time and returns the invocation closure. A plain assignment would
* not stick (no setter), so the wrapper shadows the method with a data
* property and restores the captured accessor descriptor on dispose.
*
* The namespace service appears asynchronously (the session-controller
* contribution mounts after the connection is up) and is disposed/recreated
* on contribution remounts; the caller therefore enters through
* `ctx.inject(['remote.session'], …)`, which re-fires on every remount.
*
* The wrapper is dependency-free by design (no React / ui-primitives), so
* the takeover logic is unit-testable and the file stays importable from the
* test runtime.
* the takeover logic is unit-testable and the file stays importable from
* the test runtime.
*/
/** The one service method the wrapper replaces (mirror of the runtime IWorkspaces). */
export interface OpenPathService {
openPath(path: string): Promise<void>
/** The one request shape the funnel method takes (mirror of the host's
* SessionOpenWorkspacePathRequest). */
export interface OpenWorkspacePathRequest {
/** Absolute path (the chat caller already resolved it against the cwd). */
path: string
}
/**
* What the funnel method resolves to (mirror of the typert `RemoteResult`
* envelope): EVERY remote method folds its business value into
* `{ ok: true, value }` / `{ ok: false, error }` — the chat caller branches
* on `result.ok`, so a bare business value would be misread as a failure
* (`result.error.message` reads `message` off undefined).
*/
export type OpenWorkspacePathResult =
| { readonly ok: true; readonly value: { opened: boolean } }
| { readonly ok: false; readonly error: { readonly code: string; readonly message: string; readonly details: object } }
/** The namespace slice the wrapper shadows (mirror of the gateway client's
* RemoteNamespaceService for the `session` namespace). */
export interface OpenWorkspacePathService {
openWorkspacePath(request: OpenWorkspacePathRequest, signal?: AbortSignal): Promise<OpenWorkspacePathResult>
}
/** Per-call decisions the wrapper needs (wired to the store + ctx in the client half). */
@@ -48,32 +84,61 @@ export function isFolderRevealPath(path: string): boolean {
}
/**
* Wrap `workspaces.openPath`: intercepted calls open the file in the sidebar
* editor instead of the Host OS and resolve as success (the original's
* callers ignore the result); anything that declines falls through to the
* original method untouched. The one exception is the folder-reveal gesture,
* which is routed to {@link OpenPathInterceptDeps.revealInExplorer} instead.
* @param workspaces - the client workspaces service to wrap.
* Shadow `remote.session.openWorkspacePath`: intercepted calls open the file
* in the sidebar editor and resolve with the remote SUCCESS ENVELOPE
* (`{ ok: true, value: { opened: true } }`, so ChatView shows no error
* dialog); anything that declines falls through to the captured original
* closure (the host OS's default application) untouched.
* The one exception is the folder-reveal gesture, which is routed to
* {@link OpenPathInterceptDeps.revealInExplorer} instead.
*
* The original closure is captured by ACCESSING the accessor once at wrap
* time — it invokes whatever method records are mounted at that moment. If
* the contribution remounts its methods while the shadow is installed, the
* captured closure points at the old records; the session namespace is
* effectively permanent in practice, so this is accepted and the shadow is
* re-applied anyway when the remount recreates the service (the caller's
* `ctx.inject` re-fires).
*
* @param service - the `remote.session` namespace service.
* @param deps - per-call takeover decisions.
* @returns the disposer restoring the original method (HMR-safe).
* @returns the disposer restoring the original accessor descriptor (HMR-safe).
*/
export function wrapOpenPath(workspaces: OpenPathService, deps: OpenPathInterceptDeps): () => void {
// The RAW method reference (never a bound copy): restore must put back the
// exact original so a chain of wrappers (other plugins wrapping the same
// method) keeps working across disposals in any order.
const original = workspaces.openPath
workspaces.openPath = (path: string): Promise<void> => {
export function wrapOpenWorkspacePath(
service: OpenWorkspacePathService,
deps: OpenPathInterceptDeps,
): () => void {
const KEY = 'openWorkspacePath'
const target = service as object
const descriptor = Object.getOwnPropertyDescriptor(target, KEY)
// Invoke the accessor once to capture the CURRENT invocation closure.
const original = service.openWorkspacePath
// The gateway installs the namespace's whole method group in the same
// synchronous window as the service registration, so an inject-observed
// service always has the method — but a hand-rolled composition might not;
// wrapping nothing would turn fall-throughs into crashes, so decline.
if (typeof original !== 'function') return () => {}
const wrapped = (request: OpenWorkspacePathRequest, signal?: AbortSignal): Promise<OpenWorkspacePathResult> => {
if (deps.takeoverEnabled()) {
const sessionId = deps.currentSessionId()
if (sessionId !== undefined) {
if (isFolderRevealPath(path)) deps.revealInExplorer(path, sessionId)
else deps.openInSidebar(path, sessionId)
return Promise.resolve()
if (isFolderRevealPath(request.path)) deps.revealInExplorer(request.path, sessionId)
else deps.openInSidebar(request.path, sessionId)
return Promise.resolve({ ok: true, value: { opened: true } })
}
}
return original.call(workspaces, path)
return original.call(service, request, signal)
}
Object.defineProperty(target, KEY, {
configurable: true,
enumerable: true,
writable: true,
value: wrapped,
})
return () => {
workspaces.openPath = original
// Restore the exact original property (the gateway's accessor) so a
// chain of wrappers keeps working across disposals in any order.
if (descriptor !== undefined) Object.defineProperty(target, KEY, descriptor)
else Reflect.deleteProperty(target, KEY)
}
}
+22
View File
@@ -61,3 +61,25 @@ export function isWithinWorkspace(base: string, target: string): boolean {
const lt = t.toLowerCase()
return lt === lb || lt.startsWith(`${lb}/`)
}
/**
* The last path segment of a '/'- or '\'-separated path (a diff tab title,
* a worktree label). Returns the whole string when no separator is present.
*/
export function baseName(path: string): string {
const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
return at === -1 ? path : path.slice(at + 1)
}
/**
* The lowercased file extension of a path ('' when none). The dot must sit
* inside the last segment — a dot in a directory name is not an extension.
* Shared by the editor language mapping (lang.ts) and the viewer registry's
* extension matching (service.ts), which both live in the core bundle.
*/
export function extOf(path: string): string {
const at = path.lastIndexOf('.')
if (at === -1) return ''
const base = path.slice(at + 1).toLowerCase()
return base.includes('/') || base.includes('\\') ? '' : base
}
+2 -2
View File
@@ -7,8 +7,8 @@
* 文件预览 grids).
*/
/** The GitHub topic page listing every repo tagged `dsh-better-sidebar`. */
export const PLUGIN_TOPIC_URL = 'https://github.com/topics/dsh-better-sidebar'
/** The GitHub topic page listing every repo tagged `dsh-apes-sidebar`. */
export const PLUGIN_TOPIC_URL = 'https://github.com/topics/dsh-apes-sidebar'
/** One curated plugin entry (name / url / description / install script). */
export interface PluginEntry {

Some files were not shown because too many files have changed in this diff Show More