dsh-better-workspace 0.9.4 → 0.9.5
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 +2 -0
- package/README_EN.md +2 -0
- package/dsh.plugin.json +1 -1
- package/package.json +1 -1
- package/src/client.js +229 -9
- package/src/index.js +11 -1
package/README.md
CHANGED
|
@@ -35,6 +35,8 @@ web/ ← 虚拟分组(不是真实目录,纯命名层级)
|
|
|
35
35
|
|
|
36
36
|
**重启后标题不回退**:DSH 重启后的冷列表只为命中投影缓存的会话携带标题——分叉出的会话与从未写过检查点的会话会暂时回退成「工作区名」显示,真实的标题其实还在会话日志里。本插件把每个会话**最近一次的真实标题**持久化在浏览器里(dsh 客户端 store),回退窗口内直接用记忆标题渲染,`/` 分级分组照常成立;打开会话后官方数据到位即自动纠正。
|
|
37
37
|
|
|
38
|
+
**跨端手动同步(Web ↔ 桌面客户端)**:外观自定义、显式分组与开关默认保存在各设备自己的浏览器里;设置卡片新增「跨端同步」区——Web 上点「从客户端获取」、客户端上点「从 Web 获取」,可选**覆盖本设备**或**合并两端**(并集保留各自独有项),另一端的数据经宿主设置存储(~/.dsh/settings.yaml,两端共享)送达;旁边有「发送本设备数据」按钮,旧版本的历史数据首次同步推一次即可,之后每次修改都会自动写入宿主、对端随手可取。折叠状态仍保持每端独立。
|
|
39
|
+
|
|
38
40
|
<table>
|
|
39
41
|
<tr>
|
|
40
42
|
<td align="center" width="58%"><img src="docs/screenshots/3-appearance-dialog.png" alt="外观自定义弹窗(实时预览)"/></td>
|
package/README_EN.md
CHANGED
|
@@ -33,6 +33,8 @@ Session titles nest on `/` too (e.g. `test1/plugin maintenance`). Session groups
|
|
|
33
33
|
|
|
34
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.
|
|
35
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.
|
|
37
|
+
|
|
36
38
|
<table>
|
|
37
39
|
<tr>
|
|
38
40
|
<td align="center" width="58%"><img src="docs/screenshots/3-appearance-dialog.png" alt="Appearance dialog with live preview"/></td>
|
package/dsh.plugin.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "dsh-external/dsh-better-workspace",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.5",
|
|
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.
|
|
3
|
+
"version": "0.9.5",
|
|
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 },
|
|
@@ -722,6 +775,97 @@ window.__ModuleLoader__.load({
|
|
|
722
775
|
},
|
|
723
776
|
})
|
|
724
777
|
|
|
778
|
+
/* ======================= host settings sync ======================= */
|
|
779
|
+
|
|
780
|
+
// Cross-device preferences: styling / explicit folders / the two toggles
|
|
781
|
+
// live in the HOST settings store (~/.dsh/settings.yaml through the
|
|
782
|
+
// better-workspace namespace). Web and the desktop app share one DSH_HOME,
|
|
783
|
+
// so a style set in the browser follows the user into the Electron app and
|
|
784
|
+
// back. The browser localStorage view store stays the rendering source and
|
|
785
|
+
// the fallback: host values are MIRRORED into it one-way, and every write
|
|
786
|
+
// goes to BOTH stores (local action for immediate echo, scope.set for
|
|
787
|
+
// durable cross-device persistence). Hosts without the settingsScope
|
|
788
|
+
// service — or non-loopback pages where the scope stays process-local —
|
|
789
|
+
// simply never mirror and never sync, which is exactly the pre-0.9.5
|
|
790
|
+
// browser-local behavior.
|
|
791
|
+
let prefsScopeRef = null
|
|
792
|
+
const HOST_SNAP_UNAVAILABLE = { status: 'unavailable', value: undefined }
|
|
793
|
+
const hostPrefsOf = (snap) => (snap && snap.status === 'ready' && snap.value && typeof snap.value === 'object'
|
|
794
|
+
? snap.value
|
|
795
|
+
: null)
|
|
796
|
+
const scopeSet = (field, value) => {
|
|
797
|
+
const scope = prefsScopeRef
|
|
798
|
+
if (!scope || typeof scope.set !== 'function') return
|
|
799
|
+
try {
|
|
800
|
+
Promise.resolve(scope.set(field, value)).catch((error) => {
|
|
801
|
+
console.warn('[dsh-better-workspace] host settings write failed: ' + field, error)
|
|
802
|
+
})
|
|
803
|
+
} catch (error) {
|
|
804
|
+
console.warn('[dsh-better-workspace] host settings write threw: ' + field, error)
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
// React hook over the scope snapshot; degrades to a stable "unavailable"
|
|
808
|
+
// constant when the scope is absent so useSyncExternalStore never re-binds.
|
|
809
|
+
// The hook call itself is unconditional (React discipline: no conditional
|
|
810
|
+
// hooks) — capability probing lives inside the callbacks.
|
|
811
|
+
const HAS_USE_SYNC_EXTERNAL_STORE = typeof React.useSyncExternalStore === 'function'
|
|
812
|
+
const useHostScope = () => {
|
|
813
|
+
const scope = prefsScopeRef
|
|
814
|
+
return HAS_USE_SYNC_EXTERNAL_STORE
|
|
815
|
+
? React.useSyncExternalStore(
|
|
816
|
+
(onChange) => (scope && typeof scope.subscribe === 'function' ? scope.subscribe(onChange) : () => {}),
|
|
817
|
+
() => {
|
|
818
|
+
try { return scope && typeof scope.getSnapshot === 'function' ? scope.getSnapshot() : HOST_SNAP_UNAVAILABLE } catch { return HOST_SNAP_UNAVAILABLE }
|
|
819
|
+
},
|
|
820
|
+
)
|
|
821
|
+
: HOST_SNAP_UNAVAILABLE
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// MANUAL cross-device sync (user-chosen, never automatic): the settings
|
|
825
|
+
// card offers a pull button ("从客户端获取" on the web / "从 Web 获取" in
|
|
826
|
+
// the desktop app — the other surface's copy arrives through the host
|
|
827
|
+
// settings store) with an overwrite-or-merge mode choice, plus a push
|
|
828
|
+
// button that uploads THIS device's current values (pre-0.9.5 history was
|
|
829
|
+
// never uploaded, so a first explicit push is needed once). Routine new
|
|
830
|
+
// writes already dual-write through makeSharedWrites, so the host copy
|
|
831
|
+
// stays fresh after the first push.
|
|
832
|
+
|
|
833
|
+
// Shared write wrappers: local action first (immediate echo + fallback
|
|
834
|
+
// store), then the durable host write computed from the CURRENT rendered
|
|
835
|
+
// values — after a manual pull the local store already holds the host
|
|
836
|
+
// copy, so host-only entries written on the other surface survive.
|
|
837
|
+
const makeSharedWrites = (actions, stylingMap, foldersList) => ({
|
|
838
|
+
setStyling: (key, value) => {
|
|
839
|
+
if (actions && typeof actions.setStyling === 'function') actions.setStyling(key, value)
|
|
840
|
+
const next = { ...stylingMap }
|
|
841
|
+
if (value === null) delete next[key]
|
|
842
|
+
else next[key] = value
|
|
843
|
+
scopeSet('styling', next)
|
|
844
|
+
},
|
|
845
|
+
addFolder: (path) => {
|
|
846
|
+
if (actions && typeof actions.addFolder === 'function') actions.addFolder(path)
|
|
847
|
+
const p = normPath(path)
|
|
848
|
+
const base = Array.isArray(foldersList) ? foldersList : []
|
|
849
|
+
if (p !== '' && !base.includes(p)) scopeSet('folders', [...base, p])
|
|
850
|
+
},
|
|
851
|
+
removeFolder: (path) => {
|
|
852
|
+
if (actions && typeof actions.removeFolder === 'function') actions.removeFolder(path)
|
|
853
|
+
const base = Array.isArray(foldersList) ? foldersList : []
|
|
854
|
+
scopeSet('folders', base.filter(f => f !== path))
|
|
855
|
+
},
|
|
856
|
+
renameFolder: (oldPath, newPath) => {
|
|
857
|
+
if (actions && typeof actions.renameFolder === 'function') actions.renameFolder(oldPath, newPath)
|
|
858
|
+
const base = Array.isArray(foldersList) ? foldersList : []
|
|
859
|
+
const oo = oldPath + '/'
|
|
860
|
+
const nn = newPath + '/'
|
|
861
|
+
scopeSet('folders', Array.from(new Set(base.map(f => (f === oldPath ? newPath : (f.startsWith(oo) ? nn + f.slice(oo.length) : f))))))
|
|
862
|
+
},
|
|
863
|
+
setPref: (key, value) => {
|
|
864
|
+
if (actions && typeof actions.setPref === 'function') actions.setPref(key, value)
|
|
865
|
+
scopeSet(key, value)
|
|
866
|
+
},
|
|
867
|
+
})
|
|
868
|
+
|
|
725
869
|
/* ============================ flow dialog ========================= */
|
|
726
870
|
|
|
727
871
|
const BTN = (props) => (
|
|
@@ -813,6 +957,8 @@ window.__ModuleLoader__.load({
|
|
|
813
957
|
// while closed, but its hook sequence must stay stable.
|
|
814
958
|
const snapshotItems = typeof useWorkspaces === 'function' ? useWorkspaces(s => s.items) : []
|
|
815
959
|
const storeFolders = typeof useStore === 'function' ? (useStore(s => s.folders) || []) : []
|
|
960
|
+
const storeStyling = typeof useStore === 'function' ? (useStore(s => s.styling) || {}) : {}
|
|
961
|
+
const shared = makeSharedWrites(actions, storeStyling, storeFolders)
|
|
816
962
|
|
|
817
963
|
React.useEffect(() => {
|
|
818
964
|
if (!open) {
|
|
@@ -867,7 +1013,7 @@ window.__ModuleLoader__.load({
|
|
|
867
1013
|
onCancel()
|
|
868
1014
|
return
|
|
869
1015
|
}
|
|
870
|
-
if (prefix !== ''
|
|
1016
|
+
if (prefix !== '') shared.addFolder(prefix)
|
|
871
1017
|
onCancel()
|
|
872
1018
|
})
|
|
873
1019
|
.catch((reason) => {
|
|
@@ -1262,10 +1408,53 @@ window.__ModuleLoader__.load({
|
|
|
1262
1408
|
|
|
1263
1409
|
/* ------------------------- settings page ------------------------- */
|
|
1264
1410
|
|
|
1411
|
+
// The desktop renderer serves the shell over the private dsh-app: scheme;
|
|
1412
|
+
// every other context (http/https) is the web profile.
|
|
1413
|
+
const IS_DESKTOP_SURFACE = typeof location !== 'undefined' && location.protocol === 'dsh-app:'
|
|
1414
|
+
|
|
1265
1415
|
function BetterWorkspaceSettings({ useStore, actions, t }) {
|
|
1266
1416
|
const prefs = useStore ? (useStore(s => s.prefs) || {}) : {}
|
|
1417
|
+
const localStyling = useStore ? (useStore(s => s.styling) || {}) : {}
|
|
1418
|
+
const localFolders = useStore ? (useStore(s => s.folders) || []) : []
|
|
1267
1419
|
const compactChains = prefs.compactChains !== false
|
|
1268
1420
|
const statusPulse = prefs.statusPulse !== false
|
|
1421
|
+
const setPref = (key, value) => {
|
|
1422
|
+
if (actions && typeof actions.setPref === 'function') actions.setPref(key, value)
|
|
1423
|
+
scopeSet(key, value)
|
|
1424
|
+
}
|
|
1425
|
+
// Manual cross-device sync state.
|
|
1426
|
+
const [syncMode, setSyncMode] = React.useState('overwrite')
|
|
1427
|
+
const [syncMsg, setSyncMsg] = React.useState(null)
|
|
1428
|
+
const snap = useHostScope()
|
|
1429
|
+
const host = hostPrefsOf(snap)
|
|
1430
|
+
const scopeLive = prefsScopeRef !== null
|
|
1431
|
+
const onPull = () => {
|
|
1432
|
+
if (host === null) { setSyncMsg(t('sync.empty')); return }
|
|
1433
|
+
if (syncMode === 'merge' && actions && typeof actions.mergeHost === 'function') actions.mergeHost(host)
|
|
1434
|
+
else if (actions && typeof actions.importHost === 'function') actions.importHost(host)
|
|
1435
|
+
else { setSyncMsg(t('sync.off')); return }
|
|
1436
|
+
setSyncMsg(t('sync.done'))
|
|
1437
|
+
}
|
|
1438
|
+
const onPush = () => {
|
|
1439
|
+
const folders = Array.isArray(localFolders) ? localFolders.slice() : []
|
|
1440
|
+
scopeSet('styling', localStyling)
|
|
1441
|
+
scopeSet('folders', folders)
|
|
1442
|
+
scopeSet('compactChains', prefs.compactChains !== false)
|
|
1443
|
+
scopeSet('statusPulse', prefs.statusPulse !== false)
|
|
1444
|
+
setSyncMsg(t('sync.done'))
|
|
1445
|
+
}
|
|
1446
|
+
const modeButton = (id, label) => E('button', {
|
|
1447
|
+
type: 'button',
|
|
1448
|
+
className: cls('bw-sync-mode', syncMode === id && 'bw-sync-mode-on'),
|
|
1449
|
+
'aria-pressed': syncMode === id,
|
|
1450
|
+
onClick: () => { setSyncMode(id); setSyncMsg(null) },
|
|
1451
|
+
}, label)
|
|
1452
|
+
const syncActionButton = (label, onClick, primary, disabled) => E('button', {
|
|
1453
|
+
type: 'button',
|
|
1454
|
+
className: cls('bw-sync-btn', primary && 'bw-sync-btn-primary'),
|
|
1455
|
+
disabled,
|
|
1456
|
+
onClick,
|
|
1457
|
+
}, label)
|
|
1269
1458
|
return E('div', { className: 'bw-settings' },
|
|
1270
1459
|
StyleNode(),
|
|
1271
1460
|
E('div', { className: 'bw-setting-row' },
|
|
@@ -1276,7 +1465,7 @@ window.__ModuleLoader__.load({
|
|
|
1276
1465
|
'aria-checked': compactChains,
|
|
1277
1466
|
'aria-label': t('settings.compactChains'),
|
|
1278
1467
|
className: cls('bw-switch', compactChains && 'bw-switch-on'),
|
|
1279
|
-
onClick: () => {
|
|
1468
|
+
onClick: () => { setPref('compactChains', !compactChains) },
|
|
1280
1469
|
}, E('span', { className: 'bw-switch-thumb' })),
|
|
1281
1470
|
),
|
|
1282
1471
|
E('div', { className: 'bw-hint' }, t('settings.compactChains.hint')),
|
|
@@ -1288,10 +1477,23 @@ window.__ModuleLoader__.load({
|
|
|
1288
1477
|
'aria-checked': statusPulse,
|
|
1289
1478
|
'aria-label': t('settings.statusPulse'),
|
|
1290
1479
|
className: cls('bw-switch', statusPulse && 'bw-switch-on'),
|
|
1291
|
-
onClick: () => {
|
|
1480
|
+
onClick: () => { setPref('statusPulse', !statusPulse) },
|
|
1292
1481
|
}, E('span', { className: 'bw-switch-thumb' })),
|
|
1293
1482
|
),
|
|
1294
1483
|
E('div', { className: 'bw-hint' }, t('settings.statusPulse.hint')),
|
|
1484
|
+
E('div', { className: 'bw-setting-label', style: { marginTop: 16 } }, t('sync.title')),
|
|
1485
|
+
E('div', { className: 'bw-hint' }, t('sync.desc')),
|
|
1486
|
+
E('div', { className: 'bw-sync-modes' },
|
|
1487
|
+
modeButton('overwrite', t('sync.mode.overwrite')),
|
|
1488
|
+
modeButton('merge', t('sync.mode.merge')),
|
|
1489
|
+
),
|
|
1490
|
+
E('div', { className: 'bw-sync-actions' },
|
|
1491
|
+
syncActionButton(t(IS_DESKTOP_SURFACE ? 'sync.pull.web' : 'sync.pull.desktop'), onPull, true, !scopeLive || snap.status === 'loading'),
|
|
1492
|
+
syncActionButton(t('sync.push'), onPush, false, !scopeLive),
|
|
1493
|
+
),
|
|
1494
|
+
syncMsg !== null ? E('div', { className: 'bw-hint' }, syncMsg)
|
|
1495
|
+
: (scopeLive ? null : E('div', { className: 'bw-hint' }, t('sync.off'))),
|
|
1496
|
+
scopeLive && snap.status === 'loading' ? E('div', { className: 'bw-hint' }, t('sync.loading')) : null,
|
|
1295
1497
|
)
|
|
1296
1498
|
}
|
|
1297
1499
|
|
|
@@ -1348,6 +1550,11 @@ window.__ModuleLoader__.load({
|
|
|
1348
1550
|
const sessionGroupsMap = useStore ? (useStore(s => s.sessionGroups) || {}) : {}
|
|
1349
1551
|
const prefsMap = useStore ? (useStore(s => s.prefs) || {}) : {}
|
|
1350
1552
|
const stylingMap = useStore ? (useStore(s => s.styling) || {}) : {}
|
|
1553
|
+
// Dual-write wrappers: every preference mutation lands in the local
|
|
1554
|
+
// store (immediate echo + scope-less fallback) AND the host settings
|
|
1555
|
+
// store (durable cross-device copy for the manual pull on the other
|
|
1556
|
+
// surface). Computed from the CURRENT rendered values.
|
|
1557
|
+
const shared = makeSharedWrites(actions, stylingMap, storeFolders)
|
|
1351
1558
|
const compactChains = prefsMap.compactChains !== false
|
|
1352
1559
|
const statusPulse = prefsMap.statusPulse !== false
|
|
1353
1560
|
const archivedSet = React.useMemo(() => new Set(archivedSessionIds), [archivedSessionIds])
|
|
@@ -1944,7 +2151,7 @@ window.__ModuleLoader__.load({
|
|
|
1944
2151
|
for (let i = 1; i < segs.length; i++) derived.add(segs.slice(0, i).join('/'))
|
|
1945
2152
|
}
|
|
1946
2153
|
if (storeFolders.includes(p) || derived.has(p)) { setErrorText(t('folder.error.exists')); return }
|
|
1947
|
-
|
|
2154
|
+
shared.addFolder(p)
|
|
1948
2155
|
setDialog(null)
|
|
1949
2156
|
}
|
|
1950
2157
|
|
|
@@ -1965,14 +2172,14 @@ window.__ModuleLoader__.load({
|
|
|
1965
2172
|
await renameWorkspace(w.workspaceId, nextTitle)
|
|
1966
2173
|
}
|
|
1967
2174
|
})
|
|
1968
|
-
.then(() => {
|
|
2175
|
+
.then(() => { shared.renameFolder(oldPath, newPath); setDialog(null) })
|
|
1969
2176
|
.catch(fail)
|
|
1970
2177
|
}
|
|
1971
2178
|
|
|
1972
2179
|
const submitFolderDelete = (path) => {
|
|
1973
2180
|
const node = findTreeNode(tree, path)
|
|
1974
2181
|
if (node && countWorkspaces(node) > 0) { setErrorText(t('folder.error.notEmpty')); return }
|
|
1975
|
-
|
|
2182
|
+
shared.removeFolder(path)
|
|
1976
2183
|
setDialog(null)
|
|
1977
2184
|
}
|
|
1978
2185
|
|
|
@@ -2335,8 +2542,8 @@ window.__ModuleLoader__.load({
|
|
|
2335
2542
|
open: customize !== null,
|
|
2336
2543
|
kind: customize ? customize.kind : undefined,
|
|
2337
2544
|
initial: customize ? styleEntry(customize.entryKey) : undefined,
|
|
2338
|
-
onChange: (style) => { if (customize)
|
|
2339
|
-
onReset: () => { if (customize)
|
|
2545
|
+
onChange: (style) => { if (customize) shared.setStyling(customize.entryKey, style) },
|
|
2546
|
+
onReset: () => { if (customize) shared.setStyling(customize.entryKey, null) },
|
|
2340
2547
|
onClose: () => setCustomize(null),
|
|
2341
2548
|
t,
|
|
2342
2549
|
}),
|
|
@@ -2484,6 +2691,19 @@ window.__ModuleLoader__.load({
|
|
|
2484
2691
|
// restart already sees every remembered title.
|
|
2485
2692
|
titleCacheRef = createTitleCacheStore().create()
|
|
2486
2693
|
|
|
2694
|
+
// Cross-device settings scope (manual sync, see the host-settings-sync
|
|
2695
|
+
// block). Binding is best-effort: a missing/failed bind leaves the
|
|
2696
|
+
// plugin fully browser-local — the pre-0.9.5 behavior.
|
|
2697
|
+
prefsScopeRef = null
|
|
2698
|
+
try {
|
|
2699
|
+
if (ctx.settingsScope && typeof ctx.settingsScope.bind === 'function') {
|
|
2700
|
+
prefsScopeRef = ctx.settingsScope.bind({ namespace: 'better-workspace' })
|
|
2701
|
+
}
|
|
2702
|
+
} catch (error) {
|
|
2703
|
+
console.warn('[dsh-better-workspace] settings scope bind failed; sync stays local', error)
|
|
2704
|
+
prefsScopeRef = null
|
|
2705
|
+
}
|
|
2706
|
+
|
|
2487
2707
|
// Settings → Plugins card only (the tab dispatches the intersection of
|
|
2488
2708
|
// served namespaces — registered host-side — and settings.plugin.item
|
|
2489
2709
|
// cards). The old left-nav settings.section entry was removed: the
|
|
@@ -2515,7 +2735,7 @@ window.__ModuleLoader__.load({
|
|
|
2515
2735
|
|
|
2516
2736
|
return {
|
|
2517
2737
|
name: 'dsh-better-workspace',
|
|
2518
|
-
inject: ['slots', 'sessions', 'workspaces', 'locale', 'uiWorkspace'],
|
|
2738
|
+
inject: ['slots', 'sessions', 'workspaces', 'locale', 'uiWorkspace', 'settingsScope'],
|
|
2519
2739
|
apply,
|
|
2520
2740
|
}
|
|
2521
2741
|
},
|
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({
|
|
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) => {
|