dsh-code-ui 1.5.0 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -114,6 +114,7 @@ dsh-code-ui/
114
114
 
115
115
  | 时间 | 版本 | 修复 / 新增 |
116
116
  | --- | --- | --- |
117
+ | 2026-09-11 | v1.5.2 | 修复:停靠面板拖拽分界线不再折行——编辑区去掉 min-width 下限,面板标题/按钮/状态栏等文本全部 nowrap + 省略号截断,拖到多窄只截断、不把文字竖排成一列;停靠面板左缘拖到宽度 < 80px 时自动切回 dsh UI(还原拖前宽度,下次展开不残留细条) |
117
118
  | 2026-09-11 | v1.5.0 | 里程碑:**引用体系全面升级——行内引用 chip**。引用/翻译后标签不再挂在输入框上方,而是以 chip 直接嵌入输入框句中(`1:文件名:起行-止行`,翻译带 🌐 前缀),点击跳转编辑器、退格即删;`@` 菜单可把引用插入句中任意位置;发送时 chip 原位展开为 `- 代码引用 / - 翻译引用 文件名:行号(路径)` 引用行,正文由 AI 经 `get_referenced_snippets` 读取。实现:注册宿主 InputTrigger 触发源(candidates/onPick/codec)+ ReferenceChipNode 装饰器直插(shell.insertReference,实时取 `conversation.input.shell(sid).snapshot` 的 draft/draftRev);chip ref 自包含 JSON(抗刷新/重启)。配套:**引用一次性消耗**——发送即标记已使用、chip 清空、【引用/备注】留档可查;**允许重复引用**(chip 可见可删,取消 host 去重);**引用编号全链路贯通**——标签/消息指针/引用卡/AI 工具输出同号,对 AI 说「引用N」即指对应条目;【引用/备注】条目右键菜单(复制 / 翻译 / 引用给AI);消息区引用卡 + chip 文件图标/点击跳转;提示条 ✕ 手动关闭兜底;移除「引用消耗模式」配置项(固定一次性)与输入条上方 tag 条(被行内 chip 取代)。工程:发送补丁改挂 shell.submit 必经点(原生输入条回车/按钮走 keyboard.submit,原 inputActions 补丁只覆盖插件输入卡);纯 chip 携带的发送同样消耗 |
118
119
  | 2026-09-09 | v1.4.1 | 修复:编辑器**每敲一个字符文本抖动一下**。根因:hljs 就绪后,每次击键高亮层经历「tokenizer 顶上 → 60ms 防抖后换 hljs」**两次全量 innerHTML 重写**,浏览器重写 innerHTML 时 `scrollTop`/`scrollLeft` 被重置为 0——凡滚动过(垂直或水平)的文件,每次击键可见文本都会位移。修复:≤32KB 的文件改为**同步走 hljs**(单帧渲染直接出最终结果,无交换帧、工作量减半),>32KB 保留 60ms 防抖路径;并在每次渲染提交后从 textarea 回同步高亮层与行号滚动位(兜底所有 innerHTML 重写场景)。新增:**编辑器状态按会话记忆**——切换对话时快照当前会话的标签页/激活项并恢复目标会话的(此前 `applyRoot` 换根即清空标签与缓冲,切会话打开的文件全消失);滚动位按文件路径记忆、标签恢复挂载即自动回滚;焦点不恢复,由用户自己点击决定;缓冲/脏标记以绝对路径为键全局保留,切走再切回未保存内容与 ● 状态原样还在,原「有脏文件暂不跟随根」守卫随之退役,根始终跟随新会话;**文件树展开状态同样按会话记忆**——目录展开与已加载子目录随会话快照/恢复(`applyRoot` 仅在 nodes 里没有该根节点时才重建树,同根换会话不再折叠),侧栏不再切一次对话就全部收起 |
119
120
  | 2026-09-08 | v1.4.0 | 里程碑:**语法高亮升级 highlight.js 11**(193 种语言,替换自研 tokenizer)。宿主 `webServer` exact 路由下发 `lib/vendor/` 全量包与主题 CSS,客户端首次高亮时懒加载、不拖累 SPA 启动;Electron(file://)/断网/路由缺失自动回退原 tokenizer 保底可用。插件卡片新增「语法配色」:自动(默认,随编辑器明暗 GitHub ↔ GitHub Dark)+ 10 款固定主题(GitHub / Dracula / Monokai / Nord / VS…),仅染 token 色、编辑器底色仍归「编辑器主题」管理,切换即时生效并持久化。性能:重高亮 ~60ms 防抖 + >512KB 大文件自动降级轻量高亮。`tools/vendor-hljs.mjs` 重建管线入库(支持 `--themes-only`、fetch 超时重试、Dracula 取官方源)。兼容性:适配 dsh 0.1.2+ 新版 settings 服务——不再 import 已删除的 `installSettingsSection`/`settingsNamespace` 具名导出(具名导出缺失会让宿主启动即退出),照 dshmarket 内联 `register`/`watch` 接线(服务 API 新旧一致),旧宿主 0.1.0-rc.7+ 同样可跑。交互优化:文件树选中态改为实心选中色 + 选中文字反白 + 左缘 2px 白色强调条——底色跟随宿主 `--dsw-alias-button-info-fill` 令牌(与 dshUI「对话/轨迹」页签同源,回退 #4176e6),宿主换肤自动同步;原层级灰在浅色主题下与侧栏底色仅差 5/255,选中几乎隐形。文件树根目录**按会话隔离**:宿主以 sessionId→root 映射记忆各会话的手动根(「以此文件夹为根」只影响当前会话,不再全局污染),切换会话即解析新会话的 cwd 或其记忆根——修复「新会话打开仍是旧会话地址」(原手动根为宿主级全局变量,且客户端跟随标志不持久化导致刷新后跟随失效且无提示)。未发消息的新会话也能解析根目录:会话创建时所选工作区路径已写入不可变 `SessionHeader.cwd`,经 `sessions` 服务可查(原仅查 `agents`,agent 要到首条消息后才存在);重启后重开旧对话(agent 尚未恢复)时经 `sessionController.inspect` 读持久化 header 兜底。空新对话(blank 会话)根因:宿主对 blank 会话隐藏会话头 chrome(hideChrome),插件 InputBridge 所在 slot 不渲染、捕获不到新会话 id——新增常驻 SessionWatch(root 作用域 `shell.overlay` + 标准 props `useSessions`)监听当前选中会话,blank 会话(宿主侧 agent 在 session/create 时即建、cwd=所选工作区)也能即时换根。标签页**右键菜单**:关闭 / 关闭其他 / 关闭右侧标签页 / 关闭已保存 / 关闭全部 + 复制路径(无对象项自动置灰);关闭波及未保存文件时弹确认框(保存并关闭 / 不保存 / 取消,多文件列清单、>5 折叠计数,保存失败者保留打开)——原标签 ✕ 按钮静默丢弃未保存改动,一并接入同一确认流程 |
package/lib/client.js CHANGED
@@ -118,8 +118,21 @@ window.__ModuleLoader__.load({
118
118
  .cdx-tk-punc{color:var(--cdex-tk-punc,#808080);}
119
119
  .cdx-tk-plain{color:var(--dsw-alias-label-primary,#d4d4d4);}
120
120
  .cdex-empty{color:var(--dsw-alias-label-secondary,#6f6f6f);padding:16px;font-size:13px;}
121
- .cdex-empty-root{color:var(--dsw-alias-label-secondary,#8a8a8a);font-size:12px;margin-bottom:10px;word-break:break-all;font-family:Consolas,'Cascadia Code',Menlo,monospace;}
121
+ .cdex-empty-hint{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:10px;}
122
+ .cdex-empty-root{color:var(--dsw-alias-label-secondary,#8a8a8a);font-size:12px;margin-bottom:10px;font-family:Consolas,'Cascadia Code',Menlo,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
122
123
  .cdex-empty-root b{color:var(--dsw-alias-label-primary,#d4d4d4);font-weight:600;}
124
+ /* —— 拖拽分界线时「不折行」:面板拖到多窄都只截断(省略号),绝不把文字竖排成一列 ——
125
+ min-width:0 是必须的配套:nowrap 会把 flex 项的自动最小宽度撑成整行宽,
126
+ 不放开自动最小宽度,面板其实拖不窄(等于隐式给自己设了下限)。 */
127
+ .cdex-top-title{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
128
+ .cdex-top-hint{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
129
+ .cdex-top-spacer{min-width:0;}
130
+ .cdex-btn,.cdex-btn-ghost{white-space:nowrap;}
131
+ .cdex-empty{min-width:0;}
132
+ .cdex-statusbar{overflow:hidden;}
133
+ .cdex-statusbar > span{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
134
+ .cdex-fp-name{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
135
+ .cdex-aiinput-foot > span{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
123
136
  .cdex-error{color:var(--dsw-alias-state-error-primary,#f48771);padding:8px;font-size:12px;}
124
137
  .cdex-statusbar{display:flex;align-items:center;height:24px;background:var(--dsw-alias-bg-overlay,#252526);color:var(--dsw-alias-label-secondary,#9d9d9d);font-size:12px;padding:0 12px;gap:16px;user-select:none;border-top:1px solid var(--dsw-alias-border-l1,#333333);}
125
138
  .cdex-statusbar .cdex-dirty{color:var(--dsw-alias-state-warn-primary,#ffe082);font-weight:600;}
@@ -893,7 +906,12 @@ window.__ModuleLoader__.load({
893
906
  if (!store.rootPath || (sid && sid !== store.rootSessionId)) {
894
907
  const r = await callHost('get-root', sid ? { sessionId: sid } : {})
895
908
  const root = (r && r.root) || null
896
- if (!root) { store.error = '未找到工作区根目录'; emit(); return }
909
+ if (!root) {
910
+ // 透出真实失败原因(transport failure / RPC 协议错误),不再只给笼统红字:
911
+ // 通道整体 405(请求落 SPA 静态兜底)这类传输层问题一眼可辨,避免误判成根目录解析问题
912
+ store.error = (r && r.error) ? ('获取根目录失败:' + r.error) : '未找到工作区根目录'
913
+ emit(); return
914
+ }
897
915
  await applyRoot(root)
898
916
  store.rootSessionId = sid
899
917
  } else {
@@ -1904,8 +1922,8 @@ window.__ModuleLoader__.load({
1904
1922
  // 空态时显式展示当前工作区根,避免「以此文件夹为根」后误认为未切换
1905
1923
  return h('div', { className: 'cdex-editor' },
1906
1924
  h('div', { className: 'cdex-empty' },
1907
- st.rootPath ? h('div', { className: 'cdex-empty-root' }, '当前工作区:', h('b', null, st.rootPath)) : null,
1908
- '从左侧资源管理器打开文件,或 ',
1925
+ st.rootPath ? h('div', { className: 'cdex-empty-root', title: st.rootPath }, '当前工作区:', h('b', null, st.rootPath)) : null,
1926
+ h('div', { className: 'cdex-empty-hint', title: '从左侧资源管理器打开文件' }, '从左侧资源管理器打开文件,或'),
1909
1927
  h('button', { className: 'cdex-btn', onClick: openFilePicker }, '📂 打开文件'),
1910
1928
  ),
1911
1929
  )
@@ -2196,24 +2214,36 @@ window.__ModuleLoader__.load({
2196
2214
  return (root && root.querySelector('[style*="grid-template-columns"]')) || null
2197
2215
  } catch (e) { return null }
2198
2216
  }
2217
+ // 停靠面板拖到足够窄(<80px)松手 -> 自动切回 dsh UI(与文件树侧栏 <140px 收起同款手感),
2218
+ // 宽度还原为拖前值(至少 280px),下次展开不残留细条。
2219
+ const DOCK_AUTO_HIDE_WIDTH = 80
2199
2220
  function onDockResizeDown(e) {
2200
2221
  e.preventDefault()
2201
2222
  const handle = e.currentTarget
2202
2223
  const sw = store.dockW
2203
2224
  const px = e.clientX
2204
2225
  try { handle.setPointerCapture(e.pointerId) } catch (err) {}
2226
+ function detach() {
2227
+ handle.removeEventListener('pointermove', move)
2228
+ handle.removeEventListener('pointerup', detach)
2229
+ handle.removeEventListener('pointercancel', detach)
2230
+ }
2205
2231
  function move(ev) {
2206
- store.dockW = Math.max(340, Math.min(sw + (px - ev.clientX), vw - 420))
2232
+ // 不设硬下限:拖到比 80px 更窄的区间时文字只会被截断(见上方 nowrap 规则);
2233
+ // 一旦宽度跌破 80px 就视为用户想收掉 code UI —— 直接切回 dsh UI 并结束本次拖拽。
2234
+ const next = sw + (px - ev.clientX)
2235
+ if (next < DOCK_AUTO_HIDE_WIDTH) {
2236
+ detach()
2237
+ store.dockW = Math.max(280, sw)
2238
+ toggleUiMode('native')
2239
+ return
2240
+ }
2241
+ store.dockW = Math.min(next, vw - 420)
2207
2242
  emit()
2208
2243
  }
2209
- function up() {
2210
- handle.removeEventListener('pointermove', move)
2211
- handle.removeEventListener('pointerup', up)
2212
- handle.removeEventListener('pointercancel', up)
2213
- }
2214
2244
  handle.addEventListener('pointermove', move)
2215
- handle.addEventListener('pointerup', up)
2216
- handle.addEventListener('pointercancel', up)
2245
+ handle.addEventListener('pointerup', detach)
2246
+ handle.addEventListener('pointercancel', detach)
2217
2247
  }
2218
2248
 
2219
2249
  // 文件树右缘拖宽:拖到足够窄(<140px)松手 -> 整体收起(宽度还原为拖前值,下次展开不残留)
package/lib/index.js CHANGED
@@ -952,6 +952,69 @@ function registerSnippetsTool(tools) {
952
952
  export const name = "dsh-code-ui";
953
953
  export const inject = ["tools"];
954
954
 
955
+ // —— 兼容兜底:宿主 connection.rpc.handle 不可用时的自挂通道路由 ——
956
+ // 背景(2026-09-11 405 事故):宿主 connection 的 rpc-host.ts register() 直接访问 owner.webServer,
957
+ // 而 owner 是 connection 插件自己的 ctx(inject 只有 credentials),Cordis guard 抛
958
+ // "cannot get property webServer without inject" → 插件通道路由永不注册 → 客户端全部 405
959
+ //(POST 落 webServer 的 SPA 静态兜底),文件树/更新器等 RPC 功能全挂。
960
+ // 宿主修复前的版本上,插件侧唯一可行的适配就是自己挂同路径前缀路由:
961
+ // ① 信任/认证门复用宿主公开 API connection.requestRejection(HostConnectionHandle 文档明示
962
+ // 用于「Apply Connection's Host/Origin checks and browser authentication to another Web route」),
963
+ // 因此 Host/Origin 围栏与浏览器会话 cookie 校验和宿主通道完全同策略,不降低安全性;
964
+ // ② 信封协议(client-request / server-response + rpcId 关联)按 Connection 契约复刻。
965
+ // 宿主修复上线后此兜底不再触发(仅当 handle() 抛错时才启用),可随最低支持版本提升而删除。
966
+ function selfHostedChannelRoute(connection, channel, handler) {
967
+ const ENDPOINT_SEGMENT = /^[A-Za-z0-9_$.-]+$/;
968
+ return async (req, res) => {
969
+ try {
970
+ const rejection = connection.requestRejection(req);
971
+ if (rejection !== undefined) {
972
+ res.writeHead(rejection);
973
+ res.end(rejection === 401 ? "unauthorized" : "forbidden");
974
+ return;
975
+ }
976
+ const url = new URL(req.url || "/", "http://dsh.invalid");
977
+ const endpoint = url.pathname.startsWith(channel + "/") ? url.pathname.slice(channel.length + 1) : null;
978
+ const segments = endpoint === null ? [] : endpoint.split("/");
979
+ if (req.method !== "POST" || !endpoint
980
+ || segments.some((s) => !s || s === "." || s === ".." || !ENDPOINT_SEGMENT.test(s))) {
981
+ res.writeHead(404);
982
+ res.end("not found");
983
+ return;
984
+ }
985
+ if (String(req.headers["content-type"] || "").split(";")[0].trim().toLowerCase() !== "application/json") {
986
+ res.writeHead(415);
987
+ res.end("content type must be application/json");
988
+ return;
989
+ }
990
+ const chunks = [];
991
+ for await (const chunk of req) chunks.push(chunk);
992
+ let message;
993
+ try { message = JSON.parse(Buffer.concat(chunks).toString("utf8")); }
994
+ catch (_) { res.writeHead(400); res.end("body is not JSON"); return; }
995
+ const rpcId = message && typeof message.rpcId === "string" ? message.rpcId : "invalid-request";
996
+ const send = (result) => {
997
+ res.writeHead(200, { "content-type": "application/json" });
998
+ res.end(JSON.stringify({ type: "server-response", rpcId, result }));
999
+ };
1000
+ if (!message || message.type !== "client-request" || typeof message.method !== "string") {
1001
+ send({ ok: false, error: { code: "gateway/bad-request", message: "invalid client-request message", details: {} } });
1002
+ return;
1003
+ }
1004
+ if (message.method !== endpoint) {
1005
+ send({ ok: false, error: { code: "gateway/bad-request", message: "method does not match endpoint", details: {} } });
1006
+ return;
1007
+ }
1008
+ let result;
1009
+ try { result = await handler(endpoint, message.payload, undefined); }
1010
+ catch (err) { res.writeHead(500); res.end("handler failure: " + String(err)); return; }
1011
+ send(result);
1012
+ } catch (err) {
1013
+ try { res.writeHead(500); res.end("channel failure: " + String(err)); } catch (_) {}
1014
+ }
1015
+ };
1016
+ }
1017
+
955
1018
  export function apply(ctx) {
956
1019
  // 注册 settings 命名空间(插件配置页按命名空间分发卡片;内部自带 ctx.inject(['settings']),
957
1020
  // 无 settings 服务的宿主上静默不运行,不影响其它功能)
@@ -966,12 +1029,59 @@ export function apply(ctx) {
966
1029
  registerSnippetsTool(c.tools);
967
1030
  });
968
1031
 
969
- // 等待 Connection 服务就绪后注册 RPC 通道
1032
+ // 等待 Connection 服务就绪后注册 RPC 通道。
1033
+ // 注:2026-09-11 的 405 事故根因在宿主侧——connection 的 rpc-host.ts register() 曾直接访问
1034
+ // owner.webServer,而 owner 是 connection 插件自己的 ctx(inject 列表只有 credentials),
1035
+ // Cordis guard 抛 "cannot get property webServer without inject" → 前缀路由永不注册 →
1036
+ // 所有插件 RPC 405(POST 落到 SPA 静态兜底;dsh-context 等其它插件同样中招)。
1037
+ // 宿主已修:改经 connection 自己的 webServer 作用域挂载(与 /api 路由同路),
1038
+ // 因此插件侧无需注入 webServer(实测调用方声明也救不了)。
1039
+ // 防御性注册:不同 dsh-client-connection 版本的 handle() 对第三参(options)宽容度不同
1040
+ //(0.1.5-rc.2 为 (channel, handler) 两参、多余参数被忽略;其它版本可能校验/依赖 options)。
1041
+ // 带参注册抛错时回退两参注册;仍失败则向宿主控制台大声报错——通道注册失败时客户端
1042
+ // 所有 RPC 都会 405(POST 落到 SPA 静态兜底),不留日志极难定位。
970
1043
  ctx.inject(["connection"], (c) => {
971
1044
  const connection = c.connection;
972
1045
  if (connection === undefined) return;
973
- const dispose = connection.rpc.handle(CHANNEL, rpcHandler(ctx), { authority: "loopback" });
974
- c.effect(() => dispose, "dsh-code-ui: rpc channel");
1046
+ const handler = rpcHandler(ctx);
1047
+ let dispose;
1048
+ let hostChannel = false;
1049
+ try {
1050
+ dispose = connection.rpc.handle(CHANNEL, handler, { authority: "loopback" });
1051
+ hostChannel = true;
1052
+ } catch (err) {
1053
+ console.error("[dsh-code-ui] 带 options 注册 RPC 通道失败,回退无 options 注册:" + ((err && err.message) || err));
1054
+ try {
1055
+ dispose = connection.rpc.handle(CHANNEL, handler);
1056
+ hostChannel = true;
1057
+ } catch (err2) {
1058
+ console.error("[dsh-code-ui] 宿主 RPC 通道注册失败(将启用插件自挂路由兜底):" + ((err2 && err2.message) || err2));
1059
+ }
1060
+ }
1061
+ if (hostChannel) {
1062
+ console.log("[dsh-code-ui] RPC 通道已注册(宿主 connection 通道):" + CHANNEL);
1063
+ c.effect(() => dispose, "dsh-code-ui: rpc channel");
1064
+ } else if (typeof connection.requestRejection !== "function") {
1065
+ // 安全护栏:拿不到宿主的信任/认证门就绝不自挂路由——否则等于开一条绕过
1066
+ // Host/Origin 围栏与浏览器会话校验的裸路由。
1067
+ console.error("[dsh-code-ui] 宿主通道不可用且无 requestRejection,已放弃自挂路由兜底;请更新 DSH 宿主以修复通道注册 bug");
1068
+ } else {
1069
+ // 兜底:自挂同路径前缀路由(与 vendor 静态路由同款机制),复用 requestRejection 认证门。
1070
+ // 仅在宿主通道不可用时启用,宿主修复后不会走到这里。
1071
+ c.inject(["webServer"], (w) => {
1072
+ try {
1073
+ const disposeSelf = w.webServer.register({
1074
+ kind: "prefix",
1075
+ path: CHANNEL,
1076
+ handler: selfHostedChannelRoute(connection, CHANNEL, handler),
1077
+ });
1078
+ c.effect(() => disposeSelf, "dsh-code-ui: self-hosted rpc channel");
1079
+ console.log("[dsh-code-ui] 已启用插件自挂路由兜底:" + CHANNEL);
1080
+ } catch (err3) {
1081
+ console.error("[dsh-code-ui] 兜底通道路由注册失败:" + ((err3 && err3.message) || err3));
1082
+ }
1083
+ });
1084
+ }
975
1085
  c.effect(() => () => unwatchAllFiles(), "dsh-code-ui: file watchers");
976
1086
  });
977
1087
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-code-ui",
3
- "version": "1.5.0",
3
+ "version": "1.5.2",
4
4
  "description": "Cursor 风格的 AI 代码编辑器工作台插件(DSH):文件树、多标签页编辑、语法高亮、引用/翻译行内标签给 AI、右侧停靠/悬浮窗口双布局",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",