dsh-better-workspace 0.9.5 → 0.9.6

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
@@ -33,7 +33,7 @@ web/ ← 虚拟分组(不是真实目录,纯命名层级)
33
33
 
34
34
  工作区里的会话名称同样用 `/` 分层(如 `测试1/两个插件维护`)。会话分组用**次级配色且无文件夹图标**渲染,和工作区文件夹一眼可辨;会话分组可整组重命名(批量改写成员标题)。
35
35
 
36
- **重启后标题不回退**:DSH 重启后的冷列表只为命中投影缓存的会话携带标题——分叉出的会话与从未写过检查点的会话会暂时回退成「工作区名」显示,真实的标题其实还在会话日志里。本插件把每个会话**最近一次的真实标题**持久化在浏览器里(dsh 客户端 store),回退窗口内直接用记忆标题渲染,`/` 分级分组照常成立;打开会话后官方数据到位即自动纠正。
36
+ **重启后标题不回退**:DSH 重启后的冷列表只为命中投影缓存的会话携带标题——分叉出的会话与从未写过检查点的会话会暂时回退成「工作区名」显示,真实的标题其实还在会话日志里。本插件把每个会话**最近一次的真实标题**持久化在浏览器里(带防抖的本地缓存,变更合并整写一次),回退窗口内直接用记忆标题渲染,`/` 分级分组照常成立;打开会话后官方数据到位即自动纠正。
37
37
 
38
38
  **跨端手动同步(Web ↔ 桌面客户端)**:外观自定义、显式分组与开关默认保存在各设备自己的浏览器里;设置卡片新增「跨端同步」区——Web 上点「从客户端获取」、客户端上点「从 Web 获取」,可选**覆盖本设备**或**合并两端**(并集保留各自独有项),另一端的数据经宿主设置存储(~/.dsh/settings.yaml,两端共享)送达;旁边有「发送本设备数据」按钮,旧版本的历史数据首次同步推一次即可,之后每次修改都会自动写入宿主、对端随手可取。折叠状态仍保持每端独立。
39
39
 
package/README_EN.md CHANGED
@@ -31,7 +31,7 @@ Right-click any folder row: new subfolder (parent prefix pre-filled), new worksp
31
31
 
32
32
  Session titles nest on `/` too (e.g. `test1/plugin maintenance`). Session groups render in a secondary color without a folder icon, so they are clearly distinct from workspace folders; a session group can be renamed as a whole (rewrites member titles).
33
33
 
34
- **No title regression after a restart**: the cold list right after a DSH restart only carries titles for sessions that hit the persisted projection cache — fork-born and never-checkpointed sessions temporarily fall back to the workspace *basename*, even though the real title still lives in the session log. This plugin remembers each session's **last real wire title** in the browser (dsh client store) and renders from memory during that window, so `/` grouping keeps working; the moment a session opens, the official data takes over and self-heals.
34
+ **No title regression after a restart**: the cold list right after a DSH restart only carries titles for sessions that hit the persisted projection cache — fork-born and never-checkpointed sessions temporarily fall back to the workspace *basename*, even though the real title still lives in the session log. This plugin remembers each session's **last real wire title** in the browser (a debounced local cache: real changes coalesce into one write) and renders from memory during that window, so `/` grouping keeps working; the moment a session opens, the official data takes over and self-heals.
35
35
 
36
36
  **Manual cross-device sync (web ↔ desktop app)**: appearance, explicit folders, and toggles live in each device's own browser by default. The settings card adds a "Cross-device sync" section — "Pull from desktop app" on the web / "Pull from web" in the desktop app, with an **overwrite-this-device** or **merge-both-sides** mode (the union keeps each side's unique entries); the other surface's data travels through the host settings store (~/.dsh/settings.yaml, shared by both surfaces). A "Send this device's data" button next to it covers history created before this version — push once, and afterwards every edit is written to the host automatically for the other surface to pull. Expansion state stays per-device.
37
37
 
package/dsh.plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "dsh-external/dsh-better-workspace",
3
- "version": "0.9.5",
3
+ "version": "0.9.6",
4
4
  "main": "./src/index.js",
5
5
  "description": "侧边栏工作区层级树:名称中的 / 即虚拟分组(web/前端 · web/后端),添加工作区的目录流附所属分组弹窗,重命名即时重排层级,可新建空分组。Hierarchy tree for the DSH sidebar workspace list.",
6
6
  "engines": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-better-workspace",
3
- "version": "0.9.5",
3
+ "version": "0.9.6",
4
4
  "description": "DSH web plugin: a hierarchical workspace tree for the sidebar. Workspace titles containing \"/\" group into virtual folders (web/前端 · web/后端); the add-workspace directory flow gains a parent-group popup; renames re-derive the tree; a new-folder button creates empty levels.",
5
5
  "type": "module",
6
6
  "repository": {
package/src/client.js CHANGED
@@ -749,31 +749,61 @@ window.__ModuleLoader__.load({
749
749
  // last REAL wire title per session id (learned only from snapshots where
750
750
  // summary.title exists — never from displayTitle, which is basename
751
751
  // degraded in exactly the window being patched), so the tree restores
752
- // the "/" grouping until the wire catches up. Same-value writes are
753
- // no-ops (immer produces the same state, nothing persists); entries
754
- // evict oldest-at past the hard cap. A stale remembered title (renamed
752
+ // the "/" grouping until the wire catches up. 0.9.6: plain module
753
+ // state with one debounced whole-file save per real change batch
754
+ // (issue #1: per-session reactive-store dispatches cost O(sessions)
755
+ // immer produces per snapshot tick and froze large installs). Same-
756
+ // value scans allocate nothing; entries evict oldest-at past the hard
757
+ // cap. A stale remembered title (renamed
755
758
  // elsewhere, cleared browser storage) self-heals the moment the wire
756
759
  // carries the truth again; a missing entry just leaves today's fallback.
757
760
  const TITLE_CACHE_LIMIT = 3000
758
761
  const TITLE_CACHE_KEEP = 2400
759
- const createTitleCacheStore = () => storeKit.defineStore({
760
- init: () => ({ byId: {} }),
761
- persist: 'dsh.betterWorkspace.titles.v1',
762
- actions: {
763
- rememberTitle: (d, id, title, at) => {
764
- if (typeof id !== 'string' || id === '' || typeof title !== 'string' || title === '') return
765
- if (!d.byId) d.byId = {}
766
- const prev = d.byId[id]
767
- if (prev && prev.title === title) return
768
- d.byId[id] = { title: title, at: typeof at === 'number' ? at : Date.now() }
769
- const keys = Object.keys(d.byId)
770
- if (keys.length > TITLE_CACHE_LIMIT) {
771
- keys.sort((a, b) => ((d.byId[a] && d.byId[a].at) || 0) - ((d.byId[b] && d.byId[b].at) || 0))
772
- for (let i = 0; i < keys.length - TITLE_CACHE_KEEP; i++) delete d.byId[keys[i]]
773
- }
774
- },
775
- },
776
- })
762
+ const TITLE_CACHE_KEY = 'dsh.betterWorkspace.titles.v1'
763
+ const titleCache = { byId: {} }
764
+ let titleSaveTimer = null
765
+ const loadTitleCache = () => {
766
+ try {
767
+ const raw = localStorage.getItem(TITLE_CACHE_KEY)
768
+ if (raw !== null) {
769
+ const parsed = JSON.parse(raw)
770
+ if (parsed && typeof parsed.byId === 'object' && parsed.byId !== null) titleCache.byId = parsed.byId
771
+ }
772
+ } catch (e) { /* storage unavailable/corrupt: cold start without cache */ }
773
+ }
774
+ const scheduleTitleSave = () => {
775
+ clearTimeout(titleSaveTimer)
776
+ titleSaveTimer = setTimeout(() => {
777
+ try { localStorage.setItem(TITLE_CACHE_KEY, JSON.stringify(titleCache)) } catch (e) {}
778
+ }, 200)
779
+ }
780
+ // One plain-object batch pass per snapshot (issue #1). Unchanged scans
781
+ // cost one loop with zero allocation; only real changes mark dirty;
782
+ // eviction runs once per changed batch followed by ONE debounced save.
783
+ const rememberAllTitles = (list) => {
784
+ if (!list || !list.byId) return false
785
+ const byId = titleCache.byId
786
+ const now = Date.now()
787
+ let changed = false
788
+ for (const id of Object.keys(list.byId)) {
789
+ const summary = list.byId[id]
790
+ if (!summary || summary.blank) continue
791
+ const title = summary.title
792
+ if (typeof title !== 'string' || title === '') continue
793
+ const prev = byId[id]
794
+ if (prev && prev.title === title) continue
795
+ byId[id] = { title: title, at: now }
796
+ changed = true
797
+ }
798
+ if (!changed) return false
799
+ const keys = Object.keys(byId)
800
+ if (keys.length > TITLE_CACHE_LIMIT) {
801
+ keys.sort((a, b) => ((byId[a] && byId[a].at) || 0) - ((byId[b] && byId[b].at) || 0))
802
+ for (let i = 0; i < keys.length - TITLE_CACHE_KEEP; i++) delete byId[keys[i]]
803
+ }
804
+ scheduleTitleSave()
805
+ return true
806
+ }
777
807
 
778
808
  /* ======================= host settings sync ======================= */
779
809
 
@@ -1560,7 +1590,7 @@ window.__ModuleLoader__.load({
1560
1590
  const archivedSet = React.useMemo(() => new Set(archivedSessionIds), [archivedSessionIds])
1561
1591
  const subCounts = React.useMemo(() => subagentRunningCounts(list ? list.byId : {}), [list ? list.byId : null])
1562
1592
  // Last-known real titles for the cold-restart fallback window (see
1563
- // createTitleCacheStore). Read per render via getSnapshot: no
1593
+ // the title-cache block). Read per render via getSnapshot: no
1564
1594
  // subscription, because a cache write only happens when summary.title
1565
1595
  // exists — at that moment the row already renders the wire truth and
1566
1596
  // no re-render is wanted.
@@ -1572,21 +1602,13 @@ window.__ModuleLoader__.load({
1572
1602
  // Learn real titles from every snapshot: remember exactly what the
1573
1603
  // wire carried (summary.title), never displayTitle — that is already
1574
1604
  // basename-degraded in the very window this patches. Blank rows are
1575
- // provisional New Sessions and never learn. rememberTitle itself
1576
- // no-ops on unchanged values, so the loop is cheap when idle.
1605
+ // provisional New Sessions and never learn. One plain-object batch
1606
+ // call per snapshot (issue #1): unchanged scans cost one loop with
1607
+ // zero allocation, real changes schedule one debounced save.
1577
1608
  React.useEffect(() => {
1578
1609
  if (!list || !list.byId) return
1579
- const remember = titleCacheRef && titleCacheRef.actions
1580
- ? titleCacheRef.actions.rememberTitle
1581
- : null
1582
- if (typeof remember !== 'function') return
1583
- const now = Date.now()
1584
- for (const id of Object.keys(list.byId)) {
1585
- const summary = list.byId[id]
1586
- if (!summary || summary.blank) continue
1587
- const title = summary.title
1588
- if (typeof title !== 'string' || title === '') continue
1589
- remember(String(id), title, now)
1610
+ if (titleCacheRef && typeof titleCacheRef.rememberAllTitles === 'function') {
1611
+ titleCacheRef.rememberAllTitles(list)
1590
1612
  }
1591
1613
  }, [list ? list.byId : null])
1592
1614
 
@@ -2685,11 +2707,11 @@ window.__ModuleLoader__.load({
2685
2707
  // same persisted state (expansion, folder list, prefs, styling).
2686
2708
  const viewStore = createViewStore()
2687
2709
 
2688
- // Cold-restart title fallback instance (see createTitleCacheStore).
2689
- // getSnapshot() during render is intentional: hydration reads
2690
- // localStorage synchronously at create(), so the first render after a
2691
- // restart already sees every remembered title.
2692
- titleCacheRef = createTitleCacheStore().create()
2710
+ // Cold-restart title fallback (see the title-cache block). Hydration
2711
+ // reads localStorage synchronously here, before the first render, so a
2712
+ // restart's first tree already shows every remembered title.
2713
+ loadTitleCache()
2714
+ titleCacheRef = { getSnapshot: () => titleCache, rememberAllTitles }
2693
2715
 
2694
2716
  // Cross-device settings scope (manual sync, see the host-settings-sync
2695
2717
  // block). Binding is best-effort: a missing/failed bind leaves the