dsh-better-workspace 0.9.4 → 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,9 @@ web/ ← 虚拟分组(不是真实目录,纯命名层级)
33
33
 
34
34
  工作区里的会话名称同样用 `/` 分层(如 `测试1/两个插件维护`)。会话分组用**次级配色且无文件夹图标**渲染,和工作区文件夹一眼可辨;会话分组可整组重命名(批量改写成员标题)。
35
35
 
36
- **重启后标题不回退**:DSH 重启后的冷列表只为命中投影缓存的会话携带标题——分叉出的会话与从未写过检查点的会话会暂时回退成「工作区名」显示,真实的标题其实还在会话日志里。本插件把每个会话**最近一次的真实标题**持久化在浏览器里(dsh 客户端 store),回退窗口内直接用记忆标题渲染,`/` 分级分组照常成立;打开会话后官方数据到位即自动纠正。
36
+ **重启后标题不回退**:DSH 重启后的冷列表只为命中投影缓存的会话携带标题——分叉出的会话与从未写过检查点的会话会暂时回退成「工作区名」显示,真实的标题其实还在会话日志里。本插件把每个会话**最近一次的真实标题**持久化在浏览器里(带防抖的本地缓存,变更合并整写一次),回退窗口内直接用记忆标题渲染,`/` 分级分组照常成立;打开会话后官方数据到位即自动纠正。
37
+
38
+ **跨端手动同步(Web ↔ 桌面客户端)**:外观自定义、显式分组与开关默认保存在各设备自己的浏览器里;设置卡片新增「跨端同步」区——Web 上点「从客户端获取」、客户端上点「从 Web 获取」,可选**覆盖本设备**或**合并两端**(并集保留各自独有项),另一端的数据经宿主设置存储(~/.dsh/settings.yaml,两端共享)送达;旁边有「发送本设备数据」按钮,旧版本的历史数据首次同步推一次即可,之后每次修改都会自动写入宿主、对端随手可取。折叠状态仍保持每端独立。
37
39
 
38
40
  <table>
39
41
  <tr>
package/README_EN.md CHANGED
@@ -31,7 +31,9 @@ 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
+
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.
35
37
 
36
38
  <table>
37
39
  <tr>
package/dsh.plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "dsh-external/dsh-better-workspace",
3
- "version": "0.9.4",
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.4",
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
@@ -92,6 +92,17 @@ window.__ModuleLoader__.load({
92
92
  'custom.done': '完成',
93
93
  'settings.on': '开',
94
94
  'settings.off': '关',
95
+ 'sync.title': '跨端同步',
96
+ 'sync.desc': '外观自定义、显式分组与开关保存在本设备的浏览器里;通过宿主设置存储与另一端互传。平时的新修改会自动写入宿主,另一端点「获取」即可拿到;旧版本的历史数据首次需要点一次「发送」。',
97
+ 'sync.mode.overwrite': '覆盖本设备',
98
+ 'sync.mode.merge': '合并两端',
99
+ 'sync.pull.desktop': '从客户端获取',
100
+ 'sync.pull.web': '从 Web 获取',
101
+ 'sync.push': '发送本设备数据',
102
+ 'sync.done': '已同步',
103
+ 'sync.empty': '另一端暂无数据可获取',
104
+ 'sync.off': '当前环境不支持同步(需要宿主设置服务)',
105
+ 'sync.loading': '正在连接宿主设置…',
95
106
  'flow.title': '添加工作区',
96
107
  'flow.picked': '所选文件夹',
97
108
  'flow.parent': '所属分组',
@@ -181,6 +192,17 @@ window.__ModuleLoader__.load({
181
192
  'custom.done': 'Done',
182
193
  'settings.on': 'On',
183
194
  'settings.off': 'Off',
195
+ 'sync.title': 'Cross-device sync',
196
+ 'sync.desc': "Appearance, explicit folders, and toggles live in this device's browser; they exchange with the other surface (web / desktop app) through the host settings store. New edits are written to the host automatically — the other surface just pulls. History created before this version needs one explicit Send.",
197
+ 'sync.mode.overwrite': 'Overwrite this device',
198
+ 'sync.mode.merge': 'Merge both sides',
199
+ 'sync.pull.desktop': 'Pull from desktop app',
200
+ 'sync.pull.web': 'Pull from web',
201
+ 'sync.push': "Send this device's data",
202
+ 'sync.done': 'Synced',
203
+ 'sync.empty': 'No data on the other surface yet',
204
+ 'sync.off': 'Sync unavailable here (requires the host settings service)',
205
+ 'sync.loading': 'Connecting to host settings…',
184
206
  'flow.title': 'Add workspace',
185
207
  'flow.picked': 'Chosen folder',
186
208
  'flow.parent': 'Parent group',
@@ -646,6 +668,13 @@ window.__ModuleLoader__.load({
646
668
  '@keyframes bw-breathe{0%,100%{filter:drop-shadow(0 0 1px var(--bw-pulse-color));opacity:.55}50%{filter:drop-shadow(0 0 6px var(--bw-pulse-color));opacity:1}}',
647
669
  '.bw-pulse-text{animation:bw-breathe-text 1.8s ease-in-out infinite}',
648
670
  '@keyframes bw-breathe-text{0%,100%{text-shadow:0 0 1px var(--bw-pulse-color);opacity:.65}50%{text-shadow:0 0 7px var(--bw-pulse-color);opacity:1}}',
671
+ '.bw-sync-modes{display:flex;gap:6px;margin-top:8px}',
672
+ '.bw-sync-mode{flex:1;padding:5px 10px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.3));background:transparent;color:var(--dsw-alias-label-secondary,#b8b8b8);cursor:pointer;font-size:12px}',
673
+ '.bw-sync-mode-on{border-color:var(--dsw-alias-brand-primary,#5b8def);color:var(--dsw-alias-label-primary,#e6e6e6);background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,.12))}',
674
+ '.bw-sync-actions{display:flex;gap:8px;margin-top:8px}',
675
+ '.bw-sync-btn{padding:5px 12px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.3));background:transparent;color:var(--dsw-alias-label-secondary,#b8b8b8);cursor:pointer;font-size:12px}',
676
+ '.bw-sync-btn:disabled{opacity:.45;cursor:default}',
677
+ '.bw-sync-btn-primary{border-color:var(--dsw-alias-brand-primary,#5b8def);color:var(--dsw-alias-label-primary,#e6e6e6)}',
649
678
  ].join('')
650
679
 
651
680
  const StyleNode = () => E('style', null, CSS_TEXT)
@@ -660,6 +689,30 @@ window.__ModuleLoader__.load({
660
689
  // every selector read takes a fallback.
661
690
  persist: 'dsh.betterWorkspace.view.v1',
662
691
  actions: {
692
+ // Manual-sync pull targets (overwrite / merge): the host settings
693
+ // values land in the local store, and rendering keeps reading the
694
+ // local selectors, so scope-less hosts keep working unchanged.
695
+ importHost: (d, host) => {
696
+ if (!host || typeof host !== 'object') return
697
+ if (host.styling && typeof host.styling === 'object') d.styling = host.styling
698
+ if (Array.isArray(host.folders)) d.folders = host.folders.slice()
699
+ if (!d.prefs) d.prefs = {}
700
+ if (host.compactChains !== undefined) d.prefs.compactChains = host.compactChains !== false
701
+ if (host.statusPulse !== undefined) d.prefs.statusPulse = host.statusPulse !== false
702
+ },
703
+ // Merge mode: union of both sides, the PULLED (host) copy wins any
704
+ // per-key conflict; prefs take the pulled booleans when present.
705
+ mergeHost: (d, host) => {
706
+ if (!host || typeof host !== 'object') return
707
+ if (host.styling && typeof host.styling === 'object') d.styling = { ...(d.styling || {}), ...host.styling }
708
+ if (Array.isArray(host.folders)) {
709
+ const merged = new Set([...(Array.isArray(d.folders) ? d.folders : []), ...host.folders])
710
+ d.folders = Array.from(merged)
711
+ }
712
+ if (!d.prefs) d.prefs = {}
713
+ if (host.compactChains !== undefined) d.prefs.compactChains = host.compactChains !== false
714
+ if (host.statusPulse !== undefined) d.prefs.statusPulse = host.statusPulse !== false
715
+ },
663
716
  setExpanded: (d, key, value) => { if (!d.expanded) d.expanded = {}; d.expanded[key] = value },
664
717
  setSessionsExpanded: (d, key, value) => { if (!d.sessionsExpanded) d.sessionsExpanded = {}; d.sessionsExpanded[key] = value },
665
718
  setSessionGroupExpanded: (d, key, value) => { if (!d.sessionGroups) d.sessionGroups = {}; d.sessionGroups[key] = value },
@@ -696,29 +749,150 @@ window.__ModuleLoader__.load({
696
749
  // last REAL wire title per session id (learned only from snapshots where
697
750
  // summary.title exists — never from displayTitle, which is basename
698
751
  // degraded in exactly the window being patched), so the tree restores
699
- // the "/" grouping until the wire catches up. Same-value writes are
700
- // no-ops (immer produces the same state, nothing persists); entries
701
- // 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
702
758
  // elsewhere, cleared browser storage) self-heals the moment the wire
703
759
  // carries the truth again; a missing entry just leaves today's fallback.
704
760
  const TITLE_CACHE_LIMIT = 3000
705
761
  const TITLE_CACHE_KEEP = 2400
706
- const createTitleCacheStore = () => storeKit.defineStore({
707
- init: () => ({ byId: {} }),
708
- persist: 'dsh.betterWorkspace.titles.v1',
709
- actions: {
710
- rememberTitle: (d, id, title, at) => {
711
- if (typeof id !== 'string' || id === '' || typeof title !== 'string' || title === '') return
712
- if (!d.byId) d.byId = {}
713
- const prev = d.byId[id]
714
- if (prev && prev.title === title) return
715
- d.byId[id] = { title: title, at: typeof at === 'number' ? at : Date.now() }
716
- const keys = Object.keys(d.byId)
717
- if (keys.length > TITLE_CACHE_LIMIT) {
718
- keys.sort((a, b) => ((d.byId[a] && d.byId[a].at) || 0) - ((d.byId[b] && d.byId[b].at) || 0))
719
- for (let i = 0; i < keys.length - TITLE_CACHE_KEEP; i++) delete d.byId[keys[i]]
720
- }
721
- },
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
+ }
807
+
808
+ /* ======================= host settings sync ======================= */
809
+
810
+ // Cross-device preferences: styling / explicit folders / the two toggles
811
+ // live in the HOST settings store (~/.dsh/settings.yaml through the
812
+ // better-workspace namespace). Web and the desktop app share one DSH_HOME,
813
+ // so a style set in the browser follows the user into the Electron app and
814
+ // back. The browser localStorage view store stays the rendering source and
815
+ // the fallback: host values are MIRRORED into it one-way, and every write
816
+ // goes to BOTH stores (local action for immediate echo, scope.set for
817
+ // durable cross-device persistence). Hosts without the settingsScope
818
+ // service — or non-loopback pages where the scope stays process-local —
819
+ // simply never mirror and never sync, which is exactly the pre-0.9.5
820
+ // browser-local behavior.
821
+ let prefsScopeRef = null
822
+ const HOST_SNAP_UNAVAILABLE = { status: 'unavailable', value: undefined }
823
+ const hostPrefsOf = (snap) => (snap && snap.status === 'ready' && snap.value && typeof snap.value === 'object'
824
+ ? snap.value
825
+ : null)
826
+ const scopeSet = (field, value) => {
827
+ const scope = prefsScopeRef
828
+ if (!scope || typeof scope.set !== 'function') return
829
+ try {
830
+ Promise.resolve(scope.set(field, value)).catch((error) => {
831
+ console.warn('[dsh-better-workspace] host settings write failed: ' + field, error)
832
+ })
833
+ } catch (error) {
834
+ console.warn('[dsh-better-workspace] host settings write threw: ' + field, error)
835
+ }
836
+ }
837
+ // React hook over the scope snapshot; degrades to a stable "unavailable"
838
+ // constant when the scope is absent so useSyncExternalStore never re-binds.
839
+ // The hook call itself is unconditional (React discipline: no conditional
840
+ // hooks) — capability probing lives inside the callbacks.
841
+ const HAS_USE_SYNC_EXTERNAL_STORE = typeof React.useSyncExternalStore === 'function'
842
+ const useHostScope = () => {
843
+ const scope = prefsScopeRef
844
+ return HAS_USE_SYNC_EXTERNAL_STORE
845
+ ? React.useSyncExternalStore(
846
+ (onChange) => (scope && typeof scope.subscribe === 'function' ? scope.subscribe(onChange) : () => {}),
847
+ () => {
848
+ try { return scope && typeof scope.getSnapshot === 'function' ? scope.getSnapshot() : HOST_SNAP_UNAVAILABLE } catch { return HOST_SNAP_UNAVAILABLE }
849
+ },
850
+ )
851
+ : HOST_SNAP_UNAVAILABLE
852
+ }
853
+
854
+ // MANUAL cross-device sync (user-chosen, never automatic): the settings
855
+ // card offers a pull button ("从客户端获取" on the web / "从 Web 获取" in
856
+ // the desktop app — the other surface's copy arrives through the host
857
+ // settings store) with an overwrite-or-merge mode choice, plus a push
858
+ // button that uploads THIS device's current values (pre-0.9.5 history was
859
+ // never uploaded, so a first explicit push is needed once). Routine new
860
+ // writes already dual-write through makeSharedWrites, so the host copy
861
+ // stays fresh after the first push.
862
+
863
+ // Shared write wrappers: local action first (immediate echo + fallback
864
+ // store), then the durable host write computed from the CURRENT rendered
865
+ // values — after a manual pull the local store already holds the host
866
+ // copy, so host-only entries written on the other surface survive.
867
+ const makeSharedWrites = (actions, stylingMap, foldersList) => ({
868
+ setStyling: (key, value) => {
869
+ if (actions && typeof actions.setStyling === 'function') actions.setStyling(key, value)
870
+ const next = { ...stylingMap }
871
+ if (value === null) delete next[key]
872
+ else next[key] = value
873
+ scopeSet('styling', next)
874
+ },
875
+ addFolder: (path) => {
876
+ if (actions && typeof actions.addFolder === 'function') actions.addFolder(path)
877
+ const p = normPath(path)
878
+ const base = Array.isArray(foldersList) ? foldersList : []
879
+ if (p !== '' && !base.includes(p)) scopeSet('folders', [...base, p])
880
+ },
881
+ removeFolder: (path) => {
882
+ if (actions && typeof actions.removeFolder === 'function') actions.removeFolder(path)
883
+ const base = Array.isArray(foldersList) ? foldersList : []
884
+ scopeSet('folders', base.filter(f => f !== path))
885
+ },
886
+ renameFolder: (oldPath, newPath) => {
887
+ if (actions && typeof actions.renameFolder === 'function') actions.renameFolder(oldPath, newPath)
888
+ const base = Array.isArray(foldersList) ? foldersList : []
889
+ const oo = oldPath + '/'
890
+ const nn = newPath + '/'
891
+ scopeSet('folders', Array.from(new Set(base.map(f => (f === oldPath ? newPath : (f.startsWith(oo) ? nn + f.slice(oo.length) : f))))))
892
+ },
893
+ setPref: (key, value) => {
894
+ if (actions && typeof actions.setPref === 'function') actions.setPref(key, value)
895
+ scopeSet(key, value)
722
896
  },
723
897
  })
724
898
 
@@ -813,6 +987,8 @@ window.__ModuleLoader__.load({
813
987
  // while closed, but its hook sequence must stay stable.
814
988
  const snapshotItems = typeof useWorkspaces === 'function' ? useWorkspaces(s => s.items) : []
815
989
  const storeFolders = typeof useStore === 'function' ? (useStore(s => s.folders) || []) : []
990
+ const storeStyling = typeof useStore === 'function' ? (useStore(s => s.styling) || {}) : {}
991
+ const shared = makeSharedWrites(actions, storeStyling, storeFolders)
816
992
 
817
993
  React.useEffect(() => {
818
994
  if (!open) {
@@ -867,7 +1043,7 @@ window.__ModuleLoader__.load({
867
1043
  onCancel()
868
1044
  return
869
1045
  }
870
- if (prefix !== '' && actions && typeof actions.addFolder === 'function') actions.addFolder(prefix)
1046
+ if (prefix !== '') shared.addFolder(prefix)
871
1047
  onCancel()
872
1048
  })
873
1049
  .catch((reason) => {
@@ -1262,10 +1438,53 @@ window.__ModuleLoader__.load({
1262
1438
 
1263
1439
  /* ------------------------- settings page ------------------------- */
1264
1440
 
1441
+ // The desktop renderer serves the shell over the private dsh-app: scheme;
1442
+ // every other context (http/https) is the web profile.
1443
+ const IS_DESKTOP_SURFACE = typeof location !== 'undefined' && location.protocol === 'dsh-app:'
1444
+
1265
1445
  function BetterWorkspaceSettings({ useStore, actions, t }) {
1266
1446
  const prefs = useStore ? (useStore(s => s.prefs) || {}) : {}
1447
+ const localStyling = useStore ? (useStore(s => s.styling) || {}) : {}
1448
+ const localFolders = useStore ? (useStore(s => s.folders) || []) : []
1267
1449
  const compactChains = prefs.compactChains !== false
1268
1450
  const statusPulse = prefs.statusPulse !== false
1451
+ const setPref = (key, value) => {
1452
+ if (actions && typeof actions.setPref === 'function') actions.setPref(key, value)
1453
+ scopeSet(key, value)
1454
+ }
1455
+ // Manual cross-device sync state.
1456
+ const [syncMode, setSyncMode] = React.useState('overwrite')
1457
+ const [syncMsg, setSyncMsg] = React.useState(null)
1458
+ const snap = useHostScope()
1459
+ const host = hostPrefsOf(snap)
1460
+ const scopeLive = prefsScopeRef !== null
1461
+ const onPull = () => {
1462
+ if (host === null) { setSyncMsg(t('sync.empty')); return }
1463
+ if (syncMode === 'merge' && actions && typeof actions.mergeHost === 'function') actions.mergeHost(host)
1464
+ else if (actions && typeof actions.importHost === 'function') actions.importHost(host)
1465
+ else { setSyncMsg(t('sync.off')); return }
1466
+ setSyncMsg(t('sync.done'))
1467
+ }
1468
+ const onPush = () => {
1469
+ const folders = Array.isArray(localFolders) ? localFolders.slice() : []
1470
+ scopeSet('styling', localStyling)
1471
+ scopeSet('folders', folders)
1472
+ scopeSet('compactChains', prefs.compactChains !== false)
1473
+ scopeSet('statusPulse', prefs.statusPulse !== false)
1474
+ setSyncMsg(t('sync.done'))
1475
+ }
1476
+ const modeButton = (id, label) => E('button', {
1477
+ type: 'button',
1478
+ className: cls('bw-sync-mode', syncMode === id && 'bw-sync-mode-on'),
1479
+ 'aria-pressed': syncMode === id,
1480
+ onClick: () => { setSyncMode(id); setSyncMsg(null) },
1481
+ }, label)
1482
+ const syncActionButton = (label, onClick, primary, disabled) => E('button', {
1483
+ type: 'button',
1484
+ className: cls('bw-sync-btn', primary && 'bw-sync-btn-primary'),
1485
+ disabled,
1486
+ onClick,
1487
+ }, label)
1269
1488
  return E('div', { className: 'bw-settings' },
1270
1489
  StyleNode(),
1271
1490
  E('div', { className: 'bw-setting-row' },
@@ -1276,7 +1495,7 @@ window.__ModuleLoader__.load({
1276
1495
  'aria-checked': compactChains,
1277
1496
  'aria-label': t('settings.compactChains'),
1278
1497
  className: cls('bw-switch', compactChains && 'bw-switch-on'),
1279
- onClick: () => { actions.setPref('compactChains', !compactChains) },
1498
+ onClick: () => { setPref('compactChains', !compactChains) },
1280
1499
  }, E('span', { className: 'bw-switch-thumb' })),
1281
1500
  ),
1282
1501
  E('div', { className: 'bw-hint' }, t('settings.compactChains.hint')),
@@ -1288,10 +1507,23 @@ window.__ModuleLoader__.load({
1288
1507
  'aria-checked': statusPulse,
1289
1508
  'aria-label': t('settings.statusPulse'),
1290
1509
  className: cls('bw-switch', statusPulse && 'bw-switch-on'),
1291
- onClick: () => { actions.setPref('statusPulse', !statusPulse) },
1510
+ onClick: () => { setPref('statusPulse', !statusPulse) },
1292
1511
  }, E('span', { className: 'bw-switch-thumb' })),
1293
1512
  ),
1294
1513
  E('div', { className: 'bw-hint' }, t('settings.statusPulse.hint')),
1514
+ E('div', { className: 'bw-setting-label', style: { marginTop: 16 } }, t('sync.title')),
1515
+ E('div', { className: 'bw-hint' }, t('sync.desc')),
1516
+ E('div', { className: 'bw-sync-modes' },
1517
+ modeButton('overwrite', t('sync.mode.overwrite')),
1518
+ modeButton('merge', t('sync.mode.merge')),
1519
+ ),
1520
+ E('div', { className: 'bw-sync-actions' },
1521
+ syncActionButton(t(IS_DESKTOP_SURFACE ? 'sync.pull.web' : 'sync.pull.desktop'), onPull, true, !scopeLive || snap.status === 'loading'),
1522
+ syncActionButton(t('sync.push'), onPush, false, !scopeLive),
1523
+ ),
1524
+ syncMsg !== null ? E('div', { className: 'bw-hint' }, syncMsg)
1525
+ : (scopeLive ? null : E('div', { className: 'bw-hint' }, t('sync.off'))),
1526
+ scopeLive && snap.status === 'loading' ? E('div', { className: 'bw-hint' }, t('sync.loading')) : null,
1295
1527
  )
1296
1528
  }
1297
1529
 
@@ -1348,12 +1580,17 @@ window.__ModuleLoader__.load({
1348
1580
  const sessionGroupsMap = useStore ? (useStore(s => s.sessionGroups) || {}) : {}
1349
1581
  const prefsMap = useStore ? (useStore(s => s.prefs) || {}) : {}
1350
1582
  const stylingMap = useStore ? (useStore(s => s.styling) || {}) : {}
1583
+ // Dual-write wrappers: every preference mutation lands in the local
1584
+ // store (immediate echo + scope-less fallback) AND the host settings
1585
+ // store (durable cross-device copy for the manual pull on the other
1586
+ // surface). Computed from the CURRENT rendered values.
1587
+ const shared = makeSharedWrites(actions, stylingMap, storeFolders)
1351
1588
  const compactChains = prefsMap.compactChains !== false
1352
1589
  const statusPulse = prefsMap.statusPulse !== false
1353
1590
  const archivedSet = React.useMemo(() => new Set(archivedSessionIds), [archivedSessionIds])
1354
1591
  const subCounts = React.useMemo(() => subagentRunningCounts(list ? list.byId : {}), [list ? list.byId : null])
1355
1592
  // Last-known real titles for the cold-restart fallback window (see
1356
- // createTitleCacheStore). Read per render via getSnapshot: no
1593
+ // the title-cache block). Read per render via getSnapshot: no
1357
1594
  // subscription, because a cache write only happens when summary.title
1358
1595
  // exists — at that moment the row already renders the wire truth and
1359
1596
  // no re-render is wanted.
@@ -1365,21 +1602,13 @@ window.__ModuleLoader__.load({
1365
1602
  // Learn real titles from every snapshot: remember exactly what the
1366
1603
  // wire carried (summary.title), never displayTitle — that is already
1367
1604
  // basename-degraded in the very window this patches. Blank rows are
1368
- // provisional New Sessions and never learn. rememberTitle itself
1369
- // 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.
1370
1608
  React.useEffect(() => {
1371
1609
  if (!list || !list.byId) return
1372
- const remember = titleCacheRef && titleCacheRef.actions
1373
- ? titleCacheRef.actions.rememberTitle
1374
- : null
1375
- if (typeof remember !== 'function') return
1376
- const now = Date.now()
1377
- for (const id of Object.keys(list.byId)) {
1378
- const summary = list.byId[id]
1379
- if (!summary || summary.blank) continue
1380
- const title = summary.title
1381
- if (typeof title !== 'string' || title === '') continue
1382
- remember(String(id), title, now)
1610
+ if (titleCacheRef && typeof titleCacheRef.rememberAllTitles === 'function') {
1611
+ titleCacheRef.rememberAllTitles(list)
1383
1612
  }
1384
1613
  }, [list ? list.byId : null])
1385
1614
 
@@ -1944,7 +2173,7 @@ window.__ModuleLoader__.load({
1944
2173
  for (let i = 1; i < segs.length; i++) derived.add(segs.slice(0, i).join('/'))
1945
2174
  }
1946
2175
  if (storeFolders.includes(p) || derived.has(p)) { setErrorText(t('folder.error.exists')); return }
1947
- actions.addFolder(p)
2176
+ shared.addFolder(p)
1948
2177
  setDialog(null)
1949
2178
  }
1950
2179
 
@@ -1965,14 +2194,14 @@ window.__ModuleLoader__.load({
1965
2194
  await renameWorkspace(w.workspaceId, nextTitle)
1966
2195
  }
1967
2196
  })
1968
- .then(() => { actions.renameFolder(oldPath, newPath); setDialog(null) })
2197
+ .then(() => { shared.renameFolder(oldPath, newPath); setDialog(null) })
1969
2198
  .catch(fail)
1970
2199
  }
1971
2200
 
1972
2201
  const submitFolderDelete = (path) => {
1973
2202
  const node = findTreeNode(tree, path)
1974
2203
  if (node && countWorkspaces(node) > 0) { setErrorText(t('folder.error.notEmpty')); return }
1975
- actions.removeFolder(path)
2204
+ shared.removeFolder(path)
1976
2205
  setDialog(null)
1977
2206
  }
1978
2207
 
@@ -2335,8 +2564,8 @@ window.__ModuleLoader__.load({
2335
2564
  open: customize !== null,
2336
2565
  kind: customize ? customize.kind : undefined,
2337
2566
  initial: customize ? styleEntry(customize.entryKey) : undefined,
2338
- onChange: (style) => { if (customize) actions.setStyling(customize.entryKey, style) },
2339
- onReset: () => { if (customize) actions.setStyling(customize.entryKey, null) },
2567
+ onChange: (style) => { if (customize) shared.setStyling(customize.entryKey, style) },
2568
+ onReset: () => { if (customize) shared.setStyling(customize.entryKey, null) },
2340
2569
  onClose: () => setCustomize(null),
2341
2570
  t,
2342
2571
  }),
@@ -2478,11 +2707,24 @@ window.__ModuleLoader__.load({
2478
2707
  // same persisted state (expansion, folder list, prefs, styling).
2479
2708
  const viewStore = createViewStore()
2480
2709
 
2481
- // Cold-restart title fallback instance (see createTitleCacheStore).
2482
- // getSnapshot() during render is intentional: hydration reads
2483
- // localStorage synchronously at create(), so the first render after a
2484
- // restart already sees every remembered title.
2485
- 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 }
2715
+
2716
+ // Cross-device settings scope (manual sync, see the host-settings-sync
2717
+ // block). Binding is best-effort: a missing/failed bind leaves the
2718
+ // plugin fully browser-local — the pre-0.9.5 behavior.
2719
+ prefsScopeRef = null
2720
+ try {
2721
+ if (ctx.settingsScope && typeof ctx.settingsScope.bind === 'function') {
2722
+ prefsScopeRef = ctx.settingsScope.bind({ namespace: 'better-workspace' })
2723
+ }
2724
+ } catch (error) {
2725
+ console.warn('[dsh-better-workspace] settings scope bind failed; sync stays local', error)
2726
+ prefsScopeRef = null
2727
+ }
2486
2728
 
2487
2729
  // Settings → Plugins card only (the tab dispatches the intersection of
2488
2730
  // served namespaces — registered host-side — and settings.plugin.item
@@ -2515,7 +2757,7 @@ window.__ModuleLoader__.load({
2515
2757
 
2516
2758
  return {
2517
2759
  name: 'dsh-better-workspace',
2518
- inject: ['slots', 'sessions', 'workspaces', 'locale', 'uiWorkspace'],
2760
+ inject: ['slots', 'sessions', 'workspaces', 'locale', 'uiWorkspace', 'settingsScope'],
2519
2761
  apply,
2520
2762
  }
2521
2763
  },
package/src/index.js CHANGED
@@ -49,7 +49,17 @@ export function apply(ctx) {
49
49
  const ns = typeof ds.settingsNamespace === 'function'
50
50
  ? ds.settingsNamespace('better-workspace')
51
51
  : 'better-workspace'
52
- settings.register(ns, Schema.object({ compactChains: Schema.boolean().default(true) }))
52
+ settings.register(ns, Schema.object({
53
+ // Cross-device preferences live in the HOST settings store
54
+ // (~/.dsh/settings.yaml): web and the desktop app share one
55
+ // DSH_HOME, so appearance (styling), explicit folders, and the two
56
+ // toggles follow the user across surfaces. Per-device view state
57
+ // (expansion) and the cold-restart title cache stay browser-local.
58
+ compactChains: Schema.boolean().default(true),
59
+ statusPulse: Schema.boolean().default(true),
60
+ folders: Schema.array(Schema.string()).default([]),
61
+ styling: Schema.dict(Schema.any()).default({}),
62
+ }))
53
63
  log('[dsh-better-workspace] settings namespace registered: better-workspace')
54
64
  })
55
65
  .catch((error) => {