dsh-better-workspace 0.10.0 → 0.10.2

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
@@ -42,7 +42,7 @@ web/ ← 虚拟分组(不是真实目录,纯命名层级)
42
42
  <table>
43
43
  <tr>
44
44
  <td align="center" width="58%"><img src="docs/screenshots/3-appearance-dialog.png" alt="外观自定义弹窗(实时预览)"/></td>
45
- <td valign="top"><b>外观自定义</b><br/>右键任意一层(工作区分组 / 工作区 / 会话 / 会话分组)→「自定义外观」:颜色(预设色板 + 取色器 + RGB)、发光强度(0..14 拖动条,只作用于文字)、字体粗细、字体阴影开关、**字体描边**(开关 + 0.5–4px 粗细,**默认开启**;描边颜色自动取该行字体颜色的反差色——浅色字配黑边、深色字配白边,跟随主题与背景插件的界面明暗,不用手选)、图标(与 dsh 图标库同族的 69 个,工作区与工作区分组专属),底部带实时预览。清除自定义一键复原。</td>
45
+ <td valign="top"><b>外观自定义</b><br/>右键任意一层(工作区分组 / 工作区 / 会话 / 会话分组)→「自定义外观」:颜色(预设色板 + 取色器 + RGB)、发光强度(0..14 拖动条,只作用于文字)、字体粗细、字体阴影开关、**字体描边**(开关 + 0.5–4px 粗细,**默认开启**;描边颜色默认**灰**,可改黑 / 白 / 任意取色,或选「自动」按字体颜色取反差色——浅色字配黑边、深色字配白边,并跟随主题与背景插件的界面明暗)、图标(与 dsh 图标库同族的 69 个,工作区与工作区分组专属),底部带实时预览。清除自定义一键复原。</td>
46
46
  </tr>
47
47
  <tr>
48
48
  <td align="center" width="58%"><img src="docs/screenshots/2-customized-tree.png" alt="外观自定义效果"/></td>
@@ -106,6 +106,11 @@ DSH 的侧边栏浏览区是一个公开插槽 `sidebar.workspaces`(single),官
106
106
 
107
107
  同属 `dsh-better-*` 家族,但互不重叠:[dsh-better-sidebar](https://github.com/omdsh-dev/DSH-better-sidebar) 是**右侧**的 VSCode 式面板(资源管理器/终端/Git);本插件只接管**左侧**工作区列表。两者可同装。
108
108
 
109
+ ## 版本兼容
110
+
111
+ - **dsh 0.1.6-alpha.1**:图标集与浏览器注入面都变了,本插件 **0.10.2 起自动适配**——图标网格只列本宿主真正导出的字形(退役的 `IconSendOutline16` 自动落到等价的 `IconSendOutline14`,已保存的旧选择不会变成空图标位),会话拖拽排序在宿主不再注入 `insertSessionBefore` 时改用浏览器本地顺序,**功能不丢**。升级 dsh 与升级插件谁先谁后都收敛。
112
+ - **跨端**:web 与桌面共用同一套客户端资产;外观 / 分组 / 开关可经设置卡片手动跨端同步,**折叠状态与会话本地序每端本地**(设计如此)。
113
+
109
114
  ## 限制与路线图
110
115
 
111
116
  - 会话拖到**另一分组内的会话行**仅做扁平重排(标题分组归属不变);要改分组请拖到分组行或走重命名。
package/README_EN.md CHANGED
@@ -40,7 +40,7 @@ Session titles nest on `/` too (e.g. `test1/plugin maintenance`). Session groups
40
40
  <table>
41
41
  <tr>
42
42
  <td align="center" width="58%"><img src="docs/screenshots/3-appearance-dialog.png" alt="Appearance dialog with live preview"/></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 &amp; 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 gray by default, with black / white / any-color picker and an Auto mode that derives the contrasting pole from the row's font color — light text gets a black rim, dark text a white one — following the theme or a background plugin's light/dark switch), an icon grid (69 icons from the dsh primitives family, workspace &amp; folder rows only), with a live preview at the bottom. Reset returns to default in one click.</td>
44
44
  </tr>
45
45
  <tr>
46
46
  <td align="center" width="58%"><img src="docs/screenshots/2-customized-tree.png" alt="Custom appearance result"/></td>
@@ -90,6 +90,11 @@ View state (folder collapse, session expansion, explicit empty folders), custom
90
90
 
91
91
  Same `dsh-better-*` family, zero overlap: [dsh-better-sidebar](https://github.com/omdsh-dev/DSH-better-sidebar) is the VSCode-like panel on the right; this plugin only takes over the left workspace list. They can be installed together.
92
92
 
93
+ ## Version compatibility
94
+
95
+ - **dsh 0.1.6-alpha.1**: both the primitives icon set and the browser injection face changed; this plugin **adapts automatically since 0.10.2**. The icon grid only lists glyphs this host actually exports (the retired `IconSendOutline16` resolves to the equivalent `IconSendOutline14`, so a previously saved choice never turns into an empty icon slot), and session drag-to-reorder falls back to a browser-local order when the host no longer injects `insertSessionBefore` — **no feature is lost**. Either upgrade order (plugin first or dsh first) converges.
96
+ - **Across surfaces**: web and desktop share one set of client assets; appearance / groups / switches sync manually through the settings card, while **collapse state and the local session order stay per-browser** by design.
97
+
93
98
  ## Limitations / roadmap
94
99
 
95
100
  - A session dragged onto a session row **inside another group** only gets reordered in the flat list (its title group stays); drag onto the group row or rename to move it.
package/dsh.plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "dsh-external/dsh-better-workspace",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
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.10.0",
3
+ "version": "0.10.2",
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
@@ -84,8 +84,10 @@ window.__ModuleLoader__.load({
84
84
  'custom.weight.bold': '粗',
85
85
  'custom.shadow': '字体阴影',
86
86
  'custom.stroke': '字体描边',
87
- 'custom.stroke.hint': '描边颜色自动取字体颜色的反差色(浅色字配黑描边、深色字配白描边),跟随主题明暗与背景插件的界面明暗,无需手动指定。',
87
+ 'custom.stroke.hint': '描边颜色默认灰色,可改黑 / 白 / 任意取色,或选「自动」按字体颜色取反差色(浅色字配黑边、深色字配白边);自动模式跟随主题明暗与背景插件的界面明暗。',
88
88
  'custom.strokeWidth': '描边粗细',
89
+ 'custom.strokeColor': '描边颜色',
90
+ 'custom.strokeColor.auto': '自动',
89
91
  'custom.weak': '弱',
90
92
  'custom.medium': '中',
91
93
  'custom.strong': '强',
@@ -190,8 +192,10 @@ window.__ModuleLoader__.load({
190
192
  'custom.weight.bold': 'Bold',
191
193
  'custom.shadow': 'Font shadow',
192
194
  '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.',
195
+ 'custom.stroke.hint': 'The outline is gray by default — pick black, white, any color, or Auto to derive the contrasting pole from the font color (light text gets a black rim, dark text a white one); Auto follows the theme and a background plugin\'s light/dark switch.',
194
196
  'custom.strokeWidth': 'Outline width',
197
+ 'custom.strokeColor': 'Outline color',
198
+ 'custom.strokeColor.auto': 'Auto',
195
199
  'custom.weak': 'Subtle',
196
200
  'custom.medium': 'Medium',
197
201
  'custom.strong': 'Strong',
@@ -313,6 +317,28 @@ window.__ModuleLoader__.load({
313
317
  }
314
318
  const normPath = (p) => splitTitleSegs(p).join('/')
315
319
 
320
+ /**
321
+ * Glyphs a host may have RETIRED, mapped to the surviving equivalents in
322
+ * preference order. dsh 0.1.6-alpha.1 replaced IconSendOutline16 with
323
+ * IconPaperPlaneOutline14 and deleted the old export, so a saved custom
324
+ * icon naming it must land on something real rather than an empty cell.
325
+ */
326
+ const ICON_ALIASES = { IconSendOutline16: ['IconSendOutline14'] }
327
+
328
+ /**
329
+ * Resolve one configured icon value to a glyph the LOADED primitives
330
+ * actually export (pure). Retired names follow their alias chain, unknown
331
+ * names degrade to '' — so both the picker grid and a choice persisted
332
+ * before an upgrade survive a host that renamed its icon set.
333
+ */
334
+ const resolveIconName = (name) => {
335
+ if (typeof name !== 'string' || name === '') return ''
336
+ if (ui[name]) return name
337
+ const aliases = ICON_ALIASES[name]
338
+ if (aliases) for (const alias of aliases) if (ui[alias]) return alias
339
+ return ''
340
+ }
341
+
316
342
  /** Render a primitives icon by name; unknown names degrade to null, never crash. */
317
343
  const icon = (name, size) => {
318
344
  const C = ui[name]
@@ -452,6 +478,20 @@ window.__ModuleLoader__.load({
452
478
  return root
453
479
  }
454
480
 
481
+ /**
482
+ * Move one id inside a flat list: before `anchor`, or to the end when the
483
+ * anchor is missing (pure). Mirrors the host action's insertSessionBefore
484
+ * semantics, so the browser-local fallback orders rows exactly the way the
485
+ * host would have.
486
+ */
487
+ function reorderIds(ids, id, anchor) {
488
+ const next = (ids || []).filter((x) => x !== id)
489
+ const at = anchor === undefined ? -1 : next.indexOf(anchor)
490
+ if (at === -1) next.push(id)
491
+ else next.splice(at, 0, id)
492
+ return next
493
+ }
494
+
455
495
  const countSessionTree = (node) => node.sessions.length + node.groups.reduce((sum, group) => sum + countSessionTree(group), 0)
456
496
 
457
497
  const collectSessionRows = (node) => {
@@ -598,9 +638,13 @@ window.__ModuleLoader__.load({
598
638
  '.bw-row-icon{flex:none;display:grid;place-items:center;color:var(--dsw-alias-label-tertiary,#9a9a9a)}',
599
639
  '.bw-chevron{flex:none;display:grid;place-items:center;color:var(--dsw-alias-label-tertiary,#9a9a9a);transition:transform .15s ease}',
600
640
  '.bw-chevron-open{transform:rotate(90deg)}',
601
- '.bw-row-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
641
+ '.bw-row-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:1px;margin:-1px}',
602
642
  '.bw-row-count{flex:none;font-size:11px;color:var(--dsw-alias-label-quaternary,#8a8a8a)}',
603
643
  '.bw-row-time{flex:none;font-size:11px;color:var(--dsw-alias-label-quaternary,#8a8a8a)}',
644
+ // The outline is inherited from the row: the 11px meta column (session
645
+ // count / relative time) opts out — stroked meta text outshines the title
646
+ // it is supposed to support.
647
+ '.bw-row-count,.bw-row-time{-webkit-text-stroke-width:0}',
604
648
  '.bw-schedule-badge{flex:none;display:inline-flex;align-items:center;color:var(--dsw-alias-label-tertiary,#9a9a9a);margin:0 6px}',
605
649
  '.bw-row-actions{flex:none;display:none;align-items:center;gap:2px}',
606
650
  '.bw-row:hover .bw-row-actions{display:flex}',
@@ -612,6 +656,7 @@ window.__ModuleLoader__.load({
612
656
  '.bw-session-row:hover{color:var(--dsw-alias-label-primary,#e6e6e6)}',
613
657
  '.bw-empty{padding:28px 12px;text-align:center;font-size:12px;color:var(--dsw-alias-label-dimmed,#7a7a7a)}',
614
658
  '.bw-swatch{width:20px;height:20px;border-radius:6px;border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.25));cursor:pointer;flex:none;background:transparent;padding:0}',
659
+ '.bw-swatch-wide{width:auto;min-width:38px;padding:0 8px;font-size:11px;color:var(--dsw-alias-label-secondary,#b8b8b8)}',
615
660
  '.bw-swatch-active{outline:2px solid var(--dsw-alias-brand-primary,#5b8def);outline-offset:1px}',
616
661
  '.bw-color-input{width:36px;height:26px;border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.25));border-radius:6px;background:transparent;cursor:pointer;padding:0}',
617
662
  '.bw-seg{display:flex;gap:6px;flex-wrap:wrap}',
@@ -696,7 +741,7 @@ window.__ModuleLoader__.load({
696
741
  /* ========================== view store =========================== */
697
742
 
698
743
  const createViewStore = () => storeKit.defineStore({
699
- init: () => ({ folders: [], expanded: {}, sessionsExpanded: {}, sessionGroups: {}, prefs: { compactChains: true }, styling: {} }),
744
+ init: () => ({ folders: [], expanded: {}, sessionsExpanded: {}, sessionGroups: {}, sessionOrder: {}, prefs: { compactChains: true }, styling: {} }),
700
745
  // NOTE: hydration REPLACES the state with the persisted whole value —
701
746
  // init defaults never merge. Every action must tolerate a missing key
702
747
  // (states persisted by older plugin versions lack sessionGroups), and
@@ -734,6 +779,16 @@ window.__ModuleLoader__.load({
734
779
  setSessionGroupExpanded: (d, key, value) => { if (!d.sessionGroups) d.sessionGroups = {}; d.sessionGroups[key] = value },
735
780
  setPref: (d, key, value) => { if (!d.prefs) d.prefs = {}; d.prefs[key] = value },
736
781
  setStyling: (d, key, value) => { if (!d.styling) d.styling = {}; if (value === null) delete d.styling[key]; else d.styling[key] = value },
782
+ // Browser-local session order (v0.10.2): the fallback channel for
783
+ // drag-to-reorder on hosts that no longer inject insertSessionBefore
784
+ // (dsh 0.1.6-alpha.1). Per-workspace and per-browser on purpose — the
785
+ // host order stays authoritative wherever the action exists, and this
786
+ // never rides the cross-device sync.
787
+ setSessionOrder: (d, workspaceId, order) => {
788
+ if (!d.sessionOrder || typeof d.sessionOrder !== 'object') d.sessionOrder = {}
789
+ if (!Array.isArray(order) || order.length === 0) delete d.sessionOrder[workspaceId]
790
+ else d.sessionOrder[workspaceId] = order.slice()
791
+ },
737
792
  addFolder: (d, path) => {
738
793
  if (!Array.isArray(d.folders)) d.folders = []
739
794
  const p = normPath(path)
@@ -1105,12 +1160,16 @@ window.__ModuleLoader__.load({
1105
1160
  /* ============================== rows ============================== */
1106
1161
 
1107
1162
  /** Folder glyph variants from the primitives family (solid / outline / hidden). */
1108
- /** Custom icon value → glyph: legacy slots (solid/outline/none) or any primitives icon name. */
1163
+ /**
1164
+ * Custom icon value → glyph: legacy slots (solid/outline/none) or any
1165
+ * primitives icon name, resolved through ICON_ALIASES so a value persisted
1166
+ * before a host renamed its icon set still renders something.
1167
+ */
1109
1168
  const iconOf = (mode, expanded) => {
1110
1169
  if (!mode || mode === 'none') return null
1111
1170
  if (mode === 'solid') return icon(expanded ? 'IconFolderOpen16' : 'IconFolderClose16')
1112
1171
  if (mode === 'outline') return icon('IconFolderOpenOutline16')
1113
- return icon(mode)
1172
+ return icon(resolveIconName(mode))
1114
1173
  }
1115
1174
 
1116
1175
  const colorToRgb = (hex) => {
@@ -1134,8 +1193,11 @@ window.__ModuleLoader__.load({
1134
1193
  // --bw-stroke-color, sampled from the live computed style (see the sampler
1135
1194
  // in BetterBrowser) so a palette flip needs no React render.
1136
1195
  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']
1196
+ // The outline color defaults to a plain gray: it reads on light and dark
1197
+ // backgrounds alike without shouting like pure black/white. "auto" derives
1198
+ // the contrasting pole from the font color instead.
1199
+ const DEFAULT_APPEARANCE = { color: '', glow: 0, weight: 0, shadow: false, stroke: true, strokeWidth: 1, strokeColor: '#808080' }
1200
+ const APPEARANCE_FIELDS = ['color', 'glow', 'weight', 'shadow', 'stroke', 'strokeWidth', 'strokeColor']
1139
1201
  const clampStrokeWidth = (raw) => {
1140
1202
  const n = Number(raw)
1141
1203
  if (!isFinite(n) || n <= 0) return DEFAULT_APPEARANCE.strokeWidth
@@ -1153,6 +1215,7 @@ window.__ModuleLoader__.load({
1153
1215
  shadow: src.shadow === true,
1154
1216
  stroke: src.stroke !== false,
1155
1217
  strokeWidth: src.strokeWidth === undefined ? DEFAULT_APPEARANCE.strokeWidth : clampStrokeWidth(src.strokeWidth),
1218
+ strokeColor: typeof src.strokeColor === 'string' && src.strokeColor !== '' ? src.strokeColor : DEFAULT_APPEARANCE.strokeColor,
1156
1219
  }
1157
1220
  }
1158
1221
  // A row's own entry overrides the default appearance FIELD BY FIELD, so a
@@ -1189,14 +1252,28 @@ window.__ModuleLoader__.load({
1189
1252
  return (luminance + 0.05) / 0.05 >= 1.05 / (luminance + 0.05) ? '#000000' : '#ffffff'
1190
1253
  }
1191
1254
  const STROKE_FALLBACK = 'var(--bw-stroke-color, rgba(0,0,0,.75))'
1255
+ const STROKE_AUTO = 'auto'
1256
+ // A picked color wins; "auto" derives the contrasting pole from the row's
1257
+ // font color — computed in JS when the row carries one, read from the
1258
+ // sampled --bw-stroke-color variable otherwise (so a theme or background
1259
+ // plugin flip needs no render).
1260
+ const strokeColorOf = (appearance) => {
1261
+ const picked = appearance && typeof appearance.strokeColor === 'string' && appearance.strokeColor !== ''
1262
+ ? appearance.strokeColor
1263
+ : DEFAULT_APPEARANCE.strokeColor
1264
+ if (picked === STROKE_AUTO) {
1265
+ const rgb = appearance.color ? colorToRgb(appearance.color) : null
1266
+ return rgb ? contrastStrokeColor(rgb) : STROKE_FALLBACK
1267
+ }
1268
+ return colorToRgb(picked) ? picked : DEFAULT_APPEARANCE.strokeColor
1269
+ }
1192
1270
  // paint-order keeps the stroke UNDER the fill, so the glyph does not thin
1193
1271
  // out — the whole point of an outer outline.
1194
1272
  const strokeStyleOf = (appearance) => {
1195
1273
  if (!appearance || appearance.stroke === false) return null
1196
- const rgb = appearance.color ? colorToRgb(appearance.color) : null
1197
1274
  return {
1198
1275
  WebkitTextStrokeWidth: clampStrokeWidth(appearance.strokeWidth) + 'px',
1199
- WebkitTextStrokeColor: rgb ? contrastStrokeColor(rgb) : STROKE_FALLBACK,
1276
+ WebkitTextStrokeColor: strokeColorOf(appearance),
1200
1277
  paintOrder: 'stroke fill',
1201
1278
  }
1202
1279
  }
@@ -1350,6 +1427,10 @@ window.__ModuleLoader__.load({
1350
1427
  /* --------------------- customization dialog ----------------------- */
1351
1428
 
1352
1429
  const SWATCHES = ['', '#5b8def', '#3fb950', '#d29922', '#f85149', '#a371f7', '#39c5cf', '#ec6cb9', '#ff9f45', '#6e7681']
1430
+ // Outline-color presets: gray (the default), black, white, brand blue — the
1431
+ // native picker next to them covers everything else, and "auto" derives the
1432
+ // contrasting pole from the font color.
1433
+ const STROKE_PRESETS = ['#808080', '#000000', '#ffffff', '#5b8def']
1353
1434
  const GLOW_MAX = 14
1354
1435
  const ICON_CHOICES = [
1355
1436
  'solid', 'outline', 'none',
@@ -1366,20 +1447,42 @@ window.__ModuleLoader__.load({
1366
1447
  'IconLoadingOutline16', 'IconPanelLeftOutline16', 'IconPaperclipOutline16', 'IconPersonalizationOutline16',
1367
1448
  'IconPlayOutline16', 'IconPauseOutline16', 'IconPlusOutline16', 'IconQuestionOutline14',
1368
1449
  'IconQueueOutline14', 'IconRefreshOutline14', 'IconRefreshOutline16', 'IconRightUpOutline14',
1369
- 'IconSearchOutline16', 'IconSendOutline14', 'IconSendOutline16', 'IconSettingsOutline14',
1450
+ 'IconSearchOutline16', 'IconSendOutline14', 'IconSettingsOutline14',
1370
1451
  'IconSettingsOutline16', 'IconShareOutline16', 'IconStopFill16', 'IconThinkOutline14',
1371
1452
  'IconThinkOutline16', 'IconTrashOutline16', 'IconUserOutline16', 'IconWarningOutline16',
1372
1453
  'IconLikeOutline16', 'IconDislikeOutline16', 'IconFollowsystemOutline16',
1373
1454
  'IconAlarmClockOutline16', 'IconClockOutline16', 'IconDatabaseOutline16',
1455
+ // Added with dsh 0.1.6-alpha.1 — absent on older hosts, where the picker
1456
+ // filters them out (see ICON_PICKER_CHOICES).
1457
+ 'IconPaperPlaneOutline14', 'IconShieldOutline16', 'IconPlanOutline14',
1458
+ 'IconWrapLinesOutline16', 'IconCompactOutline16',
1374
1459
  ]
1375
1460
 
1461
+ /**
1462
+ * What the picker actually offers on THIS host: every legacy slot, plus the
1463
+ * primitives this page's icon module really exports, deduped by resolved
1464
+ * name so a retired alias can never double a cell. Computed once — the icon
1465
+ * module is fixed for the lifetime of the page.
1466
+ */
1467
+ const ICON_PICKER_CHOICES = (() => {
1468
+ const seen = new Set()
1469
+ return ICON_CHOICES.filter((mode) => {
1470
+ if (mode === 'solid' || mode === 'outline' || mode === 'none') return true
1471
+ const name = resolveIconName(mode)
1472
+ if (name === '' || seen.has(name)) return false
1473
+ seen.add(name)
1474
+ return true
1475
+ })
1476
+ })()
1477
+
1376
1478
  /**
1377
1479
  * Appearance controls shared by the per-row dialog and the settings card's
1378
1480
  * "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.
1481
+ * outline (on/off + width + color) and — where the row actually renders
1482
+ * one — the folder glyph. Since v0.10.1 the outline color is a normal
1483
+ * choice (presets + picker, gray by default); "auto" is the mode that
1484
+ * derives the contrasting pole from the effective font color, so a palette
1485
+ * flip keeps working without a re-render.
1383
1486
  */
1384
1487
  function AppearanceControls({ value, onChange, allowIcon, t }) {
1385
1488
  const appearance = readAppearance(value)
@@ -1389,7 +1492,11 @@ window.__ModuleLoader__.load({
1389
1492
  const shadow = appearance.shadow
1390
1493
  const stroke = appearance.stroke
1391
1494
  const strokeWidth = appearance.strokeWidth
1495
+ const strokeColor = appearance.strokeColor
1392
1496
  const iconMode = value && value.icon ? value.icon : 'solid'
1497
+ // The glyph this choice actually resolves to on THIS host: a value saved
1498
+ // before the host renamed its icon set still highlights a real cell.
1499
+ const activeGlyph = resolveIconName(iconMode)
1393
1500
  const patch = (next) => { if (typeof onChange === 'function') onChange(next) }
1394
1501
  const channels = colorToRgb(color)
1395
1502
  const setChannel = (index, raw) => {
@@ -1507,15 +1614,44 @@ window.__ModuleLoader__.load({
1507
1614
  }),
1508
1615
  E('span', { className: 'bw-glow-value' }, strokeWidth + 'px'),
1509
1616
  ),
1617
+ ),
1618
+ E('div', { className: 'bw-field' },
1619
+ t('custom.strokeColor'),
1620
+ E('div', { className: 'bw-dialog-input-row' },
1621
+ E('button', {
1622
+ type: 'button',
1623
+ className: cls('bw-swatch', 'bw-swatch-wide', strokeColor === STROKE_AUTO && 'bw-swatch-active'),
1624
+ title: t('custom.strokeColor.auto'),
1625
+ onClick: () => patch({ strokeColor: STROKE_AUTO }),
1626
+ }, t('custom.strokeColor.auto')),
1627
+ STROKE_PRESETS.map((value) => E('button', {
1628
+ key: value,
1629
+ type: 'button',
1630
+ className: cls('bw-swatch', strokeColor === value && 'bw-swatch-active'),
1631
+ style: { background: value },
1632
+ 'aria-label': value,
1633
+ title: value,
1634
+ onClick: () => patch({ strokeColor: value }),
1635
+ })),
1636
+ E('input', {
1637
+ type: 'color',
1638
+ className: 'bw-color-input',
1639
+ value: colorToRgb(strokeColor) ? strokeColor : DEFAULT_APPEARANCE.strokeColor,
1640
+ onChange: (e) => patch({ strokeColor: e.target.value }),
1641
+ }),
1642
+ ),
1510
1643
  E('div', { className: 'bw-hint' }, t('custom.stroke.hint')),
1511
1644
  ),
1512
1645
  allowIcon ? E('div', { className: 'bw-field' },
1513
1646
  t('custom.icon'),
1514
1647
  E('div', { className: 'bw-icon-grid' },
1515
- ICON_CHOICES.map((mode) => E('button', {
1648
+ ICON_PICKER_CHOICES.map((mode) => E('button', {
1516
1649
  key: mode,
1517
1650
  type: 'button',
1518
- className: cls('bw-icon-cell', iconMode === mode && 'bw-icon-cell-active'),
1651
+ // A value saved before an alias flip still highlights the cell it
1652
+ // now resolves to, instead of leaving the grid with no selection.
1653
+ className: cls('bw-icon-cell',
1654
+ (iconMode === mode || (activeGlyph !== '' && resolveIconName(mode) === activeGlyph)) && 'bw-icon-cell-active'),
1519
1655
  title: mode === 'solid' ? t('custom.icon.solid') : (mode === 'outline' ? t('custom.icon.outline') : mode),
1520
1656
  'aria-label': mode === 'solid' ? t('custom.icon.solid') : (mode === 'outline' ? t('custom.icon.outline') : mode),
1521
1657
  onClick: () => patch({ icon: mode }),
@@ -1761,6 +1897,7 @@ window.__ModuleLoader__.load({
1761
1897
  const sessionGroupsMap = useStore ? (useStore(s => s.sessionGroups) || {}) : {}
1762
1898
  const prefsMap = useStore ? (useStore(s => s.prefs) || {}) : {}
1763
1899
  const stylingMap = useStore ? (useStore(s => s.styling) || {}) : {}
1900
+ const sessionOrderMap = useStore ? (useStore(s => s.sessionOrder) || {}) : {}
1764
1901
  // Dual-write wrappers: every preference mutation lands in the local
1765
1902
  // store (immediate echo + scope-less fallback) AND the host settings
1766
1903
  // store (durable cross-device copy for the manual pull on the other
@@ -2013,6 +2150,13 @@ window.__ModuleLoader__.load({
2013
2150
  return { ...built, folders: built.folders.map((f) => materializeChain(compressTree(f))), workspaces: built.workspaces }
2014
2151
  }, [items, storeFolders, compactChains, draggingWorkspace])
2015
2152
 
2153
+ /** Browser-local flat session order for one workspace (fallback channel). */
2154
+ const sessionOrderOf = (workspaceId) => {
2155
+ const map = sessionOrderMap && typeof sessionOrderMap === 'object' ? sessionOrderMap : {}
2156
+ const list = map[workspaceId]
2157
+ return Array.isArray(list) ? list : []
2158
+ }
2159
+
2016
2160
  const sessionsOf = (workspace) => {
2017
2161
  const rows = []
2018
2162
  for (const id of workspace.sessionIds || []) {
@@ -2031,7 +2175,21 @@ window.__ModuleLoader__.load({
2031
2175
  pending: pendingKindOf(pending, id),
2032
2176
  })
2033
2177
  }
2034
- return rows
2178
+ // Browser-local reorder fallback (dsh 0.1.6-alpha.1 stopped injecting
2179
+ // insertSessionBefore): the local flat order wins for the workspaces the
2180
+ // user dragged in, and is empty everywhere else — so a host that still
2181
+ // exposes the action keeps its authoritative order untouched.
2182
+ // Host order stays authoritative wherever the action exists.
2183
+ const local = typeof insertSessionBefore === 'function' ? [] : sessionOrderOf(workspace.workspaceId)
2184
+ if (local.length === 0) return rows
2185
+ const remaining = new Map(rows.map((row) => [row.id, row]))
2186
+ const out = []
2187
+ for (const id of local) {
2188
+ const row = remaining.get(id)
2189
+ if (row) { out.push(row); remaining.delete(id) }
2190
+ }
2191
+ for (const row of rows) if (remaining.has(row.id)) out.push(row)
2192
+ return out
2035
2193
  }
2036
2194
 
2037
2195
  const searchAgent = (agent) => {
@@ -2137,7 +2295,11 @@ window.__ModuleLoader__.load({
2137
2295
  const canDragWorkspace = typeof insertWorkspaceBefore === 'function'
2138
2296
  // Session REORDER needs the host insertSessionBefore action; dragging a
2139
2297
  // session onto a sub-group row (a pure rename) stays available without it.
2140
- const canReorderSessions = typeof insertSessionBefore === 'function'
2298
+ // Session reorder keeps working either way: the host action is preferred
2299
+ // (authoritative, visible to every surface), while hosts that no longer
2300
+ // inject it — dsh 0.1.6-alpha.1 dropped insertSessionBefore from the
2301
+ // browser contract — fall back to the browser-local flat order.
2302
+ const canReorderSessions = true
2141
2303
 
2142
2304
  /* --------------------- workspace drag & drop -------------------- */
2143
2305
 
@@ -2315,9 +2477,16 @@ window.__ModuleLoader__.load({
2315
2477
  const anchor = half === 'after'
2316
2478
  ? (index === -1 ? undefined : (flat[index + 1] ? flat[index + 1].id : undefined))
2317
2479
  : targetSessionId
2318
- Promise.resolve()
2319
- .then(() => (anchor !== undefined ? insertSessionBefore(workspaceId, source.sessionId, anchor) : insertSessionBefore(workspaceId, source.sessionId)))
2320
- .catch(fail)
2480
+ if (typeof insertSessionBefore === 'function') {
2481
+ Promise.resolve()
2482
+ .then(() => (anchor !== undefined ? insertSessionBefore(workspaceId, source.sessionId, anchor) : insertSessionBefore(workspaceId, source.sessionId)))
2483
+ .catch(fail)
2484
+ return
2485
+ }
2486
+ // Host channel gone: commit the same move into the browser-local order.
2487
+ if (actions && typeof actions.setSessionOrder === 'function') {
2488
+ actions.setSessionOrder(workspaceId, reorderIds(flat.map((s) => s.id), source.sessionId, anchor))
2489
+ }
2321
2490
  }
2322
2491
  const commitSessionMoveInto = (workspaceId, groupPath) => {
2323
2492
  const source = drag.source