dsh-code-ui 1.4.0 → 1.4.1

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-09 | v1.4.1 | 修复:编辑器**每敲一个字符文本抖动一下**。根因:hljs 就绪后,每次击键高亮层经历「tokenizer 顶上 → 60ms 防抖后换 hljs」**两次全量 innerHTML 重写**,浏览器重写 innerHTML 时 `scrollTop`/`scrollLeft` 被重置为 0——凡滚动过(垂直或水平)的文件,每次击键可见文本都会位移。修复:≤32KB 的文件改为**同步走 hljs**(单帧渲染直接出最终结果,无交换帧、工作量减半),>32KB 保留 60ms 防抖路径;并在每次渲染提交后从 textarea 回同步高亮层与行号滚动位(兜底所有 innerHTML 重写场景)。新增:**编辑器状态按会话记忆**——切换对话时快照当前会话的标签页/激活项并恢复目标会话的(此前 `applyRoot` 换根即清空标签与缓冲,切会话打开的文件全消失);滚动位按文件路径记忆、标签恢复挂载即自动回滚;焦点不恢复,由用户自己点击决定;缓冲/脏标记以绝对路径为键全局保留,切走再切回未保存内容与 ● 状态原样还在,原「有脏文件暂不跟随根」守卫随之退役,根始终跟随新会话;**文件树展开状态同样按会话记忆**——目录展开与已加载子目录随会话快照/恢复(`applyRoot` 仅在 nodes 里没有该根节点时才重建树,同根换会话不再折叠),侧栏不再切一次对话就全部收起 |
117
118
  | 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 折叠计数,保存失败者保留打开)——原标签 ✕ 按钮静默丢弃未保存改动,一并接入同一确认流程 |
118
119
  | 2026-09-02 | v1.3.2 | 调整:**切换按钮默认改为悬浮模式条**(原会话头 ⌨ 按钮,已保存选择的用户不受影响);**文件树默认根跟随当前会话的工作区目录**——打开编辑器即定位到当前对话的项目根,会话切换自动换根(有未保存文件时暂不跟随并提示,保存后点 ⟳ 即切换);手动「以此文件夹为根」后不再自动跟随 |
119
120
  | 2026-09-02 | v1.3.1 | 调整:安装后编辑器**默认隐藏**(dsh UI,新装不自动占屏,⌨ / 模式条随时唤回),布局默认仍为右侧停靠;配置卡片「编辑器界面」三态说明与 README 措辞同步。新增:一键更新 / 本地包更新成功后按钮文案临时变为「需重启 DSH 生效」(置灰防误点);README 增加「界面一览」(双布局与两种切换按钮截图)并明确配置入口路径(设置 → 插件 → 插件列表 → dsh-code-ui) |
package/lib/client.js CHANGED
@@ -281,6 +281,7 @@ window.__ModuleLoader__.load({
281
281
  toastPos: null,
282
282
  sessionId: null, // 当前会话 id(InputBridge 从 slot kit 捕获;blank 会话头隐藏时由 SessionWatch 兜底):文件树根按会话解析/记忆
283
283
  rootSessionId: null, // 当前根归属哪个会话(落后于 sessionId 时触发重新解析;手动根由宿主按会话记忆)
284
+ sessionEditor: {}, // 会话 id -> { tabs, activePath, nodes, rootPath }:切换会话时快照/恢复编辑器标签与文件树展开状态(滚动位按文件路径记忆于 scrollPos)
284
285
  scrollPos: {}, // 文件路径 -> {top,left}:每个标签记住自己的滚动位置(切换后恢复)
285
286
  error: null,
286
287
  }
@@ -620,6 +621,7 @@ window.__ModuleLoader__.load({
620
621
  ]
621
622
  const HLJS_VENDOR_BASE = '/dsh-code-ui/vendor/'
622
623
  const HLJS_BIG_CHARS = 512 * 1024 // 超大文件不做 hljs(防卡顿),继续用轻量 tokenizer
624
+ const HLJS_SYNC_MAX = 32 * 1024 // 该阈值内同步走 hljs:单帧渲染,杜绝 tokenizer↔hljs 交换帧(交换帧会重写 innerHTML 丢滚动位 → 每敲一个字符文本抖动)
623
625
  // langOf 的输出大多是 hljs 语言 id/别名;少数需归一,其余交给 hljs.getLanguage 解析,失败回退 plaintext
624
626
  const HLJS_LANG_FIX = { text: 'plaintext', jsx: 'javascript', tsx: 'typescript', htm: 'xml' }
625
627
  function hljsLangOf(lang) {
@@ -714,8 +716,24 @@ window.__ModuleLoader__.load({
714
716
  if (hljsState !== 'ready' || content.length > HLJS_BIG_CHARS) return highlight(content, lang)
715
717
  const cached = hljsCache.get(p)
716
718
  if (cached && cached.content === content) return cached.html
719
+ // 小文件同步走 hljs:一次渲染直接出最终结果。原「tokenizer 顶上 + 60ms 后换 hljs」
720
+ // 的交换帧会让高亮层每击键重写两次 innerHTML(浏览器重写时 scrollTop/scrollLeft 被
721
+ // 重置为 0),表现为滚动过的文件每敲一个字符文本抖动一下。
722
+ if (content.length <= HLJS_SYNC_MAX) {
723
+ let html
724
+ try {
725
+ html = window.hljs.highlight(content, { language: hljsLangOf(lang) || 'plaintext', ignoreIllegals: true }).value
726
+ } catch (e) {
727
+ html = highlight(content, lang) // 单文件异常(不应发生):退回 tokenizer 输出
728
+ }
729
+ // 与 tokenizer 输出保持行数一致:末尾换行补齐(textarea 会渲染最后的空行)
730
+ if (content.endsWith('\n') && !html.endsWith('\n')) html += '\n'
731
+ if (hljsCache.size > 24) { const first = hljsCache.keys().next().value; if (first !== undefined) hljsCache.delete(first) }
732
+ hljsCache.set(p, { content, html })
733
+ return html
734
+ }
717
735
  scheduleHljs(p, content, lang)
718
- return highlight(content, lang) // 间歇帧:tokenizer 顶上,保证字符即时可见有色
736
+ return highlight(content, lang) // 大文件:tokenizer 顶上(同步、即时),60ms 防抖后换 hljs
719
737
  }
720
738
 
721
739
  /* ============ §4 RPC 操作层(文件树 / 标签页 / 引用备注) ============ */
@@ -755,22 +773,51 @@ window.__ModuleLoader__.load({
755
773
  await applyRoot((res && res.root) ? res.root : path)
756
774
  showToast('工作区根目录:' + store.rootPath)
757
775
  }
758
- // 换根并整体重置文件树/标签页/缓冲(自动跟随与手动设置共用)
776
+ // 换根:标签页/缓冲/脏标记按会话记忆(handleSessionSwitch 快照恢复,均以绝对路径为键、
777
+ // 跨根安全)。树状态(目录展开/已加载子目录)同样按会话恢复——nodes 里已有该根节点时
778
+ // 跳过重置(同根换会话、或恢复了目标会话的树快照),只在新根没有树状态时重建。
779
+ // 注意不能用 toggleExpand 展开:它是切换语义,会把已展开的根折叠回去。
759
780
  async function applyRoot(newRoot) {
760
781
  store.rootPath = newRoot
761
- store.nodes = {}
762
- store.tabs = []
763
- store.activePath = null
764
- store.buffers = {}
765
- store.savedContent = {}
766
- store.dirty = {}
782
+ if (!store.nodes[newRoot]) {
783
+ store.nodes = {}
784
+ store.browsePath = null
785
+ store.browseEntries = []
786
+ store.nodes[newRoot] = { name: baseName(newRoot), path: newRoot, isDir: true, children: [], expanded: true, loaded: false }
787
+ store.filePickerOpen = false
788
+ emit()
789
+ await toggleExpand(newRoot)
790
+ return
791
+ }
792
+ emit()
793
+ }
794
+ // —— 会话切换:编辑器状态按会话记忆 ——
795
+ // 切换时快照当前会话的标签页/激活项/文件树(展开+已加载子目录)/根路径,恢复目标会话的;
796
+ // 滚动位按文件路径记忆于 store.scrollPos(标签恢复挂载时自动回滚),焦点不恢复、由用户
797
+ // 自己点击决定。缓冲/脏标记以绝对路径为键全局保留:切走再切回,未保存内容与 ● 状态
798
+ // 原样还在,因此不再需要「有脏文件暂不跟随」守卫——根始终跟随新会话。
799
+ function handleSessionSwitch(nextSid) {
800
+ if (!nextSid || nextSid === store.sessionId) return
801
+ const prevSid = store.sessionId
802
+ const keyOf = (s) => s || '__no-session__'
803
+ if (prevSid && store.rootPath) {
804
+ store.sessionEditor[keyOf(prevSid)] = {
805
+ tabs: store.tabs,
806
+ activePath: store.activePath,
807
+ nodes: store.nodes,
808
+ rootPath: store.rootPath,
809
+ }
810
+ }
811
+ const saved = store.sessionEditor[keyOf(nextSid)]
812
+ store.tabs = saved ? saved.tabs : []
813
+ store.activePath = saved ? saved.activePath : null
814
+ store.nodes = saved && saved.nodes ? saved.nodes : {}
815
+ store.rootPath = saved && saved.rootPath ? saved.rootPath : null
767
816
  store.activeNotes = []
768
- store.browsePath = null
769
- store.browseEntries = []
770
- store.nodes[newRoot] = { name: baseName(newRoot), path: newRoot, isDir: true, children: [], expanded: true, loaded: false }
771
- store.filePickerOpen = false
817
+ store.sessionId = nextSid
772
818
  emit()
773
- await toggleExpand(newRoot)
819
+ if (store.activePath) loadNotes(store.activePath)
820
+ refresh()
774
821
  }
775
822
  // —— 文件树 ——
776
823
  async function refresh() {
@@ -1217,14 +1264,8 @@ window.__ModuleLoader__.load({
1217
1264
  store.inputDraft = inputState.draft
1218
1265
  store.inputPhase = inputState.phase
1219
1266
  }
1220
- // 捕获当前会话 id:文件树根按会话隔离——切换即解析新会话的根
1221
- //(自动=该会话 cwd;手动=宿主 sessionRoots 里该会话记忆的根)
1222
- if (props.sessionId && props.sessionId !== store.sessionId) {
1223
- store.sessionId = props.sessionId
1224
- const hasDirty = Object.keys(store.dirty || {}).some((k) => store.dirty[k])
1225
- if (hasDirty) showToast('会话已切换:有未保存文件,工作区暂不跟随(保存后点 ⟳ 刷新即可)')
1226
- else refresh()
1227
- }
1267
+ // 捕获当前会话 id:编辑器状态按会话记忆(标签快照/恢复 + 根解析),见 handleSessionSwitch
1268
+ if (props.sessionId) handleSessionSwitch(props.sessionId)
1228
1269
  emit()
1229
1270
  })
1230
1271
  // 挂钩 DSH 原生输入条的发送:存在引用时把指针附进草稿再发(整文件=绝对路径)。
@@ -1259,11 +1300,7 @@ window.__ModuleLoader__.load({
1259
1300
  const useSessions = props.useSessions
1260
1301
  const sid = typeof useSessions === 'function' ? useSessions((s) => s.current) : undefined
1261
1302
  React.useEffect(() => {
1262
- if (!sid || sid === store.sessionId) return
1263
- store.sessionId = sid
1264
- const hasDirty = Object.keys(store.dirty || {}).some((k) => store.dirty[k])
1265
- if (hasDirty) showToast('会话已切换:有未保存文件,工作区暂不跟随(保存后点 ⟳ 刷新即可)')
1266
- else refresh()
1303
+ if (sid) handleSessionSwitch(sid)
1267
1304
  }, [sid])
1268
1305
  return null
1269
1306
  }
@@ -1496,6 +1533,19 @@ window.__ModuleLoader__.load({
1496
1533
  }
1497
1534
  function Editor() {
1498
1535
  const st = useStore()
1536
+ // 高亮层 innerHTML 被替换时浏览器会把 scrollTop/scrollLeft 重置为 0;每次渲染提交后
1537
+ // 从 textarea 回同步(也覆盖大文件 60ms 防抖换帧路径),杜绝输入时文本抖动。
1538
+ // 必须放在 early return 之前(Hooks 顺序约束)。
1539
+ React.useEffect(() => {
1540
+ const ta = editorTaEl
1541
+ if (!ta) return
1542
+ const layer = ta.parentNode
1543
+ if (!layer) return
1544
+ const preEl = layer.querySelector('.cdex-code-pre')
1545
+ if (preEl) { preEl.scrollTop = ta.scrollTop; preEl.scrollLeft = ta.scrollLeft }
1546
+ const gutEl = layer.parentNode ? layer.parentNode.querySelector('.cdex-gutter') : null
1547
+ if (gutEl) gutEl.scrollTop = ta.scrollTop
1548
+ })
1499
1549
  if (!st.activePath) {
1500
1550
  // 空态时显式展示当前工作区根,避免「以此文件夹为根」后误认为未切换
1501
1551
  return h('div', { className: 'cdex-editor' },