dsh-dbhub-live 5.0.1 → 5.2.0-dev.3

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.
Files changed (50) hide show
  1. package/AGENTS.md +95 -209
  2. package/README.en.md +251 -169
  3. package/README.md +251 -169
  4. package/doc/ARCHITECTURE.md +108 -0
  5. package/doc/CLIENT.md +124 -0
  6. package/doc/DESENSITIZE.md +56 -0
  7. package/doc/DEVELOPMENT.md +139 -0
  8. package/doc/IMPACT-MATRIX.md +36 -0
  9. package/doc/RELEASE.md +93 -0
  10. package/doc/REQUIREMENTS.md +177 -105
  11. package/doc/images/chat-query.png +0 -0
  12. package/doc/images/row-editor.png +0 -0
  13. package/doc/images/settings-page.png +0 -0
  14. package/lib/adhoc.mjs +412 -34
  15. package/lib/capability.mjs +157 -0
  16. package/lib/client.js +1243 -80
  17. package/lib/config.mjs +370 -21
  18. package/lib/i18n.mjs +119 -4
  19. package/lib/index.mjs +401 -43
  20. package/lib/mcp.mjs +496 -391
  21. package/lib/meta.mjs +267 -0
  22. package/lib/options.mjs +25 -5
  23. package/lib/policy.mjs +92 -0
  24. package/lib/toml.mjs +188 -0
  25. package/lib/tools.mjs +1093 -827
  26. package/lib/update.mjs +244 -0
  27. package/package.json +34 -10
  28. package/scripts/desensitize-check.mjs +10 -3
  29. package/scripts/desensitize.config.mjs +255 -237
  30. package/scripts/docs-check.mjs +214 -0
  31. package/scripts/release-preflight.mjs +180 -0
  32. package/scripts/render-check.mjs +1366 -843
  33. package/test/adhoc-toml.test.mjs +365 -0
  34. package/test/bridge.test.mjs +227 -185
  35. package/test/capability.test.mjs +231 -0
  36. package/test/client-format.test.mjs +515 -352
  37. package/test/configure-options.test.mjs +244 -0
  38. package/test/configure-ssh.test.mjs +232 -0
  39. package/test/copy-from.test.mjs +155 -0
  40. package/test/env-options.test.mjs +456 -0
  41. package/test/meta.test.mjs +234 -0
  42. package/test/options.test.mjs +160 -123
  43. package/test/policy.test.mjs +132 -0
  44. package/test/row-cache.test.mjs +237 -138
  45. package/test/ssh-probe.test.mjs +834 -0
  46. package/test/test-op-fastpath.test.mjs +208 -0
  47. package/test/toml.test.mjs +352 -0
  48. package/test/update.test.mjs +270 -0
  49. package/test/util.test.mjs +17 -0
  50. package/test/zero-knowledge.test.mjs +120 -78
package/lib/client.js CHANGED
@@ -41,10 +41,49 @@ window.__ModuleLoader__.load({ id: "dsh-dbhub-live", factory: (require) => {
41
41
  }
42
42
  var ChevronIcon = pickIcon("IconChevronDownOutline14", "▼");
43
43
  var CopyIcon = pickIcon("IconCopyOutline16", "⧉");
44
- var EditIcon = pickIcon("IconEditOutline16", "✏");
45
44
  var CloseIcon = pickIcon("IconCloseOutline16", "✕");
46
- var TrashIcon = pickIcon("IconTrashOutline16", "🗑");
47
- var RunIcon = pickIcon("IconPlayOutline16", "▶");
45
+ // Row-action glyphs are drawn here instead of taken from the icon set: at 1x the
46
+ // bundled edit glyph reads as an unidentifiable dash, while these three shapes
47
+ // (filled play, pencil, lidded trash) stay recognisable at 16px on any DPI.
48
+ var svgIcon = function (children) {
49
+ return function (props) {
50
+ var size = (props && props.size) || 16;
51
+ return h("svg", {
52
+ className: "dbh-svgicon",
53
+ width: size, height: size, viewBox: "0 0 16 16",
54
+ fill: "none", stroke: "currentColor", strokeWidth: "1.5",
55
+ strokeLinecap: "round", strokeLinejoin: "round",
56
+ "aria-hidden": true, focusable: false,
57
+ }, children);
58
+ };
59
+ };
60
+ var RunIcon = svgIcon(h("path", {
61
+ key: "play", d: "M5.2 3.3v9.4L12.6 8 5.2 3.3z",
62
+ fill: "currentColor", stroke: "none",
63
+ }));
64
+ var EditIcon = svgIcon([
65
+ h("path", { key: "body", d: "M10.9 2.6a1.6 1.6 0 0 1 2.3 2.3L5.6 12.5l-3 .7.7-3 7.6-7.6z" }),
66
+ h("path", { key: "tip", d: "M9.7 3.8l2.3 2.3" }),
67
+ ]);
68
+ var TrashIcon = svgIcon([
69
+ h("path", { key: "lid", d: "M3 4.5h10" }),
70
+ h("path", { key: "handle", d: "M6.3 4.5V3.1h3.4v1.4" }),
71
+ h("path", { key: "body", d: "M4.5 4.5l.6 8.6h5.8l.6-8.6" }),
72
+ h("path", { key: "line1", d: "M6.6 7v4.2" }),
73
+ h("path", { key: "line2", d: "M9.4 7v4.2" }),
74
+ ]);
75
+ // Read-only state: the user asked for the universally recognised emoji lock
76
+ // pair — 🔓 when writable, 🔒 when writes are blocked. The hand-drawn 16px
77
+ // shackle was too small to tell open from closed at a glance.
78
+ // The refresh glyph is the update check's affordance: a named primitive when
79
+ // the running dsh ships it, a text glyph otherwise (a rename must not blank
80
+ // the button).
81
+ var RefreshIcon = pickIcon("IconRefreshOutlineRegular", "↻");
82
+ // The platform's own toggle: same geometry, tokens and 120ms thumb animation
83
+ // as the harness settings pages, so it follows every theme/skin. A local
84
+ // fallback with the same geometry keeps the control alive if a baseline build
85
+ // predates the primitive.
86
+ var SwitchPrimitive = primitives && typeof primitives.Switch === "function" ? primitives.Switch : null;
48
87
 
49
88
  var NS = "dsh-dbhub-live";
50
89
 
@@ -67,7 +106,83 @@ window.__ModuleLoader__.load({ id: "dsh-dbhub-live", factory: (require) => {
67
106
  "cfg.off": "隐藏",
68
107
  "cfg.update": "自动更新间隔(天)",
69
108
  "cfg.sidebar": "在侧边栏显示入口",
70
- "cfg.sidebarHint": "打开后左侧栏出现「DBHub 数据库工具」快捷入口;关闭后仍可从 设置 → DBHub 数据库工具 或插件页该行的「配置」进入。",
109
+ "cfg.policy": "向模型注入数据库访问策略",
110
+ "cfg.policyHint": "开启后,系统提示词会声明「本插件已配置的连接一律走 dbhub_* 工具,不要用命令行客户端/脚本/驱动或其它插件绕过」,并说明本插件不支持的库可以用你安装的其它数据库插件。关闭只是不再注入这段文字——工具、只读与其它设置全部照常工作。",
111
+ "cfg.sidebarHint": "关闭后左侧栏不再出现「DBHub 数据库工具」快捷入口——但插件照常工作(工具仍可用),本设置页与插件页也始终可进入。注意:点「禁用」会让入口无条件消失,与这个开关无关。",
112
+ "cfg.enabledHint": "侧边栏入口 = 本开关 且 插件处于启用状态。",
113
+ "cfg.disabledHint": "插件已禁用:侧边栏入口已隐藏(与上面的开关无关)。本设置页与插件页仍然可用——点上方的「启用」即可恢复。",
114
+ "cfg.enableHint": "停用后模型无法调用本插件的工具;连接与配置全部保留,随时可以再启用。",
115
+ "cfg.updateHint": "dbhub 可执行文件的自动更新间隔(天);0 = 不自动更新。",
116
+ // Modern surfaces (5.2 redesign): one compact status line, DSH-style settings
117
+ // rows, an on-demand update link, and a collapsed technical-details block.
118
+ "status.conns": "{n} 个连接",
119
+ "settings.title": "插件设置",
120
+ "settings.toggle": "设置",
121
+ "settings.more": "说明与技术详情",
122
+ "settings.notes": "说明",
123
+ "settings.less": "收起说明",
124
+ "row.enable": "启用插件",
125
+ "row.sidebar": "侧边栏入口",
126
+ "row.policy": "向模型注入访问策略",
127
+ "row.update": "dbhub 自动更新",
128
+ "state.enable": "启用",
129
+ "state.disable": "停用",
130
+ "state.show": "显示",
131
+ "state.hide": "隐藏",
132
+ "state.on": "开启",
133
+ "state.off": "关闭",
134
+ "unit.days": "天",
135
+ "note.tools": "模型侧固定 {n} 个工具;每次调用使用一次性 dbhub 进程,无常驻服务。",
136
+ "note.update": "“dbhub 自动更新”只控制 dbhub 可执行文件;插件自身的版本更新走页头的升级提示。",
137
+ "note.sidebar": "关闭侧边栏入口只是隐藏快捷入口,插件仍然工作;停用插件才会让模型无法调用工具。",
138
+ "note.policy": "开启后,系统提示词会声明「本插件已配置的连接一律走 dbhub_* 工具,不要用命令行客户端/脚本/驱动或其它插件绕过」,并说明本插件不支持的库可以用你安装的其它数据库插件。关闭只是不再注入这段文字,工具、只读与其它设置全部照常工作。",
139
+ "note.version": "插件 v{plugin} · DSH {dsh} · {runtime}",
140
+ "ver.link": "可升级到 {v}",
141
+ "ro.chipOnHint": "只读已开启,模型无法写入;点此关闭",
142
+ "ro.chipOffHint": "开启只读,阻止模型写入(模型只能开、不能关)",
143
+ "ro.chip": "只读",
144
+ "ro.writable": "可写",
145
+ "ver.copySpec": "点击复制 dsh-dbhub-live@{v}",
146
+ "ver.check": "检查更新",
147
+ "ver.checking": "检查中…",
148
+ "ver.checkFailed": "检查更新失败",
149
+ "ver.checkedAgo": "检查于 {n} 分钟前",
150
+ "ver.webHint": "Web 版:在终端执行 dsh plugin --profile web up dsh-dbhub-live@{v},然后重启 dsh web。",
151
+ "ver.desktopHint": "桌面版:打开左侧菜单 → 插件,卸载 dsh-dbhub-live 后重新安装最新版;连接与凭据保存在 ~/.dsh/storages/dsh-dbhub-live,不会被删除。",
152
+ "ver.genericHint": "Web 版执行 dsh plugin --profile web up dsh-dbhub-live@{v} 后重启 dsh web;桌面版在插件页卸载后重新安装。",
153
+ "ver.releaseNotes": "更新说明",
154
+ "ver.copy": "复制",
155
+ "ver.copyCmd": "复制命令",
156
+ "ver.runtimeDesktop": "桌面版",
157
+ "ver.runtimeWeb": "Web 版",
158
+ "ro.turnOn": "开启只读",
159
+ "ro.turnOff": "关闭只读",
160
+ "cap.tooOld": "dbhub {v} 不支持只读 / SSH 隧道(需要 1.4.0 及以上)",
161
+ "cap.untested": "dbhub {v} 未经验证(只读 / SSH 在 1.4.0+ 实测)",
162
+ "add.readOnly": "只读模式",
163
+ "ro.chip": "只读",
164
+ "ro.title": "只读模式:dbhub 只放行只读语句,写操作会被拒绝(READONLY_VIOLATION)",
165
+ "ro.sshTitle": "SSH 隧道 {host}:{port}(用户 {user})",
166
+ "add.adv": "高级:SSH 隧道",
167
+ "adv.enable": "经 SSH 隧道连接",
168
+ "adv.host": "SSH 主机",
169
+ "adv.port": "端口",
170
+ "adv.user": "SSH 用户",
171
+ "adv.auth": "认证方式",
172
+ "adv.auth.key": "密钥",
173
+ "adv.auth.password": "密码",
174
+ "adv.keyPath": "密钥路径",
175
+ "adv.passphrase": "密钥口令",
176
+ "adv.password": "SSH 密码",
177
+ "adv.proxyJump": "ProxyJump(可选,单跳)",
178
+ "adv.keepSecret": "留空 = 保持原值",
179
+ "adv.hostHint": "主机名请填域名或 IP(不要填 ~/.ssh/config 里的别名);库地址仍来自 DSN,要写“从跳板机看过去”的地址。",
180
+ "adv.slow": "SSH 隧道首次连接较慢,请稍候。",
181
+ "btn.testConn": "测试该配置",
182
+ "btn.testSsh": "测试 SSH 隧道",
183
+ "adv.testSshHint": "只测 SSH 层:先看跳板机端口通不通、密钥文件在不在,再让 dbhub 真的建一次隧道(故意连一个必然关闭的端口——数据库那步失败才说明隧道通了)。",
184
+ "adv.testSshNeedFields": "请先填 SSH 主机与用户(并选择认证方式),再测试隧道。",
185
+ "btn.saveOpts": "保存选项",
71
186
  "btn.save": "保存配置",
72
187
  "btn.add": "添加连接",
73
188
  "btn.addConn": "+ 添加连接",
@@ -85,6 +200,7 @@ window.__ModuleLoader__.load({ id: "dsh-dbhub-live", factory: (require) => {
85
200
  "btn.enable": "启用",
86
201
  "btn.test": "测试",
87
202
  "test.testing": "测试中…",
203
+ "test.elapsed": "测试中… {s}s",
88
204
  "test.timeout": "测试超时未返回",
89
205
  "saved.ok": "已保存 ✓",
90
206
  "badge.saved": "已保存",
@@ -105,6 +221,7 @@ window.__ModuleLoader__.load({ id: "dsh-dbhub-live", factory: (require) => {
105
221
  "src.user": "已保存·用户",
106
222
  "src.collected": "已保存·扫描",
107
223
  "src.copied": "已保存·复制",
224
+ "src.promoted": "已保存·选项提升",
108
225
  "src.mise": "自动·mise env",
109
226
  "src.env": "自动·.env",
110
227
  "pretest.title": "校验并保存连接",
@@ -139,7 +256,81 @@ window.__ModuleLoader__.load({ id: "dsh-dbhub-live", factory: (require) => {
139
256
  "cfg.off": "hidden",
140
257
  "cfg.update": "Update interval (days)",
141
258
  "cfg.sidebar": "Show the sidebar entry",
142
- "cfg.sidebarHint": "When on, a DBHub Database Tools shortcut appears in the sidebar; when off the page stays reachable from Settings → DBHub Database Tools and from the plugin row's Configure control.",
259
+ "cfg.policy": "Announce the database-access policy to the model",
260
+ "cfg.policyHint": "When on, the system prompt states that this plugin's configured connections go through the dbhub_* tools (no command-line client, script, driver or other plugin detour) and that databases it does not cover may use your other database plugin. Turning it off only removes that text — tools, read-only and every other setting keep working.",
261
+ "cfg.sidebarHint": "When off, the “DBHub Database Tools” shortcut no longer appears in the sidebar — the plugin keeps working (its tools stay available) and this settings page plus the Plugins row stay reachable. Note: pressing Disable hides the entry unconditionally, independently of this switch.",
262
+ "cfg.enabledHint": "The sidebar entry = this switch AND the plugin being enabled.",
263
+ "cfg.disabledHint": "The plugin is disabled, so the sidebar entry is hidden (independently of the switch above). This settings page and the Plugins row still work — press Enable above to bring it back.",
264
+ "cfg.enableHint": "While disabled the model cannot call this plugin's tools; every connection and setting is kept and can be re-enabled at any time.",
265
+ "cfg.updateHint": "Auto-update interval (days) for the dbhub executable; 0 = never update automatically.",
266
+ "status.conns": "{n} connections",
267
+ "settings.title": "Plugin settings",
268
+ "settings.toggle": "Settings",
269
+ "settings.more": "Details & diagnostics",
270
+ "settings.notes": "Details",
271
+ "settings.less": "Hide details",
272
+ "row.enable": "Enable plugin",
273
+ "row.sidebar": "Sidebar entry",
274
+ "row.policy": "Inject database access policy into the model",
275
+ "row.update": "dbhub auto-update",
276
+ "state.enable": "Enabled",
277
+ "state.disable": "Disabled",
278
+ "state.show": "Shown",
279
+ "state.hide": "Hidden",
280
+ "state.on": "On",
281
+ "state.off": "Off",
282
+ "unit.days": "days",
283
+ "note.tools": "The model always sees {n} tools; every call runs a one-shot dbhub process (no resident service).",
284
+ "note.update": "The dbhub auto-update interval only controls the dbhub executable; the plugin's own update is offered by the header link.",
285
+ "note.sidebar": "Hiding the sidebar entry only removes the shortcut — the plugin keeps working. Only disabling the plugin stops the model from calling its tools.",
286
+ "note.policy": "When on, the system prompt states that every connection configured here goes through the dbhub_* tools (no CLI clients, scripts, drivers or other plugins), and that databases this plugin does not support may use other database plugins the user installed. Turning it off removes only that text; tools, read-only and every other setting keep working.",
287
+ "note.version": "Plugin v{plugin} · DSH {dsh} · {runtime}",
288
+ "ver.link": "Upgrade to {v}",
289
+ "ro.chipOnHint": "Read-only is on and the model cannot write; click to turn it off",
290
+ "ro.chipOffHint": "Turn read-only on to block model writes (the model can only turn it on, never off)",
291
+ "ro.chip": "RO",
292
+ "ro.writable": "Writable",
293
+ "ver.copySpec": "Click to copy dsh-dbhub-live@{v}",
294
+ "ver.check": "Check for updates",
295
+ "ver.checking": "Checking…",
296
+ "ver.checkFailed": "Update check failed",
297
+ "ver.checkedAgo": "Checked {n} min ago",
298
+ "ver.webHint": "Web: run dsh plugin --profile web up dsh-dbhub-live@{v} in a terminal, then restart dsh web.",
299
+ "ver.desktopHint": "Desktop: open Plugins in the left menu, uninstall dsh-dbhub-live, then install the latest version again. Connections and credentials live in ~/.dsh/storages/dsh-dbhub-live and are preserved.",
300
+ "ver.genericHint": "Web: run dsh plugin --profile web up dsh-dbhub-live@{v} and restart dsh web. Desktop: uninstall the plugin on the Plugins page, then reinstall it.",
301
+ "ver.releaseNotes": "Release notes",
302
+ "ver.copy": "Copy",
303
+ "ver.copyCmd": "Copy command",
304
+ "ver.runtimeDesktop": "Desktop",
305
+ "ver.runtimeWeb": "Web",
306
+ "ro.turnOn": "Turn read-only on",
307
+ "ro.turnOff": "Turn read-only off",
308
+ "cap.tooOld": "dbhub {v} does not support read-only / SSH tunnels (1.4.0 or newer required)",
309
+ "cap.untested": "dbhub {v} is untested (read-only / SSH were measured on 1.4.0+)",
310
+ "add.readOnly": "Read-only mode",
311
+ "ro.chip": "RO",
312
+ "ro.title": "Read-only mode: dbhub only allows read statements and rejects writes (READONLY_VIOLATION)",
313
+ "ro.sshTitle": "SSH tunnel {host}:{port} (user {user})",
314
+ "add.adv": "Advanced: SSH tunnel",
315
+ "adv.enable": "Connect through an SSH tunnel",
316
+ "adv.host": "SSH host",
317
+ "adv.port": "Port",
318
+ "adv.user": "SSH user",
319
+ "adv.auth": "Auth",
320
+ "adv.auth.key": "Key",
321
+ "adv.auth.password": "Password",
322
+ "adv.keyPath": "Key path",
323
+ "adv.passphrase": "Key passphrase",
324
+ "adv.password": "SSH password",
325
+ "adv.proxyJump": "ProxyJump (optional, single hop)",
326
+ "adv.keepSecret": "blank = keep the stored value",
327
+ "adv.hostHint": "Use a domain or IP (never an ~/.ssh/config alias); the database address still comes from the DSN and must be the one the bastion sees.",
328
+ "adv.slow": "The first connection through an SSH tunnel is slow — please wait.",
329
+ "btn.testConn": "Test this configuration",
330
+ "btn.testSsh": "Test SSH tunnel",
331
+ "adv.testSshHint": "Tests the SSH layer ONLY: first whether the bastion's port is reachable and the key file exists, then whether dbhub can bring the tunnel up (it deliberately aims at a port that must be closed — the database step failing is what proves the tunnel worked).",
332
+ "adv.testSshNeedFields": "Fill in the SSH host and user first (and pick an auth method), then test the tunnel.",
333
+ "btn.saveOpts": "Save options",
143
334
  "btn.save": "Save",
144
335
  "btn.add": "Add connection",
145
336
  "btn.addConn": "+ Add connection",
@@ -157,6 +348,7 @@ window.__ModuleLoader__.load({ id: "dsh-dbhub-live", factory: (require) => {
157
348
  "btn.enable": "Enable",
158
349
  "btn.test": "Test",
159
350
  "test.testing": "testing…",
351
+ "test.elapsed": "testing… {s}s",
160
352
  "test.timeout": "no test result (timed out)",
161
353
  "saved.ok": "Saved ✓",
162
354
  "badge.saved": "saved",
@@ -177,6 +369,7 @@ window.__ModuleLoader__.load({ id: "dsh-dbhub-live", factory: (require) => {
177
369
  "src.user": "saved · user",
178
370
  "src.collected": "saved · scan",
179
371
  "src.copied": "saved · copied",
372
+ "src.promoted": "saved · promoted",
180
373
  "src.mise": "auto · mise env",
181
374
  "src.env": "auto · .env",
182
375
  "pretest.title": "Validate & Save Connection",
@@ -234,6 +427,9 @@ window.__ModuleLoader__.load({ id: "dsh-dbhub-live", factory: (require) => {
234
427
  "persisted(user)": "src.user",
235
428
  "persisted(collected)": "src.collected",
236
429
  "persisted(copied)": "src.copied",
430
+ // An auto-discovered row that the user (or the model) explicitly gave an
431
+ // option to is persisted with this provenance (D10).
432
+ "persisted(promoted)": "src.promoted",
237
433
  "工作区 mise env": "src.mise",
238
434
  "工作目录 .env": "src.env",
239
435
  };
@@ -268,6 +464,16 @@ window.__ModuleLoader__.load({ id: "dsh-dbhub-live", factory: (require) => {
268
464
 
269
465
  var STYLE_ID = "dsh-dbhub-live-style";
270
466
 
467
+ // Client-side watchdogs for a connection probe. The Host answers every probe
468
+ // (success, failure, or its own timeout), so these only cover "the report
469
+ // never arrived". The tunnelled tier is longer because SSH setup is slow; the
470
+ // HOST budget (adhoc.mjs PROBE_TIMEOUT_MS / PROBE_TIMEOUT_SSH_MS) stays
471
+ // strictly below its client counterpart — asserted in test/adhoc-toml.test.mjs.
472
+ var PRETEST_WATCHDOG_MS = 30000;
473
+ var PRETEST_WATCHDOG_SSH_MS = 60000;
474
+ var TEST_WATCHDOG_MS = 30000;
475
+ var TEST_WATCHDOG_SSH_MS = 60000;
476
+
271
477
  var CSS_TEXT = [
272
478
  ".dbh-wrap{box-sizing:border-box;height:100%;width:100%;overflow:auto;padding:22px clamp(14px,3vw,32px) 44px;color:var(--dsw-alias-label-primary);font-family:inherit;}",
273
479
  // Content column: capped at 960px, which also caps the tile grid at 3 columns.
@@ -294,8 +500,8 @@ window.__ModuleLoader__.load({ id: "dsh-dbhub-live", factory: (require) => {
294
500
  ".dbh-iconbtn:disabled{cursor:default;}",
295
501
  ".dbh-iconbtn-busy{opacity:.5;}",
296
502
  ".dbh-iconbtn-danger:hover:not(:disabled){color:var(--dsw-alias-state-error-primary);background:var(--dsw-alias-interactive-bg-hover-danger);}",
297
- ".dbh-sect{display:flex;align-items:center;gap:8px;padding:8px 0 2px;flex-wrap:wrap;}",
298
- ".dbh-secttitle{font-size:12px;font-weight:600;color:var(--dsw-alias-label-secondary);letter-spacing:.02em;}",
503
+ ".dbh-sect{display:flex;align-items:center;gap:8px;padding:14px 0 2px;flex-wrap:wrap;}",
504
+ ".dbh-secttitle{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);letter-spacing:.01em;}",
299
505
  ".dbh-sectcount{font-size:11px;color:var(--dsw-alias-label-tertiary);}",
300
506
  ".dbh-grow{flex:1;min-width:0;}",
301
507
  // Level 1: one bordered card per workspace. Transparent surface + hairline
@@ -325,6 +531,7 @@ window.__ModuleLoader__.load({ id: "dsh-dbhub-live", factory: (require) => {
325
531
  ".dbh-conn-pre{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-tertiary);}",
326
532
  ".dbh-conn-db{flex:none;color:var(--dsw-alias-label-primary);font-weight:600;}",
327
533
  ".dbh-acts{display:flex;align-items:center;gap:2px;flex:none;margin-left:auto;}",
534
+ ".dbh-svgicon{display:block;flex:none;pointer-events:none;}",
328
535
  // The source handle: dashed outline + copy glyph says "id, click to copy".
329
536
  ".dbh-src{display:inline-flex;align-items:center;gap:4px;flex:none;height:22px;padding:0 6px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;color:var(--dsw-alias-label-secondary);background:transparent;border:1px dashed var(--dsw-alias-border-l2,rgba(127,127,127,.45));border-radius:6px;cursor:pointer;transition:background .14s,border-color .14s,color .14s;}",
330
537
  ".dbh-src:hover{background:var(--dsw-alias-interactive-bg-hover);border-style:solid;color:var(--dsw-alias-label-primary);}",
@@ -332,6 +539,24 @@ window.__ModuleLoader__.load({ id: "dsh-dbhub-live", factory: (require) => {
332
539
  ".dbh-src-ok{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-tertiary);border-style:solid;}",
333
540
  ".dbh-srcid{max-width:96px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}",
334
541
  ".dbh-editrow{display:flex;align-items:center;gap:6px;padding:4px 0 6px;flex-wrap:wrap;}",
542
+ // Editor block: the input row plus its advanced (SSH) section, so the row
543
+ // still contributes exactly ONE sibling to the environment wrapper.
544
+ ".dbh-editbox{display:flex;flex-direction:column;gap:6px;padding:4px 0 6px;}",
545
+ // Read-only marker inside the environment chip: an attribute that differs
546
+ // per row and CHANGES the semantics of every query, so it must be visible
547
+ // at a glance (the origin/source is the opposite case and stays a tooltip).
548
+ ".dbh-ro{flex:none;margin-left:4px;font-size:10px;font-weight:500;color:var(--dsw-alias-state-warn-primary);}",
549
+ // Checkboxes are styled by the platform's own input rendering; only the
550
+ // accent colour is themed, and the neutral fallback is a translucent grey.
551
+ ".dbh-checkbox{flex:none;accent-color:var(--dsw-alias-brand-primary,rgba(127,127,127,.8));}",
552
+ ".dbh-adv{display:flex;flex-direction:column;gap:6px;}",
553
+ ".dbh-advtoggle{align-self:flex-start;display:inline-flex;align-items:center;gap:4px;height:24px;padding:0 8px;font-size:12px;font-family:inherit;line-height:1;border:1px dashed var(--dsw-alias-border-l2,rgba(127,127,127,.45));border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;}",
554
+ ".dbh-advtoggle:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);}",
555
+ ".dbh-advtoggle:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px;}",
556
+ ".dbh-advbody{display:flex;flex-direction:column;gap:6px;margin-left:6px;padding-left:10px;border-left:2px solid var(--dsw-alias-border-l1,rgba(127,127,127,.3));}",
557
+ ".dbh-advrow{display:flex;align-items:center;gap:8px;}",
558
+ ".dbh-advlabel{flex:0 0 92px;font-size:12px;color:var(--dsw-alias-label-secondary);}",
559
+ ".dbh-advrow .dbh-input{flex:1;}",
335
560
  ".dbh-input{box-sizing:border-box;height:28px;min-width:0;padding:0 8px;font-size:12px;font-family:inherit;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-3,transparent);border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.45));border-radius:6px;}",
336
561
  ".dbh-input:focus{outline:none;border-color:var(--dsw-alias-brand-primary);}",
337
562
  ".dbh-envinput{flex:0 0 130px;}",
@@ -357,8 +582,8 @@ window.__ModuleLoader__.load({ id: "dsh-dbhub-live", factory: (require) => {
357
582
  ".dbh-hint{font-size:11px;color:var(--dsw-alias-label-tertiary);line-height:1.5;}",
358
583
  ".dbh-empty{font-size:12px;color:var(--dsw-alias-label-tertiary);padding:8px 0;}",
359
584
  ".dbh-saved{font-size:12px;color:var(--dsw-alias-state-success-primary);}",
360
- ".dbh-test-ok{font-size:12px;color:var(--dsw-alias-state-success-primary);word-break:break-all;}",
361
- ".dbh-test-fail{font-size:12px;color:var(--dsw-alias-state-error-primary);word-break:break-all;}",
585
+ ".dbh-test-ok{font-size:12px;color:var(--dsw-alias-state-success-primary);word-break:break-word;white-space:pre-wrap;}",
586
+ ".dbh-test-fail{font-size:12px;color:var(--dsw-alias-state-error-primary);word-break:break-word;white-space:pre-wrap;}",
362
587
  ".dbh-pretest{display:flex;flex-direction:column;gap:6px;padding:10px;border:1px solid var(--dsw-alias-state-error-primary);border-radius:8px;background:var(--dsw-alias-bg-layer-2,transparent);}",
363
588
  ".dbh-pretest-title{font-weight:600;font-size:13px;color:var(--dsw-alias-state-error-primary);}",
364
589
  // Overwrite confirmation: a warning, not an error — the write is still the
@@ -366,6 +591,115 @@ window.__ModuleLoader__.load({ id: "dsh-dbhub-live", factory: (require) => {
366
591
  ".dbh-warncard{display:flex;flex-direction:column;gap:6px;padding:10px;border:1px solid var(--dsw-alias-state-warn-primary,rgba(127,127,127,.6));border-radius:8px;background:var(--dsw-alias-bg-layer-2,transparent);}",
367
592
  ".dbh-warncard-title{font-weight:600;font-size:13px;color:var(--dsw-alias-state-warn-primary);}",
368
593
  ".dbh-summary{font-size:12px;color:var(--dsw-alias-label-secondary);}",
594
+ // ── modern surfaces (dsh >= 0.2.1) ──────────────────────────────────────
595
+ // One component, three surfaces: the settings dialog (narrow, unchanged from
596
+ // the 0.1.x layout), the wide page reached from the left menu / the Plugins
597
+ // row, and a phone browser. The page classes come from the MEASURED
598
+ // container width, so a docked desktop window degrades like a phone does.
599
+ ".dbh-titlebar{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap;padding:2px 0 2px;}",
600
+ ".dbh-title{margin:0;font-size:18px;font-weight:600;line-height:1.25;color:var(--dsw-alias-label-primary);}",
601
+ ".dbh-ver{flex:none;font-size:12px;color:var(--dsw-alias-label-tertiary);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;}",
602
+ // Update affordance: a quiet text link (brand colour only) that expands into a
603
+ // neutral panel on demand — never a red/error-styled banner.
604
+ ".dbh-updatelink{display:inline-flex;align-items:center;gap:3px;height:22px;padding:0 4px;font-size:12px;font-family:inherit;line-height:1;border:1px solid transparent;border-radius:6px;background:transparent;color:var(--dsw-alias-brand-text);cursor:pointer;}",
605
+ ".dbh-updatelink:hover{text-decoration:underline;}",
606
+ ".dbh-updatelink:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px;}",
607
+ ".dbh-vercheck{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;padding:0;font-size:11px;font-family:inherit;line-height:1;border:1px solid transparent;border-radius:6px;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;}",
608
+ ".dbh-vercheck:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);}",
609
+ ".dbh-vercheck:disabled{cursor:default;opacity:.7;}",
610
+ ".dbh-vercheck:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px;}",
611
+ // One status line: running/disabled + how many connections. Tool count and the
612
+ // one-shot execution mode are implementation facts and live in the notes.
613
+ ".dbh-statusline{display:flex;align-items:center;gap:6px;flex-wrap:wrap;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary);padding:0 0 6px;}",
614
+ ".dbh-dot{flex:none;width:7px;height:7px;border-radius:50%;background:var(--dsw-alias-state-success-primary);}",
615
+ ".dbh-dot-off{background:var(--dsw-alias-state-idle-primary);}",
616
+ ".dbh-statussep{color:var(--dsw-alias-label-tertiary);}",
617
+ ".dbh-linkbtn{display:inline-flex;align-items:center;gap:3px;padding:0 4px;height:20px;border:1px solid transparent;border-radius:6px;background:transparent;font-family:inherit;font-size:12px;line-height:1;color:var(--dsw-alias-label-secondary);cursor:pointer;white-space:nowrap;}",
618
+ ".dbh-linkbtn:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);}",
619
+ ".dbh-linkbtn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px;}",
620
+ ".dbh-updatepanel{display:flex;flex-direction:column;gap:6px;margin:0 0 8px;padding:10px 12px;border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.3));border-radius:8px;background:var(--dsw-alias-bg-layer-2,transparent);}",
621
+ ".dbh-updaterow{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0;}",
622
+ ".dbh-spec{flex:1 1 auto;min-width:0;padding:3px 8px;border-radius:6px;background:var(--dsw-alias-bg-layer-3,transparent);border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.3));font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;color:var(--dsw-alias-label-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}",
623
+ ".dbh-link{font-size:11px;color:var(--dsw-alias-brand-text);text-decoration:none;align-self:flex-start;}",
624
+ ".dbh-link:hover{text-decoration:underline;}",
625
+ // Settings: the harness's own row pattern — title left, control right, thin
626
+ // separators, no card/grid, no coloured chrome of our own.
627
+ ".dbh-inner.dbh-wide{max-width:1320px;gap:12px;}",
628
+ ".dbh-wide .dbh-tiles{grid-template-columns:repeat(auto-fill,minmax(320px,1fr));}",
629
+ ".dbh-settings{display:flex;flex-direction:column;}",
630
+ ".dbh-settings-title{font-size:11px;font-weight:600;color:var(--dsw-alias-label-tertiary);letter-spacing:.02em;padding:2px 0 2px;}",
631
+ // Wide page: all four controls on one quiet line — the labels stay attached
632
+ // to their switches instead of a label at the far left and a control at the
633
+ // far right with half a screen of empty middle.
634
+ ".dbh-settingsbar{padding:0 0 2px;}",
635
+ ".dbh-barline{display:flex;align-items:center;gap:8px 22px;flex-wrap:wrap;padding:2px 0 4px;}",
636
+ ".dbh-bartitle{font-size:11px;font-weight:600;color:var(--dsw-alias-label-tertiary);letter-spacing:.02em;margin-right:2px;}",
637
+ ".dbh-bargroup{display:inline-flex;align-items:center;gap:6px;flex:none;}",
638
+ ".dbh-barlabel{font-size:12px;color:var(--dsw-alias-label-secondary);}",
639
+ ".dbh-barstate{font-size:12px;color:var(--dsw-alias-label-tertiary);}",
640
+ ".dbh-settingsbar [role=\"switch\"]{transform:scale(.85);}",
641
+ // Narrow desktop column: drop the section title and the state words, tighten
642
+ // the gaps, so the four controls still flow on one/two lines instead of
643
+ // collapsing into a vertical list.
644
+ ".dbh-barnarrow .dbh-barline{gap:6px 12px;}",
645
+ ".dbh-barnarrow .dbh-bartitle{display:none;}",
646
+ ".dbh-barnarrow .dbh-barstate{display:none;}",
647
+ ".dbh-barnarrow .dbh-barlabel{font-size:11px;}",
648
+ ".dbh-barnarrow .dbh-srow-input{width:50px;}",
649
+ // Narrow surfaces: the same controls as compact rows (weak by design — the
650
+ // connection list below is the main content).
651
+ ".dbh-srow{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:8px 0;border-bottom:0.5px solid var(--dsw-alias-border-l2,rgba(127,127,127,.3));}",
652
+ ".dbh-srow:last-child{border-bottom:none;}",
653
+ ".dbh-srow-main{min-width:0;}",
654
+ ".dbh-srow-title{font-size:13px;line-height:18px;color:var(--dsw-alias-label-secondary);}",
655
+ ".dbh-srow-ctl{display:flex;align-items:center;gap:8px;flex:none;flex-wrap:nowrap;}",
656
+ ".dbh-srow-state{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary);min-width:2.4em;text-align:right;white-space:nowrap;}",
657
+ ".dbh-srow-input{width:58px;height:26px;flex:none;text-align:center;}",
658
+ ".dbh-srow-unit{font-size:12px;color:var(--dsw-alias-label-secondary);}",
659
+ ".dbh-btn-sm{height:26px;padding:0 10px;font-size:12px;}",
660
+ ".dbh-settings-foot{display:flex;align-items:center;gap:8px;padding:6px 0 2px;}",
661
+ ".dbh-notes{display:flex;flex-direction:column;gap:4px;margin:2px 0 4px;padding:10px 12px;border-radius:8px;background:var(--dsw-alias-bg-layer-2,transparent);font-size:12px;line-height:1.55;color:var(--dsw-alias-label-secondary);}",
662
+ // Read-only toggle: a quiet chip on the connection row, in EVERY layout, so it
663
+ // can be flipped while reading the list instead of opening the edit form.
664
+ ".dbh-rochip{display:inline-flex;align-items:center;gap:4px;height:20px;padding:0 7px;flex:none;border-radius:999px;border:1px dashed var(--dsw-alias-border-l2,rgba(127,127,127,.45));background:transparent;color:var(--dsw-alias-label-tertiary);font-size:11px;font-family:inherit;line-height:1;cursor:pointer;transition:background .12s,border-color .12s,color .12s;}",
665
+ ".dbh-rochip:hover:not(:disabled){border-color:var(--dsw-alias-border-l3,rgba(127,127,127,.6));color:var(--dsw-alias-label-secondary);}",
666
+ ".dbh-rochip-on{border-style:solid;border-color:var(--dsw-alias-border-l3,rgba(127,127,127,.6));background:var(--dsw-alias-bg-layer-3,rgba(127,127,127,.16));color:var(--dsw-alias-label-primary);}",
667
+ ".dbh-rochip:disabled{opacity:.55;cursor:default;}",
668
+ ".dbh-rochip:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px;}",
669
+ ".dbh-roemoji{font-size:13px;line-height:1;}",
670
+ // Fallback switch only when the baseline predates the Switch primitive: same
671
+ // 36x20 geometry and token colours as the platform control.
672
+ ".dbh-fbswitch{position:relative;flex:none;width:36px;height:20px;padding:2px;border:0;border-radius:999px;background:var(--dsw-alias-border-l3,rgba(127,127,127,.45));cursor:pointer;transition:background .12s;}",
673
+ ".dbh-fbswitch-on{background:var(--dsw-alias-brand-primary);}",
674
+ ".dbh-fbswitch:disabled{opacity:.5;cursor:default;}",
675
+ ".dbh-fbswitch:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px;}",
676
+ ".dbh-fbswitch-thumb{display:block;width:16px;height:16px;border-radius:50%;background:var(--dsw-alias-label-primary-foreground);transition:transform .12s ease;}",
677
+ ".dbh-fbswitch-on .dbh-fbswitch-thumb{transform:translateX(16px);}",
678
+ // Phone browser: one column, touch-sized controls, actions on their own row.
679
+ ".dbh-inner.dbh-mobile{font-size:13px;gap:8px;}",
680
+ ".dbh-mobile .dbh-titlebar{gap:6px;}",
681
+ ".dbh-mobile .dbh-title{font-size:16px;}",
682
+ ".dbh-mobile .dbh-statusline{font-size:12px;}",
683
+ ".dbh-mobile .dbh-updatepanel{padding:8px 10px;}",
684
+ ".dbh-mobile .dbh-envrow{gap:6px;}",
685
+ ".dbh-mobile .dbh-envname{max-width:120px;}",
686
+ ".dbh-mobile .dbh-conn{flex:1 1 100%;order:1;min-width:0;}",
687
+ ".dbh-mobile .dbh-rochip{height:24px;padding:0 8px;order:2;}",
688
+ ".dbh-mobile .dbh-roemoji{font-size:14px;}",
689
+ ".dbh-mobile .dbh-acts{order:3;width:100%;margin-left:0;justify-content:flex-end;gap:6px;padding:2px 0 0;}",
690
+ ".dbh-mobile .dbh-iconbtn{width:32px;height:32px;}",
691
+ ".dbh-mobile .dbh-btn{height:30px;padding:0 12px;}",
692
+ ".dbh-mobile .dbh-tiles{grid-template-columns:1fr;}",
693
+ ".dbh-mobile .dbh-srow{gap:8px;padding:6px 0;}",
694
+ ".dbh-mobile .dbh-srow-title{font-size:12px;}",
695
+ ".dbh-mobile .dbh-srow-state{font-size:11px;min-width:2.2em;}",
696
+ ".dbh-mobile .dbh-srow [role=\"switch\"]{transform:scale(.8);}",
697
+ ".dbh-mobile .dbh-btn-sm{height:24px;padding:0 8px;font-size:11px;}",
698
+ ".dbh-mobile .dbh-groupbody{margin:0 4px 6px 8px;padding-left:8px;}",
699
+ ".dbh-mobile .dbh-editrow .dbh-envinput{flex:1 1 100%;}",
700
+ ".dbh-mobile .dbh-editrow .dbh-input{flex:1 1 100%;}",
701
+ ".dbh-mobile .dbh-srcid{max-width:72px;}",
702
+ ".dbh-mobile .dbh-updatehead{align-items:flex-start;}",
369
703
  ].join("");
370
704
 
371
705
  function ensureStyle() {
@@ -398,10 +732,24 @@ window.__ModuleLoader__.load({ id: "dsh-dbhub-live", factory: (require) => {
398
732
  // The settings section's page (also the sidebar panel's body): the same
399
733
  // management page the Plugins page renders in its 'page' view, wrapped in its
400
734
  // own scroll container. The Plugins-row mount keeps the shell's container.
735
+ // `surface` names the MOUNT, not the width: the settings dialog is a 760px
736
+ // modal, the left-menu entry and the Plugins row are full pages, and the
737
+ // card itself still measures its column before choosing a layout.
401
738
  function ConfigPanel(props) {
402
739
  ensureStyle();
740
+ var surface = props && typeof props.surface === "string" ? props.surface : "modal";
403
741
  return h("div", { className: "dbh-wrap" },
404
- h(StatusCard, Object.assign({}, props, { view: "page" })));
742
+ h(StatusCard, Object.assign({}, props, { view: "page", surface: surface })));
743
+ }
744
+
745
+ /** Settings -> DBHub dialog (narrow, unchanged density). */
746
+ function SettingsSectionPanel(props) {
747
+ return h(ConfigPanel, Object.assign({}, props, { surface: "modal" }));
748
+ }
749
+
750
+ /** Left-menu DBHub panel: a full page with most of the viewport available. */
751
+ function MainPanel(props) {
752
+ return h(ConfigPanel, Object.assign({}, props, { surface: "main" }));
405
753
  }
406
754
 
407
755
  // ── pure layout helpers ───────────────────────────────────────────────────
@@ -568,7 +916,201 @@ window.__ModuleLoader__.load({ id: "dsh-dbhub-live", factory: (require) => {
568
916
  // same page session (switching settings sections, opening the sidebar panel)
569
917
  // and reset on reload. Deliberately not persisted anywhere: it is view state,
570
918
  // not configuration.
571
- var uiMemory = { expanded: {}, addOpen: false, addTarget: "", settingsOpen: false };
919
+ var uiMemory = { expanded: {}, addOpen: false, addTarget: "", settingsOpen: false, advOpen: false };
920
+
921
+ /**
922
+ * Upstream dbhub capabilities, published by the Host as a JSON string.
923
+ * `''` (the probe has not finished) means "assume supported": the controls stay
924
+ * usable and a genuine incompatibility surfaces as a dbhub error, whereas
925
+ * disabling them on a guess would block a working setup.
926
+ */
927
+ function capsOf(value) {
928
+ var raw = value && typeof value.capabilities === "string" ? value.capabilities : "";
929
+ if (!raw) return { version: "", readonlyTools: true, ssh: true, warning: "" };
930
+ try {
931
+ var parsed = JSON.parse(raw);
932
+ if (!parsed || typeof parsed !== "object") return { version: "", readonlyTools: true, ssh: true, warning: "" };
933
+ return {
934
+ version: typeof parsed.version === "string" ? parsed.version : "",
935
+ readonlyTools: parsed.readonlyTools !== false,
936
+ ssh: parsed.ssh !== false,
937
+ warning: typeof parsed.warning === "string" ? parsed.warning : "",
938
+ };
939
+ } catch (e) {
940
+ return { version: "", readonlyTools: true, ssh: true, warning: "" };
941
+ }
942
+ }
943
+
944
+ /** Localized one-line explanation of a capability warning ('' when fine). */
945
+ function capabilityHint(caps, t) {
946
+ if (!caps || !caps.warning) return "";
947
+ if (caps.warning === "tooOld") return t("cap.tooOld", { v: caps.version || "?" });
948
+ if (caps.warning === "untested") return t("cap.untested", { v: caps.version || "?" });
949
+ return "";
950
+ }
951
+
952
+ /**
953
+ * Build the option half of a write payload from one form draft.
954
+ *
955
+ * Secrets follow the "blank = keep the stored value" rule: an empty password /
956
+ * passphrase / key path is simply OMITTED, and the Host merges it with what is
957
+ * already stored (a user must never have to re-type a password to flip the
958
+ * read-only checkbox). `sshOn === false` is the explicit "no tunnel" signal and
959
+ * must survive as a defined `null` — otherwise it would read as "unchanged".
960
+ *
961
+ * @returns `{ readOnly, ssh? , sshOff? }` (spread into a configOp payload).
962
+ */
963
+ function connDraftOf(form) {
964
+ var f = form || {};
965
+ var out = { readOnly: f.ro === true };
966
+ var host = String(f.sshHost || "").trim();
967
+ var user = String(f.sshUser || "").trim();
968
+ var keyPath = String(f.sshKeyPath || "").trim();
969
+ var password = String(f.sshPassword || "");
970
+ var passphrase = String(f.sshPassphrase || "");
971
+ var requested = !!host || !!user || !!keyPath || !!password || f.sshOn === true;
972
+ if (f.sshOn !== true) {
973
+ if (requested === false) return out;
974
+ out.ssh = null;
975
+ return out;
976
+ }
977
+ var auth = f.sshAuthKind === "password" ? "password" : "key";
978
+ var ssh = {
979
+ host: host,
980
+ port: String(f.sshPort || "").trim(),
981
+ user: user,
982
+ auth: auth,
983
+ proxyJump: String(f.sshProxyJump || "").trim(),
984
+ };
985
+ if (auth === "password") {
986
+ if (password) ssh.password = password;
987
+ } else {
988
+ if (keyPath) ssh.keyPath = keyPath;
989
+ if (passphrase) ssh.passphrase = passphrase;
990
+ }
991
+ out.ssh = ssh;
992
+ return out;
993
+ }
994
+
995
+ /** The option draft behind a write form: forms built for a write carry the
996
+ * originating form under `_draft`, editing forms ARE the draft. */
997
+ function draftOf(form) {
998
+ return form && form._draft ? form._draft : form;
999
+ }
1000
+
1001
+ /** Spread `connDraftOf` into one configOp payload. */
1002
+ function withConnDraft(op, form) {
1003
+ var d = connDraftOf(draftOf(form));
1004
+ op.readOnly = d.readOnly === true;
1005
+ if (d.ssh === null) op.ssh = null;
1006
+ else if (d.ssh) op.ssh = d.ssh;
1007
+ return op;
1008
+ }
1009
+
1010
+ /** Empty draft fields for the advanced (SSH) section. */
1011
+ function sshDraftOf(row) {
1012
+ var ssh = row && row.ssh && typeof row.ssh === "object" ? row.ssh : null;
1013
+ return {
1014
+ sshOn: !!ssh,
1015
+ sshHost: ssh ? String(ssh.host || "") : "",
1016
+ sshPort: ssh && ssh.port ? String(ssh.port) : "",
1017
+ sshUser: ssh ? String(ssh.user || "") : "",
1018
+ sshAuthKind: ssh && ssh.authKind === "password" ? "password" : "key",
1019
+ sshKeyPath: "",
1020
+ sshPassword: "",
1021
+ sshPassphrase: "",
1022
+ sshProxyJump: ssh ? String(ssh.proxyJump || "") : "",
1023
+ };
1024
+ }
1025
+
1026
+ /**
1027
+ * Parse the Host's transient update snapshot (a JSON string). Always returns
1028
+ * the full shape so render code never has to guard field by field.
1029
+ * @returns `{ latest, latestSpec, hasUpdate, checkedAt, source, error }`
1030
+ */
1031
+ function parseUpdate(value) {
1032
+ var out = { latest: "", latestSpec: "", hasUpdate: false, checkedAt: 0, source: "", error: "" };
1033
+ var raw = value && typeof value.update === "string" ? value.update : "";
1034
+ if (!raw) return out;
1035
+ try {
1036
+ var parsed = JSON.parse(raw);
1037
+ if (!parsed || typeof parsed !== "object") return out;
1038
+ out.latest = typeof parsed.latest === "string" ? parsed.latest.trim() : "";
1039
+ out.latestSpec = typeof parsed.latestSpec === "string" && parsed.latestSpec
1040
+ ? parsed.latestSpec
1041
+ : (out.latest ? "dsh-dbhub-live@" + out.latest : "");
1042
+ out.hasUpdate = parsed.hasUpdate === true && !!out.latest;
1043
+ out.checkedAt = typeof parsed.checkedAt === "number" && parsed.checkedAt > 0 ? parsed.checkedAt : 0;
1044
+ out.source = typeof parsed.source === "string" ? parsed.source : "";
1045
+ out.error = typeof parsed.error === "string" ? parsed.error : "";
1046
+ } catch (e) {
1047
+ /* a malformed snapshot is simply "no update information" */
1048
+ }
1049
+ return out;
1050
+ }
1051
+
1052
+ /**
1053
+ * Measured width of the page's own content column. The same component serves
1054
+ * a 500px settings dialog, a full page and a phone browser, so the layout is
1055
+ * decided by how much room it actually has. ResizeObserver is absent in older
1056
+ * browsers and in the offscreen render gate; then the caller falls back to
1057
+ * `window.innerWidth` (or to the mount surface's default).
1058
+ */
1059
+ function useMeasuredWidth(ref) {
1060
+ var state = react.useState(0);
1061
+ var setWidth = state[1];
1062
+ react.useEffect(function () {
1063
+ var el = ref && ref.current;
1064
+ if (typeof ResizeObserver !== "function" || !el) return undefined;
1065
+ var observer = new ResizeObserver(function (entries) {
1066
+ var entry = entries && entries[0];
1067
+ var w = entry && entry.contentRect ? entry.contentRect.width : 0;
1068
+ if (w > 0) setWidth(Math.round(w));
1069
+ });
1070
+ try {
1071
+ observer.observe(el);
1072
+ } catch (e) {
1073
+ return undefined;
1074
+ }
1075
+ return function () {
1076
+ try { observer.disconnect(); } catch (e) { /* disposal must never throw */ }
1077
+ };
1078
+ }, []);
1079
+ return state[0];
1080
+ }
1081
+
1082
+ /**
1083
+ * The 显示/隐藏 style switch used by the modern surfaces. A native checkbox
1084
+ * stays in the DOM (keyboard + screen-reader semantics, and the offscreen gate
1085
+ * can drive it); the track/knob are decoration.
1086
+ */
1087
+ function switchControl(opts) {
1088
+ var o = opts || {};
1089
+ var label = o.ariaLabel || o.label || "";
1090
+ if (SwitchPrimitive) {
1091
+ return h(SwitchPrimitive, {
1092
+ checked: o.checked === true,
1093
+ onChange: function (next) { if (typeof o.onChange === "function") o.onChange(next); },
1094
+ label: label,
1095
+ disabled: o.disabled === true,
1096
+ title: o.title || "",
1097
+ className: o.className,
1098
+ key: o.key,
1099
+ });
1100
+ }
1101
+ // Fallback with the same 36x20 geometry and token colours as the primitive.
1102
+ return h("button", {
1103
+ type: "button",
1104
+ role: "switch",
1105
+ "aria-checked": o.checked === true,
1106
+ "aria-label": label,
1107
+ title: o.title || "",
1108
+ disabled: o.disabled === true,
1109
+ className: "dbh-fbswitch" + (o.checked === true ? " dbh-fbswitch-on" : "") + (o.className ? " " + o.className : ""),
1110
+ key: o.key,
1111
+ onClick: function () { if (typeof o.onChange === "function") o.onChange(o.checked !== true); },
1112
+ }, h("span", { className: "dbh-fbswitch-thumb" }));
1113
+ }
572
1114
 
573
1115
  // ── the page ──────────────────────────────────────────────────────────────
574
1116
 
@@ -597,6 +1139,10 @@ window.__ModuleLoader__.load({ id: "dsh-dbhub-live", factory: (require) => {
597
1139
  var lastError = typeof value.lastError === "string" && value.lastError ? value.lastError : "";
598
1140
  var modeLabel = t("mode." + (value.mode || "oneshot"));
599
1141
  var workspaces = workspacesOf(value);
1142
+ var caps = capsOf(value);
1143
+ var capHint = capabilityHint(caps, t);
1144
+ var roSupported = caps.readonlyTools !== false;
1145
+ var sshSupported = caps.ssh !== false;
600
1146
  var savedCount = workspaces.filter(function (w) { return w.persisted; }).length;
601
1147
  var groups = groupRows(workspaces);
602
1148
  var layout = layoutModeOf(groups);
@@ -609,6 +1155,79 @@ window.__ModuleLoader__.load({ id: "dsh-dbhub-live", factory: (require) => {
609
1155
  meta.emoji + " " + t(meta.key) + " · " + t("tools.fixed", { n: String(toolCount) }) + " · " + summary);
610
1156
  }
611
1157
 
1158
+ // ── modern surfaces (dsh >= 0.2.1) ───────────────────────────────────────
1159
+ //
1160
+ // The Host decides `dshModern` from the RUNNING dsh version (lib/meta.mjs):
1161
+ // on 0.1.x every flag below stays false and the page renders exactly the old
1162
+ // DOM, which the offscreen gate's legacy assertions pin.
1163
+ var modern = value.dshModern === true;
1164
+ var surface = props.surface === "main" || props.surface === "modal" || props.surface === "page"
1165
+ ? props.surface
1166
+ : "page";
1167
+ var pluginVersion = typeof value.pluginVersion === "string" ? String(value.pluginVersion).trim() : "";
1168
+ var dshVersion = typeof value.dshVersion === "string" ? String(value.dshVersion).trim() : "";
1169
+ var runtime = value.runtime === "desktop" ? "desktop" : value.runtime === "web" ? "web" : "";
1170
+ var update = parseUpdate(value);
1171
+ var innerRef = react.useRef(null);
1172
+ var measuredWidth = useMeasuredWidth(innerRef);
1173
+ var viewportWidth = 0;
1174
+ try {
1175
+ if (typeof window !== "undefined" && window && typeof window.innerWidth === "number") viewportWidth = window.innerWidth;
1176
+ } catch (e) { /* an embedded webview may restrict access */ }
1177
+ var contentWidth = measuredWidth > 0 ? measuredWidth : viewportWidth;
1178
+ // Phone browser = the VIEWPORT is small. A narrow settings dialog on a
1179
+ // desktop is not a phone: its column is small but the window is not.
1180
+ var isMobile = modern && (viewportWidth > 0 ? viewportWidth < 700 : (contentWidth > 0 && contentWidth < 600));
1181
+ var isWide = modern && !isMobile && surface !== "modal" && (contentWidth === 0 || contentWidth >= 720);
1182
+ var checkingState = react.useState(false);
1183
+ var checking = checkingState[0];
1184
+ var setChecking = checkingState[1];
1185
+ // The update explanation is progressive disclosure: the header only shows the
1186
+ // quiet "可升级到 x" link, and this state opens the neutral detail panel.
1187
+ var updateOpenState = react.useState(false);
1188
+ var updateOpen = updateOpenState[0];
1189
+ var setUpdateOpen = updateOpenState[1];
1190
+ // Implementation details (tool count, one-shot mode, policy text, versions)
1191
+ // start collapsed: the settings page is for acting, not reading.
1192
+ var notesState = react.useState(false);
1193
+ var notesOpen = notesState[0];
1194
+ var setNotesOpen = notesState[1];
1195
+ var checkBaseline = react.useRef(0);
1196
+ var checkTimer = react.useRef(null);
1197
+ var runUpdateCheck = function (force) {
1198
+ setChecking(true);
1199
+ checkBaseline.current = update.checkedAt;
1200
+ if (checkTimer.current) clearTimeout(checkTimer.current);
1201
+ checkTimer.current = setTimeout(function () {
1202
+ checkTimer.current = null;
1203
+ setChecking(false);
1204
+ }, 20000);
1205
+ try {
1206
+ if (typeof props.configOp === "function") props.configOp({ op: "updateCheck", force: force === true });
1207
+ } catch (e) {
1208
+ setChecking(false);
1209
+ }
1210
+ };
1211
+ // Opening the page asks the Host for a (throttled) update check; the Host
1212
+ // owns the network call and the cache, so a refresh here costs nothing.
1213
+ react.useEffect(function () {
1214
+ if (!modern) return undefined;
1215
+ try {
1216
+ if (typeof props.configOp === "function") props.configOp({ op: "updateCheck", force: false });
1217
+ } catch (e) { /* an offline host is not a page failure */ }
1218
+ return undefined;
1219
+ }, [modern]);
1220
+ react.useEffect(function () {
1221
+ if (!checking) return;
1222
+ if (update.checkedAt > 0 && update.checkedAt !== checkBaseline.current) {
1223
+ if (checkTimer.current) {
1224
+ clearTimeout(checkTimer.current);
1225
+ checkTimer.current = null;
1226
+ }
1227
+ setChecking(false);
1228
+ }
1229
+ }, [value.update, checking]);
1230
+
612
1231
  // ── view state (page only) ───────────────────────────────────────────────
613
1232
  //
614
1233
  // `useWorkspaces` is a standard prop of root-scoped slots: it tells us which
@@ -669,11 +1288,16 @@ window.__ModuleLoader__.load({ id: "dsh-dbhub-live", factory: (require) => {
669
1288
  setAddTarget(nextTarget);
670
1289
  };
671
1290
 
672
- // ── config draft (interval is a draft; the sidebar switch applies at once) ──
1291
+ // ── config draft (interval + the sidebar switch + the policy switch) ──────
673
1292
  var draftState = react.useState(function () {
674
1293
  return {
675
1294
  updateIntervalDays: typeof value.updateIntervalDays === "number" ? String(value.updateIntervalDays) : "7",
1295
+ // The user's own "hide the shortcut" preference. Independent of the
1296
+ // plugin's enabled state: hiding the entry does NOT disable anything.
676
1297
  showSidebarEntry: value.showSidebarEntry !== false,
1298
+ // The user's own "let the plugin announce its access policy" preference.
1299
+ // Independent of everything else: off removes one prompt section only.
1300
+ announceAccessPolicy: value.announceAccessPolicy !== false,
677
1301
  };
678
1302
  });
679
1303
  var draft = draftState[0];
@@ -686,15 +1310,24 @@ window.__ModuleLoader__.load({ id: "dsh-dbhub-live", factory: (require) => {
686
1310
  props.saveConfig({
687
1311
  updateIntervalDays: Number(draft.updateIntervalDays),
688
1312
  showSidebarEntry: draft.showSidebarEntry !== false,
1313
+ announceAccessPolicy: draft.announceAccessPolicy !== false,
689
1314
  }).then(function () { setSaved(true); });
690
1315
  };
691
1316
 
1317
+ // The switch applies immediately (no Save round-trip), like it always has.
692
1318
  var toggleSidebar = function (next) {
693
1319
  setDraft(Object.assign({}, draft, { showSidebarEntry: next }));
694
1320
  setSaved(false);
695
1321
  props.saveConfig({ showSidebarEntry: next });
696
1322
  };
697
1323
 
1324
+ // Same immediate-apply contract for the policy announcement.
1325
+ var togglePolicy = function (next) {
1326
+ setDraft(Object.assign({}, draft, { announceAccessPolicy: next }));
1327
+ setSaved(false);
1328
+ props.saveConfig({ announceAccessPolicy: next });
1329
+ };
1330
+
698
1331
  // ── pre-test + confirm dialog for add/edit ────────────────────────────────
699
1332
  //
700
1333
  // Both the add form and the edit form gate saves behind a live connection
@@ -715,25 +1348,50 @@ window.__ModuleLoader__.load({ id: "dsh-dbhub-live", factory: (require) => {
715
1348
  var pretestRef = react.useRef(null);
716
1349
  pretestRef.current = pretest;
717
1350
 
718
- var addState = react.useState({ ws: "", env: "default", dsn: "" });
1351
+ var addState = react.useState(Object.assign({ ws: "", env: "default", dsn: "", ro: false, adv: false }, sshDraftOf(null)));
719
1352
  var addForm = addState[0];
720
1353
  var setAddForm = addState[1];
721
1354
  var addDsn = function () {
722
1355
  if (!addForm.dsn.trim()) return;
723
- requestWrite({
1356
+ // The read-only switch and the tunnel ride the SAME write as the DSN, so
1357
+ // one click produces one atomic store write (and one op for the legacy
1358
+ // single-field command channel). `_draft` keeps the originating form
1359
+ // reachable for the pre-test payload and the final save.
1360
+ requestWrite(withConnDraft({
724
1361
  ws: addTarget || addForm.ws.trim(),
725
1362
  env: addForm.env.trim() || "default",
726
1363
  dsn: addForm.dsn.trim(),
727
- }, true);
1364
+ _draft: addForm,
1365
+ }, addForm), true);
728
1366
  };
729
1367
 
730
1368
  var editState = react.useState(null);
731
1369
  var editing = editState[0];
732
1370
  var setEditing = editState[1];
733
- // An edit can change the DSN, the environment NAME, or both. A name-only
734
- // change is a rename (no connection data to verify); a DSN change keeps the
735
- // pre-test gate and carries the rename along in the SAME host op — two
736
- // configOp writes would race on the single `configOp` namespace field.
1371
+ var updateEditing = function (patch) {
1372
+ setEditing(function (prev) { return prev ? Object.assign({}, prev, patch) : prev; });
1373
+ };
1374
+ // Does the draft differ from the row's mirrored OPTIONS? Secrets are not
1375
+ // comparable (a blank input means "keep"), so only the non-secret fields and
1376
+ // the read-only flag take part.
1377
+ var optionsDiffer = function (row, form) {
1378
+ var d = connDraftOf(form);
1379
+ if ((row.ro === true) !== (d.readOnly === true)) return true;
1380
+ var had = row.ssh || null;
1381
+ var want = d.ssh === undefined ? had : d.ssh;
1382
+ if (!had && !want) return false;
1383
+ if (!had || !want) return true;
1384
+ return String(had.host || "") !== String(want.host || "")
1385
+ || String(had.port || "") !== String(want.port || "")
1386
+ || String(had.user || "") !== String(want.user || "")
1387
+ || String(had.authKind || "") !== String(want.auth || "")
1388
+ || String(had.proxyJump || "") !== String(want.proxyJump || "");
1389
+ };
1390
+ // An edit can change the DSN, the environment NAME, the options, or any
1391
+ // combination. A name-only change is a rename (no connection data to verify);
1392
+ // a DSN change keeps the pre-test gate and carries the rename along in the
1393
+ // SAME host op — two configOp writes would race on the single `configOp`
1394
+ // namespace field.
737
1395
  var saveEdit = function (row) {
738
1396
  if (!editing) return;
739
1397
  var newEnv = String(editing.env || "").trim() || "default";
@@ -742,12 +1400,29 @@ window.__ModuleLoader__.load({ id: "dsh-dbhub-live", factory: (require) => {
742
1400
  // "prod " or an over-long name is not a rename onto a different env.
743
1401
  var renaming = normalizeEnvForCompare(newEnv) !== normalizeEnvForCompare(row.env);
744
1402
  if (!dsn) {
745
- if (renaming) requestWrite({ ws: row.path, env: newEnv, dsn: "", _editRow: row }, true);
1403
+ var optionsChanged = optionsDiffer(row, editing);
1404
+ if (!renaming && !optionsChanged) { setEditing(null); return; } // nothing changed
1405
+ if (!optionsChanged) {
1406
+ // Pure rename: nothing to probe, but landing on an existing
1407
+ // environment still replaces that connection, so it goes through the
1408
+ // same overwrite gate as a connection write.
1409
+ requestWrite({ op: "rename", _opKind: "rename", ws: row.path, env: newEnv, dsn: "", _editRow: row, _renameFrom: row.env }, true);
1410
+ return;
1411
+ }
1412
+ if (!renaming) {
1413
+ // Options-only write on a row that keeps its name: no DSN to verify
1414
+ // and no name that could collide.
1415
+ props.configOp(withConnDraft({ op: "options", workspace: row.path, env: row.env }, editing));
1416
+ setEditing(null);
1417
+ return;
1418
+ }
1419
+ // Renamed AND re-optioned: one atomic `options` command carrying newEnv.
1420
+ requestWrite(withConnDraft({ op: "options", _opKind: "options", ws: row.path, env: newEnv, dsn: "", _editRow: row, _renameFrom: row.env }, editing), true);
746
1421
  return;
747
1422
  }
748
1423
  // Editing the connection of the SAME environment is not an overwrite
749
1424
  // question (it is the point of the edit); renaming onto another one is.
750
- requestWrite({ ws: row.path, env: newEnv, dsn: dsn, _editRow: row, _renameFrom: renaming ? row.env : "" }, renaming);
1425
+ requestWrite(withConnDraft({ ws: row.path, env: newEnv, dsn: dsn, _editRow: row, _renameFrom: renaming ? row.env : "" }, editing), renaming);
751
1426
  };
752
1427
 
753
1428
  // ── overwrite gate ───────────────────────────────────────────────────────
@@ -762,8 +1437,15 @@ window.__ModuleLoader__.load({ id: "dsh-dbhub-live", factory: (require) => {
762
1437
 
763
1438
  var executeWrite = function (form) {
764
1439
  if (form._editRow && !String(form.dsn || "").trim()) {
765
- // a name-only rename carries no connection data to probe
766
- props.configOp({ op: "rename", workspace: form.ws, env: form._editRow.env, newEnv: form.env });
1440
+ // a name/option-only change carries no connection data to probe
1441
+ var op;
1442
+ if (form._opKind === "rename") {
1443
+ op = { op: "rename", workspace: form.ws, env: form._editRow.env, newEnv: form.env };
1444
+ } else {
1445
+ op = withConnDraft({ op: "options", workspace: form.ws, env: form._editRow.env }, draftOf(form));
1446
+ if (form._renameFrom && normalizeEnvForCompare(form._renameFrom) !== normalizeEnvForCompare(form.env)) op.newEnv = form.env;
1447
+ }
1448
+ props.configOp(op);
767
1449
  setEditing(null);
768
1450
  return;
769
1451
  }
@@ -787,12 +1469,25 @@ window.__ModuleLoader__.load({ id: "dsh-dbhub-live", factory: (require) => {
787
1469
 
788
1470
  // Dispatch a raw-DSN pre-test and record the nonce so the test-result
789
1471
  // mirror callback can route it back here. Pretest nonces are prefixed
790
- // "pretest:" so the regular effect branch skips them.
1472
+ // "pretest:" so the regular effect branch skips them. The payload carries
1473
+ // the draft's options too, so "试连" really validates the tunnel/read-only
1474
+ // combination the user is about to save.
791
1475
  var dispatchPretest = function (form) {
792
1476
  var nonce = "pretest:" + (form.ws || "") + "|" + (form.env || "default") + "|" + Date.now();
793
1477
  nonceKeys.current[nonce] = nonce;
794
1478
  setPretest({ form: form, pendingNonce: nonce, failMessage: null });
795
- props.configOp({ op: "test", dsn: form.dsn, workspace: form.ws || undefined, env: form.env || "default", nonce: nonce });
1479
+ var draft = connDraftOf(draftOf(form));
1480
+ var payload = { op: "test", dsn: form.dsn, workspace: form.ws || undefined, env: form.env || "default", nonce: nonce };
1481
+ // An edit validates the options the row already has; a brand-new form only
1482
+ // asserts what it actually carries (so it cannot clear a stored tunnel).
1483
+ if (form._editRow || draft.readOnly === true) payload.readOnly = draft.readOnly === true;
1484
+ if (draft.ssh === null) payload.ssh = null;
1485
+ else if (draft.ssh) payload.ssh = draft.ssh;
1486
+ props.configOp(payload);
1487
+ // Watchdog tier: an SSH handshake is slower than a direct connect, so a
1488
+ // tunnelled validation gets a longer leash (host side is tiered to match
1489
+ // and stays strictly below this).
1490
+ var leash = draft.ssh ? PRETEST_WATCHDOG_SSH_MS : PRETEST_WATCHDOG_MS;
796
1491
  testTimers.current[nonce] = setTimeout(function () {
797
1492
  if (!nonceKeys.current[nonce]) return;
798
1493
  delete nonceKeys.current[nonce];
@@ -803,29 +1498,36 @@ window.__ModuleLoader__.load({ id: "dsh-dbhub-live", factory: (require) => {
803
1498
  next.pendingNonce = null;
804
1499
  return next;
805
1500
  });
806
- }, 30000);
1501
+ }, leash);
1502
+ };
1503
+
1504
+ /** The `add` payload for one save (options included). */
1505
+ var saveOpOf = function (f) {
1506
+ var op = withConnDraft({
1507
+ op: "add", workspace: f.ws || undefined, env: f.env || "default", dsn: String(f.dsn || "").trim(),
1508
+ }, draftOf(f));
1509
+ if (f._renameFrom) op.renameFrom = f._renameFrom;
1510
+ return op;
807
1511
  };
808
1512
 
809
1513
  /** Close the add panel and clear it after a successful save. */
810
1514
  var finishAddPanel = function (form) {
811
1515
  if (form && form._editRow) { setEditing(null); return; }
812
- setAddForm({ ws: "", env: "default", dsn: "" });
1516
+ setAddForm(Object.assign({ ws: "", env: "default", dsn: "", ro: false, adv: uiMemory.advOpen === true }, sshDraftOf(null)));
813
1517
  setAdd(false, "");
814
1518
  };
815
1519
 
816
1520
  var confirmSave = function () {
817
1521
  if (!pretest) return;
818
1522
  var f = pretest.form;
819
- var op = { op: "add", workspace: f.ws || undefined, env: f.env || "default", dsn: f.dsn.trim() };
820
- if (f._renameFrom) op.renameFrom = f._renameFrom;
821
- props.configOp(op);
1523
+ props.configOp(saveOpOf(f));
822
1524
  finishAddPanel(f);
823
1525
  setPretest(null);
824
1526
  };
825
1527
  var cancelPretest = function () { setPretest(null); };
826
1528
 
827
1529
  var openAdd = function (target) {
828
- setAddForm({ ws: String(target || ""), env: "default", dsn: "" });
1530
+ setAddForm(Object.assign({ ws: String(target || ""), env: "default", dsn: "", ro: false, adv: uiMemory.advOpen === true }, sshDraftOf(null)));
829
1531
  setEditing(null);
830
1532
  setPretest(null);
831
1533
  setAdd(true, target || "");
@@ -877,17 +1579,69 @@ window.__ModuleLoader__.load({ id: "dsh-dbhub-live", factory: (require) => {
877
1579
  nonceKeys.current[nonce] = key;
878
1580
  setTests(function (prev) {
879
1581
  var next = Object.assign({}, prev);
880
- next[key] = { phase: "testing", nonce: nonce };
1582
+ // `startedAt` drives the elapsed readout: a probe really can take several
1583
+ // seconds, and an unlabelled spinner reads as "hung".
1584
+ next[key] = { phase: "testing", nonce: nonce, startedAt: Date.now() };
881
1585
  return next;
882
1586
  });
883
1587
  props.configOp({ op: "test", workspace: row.path, env: row.env, nonce: nonce });
884
1588
  // Backstop: the host reports on every path (incl. failures); if the
885
1589
  // mirror never carries our nonce back, surface a timeout, don't spin.
1590
+ var tunnelled = !!(row && row.ssh);
886
1591
  testTimers.current[nonce] = setTimeout(function () {
887
1592
  if (!nonceKeys.current[nonce]) return;
888
1593
  finishTest(key, nonce, false, t("test.timeout"));
889
- }, 30000);
1594
+ }, tunnelled ? TEST_WATCHDOG_SSH_MS : TEST_WATCHDOG_MS);
890
1595
  };
1596
+ /**
1597
+ * SSH-LAYER-ONLY test: is the bastion reachable / does the tunnel come up,
1598
+ * independently of the database? Answers the "which side is wrong?" question
1599
+ * that a plain connection test cannot. Shares the nonce + test-result
1600
+ * machinery with the row tests, under its own key.
1601
+ */
1602
+ var startSshTest = function (form, targetOf, key) {
1603
+ var cur = tests[key];
1604
+ if (cur && cur.phase === "testing") return;
1605
+ var target = targetOf ? targetOf() : {};
1606
+ var nonce = key + "|" + Date.now();
1607
+ nonceKeys.current[nonce] = key;
1608
+ setTests(function (prev) {
1609
+ var next = Object.assign({}, prev);
1610
+ next[key] = { phase: "testing", nonce: nonce, startedAt: Date.now() };
1611
+ return next;
1612
+ });
1613
+ var d = connDraftOf(form);
1614
+ var ssh = d.ssh && typeof d.ssh === "object" ? d.ssh : null;
1615
+ if (!ssh) {
1616
+ finishTest(key, nonce, false, t("adv.testSshNeedFields"));
1617
+ return;
1618
+ }
1619
+ props.configOp({
1620
+ op: "test", kind: "ssh",
1621
+ workspace: target.ws || undefined, env: target.env || "default",
1622
+ ssh: ssh, nonce: nonce,
1623
+ });
1624
+ testTimers.current[nonce] = setTimeout(function () {
1625
+ if (!nonceKeys.current[nonce]) return;
1626
+ finishTest(key, nonce, false, t("test.timeout"));
1627
+ }, TEST_WATCHDOG_SSH_MS);
1628
+ };
1629
+ // ── elapsed readout for a running probe ──
1630
+ //
1631
+ // The 1s ticker exists ONLY while at least one row is testing: an idle page
1632
+ // must not hold an interval (the offscreen render gate fails the run if one
1633
+ // survives), and the effect's cleanup covers finish/unmount alike.
1634
+ var tickState = react.useState(0);
1635
+ var setTick = tickState[1];
1636
+ var anyTesting = false;
1637
+ for (var tk in tests) {
1638
+ if (Object.prototype.hasOwnProperty.call(tests, tk) && tests[tk] && tests[tk].phase === "testing") { anyTesting = true; break; }
1639
+ }
1640
+ react.useEffect(function () {
1641
+ if (!anyTesting || typeof setInterval !== "function") return undefined;
1642
+ var id = setInterval(function () { setTick(function (n) { return n + 1; }); }, 1000);
1643
+ return function () { clearInterval(id); };
1644
+ }, [anyTesting]);
891
1645
  react.useEffect(function () {
892
1646
  var raw = typeof value.testResult === "string" ? value.testResult : "";
893
1647
  if (!raw) return;
@@ -905,8 +1659,8 @@ window.__ModuleLoader__.load({ id: "dsh-dbhub-live", factory: (require) => {
905
1659
  if (!pt || pt.pendingNonce !== res.nonce) return;
906
1660
  var f = pt.form;
907
1661
  if (res.ok === true) {
908
- // Test passed — save immediately.
909
- props.configOp({ op: "add", workspace: f.ws || undefined, env: f.env || "default", dsn: f.dsn.trim() });
1662
+ // Test passed — save immediately (options included, in the same op).
1663
+ props.configOp(saveOpOf(f));
910
1664
  finishAddPanel(f);
911
1665
  setPretest(null);
912
1666
  } else {
@@ -922,6 +1676,7 @@ window.__ModuleLoader__.load({ id: "dsh-dbhub-live", factory: (require) => {
922
1676
  return function () {
923
1677
  for (var id in timers) { if (Object.prototype.hasOwnProperty.call(timers, id)) clearTimeout(timers[id]); }
924
1678
  if (copyTimer.current) clearTimeout(copyTimer.current);
1679
+ if (checkTimer.current) { clearTimeout(checkTimer.current); checkTimer.current = null; }
925
1680
  };
926
1681
  }, []);
927
1682
 
@@ -949,7 +1704,14 @@ window.__ModuleLoader__.load({ id: "dsh-dbhub-live", factory: (require) => {
949
1704
  };
950
1705
 
951
1706
  // ── derived copy ──
952
- var statusText = meta.emoji + " " + t(meta.key) + " · " + t("tools.fixed", { n: String(toolCount) })
1707
+ //
1708
+ // Modern surfaces show only what a user acts on: state + connection count.
1709
+ // The legacy (0.1.x) DOM keeps its original status sentence verbatim.
1710
+ var statusText = t(meta.key);
1711
+ var connSummary = workspaces.length > 0
1712
+ ? t("status.conns", { n: String(workspaces.length) })
1713
+ : t("desc.unconfigured");
1714
+ var legacyStatusText = meta.emoji + " " + t(meta.key) + " · " + t("tools.fixed", { n: String(toolCount) })
953
1715
  + (workspaces.length === 0 ? " · " + t("desc.unconfigured") : "");
954
1716
  var cfgSummary = t("cfg.summary", {
955
1717
  n: typeof draft.updateIntervalDays === "string" && draft.updateIntervalDays ? draft.updateIntervalDays : "7",
@@ -966,8 +1728,42 @@ window.__ModuleLoader__.load({ id: "dsh-dbhub-live", factory: (require) => {
966
1728
  var originTitle = function (w, base) {
967
1729
  var text = (base ? String(base) + " · " : "") + t("ws.source") + ":" + sourceLabelOf(w, t);
968
1730
  if (!w.persisted) text += " " + t("ws.autoHint");
1731
+ // Read-only and the tunnel are per-row ATTRIBUTES (they change what a query
1732
+ // may do and how it is routed), so they ride the chip: the flag is visible,
1733
+ // the details are one hover away.
1734
+ if (w.ro === true) text += " · " + t("ro.title");
1735
+ if (w.ssh) text += " · " + t("ro.sshTitle", { host: String(w.ssh.host || ""), port: String(w.ssh.port || ""), user: String(w.ssh.user || "") });
969
1736
  return text;
970
1737
  };
1738
+ // Environment chip: the name plus, on the legacy layout, an inline read-only
1739
+ // marker. The modern layout renders read-only as its own row control below so
1740
+ // it can be flipped without opening the edit form.
1741
+ var envChip = function (w) {
1742
+ return h("span", { className: "dbh-envname", title: originTitle(w, w.env) },
1743
+ w.env,
1744
+ !modern && w.ro === true ? h("span", { className: "dbh-ro", title: t("ro.title") }, t("ro.chip")) : null);
1745
+ };
1746
+ /**
1747
+ * Per-row read-only toggle, on EVERY modern layout (dialog, wide page, phone).
1748
+ * One click writes `{op:'options', readOnly}` — no edit form, no re-typing a
1749
+ * DSN. The chip itself is the state: outlined = writable, filled + lock = the
1750
+ * model cannot write. The legacy layout keeps its marker-in-the-chip form.
1751
+ */
1752
+ var roChip = function (w) {
1753
+ if (!modern) return null;
1754
+ var on = w.ro === true;
1755
+ return h("button", {
1756
+ type: "button",
1757
+ className: "dbh-rochip" + (on ? " dbh-rochip-on" : ""),
1758
+ title: roSupported ? (on ? t("ro.chipOnHint") : t("ro.chipOffHint")) : capHint,
1759
+ "aria-label": on ? t("ro.turnOff") : t("ro.turnOn"),
1760
+ "aria-pressed": on,
1761
+ disabled: !roSupported,
1762
+ onClick: function () {
1763
+ props.configOp({ op: "options", workspace: w.path, env: w.env, readOnly: w.ro !== true });
1764
+ },
1765
+ }, h("span", { className: "dbh-roemoji", "aria-hidden": true }, on ? "🔒" : "🔓"), t(on ? "ro.chip" : "ro.writable"));
1766
+ };
971
1767
  // The source handle is a first-class, visibly-copyable chip: it shows the
972
1768
  // trailing characters that tell look-alike handles apart, and its dashed
973
1769
  // outline + copy glyph say "this is an id", not "copy the row". Action
@@ -997,19 +1793,23 @@ window.__ModuleLoader__.load({ id: "dsh-dbhub-live", factory: (require) => {
997
1793
  "aria-busy": isTesting,
998
1794
  disabled: isTesting,
999
1795
  onClick: function () { startTest(w, key); },
1000
- }, h(RunIcon, { size: 14 })),
1796
+ }, h(RunIcon, { size: 16 })),
1001
1797
  h("button", {
1002
1798
  type: "button", className: "dbh-iconbtn",
1003
1799
  title: isEditing ? t("btn.cancel") : t("btn.edit"),
1004
1800
  "aria-label": isEditing ? t("btn.cancel") : t("btn.edit"),
1005
- onClick: function () { setEditing(isEditing ? null : { key: key, dsn: "", env: w.env }); },
1006
- }, isEditing ? h(CloseIcon, { size: 14 }) : h(EditIcon, { size: 14 })),
1801
+ onClick: function () {
1802
+ setEditing(isEditing ? null : Object.assign({
1803
+ key: key, dsn: "", env: w.env, ro: w.ro === true, adv: uiMemory.advOpen === true,
1804
+ }, sshDraftOf(w)));
1805
+ },
1806
+ }, isEditing ? h(CloseIcon, { size: 14 }) : h(EditIcon, { size: 16 })),
1007
1807
  w.persisted ? h("button", {
1008
1808
  type: "button", className: "dbh-iconbtn dbh-iconbtn-danger",
1009
1809
  title: t("btn.delete"),
1010
1810
  "aria-label": t("btn.delete"),
1011
1811
  onClick: function () { props.configOp({ op: "remove", workspace: w.path, env: w.env }); },
1012
- }, h(TrashIcon, { size: 14 })) : null);
1812
+ }, h(TrashIcon, { size: 16 })) : null);
1013
1813
  };
1014
1814
  // Browsers serialize a copy of adjacent BLOCK-level boxes with a separator,
1015
1815
  // and the split label is two flex items — pasting it into the DSN field used
@@ -1025,32 +1825,163 @@ window.__ModuleLoader__.load({ id: "dsh-dbhub-live", factory: (require) => {
1025
1825
  } catch (err) { /* fall back to the browser's own serialization */ }
1026
1826
  };
1027
1827
  var pendingPretest = !!(pretest && pretest.pendingNonce);
1028
- var editRow = function (w, key) {
1029
- if (!(editing && editing.key === key)) return null;
1030
- return h("div", { className: "dbh-editrow", key: "edit" },
1031
- h("input", {
1032
- className: "dbh-input dbh-envinput",
1033
- "aria-label": t("add.env"),
1034
- placeholder: t("env.newPlaceholder"),
1035
- value: typeof editing.env === "string" ? editing.env : w.env,
1036
- onChange: function (e) { setEditing(Object.assign({}, editing, { env: e.target.value })); },
1037
- }),
1828
+
1829
+ /**
1830
+ * The advanced (SSH) section shared by the add panel and the row editor.
1831
+ *
1832
+ * Layout red line: these inputs must NOT use `.dbh-field` and must appear
1833
+ * AFTER the DSN input in document order — the offscreen gate pins the add
1834
+ * panel's field count and the 0..2 input indices.
1835
+ *
1836
+ * Secrets are never echoed: the stored password / passphrase / key path stay
1837
+ * in the Host, and a blank input means "keep what is stored".
1838
+ */
1839
+ var advPanel = function (form, update, keyPrefix, targetOf) {
1840
+ var open = form.adv === true || uiMemory.advOpen === true;
1841
+ var toggle = function () {
1842
+ var next = !open;
1843
+ uiMemory.advOpen = next;
1844
+ update({ adv: next });
1845
+ };
1846
+ var children = [];
1847
+ children.push(h("button", {
1848
+ type: "button", className: "dbh-advtoggle", "aria-expanded": open,
1849
+ disabled: !sshSupported,
1850
+ title: sshSupported ? t("add.adv") : capHint,
1851
+ key: keyPrefix + "-toggle",
1852
+ onClick: toggle,
1853
+ }, (open ? "▾ " : "▸ ") + t("add.adv")));
1854
+ if (open) {
1855
+ var body = [];
1856
+ body.push(h("label", { className: "dbh-check", key: keyPrefix + "-on" },
1857
+ h("input", {
1858
+ type: "checkbox", className: "dbh-checkbox",
1859
+ checked: form.sshOn === true,
1860
+ onChange: function (e) { update({ sshOn: e.target.checked }); },
1861
+ }),
1862
+ h("span", null, t("adv.enable"))));
1863
+ if (form.sshOn === true) {
1864
+ var field = function (key, label, props, valueKey) {
1865
+ return h("div", { className: "dbh-advrow", key: keyPrefix + "-" + key },
1866
+ h("span", { className: "dbh-advlabel" }, label),
1867
+ h("input", Object.assign({
1868
+ className: "dbh-input",
1869
+ value: String(form[valueKey] || ""),
1870
+ onChange: function (e) { var patch = {}; patch[valueKey] = e.target.value; update(patch); },
1871
+ }, props)));
1872
+ };
1873
+ body.push(field("host", t("adv.host"), { placeholder: "bastion.example.com", "aria-label": t("adv.host") }, "sshHost"));
1874
+ body.push(field("port", t("adv.port"), { placeholder: "22", "aria-label": t("adv.port") }, "sshPort"));
1875
+ body.push(field("user", t("adv.user"), { placeholder: "ops", "aria-label": t("adv.user") }, "sshUser"));
1876
+ body.push(h("label", { className: "dbh-advrow", key: keyPrefix + "-auth" },
1877
+ h("span", { className: "dbh-advlabel" }, t("adv.auth")),
1878
+ h("span", { className: "dbh-check" },
1879
+ h("input", {
1880
+ type: "radio", name: keyPrefix + "-authkind", className: "dbh-checkbox",
1881
+ checked: form.sshAuthKind !== "password",
1882
+ onChange: function () { update({ sshAuthKind: "key" }); },
1883
+ }),
1884
+ h("span", null, t("adv.auth.key"))),
1885
+ h("span", { className: "dbh-check" },
1886
+ h("input", {
1887
+ type: "radio", name: keyPrefix + "-authkind", className: "dbh-checkbox",
1888
+ checked: form.sshAuthKind === "password",
1889
+ onChange: function () { update({ sshAuthKind: "password" }); },
1890
+ }),
1891
+ h("span", null, t("adv.auth.password")))));
1892
+ if (form.sshAuthKind === "password") {
1893
+ body.push(field("password", t("adv.password"), { type: "password", placeholder: t("adv.keepSecret"), "aria-label": t("adv.password") }, "sshPassword"));
1894
+ } else {
1895
+ body.push(field("keyPath", t("adv.keyPath"), { placeholder: "~/.ssh/id_ed25519", "aria-label": t("adv.keyPath") }, "sshKeyPath"));
1896
+ body.push(field("passphrase", t("adv.passphrase"), { type: "password", placeholder: t("adv.keepSecret"), "aria-label": t("adv.passphrase") }, "sshPassphrase"));
1897
+ }
1898
+ body.push(field("proxyJump", t("adv.proxyJump"), { placeholder: "jump.example.com:2222", "aria-label": t("adv.proxyJump") }, "sshProxyJump"));
1899
+ // The SSH-layer test: reachability of the bastion, the local key file,
1900
+ // and whether dbhub can bring the tunnel up — WITHOUT the database in
1901
+ // the picture, so a failure can be attributed to one side.
1902
+ var sshKey = keyPrefix + "|sshtest";
1903
+ var sshEntry = tests[sshKey];
1904
+ var sshBusy = !!(sshEntry && sshEntry.phase === "testing");
1905
+ body.push(h("div", { className: "dbh-advrow", key: keyPrefix + "-sshtest" },
1906
+ h("button", {
1907
+ type: "button", className: "dbh-btn",
1908
+ disabled: sshBusy,
1909
+ title: t("adv.testSshHint"),
1910
+ onClick: function () { startSshTest(form, targetOf, sshKey); },
1911
+ }, t("btn.testSsh"))));
1912
+ if (sshBusy) {
1913
+ var sshSecs = sshEntry.startedAt ? Math.max(0, Math.round((Date.now() - sshEntry.startedAt) / 1000)) : 0;
1914
+ body.push(h("div", { className: "dbh-test-ok", key: keyPrefix + "-sshrun" }, "⏳ " + t("test.elapsed", { s: String(sshSecs) })));
1915
+ } else if (sshEntry && sshEntry.phase === "done") {
1916
+ body.push(h("div", {
1917
+ className: sshEntry.ok ? "dbh-test-ok" : "dbh-test-fail",
1918
+ key: keyPrefix + "-sshres",
1919
+ }, (sshEntry.ok ? "✓ " : "✗ ") + sshEntry.message));
1920
+ }
1921
+ body.push(h("div", { className: "dbh-hint", key: keyPrefix + "-hint" }, t("adv.hostHint") + " " + t("adv.slow")));
1922
+ }
1923
+ children.push(h("div", { className: "dbh-advbody", key: keyPrefix + "-body" }, body));
1924
+ }
1925
+ return h("div", { className: "dbh-adv", key: keyPrefix + "-adv" }, children);
1926
+ };
1927
+
1928
+ /** The read-only checkbox shared by both forms. */
1929
+ var roCheck = function (form, update, keyPrefix) {
1930
+ return h("label", {
1931
+ className: "dbh-check",
1932
+ key: keyPrefix + "-ro",
1933
+ title: roSupported ? t("ro.title") : capHint,
1934
+ },
1038
1935
  h("input", {
1039
- className: "dbh-input",
1040
- "aria-label": t("add.dsn"),
1041
- placeholder: t("dsn.editPlaceholder"),
1042
- value: editing.dsn,
1043
- onChange: function (e) { setEditing(Object.assign({}, editing, { dsn: e.target.value })); },
1936
+ type: "checkbox", className: "dbh-checkbox",
1937
+ checked: form.ro === true,
1938
+ disabled: !roSupported,
1939
+ "aria-label": t("add.readOnly"),
1940
+ onChange: function (e) { update({ ro: e.target.checked }); },
1044
1941
  }),
1045
- h("button", {
1046
- type: "button", className: "dbh-btn dbh-btn-primary",
1047
- disabled: pendingPretest,
1048
- onClick: function () { saveEdit(w); },
1049
- }, t("btn.saved")));
1942
+ h("span", null, t("add.readOnly")));
1943
+ };
1944
+
1945
+ /** Editor for the options of an auto-discovered (options-only) row. */
1946
+ var editRow = function (w, key) {
1947
+ if (!(editing && editing.key === key)) return null;
1948
+ var update = function (patch) { updateEditing(patch); };
1949
+ return h("div", { className: "dbh-editbox", key: "edit" },
1950
+ h("div", { className: "dbh-editrow" },
1951
+ h("input", {
1952
+ className: "dbh-input dbh-envinput",
1953
+ "aria-label": t("add.env"),
1954
+ placeholder: t("env.newPlaceholder"),
1955
+ value: typeof editing.env === "string" ? editing.env : w.env,
1956
+ onChange: function (e) { updateEditing({ env: e.target.value }); },
1957
+ }),
1958
+ h("input", {
1959
+ className: "dbh-input",
1960
+ "aria-label": t("add.dsn"),
1961
+ placeholder: t("dsn.editPlaceholder"),
1962
+ value: editing.dsn,
1963
+ onChange: function (e) { updateEditing({ dsn: e.target.value }); },
1964
+ }),
1965
+ roCheck(editing, update, "ed"),
1966
+ h("button", {
1967
+ type: "button", className: "dbh-btn dbh-btn-primary",
1968
+ disabled: pendingPretest,
1969
+ onClick: function () { saveEdit(w); },
1970
+ }, t("btn.saved"))),
1971
+ advPanel(editing, update, "ed|" + w.path + "|" + w.env, function () {
1972
+ return { ws: w.path, env: String(editing.env || "").trim() || w.env };
1973
+ }));
1050
1974
  };
1051
1975
  var testRow = function (w, key) {
1052
1976
  var entry = tests[key];
1053
- if (!entry || entry.phase !== "done") return null;
1977
+ if (!entry) return null;
1978
+ if (entry.phase === "testing") {
1979
+ // Elapsed seconds, so a slow (tunnelled) probe reads as "working", not
1980
+ // as a frozen page. Re-rendered by the testing-only ticker above.
1981
+ var secs = entry.startedAt ? Math.max(0, Math.round((Date.now() - entry.startedAt) / 1000)) : 0;
1982
+ return h("div", { key: "test", className: "dbh-test-ok" }, "⏳ " + t("test.elapsed", { s: String(secs) }));
1983
+ }
1984
+ if (entry.phase !== "done") return null;
1054
1985
  return h("div", { key: "test", className: entry.ok ? "dbh-test-ok" : "dbh-test-fail" },
1055
1986
  (entry.ok ? "✓ " : "✗ ") + entry.message);
1056
1987
  };
@@ -1061,7 +1992,11 @@ window.__ModuleLoader__.load({ id: "dsh-dbhub-live", factory: (require) => {
1061
1992
  var connLabelView = function (w) {
1062
1993
  var parts = splitConn(w.conn);
1063
1994
  return h("span", { className: "dbh-conn", title: w.conn, onCopy: function (e) { onConnCopy(w, e); } },
1064
- h("span", { className: "dbh-conn-lock", "aria-hidden": true }, "🔒"),
1995
+ // The lock used to sit in front of every connection string, which read
1996
+ // like "this row needs a password" and clashed with the read-only lock.
1997
+ // Modern rows show the plain type://host:port/db; the only lock left is
1998
+ // the read-only chip. Legacy keeps its original marker.
1999
+ !modern ? h("span", { className: "dbh-conn-lock", "aria-hidden": true }, "🔒") : null,
1065
2000
  h("span", { className: "dbh-conn-pre" }, parts.prefix),
1066
2001
  parts.tail ? h("span", { className: "dbh-conn-db" }, parts.tail) : null);
1067
2002
  };
@@ -1069,8 +2004,9 @@ window.__ModuleLoader__.load({ id: "dsh-dbhub-live", factory: (require) => {
1069
2004
  var key = w.path + "|" + w.env;
1070
2005
  return h("div", { className: "dbh-envwrap", key: key },
1071
2006
  h("div", { className: "dbh-envrow" },
1072
- h("span", { className: "dbh-envname", title: originTitle(w, w.env) }, w.env),
2007
+ envChip(w),
1073
2008
  connLabelView(w),
2009
+ roChip(w),
1074
2010
  rowActions(w, key)),
1075
2011
  editRow(w, key),
1076
2012
  testRow(w, key));
@@ -1108,17 +2044,204 @@ window.__ModuleLoader__.load({ id: "dsh-dbhub-live", factory: (require) => {
1108
2044
  h("div", { className: "dbh-tilehead" },
1109
2045
  h("span", { className: "dbh-tiletitle", title: g.path }, label)),
1110
2046
  h("div", { className: "dbh-tilemeta" },
1111
- h("span", { className: "dbh-envname", title: originTitle(w, w.env) }, w.env),
1112
- connLabelView(w)),
2047
+ envChip(w),
2048
+ connLabelView(w),
2049
+ roChip(w)),
1113
2050
  h("div", { className: "dbh-tileacts" }, rowActions(w, key)),
1114
2051
  editRow(w, key),
1115
2052
  testRow(w, key));
1116
2053
  };
1117
2054
 
1118
2055
  // ── assemble the page ──
1119
- var head = h("div", { className: "dbh-head" },
2056
+ //
2057
+ // Modern header: title + subdued version plus a quiet "可升级到 x" link that
2058
+ // expands into a neutral panel on demand. The legacy (0.1.x) header below is
2059
+ // byte-for-byte the old one.
2060
+ var latestSpec = update.latestSpec || (update.latest ? "dsh-dbhub-live@" + update.latest : "");
2061
+ var webCmd = update.latest ? "dsh plugin --profile web up dsh-dbhub-live@" + update.latest : "";
2062
+ var checkedMinutes = update.checkedAt ? Math.max(0, Math.round((Date.now() - update.checkedAt) / 60000)) : 0;
2063
+ var copyLatest = function () { if (latestSpec) copySource(latestSpec, "ver"); };
2064
+ var copyWebCmd = function () { if (webCmd) copySource(webCmd, "cmd"); };
2065
+ var checkLabel = checking
2066
+ ? t("ver.checking")
2067
+ : update.error
2068
+ ? t("ver.checkFailed") + ":" + update.error
2069
+ : update.checkedAt
2070
+ ? t("ver.checkedAgo", { n: String(checkedMinutes) })
2071
+ : t("ver.check");
2072
+ var titlebar = modern ? h("div", { className: "dbh-titlebar" },
2073
+ h("h2", { className: "dbh-title" }, t("title")),
2074
+ pluginVersion ? h("span", { className: "dbh-ver", title: dshVersion ? "DSH " + dshVersion : "" }, "v" + pluginVersion) : null,
2075
+ h("span", { className: "dbh-grow" }),
2076
+ update.hasUpdate ? h("button", {
2077
+ type: "button", className: "dbh-updatelink",
2078
+ title: t("ver.copySpec", { v: update.latest }),
2079
+ onClick: function () { setUpdateOpen(!updateOpen); },
2080
+ }, "↑ " + t("ver.link", { v: update.latest })) : null,
2081
+ h("button", {
2082
+ type: "button", className: "dbh-vercheck", disabled: checking,
2083
+ title: checkLabel, "aria-label": t("ver.check"),
2084
+ onClick: function () { runUpdateCheck(true); },
2085
+ }, h(RefreshIcon, { size: 12 }))) : null;
2086
+
2087
+ // Update guidance, expanded on demand: the Host names the runtime (web or
2088
+ // desktop) because the two profiles update differently. Neutral panel — the
2089
+ // old red "error" styling made users think something had broken.
2090
+ var updateView = null;
2091
+ if (modern && updateOpen && update.hasUpdate) {
2092
+ updateView = h("div", { className: "dbh-updatepanel" },
2093
+ h("div", { className: "dbh-updaterow" },
2094
+ h("code", { className: "dbh-spec" }, latestSpec),
2095
+ h("button", {
2096
+ type: "button", className: "dbh-linkbtn",
2097
+ title: t("ver.copySpec", { v: update.latest }),
2098
+ onClick: copyLatest,
2099
+ }, copiedKey === "ver" ? t("copy.ok") : t("ver.copy"))),
2100
+ h("div", { className: "dbh-hint" }, runtime === "desktop"
2101
+ ? t("ver.desktopHint", { v: update.latest })
2102
+ : runtime === "web"
2103
+ ? t("ver.webHint", { v: update.latest })
2104
+ : t("ver.genericHint", { v: update.latest })),
2105
+ runtime !== "desktop" && webCmd ? h("div", { className: "dbh-updaterow" },
2106
+ h("code", { className: "dbh-spec" }, webCmd),
2107
+ h("button", { type: "button", className: "dbh-linkbtn", onClick: copyWebCmd },
2108
+ copiedKey === "cmd" ? t("copy.ok") : t("ver.copyCmd"))) : null,
2109
+ h("a", {
2110
+ className: "dbh-link",
2111
+ href: "https://github.com/mr-mihu/dsh-dbhub-live/releases/tag/v" + update.latest,
2112
+ target: "_blank", rel: "noreferrer noopener",
2113
+ }, t("ver.releaseNotes")));
2114
+ }
2115
+
2116
+ // One status line: state + connection count (+ the dialog's 设置 toggle).
2117
+ var statusline = modern ? h("div", { className: "dbh-statusline" },
2118
+ h("span", { className: "dbh-dot" + (enabled ? "" : " dbh-dot-off"), "aria-hidden": true }),
2119
+ h("span", null, statusText),
2120
+ h("span", { className: "dbh-statussep" }, "·"),
2121
+ h("span", null, connSummary),
2122
+ h("span", { className: "dbh-grow" }),
2123
+ !isWide ? h("button", {
2124
+ type: "button", className: "dbh-linkbtn",
2125
+ "aria-expanded": settingsOpen,
2126
+ onClick: toggleSettings,
2127
+ }, t("settings.toggle") + (settingsOpen ? " ▴" : " ▾")) : null) : null;
2128
+ var errorLine = modern && lastError ? h("div", { className: "dbh-error" }, lastError) : null;
2129
+
2130
+ // Technical details live here, collapsed, so the rows above stay actionable.
2131
+ var notesView = h("div", { className: "dbh-notes" },
2132
+ h("div", null, t("note.tools", { n: String(toolCount) })),
2133
+ h("div", null, t("note.update")),
2134
+ h("div", null, t("note.sidebar")),
2135
+ h("div", null, t("note.policy")),
2136
+ h("div", null, t("note.version", {
2137
+ plugin: pluginVersion || "?",
2138
+ dsh: dshVersion || "?",
2139
+ runtime: runtime === "desktop" ? t("ver.runtimeDesktop") : t("ver.runtimeWeb"),
2140
+ })),
2141
+ capHint ? h("div", null, capHint) : null);
2142
+
2143
+ // Shared settings rows: the wide page shows them directly, the dialog behind
2144
+ // its 设置 toggle. Controls are the harness's own Switch primitive; the state
2145
+ // word (显示/隐藏, 启用/停用, 开启/关闭) sits next to it so the row's meaning
2146
+ // never depends on a colour or a tick alone.
2147
+ // The four settings controls, shared by the wide one-line bar and the narrow
2148
+ // stacked rows so both surfaces write through the same callbacks.
2149
+ var ctlEnable = function () {
2150
+ return switchControl({
2151
+ checked: enabled, ariaLabel: t("row.enable"),
2152
+ onChange: function (next) { props.setEnabled(next); },
2153
+ });
2154
+ };
2155
+ var ctlSidebar = function () {
2156
+ return switchControl({
2157
+ checked: draft.showSidebarEntry !== false, ariaLabel: t("row.sidebar"),
2158
+ title: t("cfg.sidebarHint"), onChange: toggleSidebar,
2159
+ });
2160
+ };
2161
+ var ctlPolicy = function () {
2162
+ return switchControl({
2163
+ checked: draft.announceAccessPolicy !== false, ariaLabel: t("row.policy"),
2164
+ title: t("cfg.policyHint"), onChange: togglePolicy,
2165
+ });
2166
+ };
2167
+ var ctlInterval = function () {
2168
+ return [
2169
+ h("input", {
2170
+ key: "input", className: "dbh-input dbh-srow-input", type: "number", min: "0",
2171
+ "aria-label": t("row.update"), title: t("cfg.updateHint"),
2172
+ value: typeof draft.updateIntervalDays === "string" ? draft.updateIntervalDays : "",
2173
+ onChange: function (e) { setSaved(false); setDraft(Object.assign({}, draft, { updateIntervalDays: e.target.value })); },
2174
+ }),
2175
+ h("span", { key: "unit", className: "dbh-srow-unit" }, t("unit.days")),
2176
+ h("button", { key: "save", type: "button", className: "dbh-btn dbh-btn-sm", onClick: saveConfig }, t("btn.save")),
2177
+ ];
2178
+ };
2179
+ var notesButton = function (labelKey) {
2180
+ return h("button", {
2181
+ type: "button", className: "dbh-linkbtn",
2182
+ "aria-expanded": notesOpen,
2183
+ onClick: function () { setNotesOpen(!notesOpen); },
2184
+ }, "ⓘ " + t(notesOpen ? "settings.less" : labelKey));
2185
+ };
2186
+
2187
+ // Wide page: one compact line so the four controls (and their labels) sit
2188
+ // together instead of stretching across the page with a gaping middle. It
2189
+ // wraps only when the docked window is genuinely narrow.
2190
+ var settingsBar = function (narrow) {
2191
+ return h("div", { className: "dbh-settings dbh-settingsbar" + (narrow ? " dbh-barnarrow" : "") },
2192
+ h("div", { className: "dbh-barline" },
2193
+ h("span", { className: "dbh-bartitle" }, t("settings.title")),
2194
+ h("div", { className: "dbh-bargroup" },
2195
+ h("span", { className: "dbh-barlabel" }, t("row.enable")),
2196
+ h("span", { className: "dbh-barstate" }, t(enabled ? "state.enable" : "state.disable")),
2197
+ ctlEnable()),
2198
+ h("div", { className: "dbh-bargroup" },
2199
+ h("span", { className: "dbh-barlabel" }, t("row.sidebar")),
2200
+ h("span", { className: "dbh-barstate" }, t(draft.showSidebarEntry !== false ? "state.show" : "state.hide")),
2201
+ ctlSidebar()),
2202
+ h("div", { className: "dbh-bargroup" },
2203
+ h("span", { className: "dbh-barlabel" }, t("row.policy")),
2204
+ h("span", { className: "dbh-barstate" }, t(draft.announceAccessPolicy !== false ? "state.on" : "state.off")),
2205
+ ctlPolicy()),
2206
+ h("div", { className: "dbh-bargroup" },
2207
+ h("span", { className: "dbh-barlabel" }, t("row.update")),
2208
+ ctlInterval()),
2209
+ h("span", { className: "dbh-grow" }),
2210
+ notesButton("settings.notes"),
2211
+ saved ? h("span", { className: "dbh-saved" }, t("saved.ok")) : null),
2212
+ notesOpen ? notesView : null);
2213
+ };
2214
+
2215
+ // Narrow surfaces (dialog + phone): compact rows, still behind the 设置 toggle.
2216
+ var settingsRows = function () {
2217
+ return h("div", { className: "dbh-settings" },
2218
+ h("div", { className: "dbh-settings-title" }, t("settings.title")),
2219
+ h("div", { className: "dbh-srow" },
2220
+ h("div", { className: "dbh-srow-main" }, h("div", { className: "dbh-srow-title" }, t("row.enable"))),
2221
+ h("div", { className: "dbh-srow-ctl" },
2222
+ h("span", { className: "dbh-srow-state" }, t(enabled ? "state.enable" : "state.disable")),
2223
+ ctlEnable())),
2224
+ h("div", { className: "dbh-srow" },
2225
+ h("div", { className: "dbh-srow-main" }, h("div", { className: "dbh-srow-title" }, t("row.sidebar"))),
2226
+ h("div", { className: "dbh-srow-ctl" },
2227
+ h("span", { className: "dbh-srow-state" }, t(draft.showSidebarEntry !== false ? "state.show" : "state.hide")),
2228
+ ctlSidebar())),
2229
+ h("div", { className: "dbh-srow" },
2230
+ h("div", { className: "dbh-srow-main" }, h("div", { className: "dbh-srow-title" }, t("row.policy"))),
2231
+ h("div", { className: "dbh-srow-ctl" },
2232
+ h("span", { className: "dbh-srow-state" }, t(draft.announceAccessPolicy !== false ? "state.on" : "state.off")),
2233
+ ctlPolicy())),
2234
+ h("div", { className: "dbh-srow" },
2235
+ h("div", { className: "dbh-srow-main" }, h("div", { className: "dbh-srow-title" }, t("row.update"))),
2236
+ h("div", { className: "dbh-srow-ctl" }, ctlInterval())),
2237
+ h("div", { className: "dbh-settings-foot" }, notesButton("settings.more")),
2238
+ notesOpen ? notesView : null);
2239
+ };
2240
+
2241
+ // Legacy (dsh 0.1.x): the original header + settings strip, byte-for-byte.
2242
+ var legacyHead = h("div", { className: "dbh-head" },
1120
2243
  h("div", { className: "dbh-headtext" },
1121
- h("span", { className: "dbh-statustext", title: modeLabel }, statusText),
2244
+ h("span", { className: "dbh-statustext", title: modeLabel }, legacyStatusText),
1122
2245
  lastError ? h("span", { className: "dbh-error" }, lastError) : null),
1123
2246
  h("span", { className: "dbh-cfgsum" }, cfgSummary),
1124
2247
  h("button", { type: "button", className: "dbh-btn", onClick: toggleSettings },
@@ -1128,7 +2251,7 @@ window.__ModuleLoader__.load({ id: "dsh-dbhub-live", factory: (require) => {
1128
2251
  onClick: function () { props.setEnabled(!enabled); },
1129
2252
  }, t(enabled ? "btn.disable" : "btn.enable")));
1130
2253
 
1131
- var settingsPanel = settingsOpen ? h("div", { className: "dbh-strip" },
2254
+ var legacySettingsPanel = settingsOpen ? h("div", { className: "dbh-strip" },
1132
2255
  h("div", { className: "dbh-row" },
1133
2256
  h("span", { className: "dbh-label" }, t("cfg.update")),
1134
2257
  h("input", {
@@ -1140,16 +2263,35 @@ window.__ModuleLoader__.load({ id: "dsh-dbhub-live", factory: (require) => {
1140
2263
  h("span", { className: "dbh-grow" }),
1141
2264
  h("label", { className: "dbh-check", title: t("cfg.sidebarHint") },
1142
2265
  h("input", {
1143
- type: "checkbox",
2266
+ type: "checkbox", className: "dbh-checkbox",
1144
2267
  checked: draft.showSidebarEntry !== false,
1145
2268
  onChange: function (e) { toggleSidebar(e.target.checked); },
1146
2269
  }),
1147
2270
  h("span", null, t("cfg.sidebar")))),
1148
- h("div", { className: "dbh-hint" }, t("cfg.sidebarHint")),
2271
+ h("div", { className: "dbh-row" },
2272
+ h("label", { className: "dbh-check", title: t("cfg.policyHint") },
2273
+ h("input", {
2274
+ type: "checkbox", className: "dbh-checkbox",
2275
+ "aria-label": t("cfg.policy"),
2276
+ checked: draft.announceAccessPolicy !== false,
2277
+ onChange: function (e) { togglePolicy(e.target.checked); },
2278
+ }),
2279
+ h("span", null, t("cfg.policy")))),
2280
+ h("div", { className: "dbh-hint" }, (enabled ? t("cfg.enabledHint") : t("cfg.disabledHint")) + " " + t("cfg.sidebarHint")),
1149
2281
  h("div", { className: "dbh-addfoot" },
1150
2282
  saved ? h("span", { className: "dbh-saved" }, t("saved.ok")) : null,
1151
2283
  h("button", { type: "button", className: "dbh-btn dbh-btn-primary", onClick: saveConfig }, t("btn.save")))) : null;
1152
2284
 
2285
+ // Any non-modal page (left-menu panel, Plugins page) uses the one-line bar,
2286
+ // even when the measured column is narrow: a docked/small desktop window used
2287
+ // to fall back to four stacked rows ("all buttons in a column"). Only the
2288
+ // dialog and the phone keep the stacked form.
2289
+ var useSettingsBar = modern && !isMobile && surface !== "modal";
2290
+ var settingsPanel = modern
2291
+ ? (settingsOpen && !useSettingsBar ? settingsRows() : null)
2292
+ : legacySettingsPanel;
2293
+ var configBlock = useSettingsBar ? settingsBar(!isWide) : null;
2294
+
1153
2295
  var section = h("div", { className: "dbh-sect" },
1154
2296
  h("span", { className: "dbh-secttitle" }, t("conn.title")),
1155
2297
  h("span", { className: "dbh-sectcount" }, t("conn.total", { n: String(workspaces.length), m: String(groups.length) })),
@@ -1239,6 +2381,15 @@ window.__ModuleLoader__.load({ id: "dsh-dbhub-live", factory: (require) => {
1239
2381
  value: addForm.dsn,
1240
2382
  onChange: function (e) { setAddForm(Object.assign({}, addForm, { dsn: e.target.value })); },
1241
2383
  })));
2384
+ // Options come AFTER the DSN input: the offscreen gate pins the field count
2385
+ // and the 0..2 input indices inside the add panel, so nothing may be
2386
+ // inserted before the DSN box.
2387
+ var updateAdd = function (patch) { setAddForm(Object.assign({}, addForm, patch)); };
2388
+ fields.push(h("div", { className: "dbh-row", key: "ro" }, roCheck(addForm, updateAdd, "add")));
2389
+ fields.push(advPanel(addForm, updateAdd, "add", function () {
2390
+ return { ws: addTarget || String(addForm.ws || "").trim(), env: String(addForm.env || "").trim() || "default" };
2391
+ }));
2392
+ if (capHint) fields.push(h("div", { className: "dbh-hint", key: "cap" }, capHint));
1242
2393
  fields.push(h("div", { className: "dbh-addfoot", key: "foot" },
1243
2394
  h("button", { type: "button", className: "dbh-btn", onClick: closeAdd }, t("btn.cancel")),
1244
2395
  h("button", {
@@ -1252,9 +2403,18 @@ window.__ModuleLoader__.load({ id: "dsh-dbhub-live", factory: (require) => {
1252
2403
  var wsList = h("datalist", { id: "dbh-ws-list" },
1253
2404
  groups.map(function (g) { return h("option", { key: g.key, value: g.path }, g.title); }));
1254
2405
 
1255
- return h("div", { className: "dbh-inner" },
1256
- head,
1257
- settingsPanel,
2406
+ var innerClass = "dbh-inner"
2407
+ + (modern ? " dbh-modern" : "")
2408
+ + (modern && isWide ? " dbh-wide" : "")
2409
+ + (modern && isMobile ? " dbh-mobile" : "")
2410
+ + (modern && surface === "modal" ? " dbh-modal" : "");
2411
+
2412
+ return h("div", { className: innerClass, ref: innerRef },
2413
+ modern ? titlebar : legacyHead,
2414
+ modern ? updateView : null,
2415
+ modern ? statusline : null,
2416
+ modern ? errorLine : null,
2417
+ configBlock || settingsPanel,
1258
2418
  section,
1259
2419
  body,
1260
2420
  overwriteView || pretestView,
@@ -1482,21 +2642,24 @@ window.__ModuleLoader__.load({ id: "dsh-dbhub-live", factory: (require) => {
1482
2642
  label: function () { return t("title"); },
1483
2643
  locale: NS,
1484
2644
  inject: function () { return face; },
1485
- }, ConfigPanel);
2645
+ }, SettingsSectionPanel);
1486
2646
  console.log("[dsh-dbhub-live] 设置页入口已注册: settings.section id=dbhub");
1487
2647
  return reg;
1488
2648
  });
1489
- // The sidebar shortcut is optional: the `showSidebarEntry` option (the
1490
- // switch on the settings page) registers and disposes the entry LIVE, so
1491
- // turning it off takes effect without a reload and never locks anyone out
1492
- // (Settings and the Plugins row keep working).
2649
+ // The sidebar shortcut is the AND of two independent things:
2650
+ // · the user's `showSidebarEntry` switch — hide the shortcut while the
2651
+ // plugin keeps working (its tools stay available); and
2652
+ // · the plugin's enabled state — a disabled plugin contributes no entry at
2653
+ // all, whatever the switch says.
2654
+ // The settings section and the Plugins-row card do NOT depend on this, so
2655
+ // neither state can lock anyone out of the configuration.
1493
2656
  var sidebarReg = null;
1494
2657
  var sidebarSlotReady = false;
1495
2658
  var sidebarWanted = function () {
1496
2659
  try {
1497
2660
  var snap = face.getSnapshot();
1498
2661
  var v = snap && typeof snap === "object" && snap.value && typeof snap.value === "object" ? snap.value : {};
1499
- return v.showSidebarEntry !== false;
2662
+ return v.enabled !== false && v.showSidebarEntry !== false;
1500
2663
  } catch (e) {
1501
2664
  return true;
1502
2665
  }
@@ -1534,7 +2697,7 @@ window.__ModuleLoader__.load({ id: "dsh-dbhub-live", factory: (require) => {
1534
2697
  key: "dbhub",
1535
2698
  locale: NS,
1536
2699
  inject: function () { return face; },
1537
- }, ConfigPanel);
2700
+ }, MainPanel);
1538
2701
  });
1539
2702
  face.subscribe(syncSidebar);
1540
2703
  }