@noob-stupid/dsh-plugin-console 0.5.77 → 0.5.87

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -111,6 +111,20 @@ window.__ModuleLoader__.load({
111
111
  safetyLockRepairPartial: "重建后仍有差距,见下方问题",
112
112
  safetyLockNoRebuild: "当前不需要重建",
113
113
  safetyLockCheckBtn: "体检",
114
+ // 0.5.84 加法(用户第 7 项):磁盘体检 / 两步清理
115
+ diskTitle: "磁盘占用(plugin-console)",
116
+ diskDesc: "只读体检:这个目录谁占了多少、哪些能安全清理、哪些绝不能动(当前回滚点指向的备份一律不许删)",
117
+ diskCheckBtn: "磁盘体检",
118
+ diskSummary: "共 {mb} MB({n} 项)· 可直接清理约 {safe} MB · 可清理(含取舍)约 {reclaim} MB",
119
+ diskSafeHint: "只清理「安全」级(日志 / 旧残留 / 状态 .bak):不影响任何功能",
120
+ diskConfirmHint: "确认清理:这一步才会真的删(服务端只删体检白名单里的条目)",
121
+ diskCleanupBtn: "清理安全项",
122
+ diskConfirmBtn: "确认清理(约 {mb} MB)",
123
+ diskPlanReady: "将清理 {n} 项、释放约 {mb} MB —— 确认后才会真的删",
124
+ diskConfirmBtn2: "确认清理",
125
+ diskDone: "已清理",
126
+ diskFailed: "磁盘体检失败",
127
+ diskNoCandidate: "没有可安全清理的条目(大头通常是当前回滚点指向的框架备份,不能删)",
114
128
  safetyLockRepairBtn: "重建 lock(显式)",
115
129
  // 2026-09-27 加法:安装结果旁的「建议动作」执行框(短句进面板,长解释进悬浮 title)
116
130
  actionBoxTitle: "建议动作",
@@ -127,6 +141,32 @@ window.__ModuleLoader__.load({
127
141
  linksToggleBtn: "开发式链接(link)明细",
128
142
  linksToggleSummary: "开发式链接(link):共 {n} 个,自足 {ready} 个",
129
143
  linksToggleTitle: "展开/收起 link 明细(默认收起)",
144
+ // 0.5.84 加法(用户第 6 项):功能包工具栏里的 link 入口 + 它打开的那块面板
145
+ linksBtn: "link",
146
+ linksPanelTitle: "开发式链接(link:)—— 直接展示 /plugin-console/links 的只读清单:指向哪、目标还在不在、依赖/peer 齐不齐、垫片在不在",
147
+ linksPanelLoading: "正在读取…",
148
+ linksPanelReload: "重新读取",
149
+ linksPanelEmpty: "没有 link: 钉住的插件(这份清单只列开发式链接)",
150
+ // 0.5.87 加法(用户 2026-10-18):link 面板从「只读清单」升级为「可操作」——
151
+ // 顶部选开发区 + 两段式钉住;每行按服务端结论给动作(缺依赖 / 缺 peer / 取消钉住)。
152
+ linksDevRootLabel: "开发区:",
153
+ linksDevRootPick: "选择已登记的开发区…",
154
+ linksDirPlaceholder: "要钉住的目录(绝对路径,必须在某个已登记开发区里)",
155
+ linksBrowse: "浏览…",
156
+ linksPinPlanBtn: "钉住",
157
+ linksPinPlanTitle: "{p} 将被钉成 link: 依赖,操作如下(还没动任何东西)",
158
+ linksPinConfirmBtn: "确认钉住",
159
+ linksCancel: "取消",
160
+ linksPinnedDone: "已钉住 {p}",
161
+ linksMtime: "改动于",
162
+ linksMtimeHint: "「改动于」= 目标目录本身或它直接下一层(不含 node_modules / .git)里最近的修改时间;更深的文件改动不会体现在这里",
163
+ linksRepairDeps: "补齐依赖",
164
+ linksRepairShims: "造垫片",
165
+ linksRepairDone: "已补齐",
166
+ linksUnpinBtn: "取消钉住",
167
+ linksUnpinConfirmBtn: "确认取消钉住",
168
+ linksUnpinDone: "已取消钉住",
169
+ linksBusyWorking: "处理中…",
130
170
  devRootsTitle: "开发区根(只有这些根里的目录能钉住)",
131
171
  devRootsNone: "(未登记)",
132
172
  devRootAddPlaceholder: "注册新的开发区根(绝对路径,例如 D:\\my-plugins)",
@@ -251,6 +291,8 @@ window.__ModuleLoader__.load({
251
291
  gatePendingNone: "当前没有待适配的行",
252
292
  gateListPending: "待适配明细({n} 行)",
253
293
  gateListAdopted: "已适配解锁({n} 行,历史上被拦过、现已放行)",
294
+ gateHistoryShow: "展开查看 {n} 条历史",
295
+ gateHistoryHide: "收起历史",
254
296
  gateListMore: "展开全部 {n} 行",
255
297
  gateListFold: "收起列表",
256
298
  gateRowReason: "原因:{reason}",
@@ -317,6 +359,8 @@ window.__ModuleLoader__.load({
317
359
  // 0.5.38 加法:三处没齐备(清单 / lock / 挂载行)时**绝不**说"已安装并启用"——
318
360
  // 那种状态下任何一次 pnpm 操作或重启都会把它清掉(真机 dshmarket 现场)。
319
361
  installedNotPersisted: "⚠ 已装上但未持久化(重启/重装会消失)",
362
+ // 0.5.84 判据降噪:只是「口径不一致」时换这句 —— 不再拿"会消失"吓人
363
+ installedNotPersistedWarn: "已装上;清单 / lock 口径还没对齐(不影响重启后加载,可顺手钉一下)",
320
364
  installNote: "HMR 正在生效;若插件带界面,请刷新页面",
321
365
  bundleNote: "该包是 bundle 层插件(如皮肤合集),重启 DSH 服务后生效",
322
366
  // ── 安装诊断短句(2026-09-26 加法):面板只放短句;解释性长文留在服务端字段里,
@@ -324,6 +368,8 @@ window.__ModuleLoader__.load({
324
368
  diagCardTitle: "安装诊断",
325
369
  // 0.5.38 加法:持久化判据(服务端 domain/persist.js)——三处任一没就位就出这张卡
326
370
  diagNotPersisted: "未持久化:清单 / lock / 挂载行有缺口",
371
+ // 0.5.84 判据降噪:降级后的短句(可折叠提示,不再报红)
372
+ diagPersistDowngraded: "口径不一致(不影响重启后加载)",
327
373
  // 分隔符也进字典:英文界面用半角标点,避免中文全角标点混进英文句子
328
374
  diagColon: ":",
329
375
  diagSemi: ";",
@@ -419,6 +465,7 @@ window.__ModuleLoader__.load({
419
465
  stageConfiguring: "写入启用配置",
420
466
  stageRepairing: "本地 AI 接管安装中…",
421
467
  restartService: "重启服务(官方路径)",
468
+ restartHostedNotice: "本实例由桌面端外壳托管 —— 请在桌面端内重启:完全退出 DeepSeek Harness 再打开(控制台不自行 kill / spawn,以免与宿主争端口)。",
422
469
  restarting: "正在按官方路径处理重启请求…",
423
470
  autoReload: "页面即将自动刷新…",
424
471
  aiRepaired: "本地 AI 已接管并完成修复,请刷新页面查看",
@@ -730,6 +777,19 @@ window.__ModuleLoader__.load({
730
777
  safetyLockRepairPartial: "Still out of sync after the rebuild — see the problems below",
731
778
  safetyLockNoRebuild: "No rebuild needed right now",
732
779
  safetyLockCheckBtn: "Check",
780
+ diskTitle: "Disk usage (plugin-console)",
781
+ diskDesc: "Read-only audit: what fills this directory, what can be cleaned safely, and what must never be touched (the backup the current rollback point refers to is off limits)",
782
+ diskCheckBtn: "Audit disk",
783
+ diskSummary: "{mb} MB total ({n} entries) - about {safe} MB safely cleanable, about {reclaim} MB cleanable with a tradeoff",
784
+ diskSafeHint: "Clean only the safe tier (logs / old residue / state .bak files): no functionality is affected",
785
+ diskConfirmHint: "Confirm cleanup: this is the step that actually deletes (the server only deletes entries from the audit whitelist)",
786
+ diskCleanupBtn: "Clean safe items",
787
+ diskConfirmBtn: "Confirm cleanup (~{mb} MB)",
788
+ diskPlanReady: "Will remove {n} items, about {mb} MB freed - nothing is deleted until you confirm",
789
+ diskConfirmBtn2: "Confirm cleanup",
790
+ diskDone: "Cleaned",
791
+ diskFailed: "Disk audit failed",
792
+ diskNoCandidate: "Nothing is safely cleanable (the largest item is usually the framework backup the current rollback point refers to, which must not be deleted)",
733
793
  safetyLockRepairBtn: "Rebuild lock (explicit)",
734
794
  actionBoxTitle: "Suggested action",
735
795
  actionPinLabel: "Pin this dependency (record as link:)",
@@ -745,6 +805,29 @@ window.__ModuleLoader__.load({
745
805
  linksToggleBtn: "Dev link (link:) details",
746
806
  linksToggleSummary: "Dev links (link:): {n} total, {ready} self-sufficient",
747
807
  linksToggleTitle: "Expand/collapse link details (collapsed by default)",
808
+ linksBtn: "link",
809
+ linksPanelTitle: "Dev links (link:) - the read-only list from /plugin-console/links: where it points, whether the target still exists, whether deps/peers are complete, and whether shims are in place",
810
+ linksPanelLoading: "Reading...",
811
+ linksPanelReload: "Reload",
812
+ linksPanelEmpty: "No plugin is pinned with link: (this list only covers dev links)",
813
+ linksDevRootLabel: "Dev root:",
814
+ linksDevRootPick: "Pick a registered dev root...",
815
+ linksDirPlaceholder: "Directory to pin (absolute path, must be inside a registered dev root)",
816
+ linksBrowse: "Browse...",
817
+ linksPinPlanBtn: "Pin",
818
+ linksPinPlanTitle: "{p} will be pinned as a link: dependency - steps below (nothing has changed yet)",
819
+ linksPinConfirmBtn: "Confirm pin",
820
+ linksCancel: "Cancel",
821
+ linksPinnedDone: "Pinned {p}",
822
+ linksMtime: "changed",
823
+ linksMtimeHint: "'changed' = the newest mtime of the target directory itself or its direct children (node_modules / .git excluded); deeper file edits do not show up here",
824
+ linksRepairDeps: "Install deps",
825
+ linksRepairShims: "Create shims",
826
+ linksRepairDone: "Repaired",
827
+ linksUnpinBtn: "Unpin",
828
+ linksUnpinConfirmBtn: "Confirm unpin",
829
+ linksUnpinDone: "Unpinned",
830
+ linksBusyWorking: "Working...",
748
831
  devRootsTitle: "Dev roots (only directories inside these can be pinned)",
749
832
  devRootsNone: "(none registered)",
750
833
  devRootAddPlaceholder: "Register a dev root (absolute path, e.g. D:\\my-plugins)",
@@ -930,12 +1013,14 @@ window.__ModuleLoader__.load({
930
1013
  recentFailures: "Recent install failures",
931
1014
  installed: "Installed and enabled",
932
1015
  installedNotPersisted: "⚠ Installed but NOT persisted (it disappears on restart/reinstall)",
1016
+ installedNotPersistedWarn: "Installed; manifest / lock are not fully aligned (this does NOT affect loading after a restart - pin it whenever convenient)",
933
1017
  installNote: "HMR is applying; refresh the page if the plugin ships UI",
934
1018
  bundleNote: "This package is a bundle-layer plugin (e.g. skin packs); restart the DSH service to activate it",
935
1019
  // ── install-diagnosis one-liners (2026-09-26, additive): the panel shows short sentences only;
936
1020
  // the long server-side explanations go to the title tooltip, never into the panel body ──
937
1021
  diagCardTitle: "Install diagnosis",
938
1022
  diagNotPersisted: "Not persisted: a gap remains in manifest / lock / mount row",
1023
+ diagPersistDowngraded: "Not aligned (this does not affect loading after a restart)",
939
1024
  // separators live in the dictionary too: English uses half-width punctuation
940
1025
  diagColon: ": ",
941
1026
  diagSemi: "; ",
@@ -1027,6 +1112,7 @@ window.__ModuleLoader__.load({
1027
1112
  stageConfiguring: "Writing enable config",
1028
1113
  stageRepairing: "Local AI is taking over the install…",
1029
1114
  restartService: "Restart service (official path)",
1115
+ restartHostedNotice: "This instance is hosted by the desktop shell - restart it there by fully quitting and reopening DeepSeek Harness.",
1030
1116
  restarting: "Handling the restart request via the official path…",
1031
1117
  autoReload: "Page will auto-reload…",
1032
1118
  aiRepaired: "Local AI took over and finished the repair; refresh the page",
@@ -1947,6 +2033,40 @@ function healthSignature(health) {
1947
2033
  return false;
1948
2034
  }
1949
2035
  }
2036
+ // ★★ 0.5.87 加法:宿主**真的**有没有系统目录对话框?★★
2037
+ // 判据只有一条、且必须是**正面证据**:桌面端外壳通过 contextBridge 注入的
2038
+ // `window.__DSH_DIRECTORY_PICKER__.pick()`(Electron 主进程 dialog.showOpenDialog)。
2039
+ // 为什么不能"先摆一个按钮、点了再说":用户 2026-10-18 的话是「点了没反应」算缺陷 ——
2040
+ // web 端(浏览器直接打开控制台)没有这个桥,那种按钮点下去只会抛个没意义的错。
2041
+ // 所以:有桥才渲染「浏览…」,没有就**不渲染**(不是灰掉,是根本不出现)。
2042
+ // 放在模块级(不放组件里):与 skippedSignature / healthSignature 同一条纪律 ——
2043
+ // 主面板不许依赖"只属于某个子组件"的局部东西。
2044
+ let directoryPicker = null;
2045
+ function detectDirectoryPicker() {
2046
+ try {
2047
+ const bridge = typeof globalThis === "undefined" ? null : globalThis.__DSH_DIRECTORY_PICKER__;
2048
+ if (bridge !== null && typeof bridge === "object" && typeof bridge.pick === "function") return () => bridge.pick();
2049
+ } catch {}
2050
+ return null;
2051
+ }
2052
+ /** 目标目录的修改时间:ISO → 本地可读。解析不了就原样回显 —— 绝不编造一个时间。 */
2053
+ function formatMtimeText(iso) {
2054
+ try {
2055
+ const when = new Date(String(iso));
2056
+ if (Number.isNaN(when.getTime())) return String(iso);
2057
+ return when.toLocaleString();
2058
+ } catch { return String(iso); }
2059
+ }
2060
+ /** 从下拉里选了一个开发区 → 补成"待续写"的草稿路径。
2061
+ * 为什么:**根本身不能当钉住目标**(服务端刻意不允许,领域判据没放宽),
2062
+ * 所以直接塞根本身会让人点了必被拒。补上分隔符,接着写根里的那个插件目录即可。
2063
+ * 分隔符按路径自身风格推断(Windows 的 `\` / 其它 `/`)—— 不猜平台,只看字符串。 */
2064
+ function devRootDraft(root) {
2065
+ const text = String(root ?? "");
2066
+ if (text === "") return "";
2067
+ if (/[\\/]$/u.test(text)) return text;
2068
+ return text + (text.includes("\\") ? "\\" : "/");
2069
+ }
1950
2070
  function PluginConsoleTab({ t, section = null }) {
1951
2071
  const [state, setState] = react.useState({ status: "loading" });
1952
2072
  // ★ 0.5.59:健康摘要**可关掉**(用户 2026-10-17「这个提示可以关掉」)。
@@ -2311,6 +2431,128 @@ function healthSignature(health) {
2311
2431
  });
2312
2432
  // 门控面板(用户要求:总开关收进「功能包」里的 [门控] 按钮,不再占已安装列表的表头)
2313
2433
  const [gateOpen, setGateOpen] = react.useState(false);
2434
+ // ★ 0.5.84 加法(用户第 6 项):功能包工具栏里的 **link 入口**。
2435
+ // 过去开发式链接(link:)清单只长在「升级安全」面板**内部**的折叠块里 —— 得先找到那个入口、
2436
+ // 再展开才看得到;用户要的是从工具栏直接跳到那份数据。数据源与判据与安全面板**完全同一份**
2437
+ // (`GET /plugin-console/links`,纯只读,判据全在 domain/link-inventory.js);
2438
+ // 这里只负责如实展示,不推断、不美化,也不自己算"自足/缺什么"。
2439
+ // 安全面板里那块折叠展示**保留不动**(老入口照旧),这是加法。
2440
+ const [linksPanelOpen, setLinksPanelOpen] = react.useState(false);
2441
+ const [linksPanelData, setLinksPanelData] = react.useState(null);
2442
+ const [linksPanelBusy, setLinksPanelBusy] = react.useState(false);
2443
+ const linksPanelLoad = async () => {
2444
+ if (linksPanelBusy) return;
2445
+ setLinksPanelBusy(true);
2446
+ try {
2447
+ const data = await call("/plugin-console/links");
2448
+ if (data !== null) setLinksPanelData(data);
2449
+ } catch (error) {
2450
+ // 拿不到就如实说(不静默成"没有 link 依赖")
2451
+ setLinksPanelData({ ok: false, items: [], count: 0, readyCount: 0, error: (error && error.message) || t("safetySwitchFailed") });
2452
+ } finally { setLinksPanelBusy(false); }
2453
+ };
2454
+ // ★★ 0.5.87 加法:把 link 面板从「只读清单」升级为「可操作」★★
2455
+ // 边界(写死):**判据全部在服务端**(/links 给 ready / 缺什么 / 目标 / 改动时间),
2456
+ // 这里只做三件事:选开发区 → 两段式钉住(先只读计划、确认后才 apply)、
2457
+ // 行内动作 → 调对应写接口 → 重新读取。客户端不重算任何"缺什么 / 能不能补"。
2458
+ const [linksDevRoots, setLinksDevRoots] = react.useState([]);
2459
+ const [linksDevRootsNote, setLinksDevRootsNote] = react.useState(null);
2460
+ const [linksDir, setLinksDir] = react.useState("");
2461
+ const [linksPinPlan, setLinksPinPlan] = react.useState(null);
2462
+ const [linksPinBusy, setLinksPinBusy] = react.useState(false);
2463
+ const [linksRowBusy, setLinksRowBusy] = react.useState(null);
2464
+ const [linksRowNote, setLinksRowNote] = react.useState({});
2465
+ const [linksUnpinPlan, setLinksUnpinPlan] = react.useState(null);
2466
+ // 「浏览…」只在宿主**确实**提供系统目录对话框时才存在(见模块级 detectDirectoryPicker)
2467
+ const linksCanBrowse = typeof directoryPicker === "function";
2468
+ /** 已登记开发区(来自 /dev-roots 的只读 list 分支;登记表坏了要如实说,不静默成空)。 */
2469
+ const linksDevRootsLoad = async () => {
2470
+ try {
2471
+ const data = await call("/plugin-console/dev-roots", { action: "list" });
2472
+ setLinksDevRoots(Array.isArray(data && data.roots) ? data.roots : []);
2473
+ setLinksDevRootsNote(data && Array.isArray(data.warnings) && data.warnings.length > 0 ? data.warnings.join(";") : null);
2474
+ } catch (error) {
2475
+ setLinksDevRoots([]);
2476
+ setLinksDevRootsNote((error && error.message) || t("safetySwitchFailed"));
2477
+ }
2478
+ };
2479
+ const linksBrowse = async () => {
2480
+ if (!linksCanBrowse) return;
2481
+ try {
2482
+ const picked = await directoryPicker();
2483
+ if (typeof picked === "string" && picked !== "") setLinksDir(picked);
2484
+ } catch (error) {
2485
+ setLinksPinPlan({ ok: false, applied: false, error: (error && error.message) || t("safetySwitchFailed") });
2486
+ }
2487
+ };
2488
+ /** 第一段:**只读计划**(服务端 plan 段,一个字节都不写)——先让人看清会发生什么。 */
2489
+ const linksPinPlanLoad = async () => {
2490
+ const dir = linksDir.trim();
2491
+ if (dir === "" || linksPinBusy) return;
2492
+ setLinksPinBusy(true);
2493
+ setLinksPinPlan(null);
2494
+ try {
2495
+ const data = await call("/plugin-console/pin-dev-dir", { dir });
2496
+ setLinksPinPlan({ ok: true, applied: false, plan: data });
2497
+ } catch (error) {
2498
+ setLinksPinPlan({ ok: false, applied: false, error: (error && error.message) || t("safetySwitchFailed") });
2499
+ } finally { setLinksPinBusy(false); }
2500
+ };
2501
+ /** 第二段:用户点了「确认钉住」才真写(前端绝不跳过确认去调 apply)。 */
2502
+ const linksPinConfirm = async () => {
2503
+ const dir = linksDir.trim();
2504
+ if (dir === "" || linksPinBusy) return;
2505
+ setLinksPinBusy(true);
2506
+ try {
2507
+ const data = await call("/plugin-console/pin-dev-dir", { dir, confirm: true });
2508
+ setLinksPinPlan({ ok: true, applied: true, plan: data });
2509
+ await linksPanelLoad();
2510
+ } catch (error) {
2511
+ setLinksPinPlan({ ok: false, applied: false, error: (error && error.message) || t("safetySwitchFailed") });
2512
+ } finally { setLinksPinBusy(false); }
2513
+ };
2514
+ /** 行内动作:deps=补齐依赖 / shims=造垫片。成败都按服务端 note 原话展示。 */
2515
+ const linksRowRun = async (item, kind) => {
2516
+ if (linksRowBusy !== null) return;
2517
+ const name = String(item.packageName);
2518
+ setLinksRowBusy(name + "|" + kind);
2519
+ setLinksRowNote((prev) => ({ ...prev, [name]: t("linksBusyWorking") }));
2520
+ try {
2521
+ const data = await call("/plugin-console/link-repair", { packageName: name, kind });
2522
+ setLinksRowNote((prev) => ({ ...prev, [name]: (data && data.note) || t("linksRepairDone") }));
2523
+ await linksPanelLoad();
2524
+ } catch (error) {
2525
+ setLinksRowNote((prev) => ({ ...prev, [name]: t("safetySwitchFailed") + ":" + ((error && error.message) || "") }));
2526
+ } finally { setLinksRowBusy(null); }
2527
+ };
2528
+ /** 取消钉住的第一段:只读计划(会明说"它还在 bundles 里"这类后果)。 */
2529
+ const linksUnpinPlanLoad = async (item) => {
2530
+ if (linksRowBusy !== null) return;
2531
+ const name = String(item.packageName);
2532
+ setLinksRowBusy(name + "|unpin-plan");
2533
+ setLinksUnpinPlan(null);
2534
+ try {
2535
+ const data = await call("/plugin-console/unpin-dev-dir", { packageName: name });
2536
+ setLinksUnpinPlan({ ok: true, packageName: name, plan: data });
2537
+ } catch (error) {
2538
+ setLinksUnpinPlan({ ok: false, packageName: name, error: (error && error.message) || t("safetySwitchFailed") });
2539
+ } finally { setLinksRowBusy(null); }
2540
+ };
2541
+ const linksUnpinConfirm = async (item) => {
2542
+ if (linksRowBusy !== null) return;
2543
+ const name = String(item.packageName);
2544
+ setLinksRowBusy(name + "|unpin");
2545
+ try {
2546
+ const data = await call("/plugin-console/unpin-dev-dir", { packageName: name, confirm: true });
2547
+ setLinksUnpinPlan(null);
2548
+ setLinksRowNote((prev) => ({ ...prev, [name]: (data && data.note) || t("linksUnpinDone") }));
2549
+ await linksPanelLoad();
2550
+ } catch (error) {
2551
+ setLinksUnpinPlan({ ok: false, packageName: name, error: (error && error.message) || t("safetySwitchFailed") });
2552
+ } finally { setLinksRowBusy(null); }
2553
+ };
2554
+ // ★ 0.5.80:历史解锁记录默认折叠(用户:新的应覆盖旧的,别一直铺满)
2555
+ const [gateHistoryOpen, setGateHistoryOpen] = react.useState(false);
2314
2556
  // 功能包位置:长按拖动,持久化 localStorage(每次打开沿用上次位置)
2315
2557
  const [toolbarPos, setToolbarPos] = react.useState(() => {
2316
2558
  try {
@@ -3473,7 +3715,13 @@ function healthSignature(health) {
3473
3715
  // 0.5.38 加法(用户诉求「下载下来就不用 lock、重启也不会消失」):
3474
3716
  // 三处没齐备时**绝不报"已安装并启用"** —— 说清它还缺什么、后果是什么,
3475
3717
  // 并把服务端下发的「一键钉住」执行框带出来(下面的 suggestedAction 分支)。
3476
- setMessage(t("installedNotPersisted") + ":" + data.packageName + (typeof data.persistNote === "string" && data.persistNote !== "" ? "。" + data.persistNote : "") + diagMessage(data));
3718
+ // 0.5.84 降噪:只有「会丢插件」那一档才用"重启/重装会消失"这句;只是口径
3719
+ // 不一致(lock 形态、清单与磁盘版本)时用软一点的措辞,原因照样在诊断行里。
3720
+ const sev = data.persist !== null && data.persist !== undefined && typeof data.persist.severity === "object" && data.persist.severity !== null ? data.persist.severity : null;
3721
+ const criticalGap = sev === null ? true : sev.level === "critical";
3722
+ const head = criticalGap ? t("installedNotPersisted") : t("installedNotPersistedWarn");
3723
+ const detail = typeof data.persistNote === "string" && data.persistNote !== "" ? data.persistNote : (typeof sev?.note === "string" ? sev.note : "");
3724
+ setMessage(head + ":" + data.packageName + (detail !== "" ? "。" + detail : "") + diagMessage(data));
3477
3725
  setReloadHint(true);
3478
3726
  } else {
3479
3727
  setMessage(t("installed") + ":" + data.packageName + "(" + (data.entryId ?? "") + ")。" + (data.curlNote ?? (data.bundle ? t("bundleNote") : t("installNote"))) + (data.bundleNote ? "。" + data.bundleNote : "") + (data.lockNote ? "。⚠️ " + data.lockNote : "") + (data.depNote ? "。" + data.depNote : "") + diagMessage(data));
@@ -3624,15 +3872,27 @@ function healthSignature(health) {
3624
3872
  title: channelNotes.join("\n"),
3625
3873
  });
3626
3874
  }
3627
- // ⑤ 持久化判据(0.5.38 加法,服务端 domain/persist.js:{ missing, actual, note }):
3628
- // 三处(清单 spec / pnpm-lock.yaml 条目 / 挂载行)任一没就位 → error 级诊断行,
3875
+ // ⑤ 持久化判据(0.5.38 加法,服务端 domain/persist.js:{ missing, actual, note, severity }):
3876
+ // 三处(清单 spec / pnpm-lock.yaml 条目 / 挂载行)任一没就位 → 诊断行,
3629
3877
  // 完整缺口清单挂 title。**装成功但没钉住**的作业从此不会只显示"已安装并启用"。
3878
+ // ★ 0.5.84 判据降噪(用户 2026-10 反馈:红字太多,真要紧的那条被淹了):
3879
+ // 服务端 persistSeverity() 已把每个缺口分成「会丢插件 / 只是口径不一致」两档,
3880
+ // 这里**只认那一份结论**,不自己重判:
3881
+ // · critical(清单里没有它 / 重启后挂不上)→ error 级(红),并给完整缺口清单;
3882
+ // · warn(lock 形态/目标、清单形态与磁盘版本不一致)→ warn 级(可折叠提示)+
3883
+ // 明说"不影响重启后加载",不再拿红字吓人。
3884
+ // 老服务端没有 severity 字段 → 按 critical 处理(保持 0.5.38 以来的既有行为,不静默降级)。
3630
3885
  if (view.status === "done" && view.persisted === false) {
3886
+ const severity = view.persist !== null && view.persist !== undefined && typeof view.persist.severity === "object" && view.persist.severity !== null ? view.persist.severity : null;
3887
+ const critical = severity === null ? true : severity.level === "critical";
3888
+ const missingText = Array.isArray(view.persist?.missing) && view.persist.missing.length > 0 ? t("diagColon") + view.persist.missing.join(" / ") : "";
3889
+ const longText = typeof view.persistNote === "string" && view.persistNote !== "" ? view.persistNote
3890
+ : (typeof view.persist?.note === "string" ? view.persist.note : null);
3631
3891
  lines.push({
3632
3892
  key: "persist",
3633
- text: t("diagNotPersisted") + (Array.isArray(view.persist?.missing) && view.persist.missing.length > 0 ? t("diagColon") + view.persist.missing.join(" / ") : ""),
3634
- level: "error",
3635
- title: typeof view.persistNote === "string" && view.persistNote !== "" ? view.persistNote : (typeof view.persist?.note === "string" ? view.persist.note : null),
3893
+ text: critical ? t("diagNotPersisted") + missingText : t("diagPersistDowngraded") + missingText,
3894
+ level: critical ? "error" : "warn",
3895
+ title: !critical && typeof severity?.note === "string" && severity.note !== "" ? severity.note : longText,
3636
3896
  });
3637
3897
  }
3638
3898
  return lines;
@@ -3821,7 +4081,7 @@ function healthSignature(health) {
3821
4081
  };
3822
4082
  // subdir 用于「从这个子目录安装」(0.5.47 加法):服务端按它去读 <subdir>/package.json、
3823
4083
  // 稀疏取该子目录源码、再按既有本地目录来源(link:)装。为空时请求体形状与改动前逐字相同。
3824
- const startJob = (repo, packageName, source = "github", kind = "plugin", update = false, subdir = null) => {
4084
+ const startJob = (repo, packageName, source = "github", kind = "plugin", update = false, subdir = null, version = null) => {
3825
4085
  // 防并发(与 addLocal 一致):已有安装/更新进行中时忽略新点击(更新按钮此前可绕过,
3826
4086
  // 反复点击会产生几十个并发任务装同一插件)
3827
4087
  // 只拦**同一个目标**:不同插件可以并行更新(服务端是作业模型,天然支持)
@@ -3836,6 +4096,9 @@ function healthSignature(health) {
3836
4096
  setRestartHint(false);
3837
4097
  setMessage(null);
3838
4098
  const body = { repo: repo ?? "", packageName, source, kind, update };
4099
+ // ★ 0.5.81(用户 2026-10-18):目标版本必须传下去 —— 不带版本时 pnpm 按 latest 把旧版本原地重装,
4100
+ // 表现就是"更新了、刷新后还是老版本"。null/空串一律不带(行为与改动前逐字相同)。
4101
+ if (typeof version === "string" && version !== "") body.version = version;
3839
4102
  if (subdir !== null && subdir !== undefined && String(subdir) !== "") body.subdir = String(subdir);
3840
4103
  call("/plugin-console/install", body).then(
3841
4104
  (data) => {
@@ -4121,7 +4384,8 @@ function healthSignature(health) {
4121
4384
  type: "button",
4122
4385
  className: styles.toggle,
4123
4386
  disabled: installing === entry.moduleName,
4124
- onClick: () => startJob("", entry.moduleName, "github", "plugin", true),
4387
+ // ★ 0.5.81:把服务端算出的目标版本一起传下去(传 null 时行为与改动前逐字相同)
4388
+ onClick: () => startJob("", entry.moduleName, "github", "plugin", true, null, updateCheck.target ?? updateCheck.latest ?? null),
4125
4389
  }, installing === entry.moduleName ? t("installing") : (entry.pendingCompat === true ? t("updateAndAdapt") : t("updateNow")))),
4126
4390
  Array.isArray(updateCheck.depsOutdated) && updateCheck.depsOutdated.length > 0
4127
4391
  ? el("p", { className: styles.status, "data-error": "true" },
@@ -4193,13 +4457,14 @@ function healthSignature(health) {
4193
4457
  el("button", {
4194
4458
  type: "button",
4195
4459
  className: styles.toggle,
4460
+ // ★ 强制启用且未运行 ⇒ 这个开关本身是空操作(patch.js#enableEntry 直接 changed:false),藏起来
4196
4461
  disabled: !entry.toggleable || busy === entry.entryId, // ★ 待适配不再禁用启用(用户 2026-10-18):启用=确认启用,服务端会把它标为已采纳
4197
4462
  // 0.5.34:结论已被当前判据撤回的行不再显示「待适配」(旧判据的误报不许当事实展示)
4198
4463
  title: entry.pendingCompat === true ? (entry.compatRescinded === true ? t("compatRescindedHint") : t("pendingCompatHint")) : undefined,
4199
4464
  onClick: () => toggle(entry, !entry.enabled),
4200
4465
  }, busy === entry.entryId ? t("loadingPhase") : (entry.pendingCompat === true ? (entry.compatRescinded === true ? t("compatRescindedTag") : t("pendingCompatTag")) : t(entry.enabled ? "off" : "on"))),
4201
4466
  // ★ 0.5.76:补丁写了强制开启但运行时没起来 ⇒ 给一个撤销覆盖的入口
4202
- entry.forcedNotLoaded === true ? el("button", {
4467
+ entry.userForced === true ? el("button", {
4203
4468
  type: "button",
4204
4469
  className: styles.toggle,
4205
4470
  title: t("revokeForceHint"),
@@ -4924,7 +5189,122 @@ function healthSignature(health) {
4924
5189
  el("button", { type: "button", className: styles.toggle, onClick: fwStatusRefresh }, t("fwPanelRefreshStatus")),
4925
5190
  el("button", { type: "button", className: styles.toggle, disabled: fwCheckBusy, onClick: () => fwCheckRefresh(true) }, fwCheckBusy ? t("fwPanelChecking") : t("fwPanelRecheck"))),
4926
5191
  el("div", { className: styles.rowTop },
4927
- el("button", { type: "button", className: styles.toggle, onClick: () => { restartServiceViaServer(setMessage); } }, t("restartService")))))
5192
+ hostedByShell
5193
+ ? el("p", { className: styles.status, "data-hosted": "true" }, t("restartHostedNotice"))
5194
+ : el("button", { type: "button", className: styles.toggle, onClick: () => { restartServiceViaServer(setMessage); } }, t("restartService")))))
5195
+ : null,
5196
+ // ★ 0.5.84(用户第 6 项):功能包 → [开发式链接] 的面板:直接展示 /plugin-console/links
5197
+ // 的同一份数据(与「升级安全」里那块折叠展示同源)。判据来自服务端,这里只如实展示:
5198
+ // 每行 = ✓/• + 包名 + 版本 + 指向 + 缺什么(悬停看完整原因)。
5199
+ // ★ 0.5.87 把这份只读清单升级为**可操作**(用户 2026-10-18):顶部选开发区 → 两段式钉住;
5200
+ // 每行按**服务端说的缺什么**给按钮(有哪个显示哪个,没有就不显示),底部照旧「重新读取」。
5201
+ linksPanelOpen
5202
+ ? el("div", { className: styles.modalBackdrop, onClick: (event) => { if (event.target === event.currentTarget) setLinksPanelOpen(false); } },
5203
+ el("div", { className: styles.modalCard, style: { maxWidth: 720 } },
5204
+ el("div", { className: styles.rowTop, style: { justifyContent: "space-between" } },
5205
+ el("strong", { className: styles.name }, t("linksBtn")),
5206
+ el("button", { type: "button", className: styles.trashBtn, style: { fontSize: 14, padding: "4px 8px" }, title: t("closeModal"), "aria-label": t("closeModal"), onClick: () => setLinksPanelOpen(false) }, "✕")),
5207
+ el("p", { className: styles.message }, t("linksPanelTitle")),
5208
+ // ── 顶部:选开发区 → [钉住](先只读计划,用户确认后才真写)────────────────
5209
+ el("div", { style: { display: "flex", flexDirection: "column", gap: 6, marginTop: 6, paddingTop: 8, borderTop: "1px solid var(--dsw-alias-border-secondary, rgba(127,127,127,.18))" } },
5210
+ el("div", { style: { display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" } },
5211
+ el("span", { className: styles.status }, t("linksDevRootLabel")),
5212
+ el("select", {
5213
+ "aria-label": t("linksDevRootLabel"),
5214
+ value: "",
5215
+ disabled: linksPinBusy,
5216
+ onChange: (event) => { const picked = String(event.currentTarget.value ?? ""); if (picked !== "") setLinksDir(devRootDraft(picked)); },
5217
+ style: { flex: "0 1 300px", minWidth: 180, font: "inherit", padding: "4px 6px" },
5218
+ },
5219
+ el("option", { value: "" }, t("linksDevRootPick")),
5220
+ linksDevRoots.map((root) => el("option", { key: "dr-" + String(root), value: String(root) }, String(root))))),
5221
+ el("div", { style: { display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" } },
5222
+ el("input", {
5223
+ type: "text",
5224
+ placeholder: t("linksDirPlaceholder"),
5225
+ value: linksDir,
5226
+ disabled: linksPinBusy,
5227
+ "aria-label": t("linksDirPlaceholder"),
5228
+ onChange: (event) => setLinksDir(String(event.currentTarget.value ?? "")),
5229
+ style: { flex: 1, minWidth: 220, font: "inherit", padding: "4px 8px" },
5230
+ }),
5231
+ // ★ 只在宿主**确实**有系统目录对话框时才渲染(web 端没有 ⇒ 不显示,不是灰掉)
5232
+ linksCanBrowse
5233
+ ? el("button", { type: "button", className: styles.toggle, disabled: linksPinBusy, onClick: linksBrowse }, t("linksBrowse"))
5234
+ : null,
5235
+ el("button", {
5236
+ type: "button",
5237
+ className: styles.toggle,
5238
+ disabled: linksPinBusy || linksDir.trim() === "",
5239
+ onClick: linksPinPlanLoad,
5240
+ }, linksPinBusy ? t("linksPanelLoading") : t("linksPinPlanBtn"))),
5241
+ linksDevRootsNote === null ? null : el("p", { className: styles.status, "data-warn": "true" }, linksDevRootsNote),
5242
+ // 计划 / 结果卡片(两段式的第一段就在这里给人看)
5243
+ linksPinPlan === null
5244
+ ? null
5245
+ : el("div", { style: { display: "flex", flexDirection: "column", gap: 4, fontSize: 12, padding: "6px 8px", border: "1px solid var(--dsw-alias-border-secondary, rgba(127,127,127,.18))", borderRadius: 6 } },
5246
+ linksPinPlan.ok !== true
5247
+ ? el("div", { "data-warn": "true" }, t("safetySwitchFailed") + ":" + String(linksPinPlan.error ?? "-"))
5248
+ : (linksPinPlan.applied === true
5249
+ ? el("div", null,
5250
+ t("linksPinnedDone").replace("{p}", String(linksPinPlan.plan.packageName ?? linksDir.trim()))
5251
+ + (linksPinPlan.plan.after ? " → " + String(linksPinPlan.plan.after) : "")
5252
+ + (linksPinPlan.plan.sufficiency && linksPinPlan.plan.sufficiency.note ? ";" + String(linksPinPlan.plan.sufficiency.note) : ""))
5253
+ : el("div", { style: { display: "flex", flexDirection: "column", gap: 4 } },
5254
+ el("div", { style: { fontWeight: 500 } }, t("linksPinPlanTitle").replace("{p}", String(linksPinPlan.plan.packageName ?? "?"))),
5255
+ (Array.isArray(linksPinPlan.plan.steps) ? linksPinPlan.plan.steps : []).map((step, stepIndex) => el("div", { key: "pin-step-" + stepIndex, style: { opacity: .85 } }, "· " + String(step))),
5256
+ el("div", { style: { display: "flex", gap: 8, marginTop: 4 } },
5257
+ el("button", { type: "button", className: styles.toggle, style: { fontSize: 11, padding: "2px 10px" }, disabled: linksPinBusy, onClick: linksPinConfirm }, t("linksPinConfirmBtn")),
5258
+ el("button", { type: "button", className: styles.toggle, style: { fontSize: 11, padding: "2px 10px" }, disabled: linksPinBusy, onClick: () => setLinksPinPlan(null) }, t("linksCancel"))))))),
5259
+ // ── 清单(每行按服务端结论给动作)──────────────────────────────────────
5260
+ linksPanelData === null
5261
+ ? el("p", { className: styles.status }, t("linksPanelLoading"))
5262
+ : (linksPanelData.ok === false
5263
+ ? el("p", { className: styles.message, "data-warn": "true" }, t("linksFailed") + ":" + String(linksPanelData.error ?? "-"))
5264
+ : el("div", { style: { flexDirection: "column", gap: 4, display: "flex", maxHeight: "52vh", overflow: "auto" } },
5265
+ // ⚠️ 这里**不能**用 fill():它是 PluginSafetyTab 内部的局部助手,
5266
+ // 主面板里出现 fill( 就是 0.5.59 那次「整块空白」事故的根因
5267
+ // (tests/test-ui-contract.mjs ⑨ 与 test-client-scope.mjs ★★ 都把这条钉死了)。
5268
+ // 这里用最朴素的字符串替换,语义与 fill 的两个占位符一致。
5269
+ el("div", { style: { fontSize: 12, fontWeight: 500 } },
5270
+ t("linksTitle").replace("{n}", String(linksPanelData.count ?? 0)).replace("{ready}", String(linksPanelData.readyCount ?? 0))),
5271
+ (Array.isArray(linksPanelData.items) ? linksPanelData.items : []).length === 0
5272
+ ? el("p", { className: styles.status }, t("linksPanelEmpty"))
5273
+ : null,
5274
+ (Array.isArray(linksPanelData.items) ? linksPanelData.items : []).map((x) => el("div", { key: "lp-" + x.packageName, title: [x.note ?? null, t("linksMtimeHint")].filter((v) => typeof v === "string" && v !== "").join("\n") || undefined, style: { fontSize: 12, opacity: .9, wordBreak: "break-all", display: "flex", flexDirection: "column", gap: 3, paddingBottom: 5 } },
5275
+ el("div", null,
5276
+ (x.ready === true ? "✓ " : "• ") + x.packageName + (x.version ? " v" + x.version : "")
5277
+ + (x.target ? " → " + x.target : "")
5278
+ + " · " + t("linksMtime") + " " + (typeof x.targetMtime === "string" && x.targetMtime !== "" ? formatMtimeText(x.targetMtime) : "—")
5279
+ + (Array.isArray(x.missingDeps) && x.missingDeps.length > 0 ? " · 缺依赖 " + x.missingDeps.length : "")
5280
+ + (Array.isArray(x.missingPeers) && x.missingPeers.length > 0 ? " · 缺 peer " + x.missingPeers.length : "")
5281
+ + (x.foreignPeers !== null && x.foreignPeers !== undefined && Array.isArray(x.foreignPeers) && x.foreignPeers.length > 0 ? " · 异副本 " + x.foreignPeers.length : "")
5282
+ + (x.shims !== null && x.shims !== undefined ? " · 垫片 " + String(x.shims.count ?? 0) : "")
5283
+ + (x.persisted !== null && x.persisted !== undefined && x.persisted.ok === false ? " · 未持久化" : "")),
5284
+ // 行内动作:**有哪个显示哪个**(没有就不显示,绝不摆灰按钮)
5285
+ el("div", { style: { display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center" } },
5286
+ Array.isArray(x.missingDeps) && x.missingDeps.length > 0
5287
+ ? el("button", { type: "button", className: styles.toggle, style: { fontSize: 11, padding: "2px 8px" }, disabled: linksRowBusy !== null, onClick: () => linksRowRun(x, "deps") }, t("linksRepairDeps"))
5288
+ : null,
5289
+ Array.isArray(x.shimmablePeers) && x.shimmablePeers.length > 0
5290
+ ? el("button", { type: "button", className: styles.toggle, style: { fontSize: 11, padding: "2px 8px" }, disabled: linksRowBusy !== null, onClick: () => linksRowRun(x, "shims") }, t("linksRepairShims"))
5291
+ : null,
5292
+ el("button", { type: "button", className: styles.toggle, style: { fontSize: 11, padding: "2px 8px" }, disabled: linksRowBusy !== null, onClick: () => linksUnpinPlanLoad(x) }, t("linksUnpinBtn")),
5293
+ linksRowNote[x.packageName] ? el("span", { className: styles.status }, String(linksRowNote[x.packageName])) : null),
5294
+ // 取消钉住的第一段:把"将会发生什么 + 后果"先摆出来(两段式,确认才动手)
5295
+ linksUnpinPlan !== null && linksUnpinPlan.packageName === x.packageName
5296
+ ? el("div", { style: { display: "flex", flexDirection: "column", gap: 3, padding: "4px 6px", border: "1px solid var(--dsw-alias-border-secondary, rgba(127,127,127,.18))", borderRadius: 6 } },
5297
+ linksUnpinPlan.ok !== true
5298
+ ? el("div", { "data-warn": "true" }, t("safetySwitchFailed") + ":" + String(linksUnpinPlan.error ?? "-"))
5299
+ : el("div", { style: { display: "flex", flexDirection: "column", gap: 3 } },
5300
+ (Array.isArray(linksUnpinPlan.plan.steps) ? linksUnpinPlan.plan.steps : []).map((step, unpinStepIndex) => el("div", { key: "unpin-step-" + unpinStepIndex, style: { opacity: .85 } }, "· " + String(step))),
5301
+ (Array.isArray(linksUnpinPlan.plan.warnings) ? linksUnpinPlan.plan.warnings : []).map((warning, warnIndex) => el("div", { key: "unpin-warn-" + warnIndex, "data-warn": "true" }, "! " + String(warning))),
5302
+ el("div", { style: { display: "flex", gap: 8, marginTop: 2 } },
5303
+ el("button", { type: "button", className: styles.toggle, style: { fontSize: 11, padding: "2px 8px" }, disabled: linksRowBusy !== null, onClick: () => linksUnpinConfirm(x) }, t("linksUnpinConfirmBtn")),
5304
+ el("button", { type: "button", className: styles.toggle, style: { fontSize: 11, padding: "2px 8px" }, disabled: linksRowBusy !== null, onClick: () => setLinksUnpinPlan(null) }, t("linksCancel")))))
5305
+ : null)))),
5306
+ el("div", { className: styles.rowTop, style: { justifyContent: "flex-end", gap: 8, marginTop: 10 } },
5307
+ el("button", { type: "button", className: styles.toolItem, "data-busy": linksPanelBusy ? "true" : "false", onClick: () => { linksPanelLoad(); linksDevRootsLoad(); } }, linksPanelBusy ? t("linksPanelLoading") : t("linksPanelReload")))))
4928
5308
  : null,
4929
5309
  gateOpen
4930
5310
  ? el("div", { className: styles.modalBackdrop, onClick: (event) => { if (event.target === event.currentTarget) setGateOpen(false); } },
@@ -4981,9 +5361,17 @@ function healthSignature(health) {
4981
5361
  : null,
4982
5362
  gateRows.adoptedCount > 0
4983
5363
  ? el("div", { style: { flexDirection: "column", gap: 2, display: "flex" } },
4984
- el("strong", { className: styles.name }, t("gateListAdopted").replace("{n}", String(gateRows.adoptedCount))),
4985
- gateRows.adopted.slice(0, 8).map((r) => el("span", { key: "a-" + String(r.rowId), className: styles.status },
4986
- "✓ " + String(r.name) + (r.version ? " v" + r.version : "") + (r.adoptedAt ? " · " + gateTime(r.adoptedAt) : ""))))
5364
+ // ★ 0.5.80:默认折叠,只留一行摘要(点开才列明细)—— 历史不该把当前态挤下去
5365
+ el("button", {
5366
+ type: "button",
5367
+ className: styles.toggle,
5368
+ onClick: () => setGateHistoryOpen((v) => !v),
5369
+ }, (gateHistoryOpen ? t("gateHistoryHide") : t("gateHistoryShow").replace("{n}", String(gateRows.adoptedCount)))
5370
+ + " · " + t("gateListAdopted").replace("{n}", String(gateRows.adoptedCount))),
5371
+ gateHistoryOpen
5372
+ ? gateRows.adopted.map((r) => el("span", { key: "a-" + String(r.rowId), className: styles.status },
5373
+ "✓ " + String(r.name) + (r.version ? " v" + r.version : "") + (r.adoptedAt ? " · " + gateTime(r.adoptedAt) : "")))
5374
+ : null)
4987
5375
  : null)
4988
5376
  : null),
4989
5377
  // 0.5.42 加法(用户点名):右侧贴底的 Star 提示 —— 只加这一行 UI,
@@ -5406,13 +5794,14 @@ function healthSignature(health) {
5406
5794
  }, t("toolbarLabel") + (toolbarOpen ? " ▾" : " ▸")),
5407
5795
  toolbarOpen
5408
5796
  ? el("div", { className: styles.toolbarGroup, style: (dragPos ?? toolbarPos) !== null ? { left: (dragPos ?? toolbarPos).left, top: (dragPos ?? toolbarPos).top + 36, right: "auto" } : undefined },
5409
- el("button", {
5410
- type: "button",
5411
- className: styles.toolItem,
5412
- title: t("restartService"),
5413
- onClick: () => { restartServiceViaServer(setMessage); },
5414
- }, t("restartService")),
5415
- el("button", {
5797
+
5798
+ hostedByShell ? null : el("button", { // ★ 0.5.84:桌面端(shell 托管)不显示;web 端保留且可用
5799
+ type: "button",
5800
+ className: styles.toolItem,
5801
+ title: t("restartService"),
5802
+ onClick: () => { restartServiceViaServer(setMessage); },
5803
+ }, t("restartService")),
5804
+ el("button", {
5416
5805
  type: "button",
5417
5806
  className: styles.toolItem,
5418
5807
  "data-active": repoLandOn ? "true" : "false",
@@ -5448,6 +5837,17 @@ function healthSignature(health) {
5448
5837
  "aria-label": t("sourcesBtn"),
5449
5838
  onClick: openSources,
5450
5839
  }, t("sourcesBtn")),
5840
+ el("button", {
5841
+ type: "button",
5842
+ className: styles.toolItem,
5843
+ "data-active": linksPanelOpen ? "true" : "false",
5844
+ "data-busy": linksPanelBusy ? "true" : "false",
5845
+ title: t("linksPanelTitle"),
5846
+ "aria-label": t("linksBtn"),
5847
+ // ★ 0.5.84(用户第 6 项):打开时拉一次真实清单(与安全面板同一个只读接口)
5848
+ // ★ 0.5.87:同时拉一次「已登记开发区」(钉住要用;没有它下拉就是空的)
5849
+ onClick: () => setLinksPanelOpen((v) => { const next = !v; if (next) { linksPanelLoad(); linksDevRootsLoad(); } return next; }),
5850
+ }, t("linksBtn") + (linksPanelData !== null && Array.isArray(linksPanelData.items) ? " " + linksPanelData.items.length : "")),
5451
5851
  el("button", {
5452
5852
  type: "button",
5453
5853
  className: styles.toolItem,
@@ -6281,6 +6681,45 @@ function healthSignature(health) {
6281
6681
  const [pinInput, setPinInput] = react.useState("");
6282
6682
  const [pinNote, setPinNote] = react.useState(null);
6283
6683
  const [allowAgent, setAllowAgent] = react.useState(false);
6684
+ // ★ 0.5.84 加法(用户第 7 项):磁盘体检 / 清理的状态与两个动作。
6685
+ // 判据全在服务端(domain/disk-audit.js):面板只展示服务端给的 level 与体积,
6686
+ // 并且**必须**经过「先要计划(不带 confirm)→ 再确认(confirm:true)」两步。
6687
+ const [diskAudit, setDiskAudit] = react.useState(null);
6688
+ const [diskPlan, setDiskPlan] = react.useState(null);
6689
+ const [diskBusy, setDiskBusy] = react.useState(false);
6690
+ const [diskNote, setDiskNote] = react.useState(null);
6691
+ const diskCheck = async () => {
6692
+ if (diskBusy) return;
6693
+ setDiskBusy(true); setDiskNote(null); setDiskPlan(null);
6694
+ try {
6695
+ const data = await call("/plugin-console/disk-audit"); // 只读:无 body ⇒ GET
6696
+ setDiskAudit(data ?? null);
6697
+ if (data !== null && data.ok === true) setDiskNote({ text: fill(t("diskSummary"), [["mb", String(data.totalMb ?? 0)], ["n", String((data.entries ?? []).length)], ["safe", String(data.safeMb ?? 0)], ["reclaim", String(data.reclaimableMb ?? 0)]]) + (data.truncated === true ? "(统计已截断)" : ""), level: null });
6698
+ } catch (error) { setDiskNote({ text: String((error && error.message) || error), level: "error" }); }
6699
+ finally { setDiskBusy(false); }
6700
+ };
6701
+ // confirm=false ⇒ 服务端**只回计划**(一个文件都不动);confirm=true 才会真删。
6702
+ const diskCleanup = async (confirm) => {
6703
+ if (diskBusy) return;
6704
+ setDiskBusy(true); setDiskNote(null);
6705
+ try {
6706
+ const ids = confirm && diskPlan !== null && Array.isArray(diskPlan.ids) && diskPlan.ids.length > 0
6707
+ ? diskPlan.ids
6708
+ : (diskAudit !== null && Array.isArray(diskAudit.entries) ? diskAudit.entries.filter((x) => x.level === "safe").map((x) => x.id) : []);
6709
+ const data = await call("/plugin-console/disk-cleanup", { ids: ids, confirm: confirm === true });
6710
+ if (data !== null && data.needsConfirm === true) {
6711
+ // 第一步的回报:把计划(哪些项 / 能省多少)留下,等用户点「确认清理」
6712
+ const plan = data.plan ?? {};
6713
+ setDiskPlan({ ids: (plan.plan ?? []).map((p) => p.id), freedMb: plan.freedMb ?? 0 });
6714
+ setDiskNote({ text: fill(t("diskPlanReady"), [["n", String((plan.plan ?? []).length)], ["mb", String(plan.freedMb ?? 0)]]) + ((plan.refused ?? []).length === 0 ? "" : " · 已拒绝 " + plan.refused.length + " 项"), level: null });
6715
+ } else if (data !== null) {
6716
+ setDiskPlan(null);
6717
+ setDiskNote({ text: String(data.note ?? t("diskDone")), level: data.ok === true ? null : "error" });
6718
+ await diskCheck(); // 删完重新体检,让面板显示真实的新体积
6719
+ }
6720
+ } catch (error) { setDiskNote({ text: String((error && error.message) || error), level: "error" }); }
6721
+ finally { setDiskBusy(false); }
6722
+ };
6284
6723
  const load = async () => {
6285
6724
  try {
6286
6725
  // link 清单:独立 try(拿不到时这块显示"读取失败",绝不静默)
@@ -6530,6 +6969,28 @@ function healthSignature(health) {
6530
6969
  lockFix !== null && lockFix.ok !== true && Array.isArray(lockFix.problems) && lockFix.problems.length > 0
6531
6970
  ? lockFix.problems.map((p) => p.hint).join("\n")
6532
6971
  : undefined),
6972
+ // ★ 0.5.84 加法(用户第 7 项):`<DSH_HOME>/plugin-console` 的**磁盘体检 + 显式确认后清理**。
6973
+ // 两段式(不让人闭眼点):先「磁盘体检」(GET /disk-audit,纯只读)→ 再「清理安全项」
6974
+ // (POST /disk-cleanup,**不带 confirm** ⇒ 服务端只回计划)→ 最后「确认清理」(confirm:true)。
6975
+ // 哪些能删、哪些绝不能删**全由服务端判**(domain/disk-audit.js):真机实测最大的 2.1 GB
6976
+ // 恰恰是当前回滚点指向的备份,面板不自己按体积猜。
6977
+ row("trash", t("diskTitle"),
6978
+ diskAudit === null ? t("diskDesc")
6979
+ : diskAudit.ok !== true ? t("diskFailed") + ":" + String(diskAudit.error ?? "-")
6980
+ : fill(t("diskSummary"), [["mb", String(diskAudit.totalMb ?? 0)], ["n", String((diskAudit.entries ?? []).length)], ["safe", String(diskAudit.safeMb ?? 0)], ["reclaim", String(diskAudit.reclaimableMb ?? 0)]]),
6981
+ el("div", { style: { display: "flex", gap: "8px", flex: "0 0 auto" } },
6982
+ el("button", { type: "button", className: styles.toggle, disabled: busy || diskBusy, title: t("diskDesc"), onClick: diskCheck }, t("diskCheckBtn")),
6983
+ diskAudit !== null && diskAudit.ok === true && (diskAudit.safeBytes ?? 0) > 0
6984
+ ? el("button", { type: "button", className: styles.toggle, disabled: busy || diskBusy, title: t("diskSafeHint"), onClick: () => diskCleanup(false) }, t("diskCleanupBtn"))
6985
+ : null,
6986
+ diskPlan !== null
6987
+ ? el("button", { type: "button", className: styles.toggle, disabled: busy || diskBusy, title: t("diskConfirmHint"), onClick: () => diskCleanup(true) }, fill(t("diskConfirmBtn"), [["mb", String(diskPlan.freedMb ?? 0)]]))
6988
+ : null),
6989
+ diskAudit !== null && diskAudit.ok === true && Array.isArray(diskAudit.entries)
6990
+ ? diskAudit.entries.filter((x) => x.level !== "protected").slice(0, 6)
6991
+ .map((x) => (x.level === "safe" ? "✓ " : "! ") + x.name + " " + x.mb + " MB").join("\n") || t("diskNoCandidate")
6992
+ : undefined),
6993
+ diskNote === null ? null : el("div", { style: { fontSize: "12px", padding: "0 12px 6px", color: diskNote.level === "error" ? "#d33" : "inherit", wordBreak: "break-word" } }, diskNote.text),
6533
6994
  row("pulse", t("safetyAdaptTitle"), t("safetyAdaptDesc"), el("div", { style: { display: "flex", gap: "8px", flex: "0 0 auto" } },
6534
6995
  el("button", { type: "button", className: styles.toggle, disabled: busy, onClick: () => preflight(false) }, t("fwPreflightBtn")),
6535
6996
  el("button", { type: "button", className: styles.toggle, disabled: busy, onClick: () => preflight(true) }, t("fwPreflightPatch")))),
@@ -6556,6 +7017,10 @@ function healthSignature(health) {
6556
7017
  } catch {}
6557
7018
  }
6558
7019
  function apply(ctx) {
7020
+ // ★ 0.5.87:探测宿主有没有系统目录对话框(桌面端外壳有、web 端没有)。
7021
+ // 主面板据此决定**是否渲染**「浏览…」—— 没有就根本不存在这个按钮,
7022
+ // 而不是摆一个点了没反应的(用户 2026-10-18 明确要求)。
7023
+ directoryPicker = detectDirectoryPicker();
6559
7024
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), "plugin-console: dictionaries");
6560
7025
  const t = ctx.locale.bind(NS);
6561
7026
  ctx.slots.inject("settings.plugins.tab", () => ctx.slots.register({
@@ -6599,6 +7064,11 @@ function healthSignature(health) {
6599
7064
  // 仅测试用导出(tests/test-adapt-unlock-gate.mjs ①-2:断言「已适配,立即解锁」按钮的门控判据,
6600
7065
  // 以及它必须与卡片正文用同一个判据)。
6601
7066
  exports.canAdaptUnlock = canAdaptUnlock;
7067
+ // 仅测试用导出(tests/test-links-actions-ui.mjs:假 React **真渲染**主面板,断言 link 面板
7068
+ // 的按钮按服务端结论出现/不出现、「浏览…」只在宿主提供目录对话框时存在、钉住是两段式)。
7069
+ exports.PluginConsoleTab = PluginConsoleTab;
7070
+ // 仅测试用导出(同上:断言「宿主有没有系统目录对话框」这条探测本身)。
7071
+ exports.detectDirectoryPicker = detectDirectoryPicker;
6602
7072
  return module.exports;
6603
7073
  }
6604
7074
  });