dsh-better-workspace 0.9.5 → 0.10.0
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 +6 -4
- package/README_EN.md +6 -4
- package/dsh.plugin.json +1 -1
- package/package.json +1 -1
- package/src/client.js +433 -179
- package/src/index.js +5 -0
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://awesome-dsh-plugin.com)
|
|
4
4
|
|
|
5
|
+
简体中文 | [English](README_EN.md)
|
|
6
|
+
|
|
5
7
|
> 给 [DeepSeek Harness (DSH)](https://www.npmjs.com/package/@deepseek-ai/dsh) 的侧边栏「工作区」列表装上一套**文件夹系统**——工作区还是那个工作区(一个目录),但名字里的 `/` 就是层级,让「网页前端」「网页后端」这类工作区可以归到同一个 `web` 分组下。
|
|
6
8
|
|
|
7
9
|
```
|
|
@@ -33,14 +35,14 @@ web/ ← 虚拟分组(不是真实目录,纯命名层级)
|
|
|
33
35
|
|
|
34
36
|
工作区里的会话名称同样用 `/` 分层(如 `测试1/两个插件维护`)。会话分组用**次级配色且无文件夹图标**渲染,和工作区文件夹一眼可辨;会话分组可整组重命名(批量改写成员标题)。
|
|
35
37
|
|
|
36
|
-
**重启后标题不回退**:DSH 重启后的冷列表只为命中投影缓存的会话携带标题——分叉出的会话与从未写过检查点的会话会暂时回退成「工作区名」显示,真实的标题其实还在会话日志里。本插件把每个会话**最近一次的真实标题**持久化在浏览器里(
|
|
38
|
+
**重启后标题不回退**:DSH 重启后的冷列表只为命中投影缓存的会话携带标题——分叉出的会话与从未写过检查点的会话会暂时回退成「工作区名」显示,真实的标题其实还在会话日志里。本插件把每个会话**最近一次的真实标题**持久化在浏览器里(带防抖的本地缓存,变更合并整写一次),回退窗口内直接用记忆标题渲染,`/` 分级分组照常成立;打开会话后官方数据到位即自动纠正。
|
|
37
39
|
|
|
38
|
-
**跨端手动同步(Web ↔ 桌面客户端)
|
|
40
|
+
**跨端手动同步(Web ↔ 桌面客户端)**:外观自定义、默认外观、显式分组与开关默认保存在各设备自己的浏览器里;设置卡片新增「跨端同步」区——Web 上点「从客户端获取」、客户端上点「从 Web 获取」,可选**覆盖本设备**或**合并两端**(并集保留各自独有项),另一端的数据经宿主设置存储(~/.dsh/settings.yaml,两端共享)送达;旁边有「发送本设备数据」按钮,旧版本的历史数据首次同步推一次即可,之后每次修改都会自动写入宿主、对端随手可取。折叠状态仍保持每端独立。
|
|
39
41
|
|
|
40
42
|
<table>
|
|
41
43
|
<tr>
|
|
42
44
|
<td align="center" width="58%"><img src="docs/screenshots/3-appearance-dialog.png" alt="外观自定义弹窗(实时预览)"/></td>
|
|
43
|
-
<td valign="top"><b>外观自定义</b><br/>右键任意一层(工作区分组 / 工作区 / 会话 / 会话分组)→「自定义外观」:颜色(预设色板 + 取色器 + RGB)、发光强度(0..14 拖动条,只作用于文字)
|
|
45
|
+
<td valign="top"><b>外观自定义</b><br/>右键任意一层(工作区分组 / 工作区 / 会话 / 会话分组)→「自定义外观」:颜色(预设色板 + 取色器 + RGB)、发光强度(0..14 拖动条,只作用于文字)、字体粗细、字体阴影开关、**字体描边**(开关 + 0.5–4px 粗细,**默认开启**;描边颜色自动取该行字体颜色的反差色——浅色字配黑边、深色字配白边,跟随主题与背景插件的界面明暗,不用手选)、图标(与 dsh 图标库同族的 69 个,工作区与工作区分组专属),底部带实时预览。清除自定义一键复原。</td>
|
|
44
46
|
</tr>
|
|
45
47
|
<tr>
|
|
46
48
|
<td align="center" width="58%"><img src="docs/screenshots/2-customized-tree.png" alt="外观自定义效果"/></td>
|
|
@@ -58,7 +60,7 @@ web/ ← 虚拟分组(不是真实目录,纯命名层级)
|
|
|
58
60
|
<table>
|
|
59
61
|
<tr>
|
|
60
62
|
<td align="center" width="58%"><img src="docs/screenshots/4-settings-card.png" alt="插件设置卡片"/></td>
|
|
61
|
-
<td valign="top"><b>设置卡片</b><br/>设置 → 插件 → 插件配置 里新增「更好的工作区」卡片(官方折叠卡片样式),提供<b>单链分组合并</b>开关(如 测试层1 下只有 AI交易 一个工作区时,合并显示为一行 测试层1/AI交易)与<b>状态呼吸灯</b>开关(被折叠藏起的状态灯沿层级向外冒泡呼吸发光,默认开)
|
|
63
|
+
<td valign="top"><b>设置卡片</b><br/>设置 → 插件 → 插件配置 里新增「更好的工作区」卡片(官方折叠卡片样式),提供<b>单链分组合并</b>开关(如 测试层1 下只有 AI交易 一个工作区时,合并显示为一行 测试层1/AI交易)与<b>状态呼吸灯</b>开关(被折叠藏起的状态灯沿层级向外冒泡呼吸发光,默认开);另有<b>默认外观</b>区——颜色 / 发光 / 字体粗细 / 字体阴影 / 字体描边开关 / 描边粗细一次配好,没有单独自定义过的行全部继承(只有描边默认开,其余同「无自定义」),并随宿主设置存储跨端同步。展开状态与外观自定义经 dsh 客户端 store 持久化在当前浏览器。</td>
|
|
62
64
|
</tr>
|
|
63
65
|
</table>
|
|
64
66
|
|
package/README_EN.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://awesome-dsh-plugin.com)
|
|
4
4
|
|
|
5
|
+
[简体中文](README.md) | English
|
|
6
|
+
|
|
5
7
|
> A **folder system** for the DeepSeek Harness (DSH) sidebar workspace list — a workspace is still one directory, but every `/` in its name becomes hierarchy, so `web/frontend` and `web/backend` group under one virtual `web` folder.
|
|
6
8
|
|
|
7
9
|
```
|
|
@@ -31,14 +33,14 @@ Right-click any folder row: new subfolder (parent prefix pre-filled), new worksp
|
|
|
31
33
|
|
|
32
34
|
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
35
|
|
|
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 (
|
|
36
|
+
**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
37
|
|
|
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.
|
|
38
|
+
**Manual cross-device sync (web ↔ desktop app)**: appearance, the default 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
39
|
|
|
38
40
|
<table>
|
|
39
41
|
<tr>
|
|
40
42
|
<td align="center" width="58%"><img src="docs/screenshots/3-appearance-dialog.png" alt="Appearance dialog with live preview"/></td>
|
|
41
|
-
<td valign="top"><b>Appearance customization</b><br/>Right-click any row (workspace folder / workspace / session / session group) → Customize: color (swatches + picker + RGB), glow strength (0–14 slider, text only), font weight, font shadow, an icon grid (69 icons from the dsh primitives family, workspace & folder rows only), with a live preview at the bottom. Reset returns to default in one click.</td>
|
|
43
|
+
<td valign="top"><b>Appearance customization</b><br/>Right-click any row (workspace folder / workspace / session / session group) → Customize: color (swatches + picker + RGB), glow strength (0–14 slider, text only), font weight, font shadow, a <b>text outline</b> (toggle + 0.5–4px width, <b>on by default</b>; its color is derived from the row's own font color — light text gets a black rim, dark text a white one — and follows the theme or a background plugin's light/dark switch, never picked by hand), an icon grid (69 icons from the dsh primitives family, workspace & folder rows only), with a live preview at the bottom. Reset returns to default in one click.</td>
|
|
42
44
|
</tr>
|
|
43
45
|
<tr>
|
|
44
46
|
<td align="center" width="58%"><img src="docs/screenshots/2-customized-tree.png" alt="Custom appearance result"/></td>
|
|
@@ -56,7 +58,7 @@ Session titles nest on `/` too (e.g. `test1/plugin maintenance`). Session groups
|
|
|
56
58
|
<table>
|
|
57
59
|
<tr>
|
|
58
60
|
<td align="center" width="58%"><img src="docs/screenshots/4-settings-card.png" alt="Settings card"/></td>
|
|
59
|
-
<td valign="top"><b>Settings card</b><br/>Settings → Plugins → Configuration adds a “Better Workspaces” card (official accordion style) with a <b>single-child-chain merge</b> toggle (e.g. level1 containing only AI trade renders as one row level1/AI trade) and a <b>status breathing light</b> toggle (status dots hidden by collapse bubble outward as breathing glows; on by default);
|
|
61
|
+
<td valign="top"><b>Settings card</b><br/>Settings → Plugins → Configuration adds a “Better Workspaces” card (official accordion style) with a <b>single-child-chain merge</b> toggle (e.g. level1 containing only AI trade renders as one row level1/AI trade) and a <b>status breathing light</b> toggle (status dots hidden by collapse bubble outward as breathing glows; on by default); a <b>default appearance</b> section configures color / glow / weight / shadow / outline toggle / outline width once for every row that was never customized (only the outline defaults to on, the rest match “no customization”) and syncs across surfaces through the host settings store. Expansion state and styling persist via the dsh client store in the browser.</td>
|
|
60
62
|
</tr>
|
|
61
63
|
</table>
|
|
62
64
|
|
package/dsh.plugin.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "dsh-external/dsh-better-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
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.
|
|
3
|
+
"version": "0.10.0",
|
|
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
|
@@ -69,6 +69,9 @@ window.__ModuleLoader__.load({
|
|
|
69
69
|
'settings.compactChains.hint': '单层链合并为一行,出现多个子级时自动展开为树状;拖拽工作区期间单链临时展开回文件夹树,可放入任意一级;展开状态与自定义外观保存在当前浏览器。',
|
|
70
70
|
'settings.statusPulse': '状态呼吸灯',
|
|
71
71
|
'settings.statusPulse.hint': '被折叠藏起的状态灯(完成绿 / 运行蓝 / 待交互琥珀)沿层级向外冒泡:工作区与分组行以图标呼吸发光(颜色随状态,自定义过发光的标题一起呼吸),会话分组行显示呼吸状态灯;默认开启,可在此关闭。',
|
|
72
|
+
'settings.appearance': '默认外观',
|
|
73
|
+
'settings.appearance.hint': '没有单独自定义过的行使用这套外观;字体描边默认开启——背景画面下不描边文字常常看不清。字体颜色留空即跟随主题。',
|
|
74
|
+
'settings.appearance.reset': '恢复默认外观',
|
|
72
75
|
'custom.title': '自定义外观',
|
|
73
76
|
'custom.color': '颜色',
|
|
74
77
|
'custom.glow': '发光',
|
|
@@ -80,6 +83,9 @@ window.__ModuleLoader__.load({
|
|
|
80
83
|
'custom.weight.semibold': '半粗',
|
|
81
84
|
'custom.weight.bold': '粗',
|
|
82
85
|
'custom.shadow': '字体阴影',
|
|
86
|
+
'custom.stroke': '字体描边',
|
|
87
|
+
'custom.stroke.hint': '描边颜色自动取字体颜色的反差色(浅色字配黑描边、深色字配白描边),跟随主题明暗与背景插件的界面明暗,无需手动指定。',
|
|
88
|
+
'custom.strokeWidth': '描边粗细',
|
|
83
89
|
'custom.weak': '弱',
|
|
84
90
|
'custom.medium': '中',
|
|
85
91
|
'custom.strong': '强',
|
|
@@ -169,6 +175,9 @@ window.__ModuleLoader__.load({
|
|
|
169
175
|
'settings.compactChains.hint': 'Single-child chains merge into one row; levels with multiple children expand as a tree. Chains re-expand into folder rows while you drag a workspace, so it can drop into any level. State and custom styling persist in this browser.',
|
|
170
176
|
'settings.statusPulse': 'Status breathing light',
|
|
171
177
|
'settings.statusPulse.hint': 'Status dots hidden by collapse (done green / running blue / pending amber) bubble outward: workspace and folder rows breathe on their icon in the status color (custom-glow labels breathe along), session-group rows show a breathing dot; on by default, turn it off here.',
|
|
178
|
+
'settings.appearance': 'Default appearance',
|
|
179
|
+
'settings.appearance.hint': 'Rows that were never customized use this appearance; the outline is on by default — text without it is often unreadable over a background image. Leave the color empty to follow the theme.',
|
|
180
|
+
'settings.appearance.reset': 'Reset to default',
|
|
172
181
|
'custom.title': 'Customize',
|
|
173
182
|
'custom.color': 'Color',
|
|
174
183
|
'custom.glow': 'Glow',
|
|
@@ -180,6 +189,9 @@ window.__ModuleLoader__.load({
|
|
|
180
189
|
'custom.weight.semibold': 'Semi-bold',
|
|
181
190
|
'custom.weight.bold': 'Bold',
|
|
182
191
|
'custom.shadow': 'Font shadow',
|
|
192
|
+
'custom.stroke': 'Font outline',
|
|
193
|
+
'custom.stroke.hint': 'The outline color is derived from the font color (light text gets a black outline, dark text a white one) and follows the theme or a background plugin\'s light/dark switch — no manual color to pick.',
|
|
194
|
+
'custom.strokeWidth': 'Outline width',
|
|
183
195
|
'custom.weak': 'Subtle',
|
|
184
196
|
'custom.medium': 'Medium',
|
|
185
197
|
'custom.strong': 'Strong',
|
|
@@ -647,6 +659,8 @@ window.__ModuleLoader__.load({
|
|
|
647
659
|
'.bw-rail-btn:hover{background:var(--dsw-specific-sidebar-nav-item-hover,var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12)));color:var(--dsw-alias-label-primary,#e6e6e6)}',
|
|
648
660
|
'.bw-modal-body{display:flex;flex-direction:column;gap:10px;min-width:300px;max-width:380px;box-sizing:border-box}',
|
|
649
661
|
'.bw-field{display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--dsw-alias-label-secondary,#b8b8b8)}',
|
|
662
|
+
'.bw-appearance{display:flex;flex-direction:column;gap:10px}',
|
|
663
|
+
'.bw-appearance-box{margin-top:8px;padding:10px 12px;border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.22));border-radius:8px;background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,.06))}',
|
|
650
664
|
'.bw-hint{font-size:11px;color:var(--dsw-alias-label-quaternary,#8a8a8a);line-height:1.5;white-space:normal;word-break:break-word}',
|
|
651
665
|
'.bw-path-echo{font-size:11px;color:var(--dsw-alias-label-tertiary,#9a9a9a);word-break:break-all;max-width:380px}',
|
|
652
666
|
'.bw-modal-actions{display:flex;justify-content:flex-end;gap:8px}',
|
|
@@ -699,6 +713,7 @@ window.__ModuleLoader__.load({
|
|
|
699
713
|
if (!d.prefs) d.prefs = {}
|
|
700
714
|
if (host.compactChains !== undefined) d.prefs.compactChains = host.compactChains !== false
|
|
701
715
|
if (host.statusPulse !== undefined) d.prefs.statusPulse = host.statusPulse !== false
|
|
716
|
+
if (host.appearance && typeof host.appearance === 'object') d.prefs.appearance = host.appearance
|
|
702
717
|
},
|
|
703
718
|
// Merge mode: union of both sides, the PULLED (host) copy wins any
|
|
704
719
|
// per-key conflict; prefs take the pulled booleans when present.
|
|
@@ -712,6 +727,7 @@ window.__ModuleLoader__.load({
|
|
|
712
727
|
if (!d.prefs) d.prefs = {}
|
|
713
728
|
if (host.compactChains !== undefined) d.prefs.compactChains = host.compactChains !== false
|
|
714
729
|
if (host.statusPulse !== undefined) d.prefs.statusPulse = host.statusPulse !== false
|
|
730
|
+
if (host.appearance && typeof host.appearance === 'object') d.prefs.appearance = { ...(d.prefs.appearance || {}), ...host.appearance }
|
|
715
731
|
},
|
|
716
732
|
setExpanded: (d, key, value) => { if (!d.expanded) d.expanded = {}; d.expanded[key] = value },
|
|
717
733
|
setSessionsExpanded: (d, key, value) => { if (!d.sessionsExpanded) d.sessionsExpanded = {}; d.sessionsExpanded[key] = value },
|
|
@@ -749,31 +765,61 @@ window.__ModuleLoader__.load({
|
|
|
749
765
|
// last REAL wire title per session id (learned only from snapshots where
|
|
750
766
|
// summary.title exists — never from displayTitle, which is basename
|
|
751
767
|
// degraded in exactly the window being patched), so the tree restores
|
|
752
|
-
// the "/" grouping until the wire catches up.
|
|
753
|
-
//
|
|
754
|
-
//
|
|
768
|
+
// the "/" grouping until the wire catches up. 0.9.6: plain module
|
|
769
|
+
// state with one debounced whole-file save per real change batch
|
|
770
|
+
// (issue #1: per-session reactive-store dispatches cost O(sessions)
|
|
771
|
+
// immer produces per snapshot tick and froze large installs). Same-
|
|
772
|
+
// value scans allocate nothing; entries evict oldest-at past the hard
|
|
773
|
+
// cap. A stale remembered title (renamed
|
|
755
774
|
// elsewhere, cleared browser storage) self-heals the moment the wire
|
|
756
775
|
// carries the truth again; a missing entry just leaves today's fallback.
|
|
757
776
|
const TITLE_CACHE_LIMIT = 3000
|
|
758
777
|
const TITLE_CACHE_KEEP = 2400
|
|
759
|
-
const
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
const
|
|
767
|
-
if (
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
}
|
|
775
|
-
},
|
|
776
|
-
}
|
|
778
|
+
const TITLE_CACHE_KEY = 'dsh.betterWorkspace.titles.v1'
|
|
779
|
+
const titleCache = { byId: {} }
|
|
780
|
+
let titleSaveTimer = null
|
|
781
|
+
const loadTitleCache = () => {
|
|
782
|
+
try {
|
|
783
|
+
const raw = localStorage.getItem(TITLE_CACHE_KEY)
|
|
784
|
+
if (raw !== null) {
|
|
785
|
+
const parsed = JSON.parse(raw)
|
|
786
|
+
if (parsed && typeof parsed.byId === 'object' && parsed.byId !== null) titleCache.byId = parsed.byId
|
|
787
|
+
}
|
|
788
|
+
} catch (e) { /* storage unavailable/corrupt: cold start without cache */ }
|
|
789
|
+
}
|
|
790
|
+
const scheduleTitleSave = () => {
|
|
791
|
+
clearTimeout(titleSaveTimer)
|
|
792
|
+
titleSaveTimer = setTimeout(() => {
|
|
793
|
+
try { localStorage.setItem(TITLE_CACHE_KEY, JSON.stringify(titleCache)) } catch (e) {}
|
|
794
|
+
}, 200)
|
|
795
|
+
}
|
|
796
|
+
// One plain-object batch pass per snapshot (issue #1). Unchanged scans
|
|
797
|
+
// cost one loop with zero allocation; only real changes mark dirty;
|
|
798
|
+
// eviction runs once per changed batch followed by ONE debounced save.
|
|
799
|
+
const rememberAllTitles = (list) => {
|
|
800
|
+
if (!list || !list.byId) return false
|
|
801
|
+
const byId = titleCache.byId
|
|
802
|
+
const now = Date.now()
|
|
803
|
+
let changed = false
|
|
804
|
+
for (const id of Object.keys(list.byId)) {
|
|
805
|
+
const summary = list.byId[id]
|
|
806
|
+
if (!summary || summary.blank) continue
|
|
807
|
+
const title = summary.title
|
|
808
|
+
if (typeof title !== 'string' || title === '') continue
|
|
809
|
+
const prev = byId[id]
|
|
810
|
+
if (prev && prev.title === title) continue
|
|
811
|
+
byId[id] = { title: title, at: now }
|
|
812
|
+
changed = true
|
|
813
|
+
}
|
|
814
|
+
if (!changed) return false
|
|
815
|
+
const keys = Object.keys(byId)
|
|
816
|
+
if (keys.length > TITLE_CACHE_LIMIT) {
|
|
817
|
+
keys.sort((a, b) => ((byId[a] && byId[a].at) || 0) - ((byId[b] && byId[b].at) || 0))
|
|
818
|
+
for (let i = 0; i < keys.length - TITLE_CACHE_KEEP; i++) delete byId[keys[i]]
|
|
819
|
+
}
|
|
820
|
+
scheduleTitleSave()
|
|
821
|
+
return true
|
|
822
|
+
}
|
|
777
823
|
|
|
778
824
|
/* ======================= host settings sync ======================= */
|
|
779
825
|
|
|
@@ -1078,6 +1124,83 @@ window.__ModuleLoader__.load({
|
|
|
1078
1124
|
.map(n => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0'))
|
|
1079
1125
|
.join('')
|
|
1080
1126
|
|
|
1127
|
+
/* ------------------- appearance: defaults + outline ------------------- */
|
|
1128
|
+
|
|
1129
|
+
// Text outline. Width and color are DERIVED, never hand-picked: an outline
|
|
1130
|
+
// only helps when it contrasts with the label, and the label color is
|
|
1131
|
+
// whatever the theme — or a background plugin's light/dark switch — hands
|
|
1132
|
+
// us at that moment. contrastStrokeColor() reads the effective font color
|
|
1133
|
+
// and returns the opposite pole; rows without a custom color inherit
|
|
1134
|
+
// --bw-stroke-color, sampled from the live computed style (see the sampler
|
|
1135
|
+
// in BetterBrowser) so a palette flip needs no React render.
|
|
1136
|
+
const STROKE_MAX = 4
|
|
1137
|
+
const DEFAULT_APPEARANCE = { color: '', glow: 0, weight: 0, shadow: false, stroke: true, strokeWidth: 1 }
|
|
1138
|
+
const APPEARANCE_FIELDS = ['color', 'glow', 'weight', 'shadow', 'stroke', 'strokeWidth']
|
|
1139
|
+
const clampStrokeWidth = (raw) => {
|
|
1140
|
+
const n = Number(raw)
|
|
1141
|
+
if (!isFinite(n) || n <= 0) return DEFAULT_APPEARANCE.strokeWidth
|
|
1142
|
+
return Math.min(STROKE_MAX, Math.max(0.5, Math.round(n * 2) / 2))
|
|
1143
|
+
}
|
|
1144
|
+
// Persisted values arrive from older plugin versions and from the other
|
|
1145
|
+
// surface: every read tolerates missing keys and falls back to the default
|
|
1146
|
+
// (hydration replaces state wholesale — the 0.9.x hard rule).
|
|
1147
|
+
const readAppearance = (raw) => {
|
|
1148
|
+
const src = raw && typeof raw === 'object' ? raw : {}
|
|
1149
|
+
return {
|
|
1150
|
+
color: typeof src.color === 'string' ? src.color : '',
|
|
1151
|
+
glow: Math.max(0, Number(src.glow) || 0),
|
|
1152
|
+
weight: Number(src.weight) || 0,
|
|
1153
|
+
shadow: src.shadow === true,
|
|
1154
|
+
stroke: src.stroke !== false,
|
|
1155
|
+
strokeWidth: src.strokeWidth === undefined ? DEFAULT_APPEARANCE.strokeWidth : clampStrokeWidth(src.strokeWidth),
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
// A row's own entry overrides the default appearance FIELD BY FIELD, so a
|
|
1159
|
+
// row customized before this version (no stroke key) still inherits the new
|
|
1160
|
+
// default outline instead of losing it.
|
|
1161
|
+
const mergeAppearance = (base, entry) => {
|
|
1162
|
+
if (!entry || typeof entry !== 'object') return readAppearance(base)
|
|
1163
|
+
const merged = { ...base }
|
|
1164
|
+
for (const field of APPEARANCE_FIELDS) {
|
|
1165
|
+
if (entry[field] !== undefined) merged[field] = entry[field]
|
|
1166
|
+
}
|
|
1167
|
+
return readAppearance(merged)
|
|
1168
|
+
}
|
|
1169
|
+
const parseCssColor = (css) => {
|
|
1170
|
+
const text = String(css || '')
|
|
1171
|
+
const rgb = /^rgba?\(\s*([0-9.]+)[,\s]+([0-9.]+)[,\s]+([0-9.]+)/.exec(text)
|
|
1172
|
+
if (rgb) return [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])]
|
|
1173
|
+
const srgb = /^color\(srgb\s+([0-9.]+)\s+([0-9.]+)\s+([0-9.]+)/.exec(text)
|
|
1174
|
+
if (srgb) return [Number(srgb[1]) * 255, Number(srgb[2]) * 255, Number(srgb[3]) * 255]
|
|
1175
|
+
return null
|
|
1176
|
+
}
|
|
1177
|
+
const relativeLuminance = (rgb) => {
|
|
1178
|
+
const [r, g, b] = rgb.map((channel) => {
|
|
1179
|
+
const s = Math.max(0, Math.min(255, Number(channel) || 0)) / 255
|
|
1180
|
+
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4)
|
|
1181
|
+
})
|
|
1182
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
|
1183
|
+
}
|
|
1184
|
+
// WCAG contrast against both poles: the outline takes whichever of black /
|
|
1185
|
+
// white stands out more from the label color.
|
|
1186
|
+
const contrastStrokeColor = (rgb) => {
|
|
1187
|
+
if (!rgb) return '#000000'
|
|
1188
|
+
const luminance = relativeLuminance(rgb)
|
|
1189
|
+
return (luminance + 0.05) / 0.05 >= 1.05 / (luminance + 0.05) ? '#000000' : '#ffffff'
|
|
1190
|
+
}
|
|
1191
|
+
const STROKE_FALLBACK = 'var(--bw-stroke-color, rgba(0,0,0,.75))'
|
|
1192
|
+
// paint-order keeps the stroke UNDER the fill, so the glyph does not thin
|
|
1193
|
+
// out — the whole point of an outer outline.
|
|
1194
|
+
const strokeStyleOf = (appearance) => {
|
|
1195
|
+
if (!appearance || appearance.stroke === false) return null
|
|
1196
|
+
const rgb = appearance.color ? colorToRgb(appearance.color) : null
|
|
1197
|
+
return {
|
|
1198
|
+
WebkitTextStrokeWidth: clampStrokeWidth(appearance.strokeWidth) + 'px',
|
|
1199
|
+
WebkitTextStrokeColor: rgb ? contrastStrokeColor(rgb) : STROKE_FALLBACK,
|
|
1200
|
+
paintOrder: 'stroke fill',
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1081
1204
|
function FolderRow({ node, depth, expanded, onToggle, onContextMenu, dropInto, dragEvents, custStyle, iconMode, pulse, t }) {
|
|
1082
1205
|
const total = countWorkspaces(node)
|
|
1083
1206
|
const iconEl = iconOf(iconMode, expanded)
|
|
@@ -1251,51 +1374,208 @@ window.__ModuleLoader__.load({
|
|
|
1251
1374
|
]
|
|
1252
1375
|
|
|
1253
1376
|
/**
|
|
1254
|
-
*
|
|
1255
|
-
*
|
|
1256
|
-
*
|
|
1377
|
+
* Appearance controls shared by the per-row dialog and the settings card's
|
|
1378
|
+
* "default appearance" section: color, glow, weight, shadow, the text
|
|
1379
|
+
* outline (on/off + width) and — where the row actually renders one — the
|
|
1380
|
+
* folder glyph. The outline color is never picked by hand (see the
|
|
1381
|
+
* appearance block above): it is derived from the effective font color, so
|
|
1382
|
+
* it keeps working when the palette flips.
|
|
1383
|
+
*/
|
|
1384
|
+
function AppearanceControls({ value, onChange, allowIcon, t }) {
|
|
1385
|
+
const appearance = readAppearance(value)
|
|
1386
|
+
const color = appearance.color || ''
|
|
1387
|
+
const glow = appearance.glow
|
|
1388
|
+
const weight = appearance.weight
|
|
1389
|
+
const shadow = appearance.shadow
|
|
1390
|
+
const stroke = appearance.stroke
|
|
1391
|
+
const strokeWidth = appearance.strokeWidth
|
|
1392
|
+
const iconMode = value && value.icon ? value.icon : 'solid'
|
|
1393
|
+
const patch = (next) => { if (typeof onChange === 'function') onChange(next) }
|
|
1394
|
+
const channels = colorToRgb(color)
|
|
1395
|
+
const setChannel = (index, raw) => {
|
|
1396
|
+
const n = Math.max(0, Math.min(255, parseInt(raw, 10) || 0))
|
|
1397
|
+
const base = channels || [0, 0, 0]
|
|
1398
|
+
const next = base.slice()
|
|
1399
|
+
next[index] = n
|
|
1400
|
+
patch({ color: rgbToHex(next[0], next[1], next[2]) })
|
|
1401
|
+
}
|
|
1402
|
+
const previewShadows = []
|
|
1403
|
+
if (glow > 0 && color) previewShadows.push('0 0 ' + glow + 'px ' + color)
|
|
1404
|
+
if (shadow) previewShadows.push('1px 1px 2px rgba(0,0,0,.85)')
|
|
1405
|
+
// The preview must show the REAL outline: with no custom color the pole
|
|
1406
|
+
// comes from the preview text's own computed color, so it reads correctly
|
|
1407
|
+
// in the current theme (including a background plugin's light/dark flip).
|
|
1408
|
+
const previewRef = React.useRef(null)
|
|
1409
|
+
React.useEffect(() => {
|
|
1410
|
+
const el = previewRef.current
|
|
1411
|
+
if (!el || typeof getComputedStyle !== 'function') return undefined
|
|
1412
|
+
const sample = () => {
|
|
1413
|
+
try {
|
|
1414
|
+
const rgb = parseCssColor(getComputedStyle(el).color)
|
|
1415
|
+
if (rgb) el.style.setProperty('--bw-stroke-color', contrastStrokeColor(rgb))
|
|
1416
|
+
} catch (error) { /* best effort: the CSS fallback stays in place */ }
|
|
1417
|
+
}
|
|
1418
|
+
sample()
|
|
1419
|
+
const poll = setInterval(sample, 1500)
|
|
1420
|
+
return () => clearInterval(poll)
|
|
1421
|
+
}, [color, stroke])
|
|
1422
|
+
const previewStroke = strokeStyleOf(appearance)
|
|
1423
|
+
return E('div', { className: 'bw-appearance' },
|
|
1424
|
+
E('div', { className: 'bw-field' },
|
|
1425
|
+
t('custom.color'),
|
|
1426
|
+
E('div', { className: 'bw-dialog-input-row' },
|
|
1427
|
+
SWATCHES.map((swatch) => E('button', {
|
|
1428
|
+
key: swatch || 'none',
|
|
1429
|
+
type: 'button',
|
|
1430
|
+
className: cls('bw-swatch', color === swatch && 'bw-swatch-active'),
|
|
1431
|
+
style: swatch === '' ? undefined : { background: swatch },
|
|
1432
|
+
'aria-label': swatch === '' ? t('custom.reset') : swatch,
|
|
1433
|
+
onClick: () => patch({ color: swatch }),
|
|
1434
|
+
})),
|
|
1435
|
+
E('input', {
|
|
1436
|
+
type: 'color',
|
|
1437
|
+
className: 'bw-color-input',
|
|
1438
|
+
value: color || '#5b8def',
|
|
1439
|
+
onChange: (e) => patch({ color: e.target.value }),
|
|
1440
|
+
}),
|
|
1441
|
+
),
|
|
1442
|
+
E('div', { className: 'bw-rgb-row' },
|
|
1443
|
+
['R', 'G', 'B'].map((label, index) => E('label', { key: label, className: 'bw-rgb-label' },
|
|
1444
|
+
label,
|
|
1445
|
+
E('input', {
|
|
1446
|
+
type: 'number',
|
|
1447
|
+
className: 'bw-rgb-input',
|
|
1448
|
+
min: 0,
|
|
1449
|
+
max: 255,
|
|
1450
|
+
value: channels ? channels[index] : '',
|
|
1451
|
+
placeholder: '—',
|
|
1452
|
+
onChange: (e) => setChannel(index, e.target.value),
|
|
1453
|
+
}),
|
|
1454
|
+
)),
|
|
1455
|
+
),
|
|
1456
|
+
),
|
|
1457
|
+
E('div', { className: 'bw-field' },
|
|
1458
|
+
t('custom.glow'),
|
|
1459
|
+
E('div', { className: 'bw-glow-row' },
|
|
1460
|
+
E('input', {
|
|
1461
|
+
type: 'range',
|
|
1462
|
+
className: 'bw-slider',
|
|
1463
|
+
min: 0,
|
|
1464
|
+
max: GLOW_MAX,
|
|
1465
|
+
step: 1,
|
|
1466
|
+
value: glow,
|
|
1467
|
+
'aria-label': t('custom.glow'),
|
|
1468
|
+
onChange: (e) => patch({ glow: Number(e.target.value) }),
|
|
1469
|
+
}),
|
|
1470
|
+
E('span', { className: 'bw-glow-value' }, glow === 0 ? t('custom.none') : glow + 'px'),
|
|
1471
|
+
),
|
|
1472
|
+
),
|
|
1473
|
+
E('div', { className: 'bw-field' },
|
|
1474
|
+
t('custom.weight'),
|
|
1475
|
+
E('div', { className: 'bw-seg' },
|
|
1476
|
+
[400, 500, 600, 700].map((wt) => E('button', {
|
|
1477
|
+
key: String(wt),
|
|
1478
|
+
type: 'button',
|
|
1479
|
+
className: cls('bw-seg-btn', weight === wt && 'bw-seg-btn-active'),
|
|
1480
|
+
onClick: () => patch({ weight: wt }),
|
|
1481
|
+
}, wt === 400 ? t('custom.weight.regular') : wt === 500 ? t('custom.weight.medium') : wt === 600 ? t('custom.weight.semibold') : t('custom.weight.bold'))),
|
|
1482
|
+
),
|
|
1483
|
+
),
|
|
1484
|
+
E('div', { className: 'bw-field' },
|
|
1485
|
+
t('custom.shadow'),
|
|
1486
|
+
E('div', { className: 'bw-seg' },
|
|
1487
|
+
E('button', { type: 'button', className: cls('bw-seg-btn', !shadow && 'bw-seg-btn-active'), onClick: () => patch({ shadow: false }) }, t('custom.none')),
|
|
1488
|
+
E('button', { type: 'button', className: cls('bw-seg-btn', shadow && 'bw-seg-btn-active'), onClick: () => patch({ shadow: true }) }, t('settings.on')),
|
|
1489
|
+
),
|
|
1490
|
+
),
|
|
1491
|
+
E('div', { className: 'bw-field' },
|
|
1492
|
+
t('custom.stroke'),
|
|
1493
|
+
E('div', { className: 'bw-seg' },
|
|
1494
|
+
E('button', { type: 'button', className: cls('bw-seg-btn', !stroke && 'bw-seg-btn-active'), onClick: () => patch({ stroke: false }) }, t('settings.off')),
|
|
1495
|
+
E('button', { type: 'button', className: cls('bw-seg-btn', stroke && 'bw-seg-btn-active'), onClick: () => patch({ stroke: true }) }, t('settings.on')),
|
|
1496
|
+
),
|
|
1497
|
+
E('div', { className: 'bw-glow-row' },
|
|
1498
|
+
E('input', {
|
|
1499
|
+
type: 'range',
|
|
1500
|
+
className: 'bw-slider',
|
|
1501
|
+
min: 0.5,
|
|
1502
|
+
max: STROKE_MAX,
|
|
1503
|
+
step: 0.5,
|
|
1504
|
+
value: strokeWidth,
|
|
1505
|
+
'aria-label': t('custom.strokeWidth'),
|
|
1506
|
+
onChange: (e) => patch({ strokeWidth: Number(e.target.value) }),
|
|
1507
|
+
}),
|
|
1508
|
+
E('span', { className: 'bw-glow-value' }, strokeWidth + 'px'),
|
|
1509
|
+
),
|
|
1510
|
+
E('div', { className: 'bw-hint' }, t('custom.stroke.hint')),
|
|
1511
|
+
),
|
|
1512
|
+
allowIcon ? E('div', { className: 'bw-field' },
|
|
1513
|
+
t('custom.icon'),
|
|
1514
|
+
E('div', { className: 'bw-icon-grid' },
|
|
1515
|
+
ICON_CHOICES.map((mode) => E('button', {
|
|
1516
|
+
key: mode,
|
|
1517
|
+
type: 'button',
|
|
1518
|
+
className: cls('bw-icon-cell', iconMode === mode && 'bw-icon-cell-active'),
|
|
1519
|
+
title: mode === 'solid' ? t('custom.icon.solid') : (mode === 'outline' ? t('custom.icon.outline') : mode),
|
|
1520
|
+
'aria-label': mode === 'solid' ? t('custom.icon.solid') : (mode === 'outline' ? t('custom.icon.outline') : mode),
|
|
1521
|
+
onClick: () => patch({ icon: mode }),
|
|
1522
|
+
}, mode === 'none' ? E('span', { className: 'bw-icon-none' }) : iconOf(mode, false))),
|
|
1523
|
+
),
|
|
1524
|
+
) : null,
|
|
1525
|
+
E('div', { className: 'bw-field' },
|
|
1526
|
+
t('custom.preview'),
|
|
1527
|
+
E('div', {
|
|
1528
|
+
ref: previewRef,
|
|
1529
|
+
className: 'bw-preview',
|
|
1530
|
+
style: {
|
|
1531
|
+
color: color || undefined,
|
|
1532
|
+
fontWeight: weight > 0 ? weight : undefined,
|
|
1533
|
+
textShadow: previewShadows.length > 0 ? previewShadows.join(',') : undefined,
|
|
1534
|
+
...(previewStroke || null),
|
|
1535
|
+
},
|
|
1536
|
+
},
|
|
1537
|
+
allowIcon ? E('span', { className: 'bw-preview-icon' }, iconOf(iconMode, true)) : null,
|
|
1538
|
+
E('span', { className: 'bw-preview-label' }, t('custom.preview.sample')),
|
|
1539
|
+
),
|
|
1540
|
+
),
|
|
1541
|
+
)
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
/**
|
|
1545
|
+
* Per-row appearance dialog: the shared controls plus the commit / reset
|
|
1546
|
+
* semantics. Committing exactly the default appearance (with the default
|
|
1547
|
+
* solid icon) clears the row's entry instead of pinning a redundant copy;
|
|
1548
|
+
* Reset removes the entry outright.
|
|
1257
1549
|
*/
|
|
1258
|
-
function CustomizeDialog({ open, initial, kind, onChange, onReset, onClose, t }) {
|
|
1550
|
+
function CustomizeDialog({ open, initial, defaults, kind, onChange, onReset, onClose, t }) {
|
|
1259
1551
|
// Icons render only for workspace / workspace-folder rows. Session rows
|
|
1260
1552
|
// already carry the official status dot (pending/running/done) in the
|
|
1261
1553
|
// leading slot, and session sub-group rows have no icon either — so the
|
|
1262
1554
|
// icon grid is offered only where it actually displays.
|
|
1263
1555
|
const allowIcon = kind === 'folder' || kind === 'workspace'
|
|
1264
|
-
const
|
|
1265
|
-
const [
|
|
1266
|
-
const [iconMode, setIconMode] = React.useState('solid')
|
|
1267
|
-
const [weight, setWeight] = React.useState(0)
|
|
1268
|
-
const [shadow, setShadow] = React.useState(false)
|
|
1556
|
+
const fallback = readAppearance(defaults)
|
|
1557
|
+
const [draft, setDraft] = React.useState(DEFAULT_APPEARANCE)
|
|
1269
1558
|
React.useEffect(() => {
|
|
1270
1559
|
if (!open) return
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
setIconMode(initial && initial.icon ? initial.icon : 'solid')
|
|
1274
|
-
setWeight(initial && initial.weight ? Number(initial.weight) || 0 : 0)
|
|
1275
|
-
setShadow(initial && initial.shadow === true)
|
|
1560
|
+
const base = readAppearance(initial)
|
|
1561
|
+
setDraft({ ...base, icon: (initial && initial.icon) || 'solid' })
|
|
1276
1562
|
}, [open, initial])
|
|
1277
1563
|
if (!open) return null
|
|
1564
|
+
const unchanged = APPEARANCE_FIELDS.every((field) => draft[field] === fallback[field])
|
|
1565
|
+
&& (!allowIcon || (draft.icon || 'solid') === 'solid')
|
|
1278
1566
|
const commit = () => {
|
|
1279
|
-
const
|
|
1280
|
-
|
|
1281
|
-
:
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
:
|
|
1285
|
-
|
|
1567
|
+
const entry = {
|
|
1568
|
+
color: draft.color,
|
|
1569
|
+
glow: draft.glow,
|
|
1570
|
+
weight: draft.weight > 0 ? draft.weight : undefined,
|
|
1571
|
+
shadow: draft.shadow || undefined,
|
|
1572
|
+
stroke: draft.stroke,
|
|
1573
|
+
strokeWidth: draft.strokeWidth,
|
|
1574
|
+
...(allowIcon ? { icon: draft.icon || 'solid' } : {}),
|
|
1575
|
+
}
|
|
1576
|
+
onChange(unchanged ? null : entry)
|
|
1286
1577
|
onClose()
|
|
1287
1578
|
}
|
|
1288
|
-
const channels = colorToRgb(color)
|
|
1289
|
-
const setChannel = (index, raw) => {
|
|
1290
|
-
const n = Math.max(0, Math.min(255, parseInt(raw, 10) || 0))
|
|
1291
|
-
const base = channels || [0, 0, 0]
|
|
1292
|
-
const next = base.slice()
|
|
1293
|
-
next[index] = n
|
|
1294
|
-
setColor(rgbToHex(next[0], next[1], next[2]))
|
|
1295
|
-
}
|
|
1296
|
-
const previewShadows = []
|
|
1297
|
-
if (glow > 0 && color) previewShadows.push('0 0 ' + glow + 'px ' + color)
|
|
1298
|
-
if (shadow) previewShadows.push('1px 1px 2px rgba(0,0,0,.85)')
|
|
1299
1579
|
return E(ui.Modal, {
|
|
1300
1580
|
open: true,
|
|
1301
1581
|
onClose,
|
|
@@ -1307,100 +1587,12 @@ window.__ModuleLoader__.load({
|
|
|
1307
1587
|
),
|
|
1308
1588
|
},
|
|
1309
1589
|
E('div', { className: 'bw-modal-body' },
|
|
1310
|
-
E(
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
className: cls('bw-swatch', color === swatch && 'bw-swatch-active'),
|
|
1317
|
-
style: swatch === '' ? undefined : { background: swatch },
|
|
1318
|
-
'aria-label': swatch === '' ? t('custom.reset') : swatch,
|
|
1319
|
-
onClick: () => setColor(swatch),
|
|
1320
|
-
})),
|
|
1321
|
-
E('input', {
|
|
1322
|
-
type: 'color',
|
|
1323
|
-
className: 'bw-color-input',
|
|
1324
|
-
value: color || '#5b8def',
|
|
1325
|
-
onChange: (e) => setColor(e.target.value),
|
|
1326
|
-
}),
|
|
1327
|
-
),
|
|
1328
|
-
E('div', { className: 'bw-rgb-row' },
|
|
1329
|
-
['R', 'G', 'B'].map((label, index) => E('label', { key: label, className: 'bw-rgb-label' },
|
|
1330
|
-
label,
|
|
1331
|
-
E('input', {
|
|
1332
|
-
type: 'number',
|
|
1333
|
-
className: 'bw-rgb-input',
|
|
1334
|
-
min: 0,
|
|
1335
|
-
max: 255,
|
|
1336
|
-
value: channels ? channels[index] : '',
|
|
1337
|
-
placeholder: '—',
|
|
1338
|
-
onChange: (e) => setChannel(index, e.target.value),
|
|
1339
|
-
}),
|
|
1340
|
-
)),
|
|
1341
|
-
),
|
|
1342
|
-
),
|
|
1343
|
-
E('div', { className: 'bw-field' },
|
|
1344
|
-
t('custom.glow'),
|
|
1345
|
-
E('div', { className: 'bw-glow-row' },
|
|
1346
|
-
E('input', {
|
|
1347
|
-
type: 'range',
|
|
1348
|
-
className: 'bw-slider',
|
|
1349
|
-
min: 0,
|
|
1350
|
-
max: GLOW_MAX,
|
|
1351
|
-
step: 1,
|
|
1352
|
-
value: glow,
|
|
1353
|
-
'aria-label': t('custom.glow'),
|
|
1354
|
-
onChange: (e) => setGlow(Number(e.target.value)),
|
|
1355
|
-
}),
|
|
1356
|
-
E('span', { className: 'bw-glow-value' }, glow === 0 ? t('custom.none') : glow + 'px'),
|
|
1357
|
-
),
|
|
1358
|
-
),
|
|
1359
|
-
E('div', { className: 'bw-field' },
|
|
1360
|
-
t('custom.weight'),
|
|
1361
|
-
E('div', { className: 'bw-seg' },
|
|
1362
|
-
[400, 500, 600, 700].map((wt) => E('button', {
|
|
1363
|
-
key: String(wt),
|
|
1364
|
-
type: 'button',
|
|
1365
|
-
className: cls('bw-seg-btn', weight === wt && 'bw-seg-btn-active'),
|
|
1366
|
-
onClick: () => setWeight(wt),
|
|
1367
|
-
}, wt === 400 ? t('custom.weight.regular') : wt === 500 ? t('custom.weight.medium') : wt === 600 ? t('custom.weight.semibold') : t('custom.weight.bold'))),
|
|
1368
|
-
),
|
|
1369
|
-
),
|
|
1370
|
-
E('div', { className: 'bw-field' },
|
|
1371
|
-
t('custom.shadow'),
|
|
1372
|
-
E('div', { className: 'bw-seg' },
|
|
1373
|
-
E('button', { type: 'button', className: cls('bw-seg-btn', !shadow && 'bw-seg-btn-active'), onClick: () => setShadow(false) }, t('custom.none')),
|
|
1374
|
-
E('button', { type: 'button', className: cls('bw-seg-btn', shadow && 'bw-seg-btn-active'), onClick: () => setShadow(true) }, t('settings.on')),
|
|
1375
|
-
),
|
|
1376
|
-
),
|
|
1377
|
-
allowIcon ? E('div', { className: 'bw-field' },
|
|
1378
|
-
t('custom.icon'),
|
|
1379
|
-
E('div', { className: 'bw-icon-grid' },
|
|
1380
|
-
ICON_CHOICES.map((mode) => E('button', {
|
|
1381
|
-
key: mode,
|
|
1382
|
-
type: 'button',
|
|
1383
|
-
className: cls('bw-icon-cell', iconMode === mode && 'bw-icon-cell-active'),
|
|
1384
|
-
title: mode === 'solid' ? t('custom.icon.solid') : (mode === 'outline' ? t('custom.icon.outline') : mode),
|
|
1385
|
-
'aria-label': mode === 'solid' ? t('custom.icon.solid') : (mode === 'outline' ? t('custom.icon.outline') : mode),
|
|
1386
|
-
onClick: () => setIconMode(mode),
|
|
1387
|
-
}, mode === 'none' ? E('span', { className: 'bw-icon-none' }) : iconOf(mode, false))),
|
|
1388
|
-
),
|
|
1389
|
-
) : null,
|
|
1390
|
-
E('div', { className: 'bw-field' },
|
|
1391
|
-
t('custom.preview'),
|
|
1392
|
-
E('div', {
|
|
1393
|
-
className: 'bw-preview',
|
|
1394
|
-
style: {
|
|
1395
|
-
color: color || undefined,
|
|
1396
|
-
fontWeight: weight > 0 ? weight : undefined,
|
|
1397
|
-
textShadow: previewShadows.length > 0 ? previewShadows.join(',') : undefined,
|
|
1398
|
-
},
|
|
1399
|
-
},
|
|
1400
|
-
allowIcon ? E('span', { className: 'bw-preview-icon' }, iconOf(iconMode, true)) : null,
|
|
1401
|
-
E('span', { className: 'bw-preview-label' }, t('custom.preview.sample')),
|
|
1402
|
-
),
|
|
1403
|
-
),
|
|
1590
|
+
E(AppearanceControls, {
|
|
1591
|
+
value: draft,
|
|
1592
|
+
onChange: (patch) => setDraft((prev) => ({ ...readAppearance(prev), ...patch, icon: prev.icon || 'solid' })),
|
|
1593
|
+
allowIcon,
|
|
1594
|
+
t,
|
|
1595
|
+
}),
|
|
1404
1596
|
),
|
|
1405
1597
|
StyleNode(),
|
|
1406
1598
|
)
|
|
@@ -1418,10 +1610,15 @@ window.__ModuleLoader__.load({
|
|
|
1418
1610
|
const localFolders = useStore ? (useStore(s => s.folders) || []) : []
|
|
1419
1611
|
const compactChains = prefs.compactChains !== false
|
|
1420
1612
|
const statusPulse = prefs.statusPulse !== false
|
|
1613
|
+
// Default appearance: the base every row inherits unless it carries its
|
|
1614
|
+
// own per-row entry. The outline ships ON — text over a background image
|
|
1615
|
+
// is often unreadable without it.
|
|
1616
|
+
const appearance = readAppearance(prefs.appearance)
|
|
1421
1617
|
const setPref = (key, value) => {
|
|
1422
1618
|
if (actions && typeof actions.setPref === 'function') actions.setPref(key, value)
|
|
1423
1619
|
scopeSet(key, value)
|
|
1424
1620
|
}
|
|
1621
|
+
const setAppearance = (next) => setPref('appearance', readAppearance(next))
|
|
1425
1622
|
// Manual cross-device sync state.
|
|
1426
1623
|
const [syncMode, setSyncMode] = React.useState('overwrite')
|
|
1427
1624
|
const [syncMsg, setSyncMsg] = React.useState(null)
|
|
@@ -1441,6 +1638,7 @@ window.__ModuleLoader__.load({
|
|
|
1441
1638
|
scopeSet('folders', folders)
|
|
1442
1639
|
scopeSet('compactChains', prefs.compactChains !== false)
|
|
1443
1640
|
scopeSet('statusPulse', prefs.statusPulse !== false)
|
|
1641
|
+
scopeSet('appearance', appearance)
|
|
1444
1642
|
setSyncMsg(t('sync.done'))
|
|
1445
1643
|
}
|
|
1446
1644
|
const modeButton = (id, label) => E('button', {
|
|
@@ -1481,6 +1679,19 @@ window.__ModuleLoader__.load({
|
|
|
1481
1679
|
}, E('span', { className: 'bw-switch-thumb' })),
|
|
1482
1680
|
),
|
|
1483
1681
|
E('div', { className: 'bw-hint' }, t('settings.statusPulse.hint')),
|
|
1682
|
+
E('div', { className: 'bw-setting-label', style: { marginTop: 16 } }, t('settings.appearance')),
|
|
1683
|
+
E('div', { className: 'bw-hint' }, t('settings.appearance.hint')),
|
|
1684
|
+
E('div', { className: 'bw-appearance-box' },
|
|
1685
|
+
E(AppearanceControls, {
|
|
1686
|
+
value: appearance,
|
|
1687
|
+
onChange: (patch) => setAppearance({ ...appearance, ...patch }),
|
|
1688
|
+
allowIcon: false,
|
|
1689
|
+
t,
|
|
1690
|
+
}),
|
|
1691
|
+
E('div', { style: { marginTop: 10 } },
|
|
1692
|
+
E(BTN, { variant: 'outline', onClick: () => setAppearance(DEFAULT_APPEARANCE) }, t('settings.appearance.reset')),
|
|
1693
|
+
),
|
|
1694
|
+
),
|
|
1484
1695
|
E('div', { className: 'bw-setting-label', style: { marginTop: 16 } }, t('sync.title')),
|
|
1485
1696
|
E('div', { className: 'bw-hint' }, t('sync.desc')),
|
|
1486
1697
|
E('div', { className: 'bw-sync-modes' },
|
|
@@ -1560,7 +1771,7 @@ window.__ModuleLoader__.load({
|
|
|
1560
1771
|
const archivedSet = React.useMemo(() => new Set(archivedSessionIds), [archivedSessionIds])
|
|
1561
1772
|
const subCounts = React.useMemo(() => subagentRunningCounts(list ? list.byId : {}), [list ? list.byId : null])
|
|
1562
1773
|
// Last-known real titles for the cold-restart fallback window (see
|
|
1563
|
-
//
|
|
1774
|
+
// the title-cache block). Read per render via getSnapshot: no
|
|
1564
1775
|
// subscription, because a cache write only happens when summary.title
|
|
1565
1776
|
// exists — at that moment the row already renders the wire truth and
|
|
1566
1777
|
// no re-render is wanted.
|
|
@@ -1572,24 +1783,58 @@ window.__ModuleLoader__.load({
|
|
|
1572
1783
|
// Learn real titles from every snapshot: remember exactly what the
|
|
1573
1784
|
// wire carried (summary.title), never displayTitle — that is already
|
|
1574
1785
|
// basename-degraded in the very window this patches. Blank rows are
|
|
1575
|
-
// provisional New Sessions and never learn.
|
|
1576
|
-
//
|
|
1786
|
+
// provisional New Sessions and never learn. One plain-object batch
|
|
1787
|
+
// call per snapshot (issue #1): unchanged scans cost one loop with
|
|
1788
|
+
// zero allocation, real changes schedule one debounced save.
|
|
1577
1789
|
React.useEffect(() => {
|
|
1578
1790
|
if (!list || !list.byId) return
|
|
1579
|
-
|
|
1580
|
-
|
|
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)
|
|
1791
|
+
if (titleCacheRef && typeof titleCacheRef.rememberAllTitles === 'function') {
|
|
1792
|
+
titleCacheRef.rememberAllTitles(list)
|
|
1590
1793
|
}
|
|
1591
1794
|
}, [list ? list.byId : null])
|
|
1592
1795
|
|
|
1796
|
+
// Outline color sampler. The outline has to contrast with the LABEL
|
|
1797
|
+
// color, and the palette can flip (theme, or a background plugin's
|
|
1798
|
+
// light/dark switch) without any React render. So sample the tree's live
|
|
1799
|
+
// computed color into --bw-stroke-color: attribute mutations on
|
|
1800
|
+
// <html>/<body> catch class- and style-driven flips at once, and the slow
|
|
1801
|
+
// poll catches stylesheet-only rewrites (CSSOM variable swaps). Rows with
|
|
1802
|
+
// their own color compute the pole in JS and never read the variable.
|
|
1803
|
+
const treeRef = React.useRef(null)
|
|
1804
|
+
React.useEffect(() => {
|
|
1805
|
+
const el = treeRef.current
|
|
1806
|
+
if (!el || typeof getComputedStyle !== 'function') return undefined
|
|
1807
|
+
let stopped = false
|
|
1808
|
+
let pending = null
|
|
1809
|
+
const sample = () => {
|
|
1810
|
+
if (stopped) return
|
|
1811
|
+
try {
|
|
1812
|
+
const rgb = parseCssColor(getComputedStyle(el).color)
|
|
1813
|
+
if (rgb) el.style.setProperty('--bw-stroke-color', contrastStrokeColor(rgb))
|
|
1814
|
+
} catch (error) { /* best effort: the CSS fallback stays in place */ }
|
|
1815
|
+
}
|
|
1816
|
+
const schedule = () => {
|
|
1817
|
+
if (stopped || pending !== null) return
|
|
1818
|
+
pending = setTimeout(() => { pending = null; sample() }, 150)
|
|
1819
|
+
}
|
|
1820
|
+
sample()
|
|
1821
|
+
let observer = null
|
|
1822
|
+
if (typeof MutationObserver === 'function') {
|
|
1823
|
+
observer = new MutationObserver(schedule)
|
|
1824
|
+
try {
|
|
1825
|
+
observer.observe(document.documentElement, { attributes: true })
|
|
1826
|
+
if (document.body && document.body !== document.documentElement) observer.observe(document.body, { attributes: true })
|
|
1827
|
+
} catch (error) { /* observing is optional */ }
|
|
1828
|
+
}
|
|
1829
|
+
const poll = setInterval(sample, 1500)
|
|
1830
|
+
return () => {
|
|
1831
|
+
stopped = true
|
|
1832
|
+
if (pending !== null) clearTimeout(pending)
|
|
1833
|
+
clearInterval(poll)
|
|
1834
|
+
if (observer) observer.disconnect()
|
|
1835
|
+
}
|
|
1836
|
+
}, [])
|
|
1837
|
+
|
|
1593
1838
|
const [query, setQuery] = React.useState('')
|
|
1594
1839
|
const [searchOpen, setSearchOpen] = React.useState(false)
|
|
1595
1840
|
const [flowOpen, setFlowOpen] = React.useState(false)
|
|
@@ -1702,20 +1947,26 @@ window.__ModuleLoader__.load({
|
|
|
1702
1947
|
const fail = (text) => { setFlowOpen(false); setDialog(null); setErrorText(String(text || 'unknown error')) }
|
|
1703
1948
|
|
|
1704
1949
|
const styleEntry = (key) => stylingMap[key] || null
|
|
1950
|
+
// Effective appearance = the settings card's default, overridden field by
|
|
1951
|
+
// field by this row's own entry.
|
|
1952
|
+
const defaultAppearance = readAppearance(prefsMap.appearance)
|
|
1953
|
+
const appearanceOf = (key) => mergeAppearance(defaultAppearance, styleEntry(key))
|
|
1705
1954
|
const rowStyleOf = (key) => {
|
|
1706
|
-
const
|
|
1707
|
-
|
|
1708
|
-
const glow = Number(
|
|
1709
|
-
const weight = Number(
|
|
1710
|
-
const shadow =
|
|
1955
|
+
const appearance = appearanceOf(key)
|
|
1956
|
+
const color = appearance.color || ''
|
|
1957
|
+
const glow = Number(appearance.glow) || 0
|
|
1958
|
+
const weight = Number(appearance.weight) || 0
|
|
1959
|
+
const shadow = appearance.shadow === true
|
|
1711
1960
|
const shadows = []
|
|
1712
|
-
if (glow > 0) shadows.push('0 0 ' + glow + 'px ' +
|
|
1961
|
+
if (glow > 0 && color) shadows.push('0 0 ' + glow + 'px ' + color)
|
|
1713
1962
|
if (shadow) shadows.push('1px 1px 2px rgba(0,0,0,.85)')
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1963
|
+
const style = {}
|
|
1964
|
+
if (color) style.color = color
|
|
1965
|
+
if (weight > 0) style.fontWeight = weight
|
|
1966
|
+
if (shadows.length > 0) style.textShadow = shadows.join(',')
|
|
1967
|
+
const stroke = strokeStyleOf(appearance)
|
|
1968
|
+
if (stroke) Object.assign(style, stroke)
|
|
1969
|
+
return Object.keys(style).length > 0 ? style : null
|
|
1719
1970
|
}
|
|
1720
1971
|
const keyOf = (kind, payload) => {
|
|
1721
1972
|
if (kind === 'folder') return 'folder:' + payload.path
|
|
@@ -2499,7 +2750,7 @@ window.__ModuleLoader__.load({
|
|
|
2499
2750
|
E('button', { type: 'button', className: 'bw-icon-btn', 'aria-label': t('search.placeholder'), onClick: () => setSearchOpen(v => !v) }, icon('IconSearchOutline16')),
|
|
2500
2751
|
E('button', { type: 'button', className: 'bw-icon-btn', 'aria-label': t('add'), onClick: () => { setFlowParent(''); setFlowOpen(true) } }, icon('IconProjectAddOutline16')),
|
|
2501
2752
|
),
|
|
2502
|
-
E('div', { className: 'bw-tree', role: 'tree', 'aria-label': t('title') }, bodyRows),
|
|
2753
|
+
E('div', { ref: treeRef, className: 'bw-tree', role: 'tree', 'aria-label': t('title') }, bodyRows),
|
|
2503
2754
|
E(BetterFlow, {
|
|
2504
2755
|
open: flowOpen,
|
|
2505
2756
|
busy: false,
|
|
@@ -2541,7 +2792,10 @@ window.__ModuleLoader__.load({
|
|
|
2541
2792
|
E(CustomizeDialog, {
|
|
2542
2793
|
open: customize !== null,
|
|
2543
2794
|
kind: customize ? customize.kind : undefined,
|
|
2544
|
-
|
|
2795
|
+
defaults: defaultAppearance,
|
|
2796
|
+
initial: customize
|
|
2797
|
+
? { ...appearanceOf(customize.entryKey), icon: (styleEntry(customize.entryKey) || {}).icon || 'solid' }
|
|
2798
|
+
: undefined,
|
|
2545
2799
|
onChange: (style) => { if (customize) shared.setStyling(customize.entryKey, style) },
|
|
2546
2800
|
onReset: () => { if (customize) shared.setStyling(customize.entryKey, null) },
|
|
2547
2801
|
onClose: () => setCustomize(null),
|
|
@@ -2685,11 +2939,11 @@ window.__ModuleLoader__.load({
|
|
|
2685
2939
|
// same persisted state (expansion, folder list, prefs, styling).
|
|
2686
2940
|
const viewStore = createViewStore()
|
|
2687
2941
|
|
|
2688
|
-
// Cold-restart title fallback
|
|
2689
|
-
//
|
|
2690
|
-
//
|
|
2691
|
-
|
|
2692
|
-
titleCacheRef =
|
|
2942
|
+
// Cold-restart title fallback (see the title-cache block). Hydration
|
|
2943
|
+
// reads localStorage synchronously here, before the first render, so a
|
|
2944
|
+
// restart's first tree already shows every remembered title.
|
|
2945
|
+
loadTitleCache()
|
|
2946
|
+
titleCacheRef = { getSnapshot: () => titleCache, rememberAllTitles }
|
|
2693
2947
|
|
|
2694
2948
|
// Cross-device settings scope (manual sync, see the host-settings-sync
|
|
2695
2949
|
// block). Binding is best-effort: a missing/failed bind leaves the
|
package/src/index.js
CHANGED
|
@@ -59,6 +59,11 @@ export function apply(ctx) {
|
|
|
59
59
|
statusPulse: Schema.boolean().default(true),
|
|
60
60
|
folders: Schema.array(Schema.string()).default([]),
|
|
61
61
|
styling: Schema.dict(Schema.any()).default({}),
|
|
62
|
+
// Default appearance (color / glow / weight / shadow / outline) for
|
|
63
|
+
// rows without their own entry — shared with the other surface like
|
|
64
|
+
// the rest of the prefs. The client half owns the shape, so it stays
|
|
65
|
+
// a permissive dict.
|
|
66
|
+
appearance: Schema.dict(Schema.any()).default({}),
|
|
62
67
|
}))
|
|
63
68
|
log('[dsh-better-workspace] settings namespace registered: better-workspace')
|
|
64
69
|
})
|