@snap-rail/framework-shadcn 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/README.md +20 -2
  2. package/docs/framework-authoring.md +59 -0
  3. package/lib/fallback.js +307 -0
  4. package/lib/index.js +3036 -0
  5. package/lib/runtime.js +2827 -0
  6. package/lib/semantic.js +96 -0
  7. package/lib/session.js +48 -0
  8. package/lib/settings-pages.js +68 -0
  9. package/lib/slots.js +95 -0
  10. package/lib/types/Shell.d.ts +13 -0
  11. package/lib/types/Shell.js +17 -0
  12. package/lib/types/client.d.ts +41 -0
  13. package/lib/types/client.js +39 -0
  14. package/lib/types/fallback.d.ts +15 -0
  15. package/lib/types/fallback.js +52 -0
  16. package/lib/types/index.d.ts +17 -0
  17. package/lib/types/index.js +17 -0
  18. package/lib/types/runtime.d.ts +53 -0
  19. package/lib/types/runtime.js +75 -0
  20. package/lib/types/semantic.d.ts +55 -0
  21. package/lib/types/semantic.js +63 -0
  22. package/lib/types/session.d.ts +43 -0
  23. package/lib/types/session.js +76 -0
  24. package/lib/types/settings-dialog.d.ts +17 -0
  25. package/lib/types/settings-dialog.js +138 -0
  26. package/lib/types/settings-pages.d.ts +68 -0
  27. package/lib/types/settings-pages.js +85 -0
  28. package/lib/types/settings-plugins-page.d.ts +29 -0
  29. package/lib/types/settings-plugins-page.js +466 -0
  30. package/lib/types/settings-update-page.d.ts +17 -0
  31. package/lib/types/settings-update-page.js +124 -0
  32. package/lib/types/slots.d.ts +114 -0
  33. package/lib/types/slots.js +119 -0
  34. package/lib/types/ui/badge.d.ts +17 -0
  35. package/lib/types/ui/badge.js +29 -0
  36. package/lib/types/ui/button.d.ts +20 -0
  37. package/lib/types/ui/button.js +39 -0
  38. package/lib/types/ui/card.d.ts +14 -0
  39. package/lib/types/ui/card.js +18 -0
  40. package/lib/types/ui/checkbox.d.ts +10 -0
  41. package/lib/types/ui/checkbox.js +13 -0
  42. package/lib/types/ui/collapsible.d.ts +24 -0
  43. package/lib/types/ui/collapsible.js +31 -0
  44. package/lib/types/ui/dialog.d.ts +22 -0
  45. package/lib/types/ui/dialog.js +29 -0
  46. package/lib/types/ui/drag-scroll.d.ts +13 -0
  47. package/lib/types/ui/drag-scroll.js +85 -0
  48. package/lib/types/ui/hooks.d.ts +16 -0
  49. package/lib/types/ui/hooks.js +26 -0
  50. package/lib/types/ui/icons.d.ts +21 -0
  51. package/lib/types/ui/icons.js +18 -0
  52. package/lib/types/ui/index.d.ts +32 -0
  53. package/lib/types/ui/index.js +32 -0
  54. package/lib/types/ui/input.d.ts +8 -0
  55. package/lib/types/ui/input.js +6 -0
  56. package/lib/types/ui/label.d.ts +9 -0
  57. package/lib/types/ui/label.js +12 -0
  58. package/lib/types/ui/led.d.ts +13 -0
  59. package/lib/types/ui/led.js +11 -0
  60. package/lib/types/ui/number-input.d.ts +35 -0
  61. package/lib/types/ui/number-input.js +48 -0
  62. package/lib/types/ui/progress.d.ts +10 -0
  63. package/lib/types/ui/progress.js +14 -0
  64. package/lib/types/ui/radio-group.d.ts +12 -0
  65. package/lib/types/ui/radio-group.js +17 -0
  66. package/lib/types/ui/schema-form.d.ts +51 -0
  67. package/lib/types/ui/schema-form.js +124 -0
  68. package/lib/types/ui/scroll-area.d.ts +10 -0
  69. package/lib/types/ui/scroll-area.js +13 -0
  70. package/lib/types/ui/switch.d.ts +10 -0
  71. package/lib/types/ui/switch.js +13 -0
  72. package/lib/types/ui/table.d.ts +20 -0
  73. package/lib/types/ui/table.js +28 -0
  74. package/lib/types/ui/tabs.d.ts +16 -0
  75. package/lib/types/ui/tabs.js +23 -0
  76. package/lib/types/ui/textarea.d.ts +10 -0
  77. package/lib/types/ui/textarea.js +6 -0
  78. package/lib/types/ui/theme.d.ts +30 -0
  79. package/lib/types/ui/theme.js +33 -0
  80. package/lib/types/ui/tooltip.d.ts +23 -0
  81. package/lib/types/ui/tooltip.js +15 -0
  82. package/lib/types/ui/touch-select.d.ts +29 -0
  83. package/lib/types/ui/touch-select.js +37 -0
  84. package/lib/types/ui/utils.d.ts +13 -0
  85. package/lib/types/ui/utils.js +16 -0
  86. package/lib/types/variables.d.ts +168 -0
  87. package/lib/types/variables.js +282 -0
  88. package/lib/types/workflows.d.ts +98 -0
  89. package/lib/types/workflows.js +95 -0
  90. package/lib/ui/index.js +1101 -0
  91. package/lib/ui/theme.css +271 -0
  92. package/lib/variables.js +260 -0
  93. package/lib/workflows.js +85 -0
  94. package/package.json +138 -3
  95. package/src/ui/theme.css +271 -0
  96. package/templates/ui-extension/README.md +17 -0
  97. package/templates/ui-view/README.md +17 -0
package/lib/index.js ADDED
@@ -0,0 +1,3036 @@
1
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
2
+ import { Context } from "@snap-rail/cordis";
3
+ import { TimerService } from "@snap-rail/cordis-plugin-timer";
4
+ import { clsx } from "clsx";
5
+ import { twMerge } from "tailwind-merge";
6
+ import { Fragment as Fragment$1, createContext, createElement, forwardRef, useCallback, useContext, useEffect, useRef, useState } from "react";
7
+ import { Slot } from "@radix-ui/react-slot";
8
+ import { cva } from "class-variance-authority";
9
+ import * as SwitchPrimitive from "@radix-ui/react-switch";
10
+ import "@radix-ui/react-tooltip";
11
+ import "@radix-ui/react-scroll-area";
12
+ import * as DialogPrimitive from "@radix-ui/react-dialog";
13
+ import * as LabelPrimitive from "@radix-ui/react-label";
14
+ import "@radix-ui/react-checkbox";
15
+ import * as ProgressPrimitive from "@radix-ui/react-progress";
16
+ import "@radix-ui/react-radio-group";
17
+ import * as TabsPrimitive from "@radix-ui/react-tabs";
18
+ import "@radix-ui/react-collapsible";
19
+ import { z } from "zod";
20
+ import { rpcErrorText, subscribeFrame } from "@snap-rail/connection";
21
+ import { SESSION_OPERATOR_KEY, settingsChangedSchema } from "@snap-rail/station-rpc/contract";
22
+ import { PLUGINS_FEED_URLS_KEY, UPDATE_AUTO_CHECK_KEY, UPDATE_FEED_URL_KEY, pluginUpdatesChangedSchema, updateStatusSchema } from "@snap-rail/app-boot/contract";
23
+ import { equipmentFrameSchemas, pointKey } from "@snap-rail/equipment/contract";
24
+ import { createRoot } from "react-dom/client";
25
+ //#region lib/types/settings-pages.js
26
+ /**
27
+ * Mounts the `ctx.settingsPages` registry. All state lives in
28
+ * construction-time closures: traceable context proxies rebind `this` on
29
+ * every method access, so `this`-reached state cannot back a service here
30
+ * (the uiSlots lesson).
31
+ */
32
+ const settingsPlugin = {
33
+ name: "client-settings",
34
+ apply(ctx) {
35
+ const pages = /* @__PURE__ */ new Map();
36
+ let nextSeq = 0;
37
+ let open = false;
38
+ let requestedId;
39
+ const firstId = () => [...pages.values()].sort((a, b) => a.def.order - b.def.order || a.seq - b.seq)[0]?.def.id;
40
+ const activeId = () => {
41
+ if (!open) return void 0;
42
+ if (requestedId !== void 0 && pages.has(requestedId)) return requestedId;
43
+ return firstId();
44
+ };
45
+ const openDialog = (id) => {
46
+ if (id !== void 0) requestedId = id;
47
+ if (open && id === void 0) return;
48
+ open = true;
49
+ ctx.emit("settings/open-changed");
50
+ };
51
+ const closeDialog = () => {
52
+ if (!open) return;
53
+ open = false;
54
+ ctx.emit("settings/open-changed");
55
+ };
56
+ ctx.provide("settingsPages", {
57
+ list() {
58
+ const current = activeId();
59
+ return [...pages.values()].sort((a, b) => a.def.order - b.def.order || a.seq - b.seq).map(({ def }) => ({
60
+ ...def,
61
+ active: def.id === current
62
+ }));
63
+ },
64
+ register(caller, def) {
65
+ const replaced = pages.has(def.id);
66
+ pages.set(def.id, {
67
+ def,
68
+ seq: nextSeq++
69
+ });
70
+ ctx.emit(replaced ? "settings/open-changed" : "settings/pages-changed");
71
+ const remove = () => {
72
+ if (pages.delete(def.id)) ctx.emit("settings/pages-changed");
73
+ };
74
+ caller.effect(() => remove);
75
+ return remove;
76
+ },
77
+ open: openDialog,
78
+ close: closeDialog,
79
+ toggle() {
80
+ if (open) closeDialog();
81
+ else openDialog();
82
+ },
83
+ isOpen() {
84
+ return open;
85
+ },
86
+ active: activeId
87
+ });
88
+ }
89
+ };
90
+ //#endregion
91
+ //#region lib/types/slots.js
92
+ /** Stable semantic slot names shared by every layout implementation. */
93
+ const UI_SLOT_IDS = {
94
+ layout: "shell.layout",
95
+ titlebar: "shell.titlebar",
96
+ titlebarActions: "shell.titlebar.actions",
97
+ menu: "shell.menu",
98
+ sidebar: "shell.sidebar",
99
+ main: "shell.main",
100
+ view: "shell.view",
101
+ statusbar: "shell.statusbar",
102
+ overlay: "shell.overlay"
103
+ };
104
+ /** Runtime set used by manifest/semantic validation as well as TypeScript. */
105
+ const STANDARD_UI_SLOT_IDS = new Set(Object.values(UI_SLOT_IDS));
106
+ /** A custom slot is owned by a publisher and a local slot name. */
107
+ const CUSTOM_UI_SLOT_PATTERN = /^custom:[a-z0-9][a-z0-9-]*\.[a-z0-9][a-z0-9-]*$/;
108
+ /** Check the public slot naming contract at runtime. */
109
+ function isValidUiSlotId(value) {
110
+ return STANDARD_UI_SLOT_IDS.has(value) || CUSTOM_UI_SLOT_PATTERN.test(value);
111
+ }
112
+ /**
113
+ * The mode table for the standard slots — the framework owns it.
114
+ *
115
+ * `titlebar` and `main` are `single`: exactly one tenant renders (a skin or
116
+ * replacement displaces the default and falls back on unload). `layout`
117
+ * stays `stack` on purpose — the settings dialog is a permanent overlay
118
+ * occupant beside the active layout, and the fallback shell yields by
119
+ * counting; every other slot collects contributions.
120
+ */
121
+ const UI_SLOT_MODES = {
122
+ [UI_SLOT_IDS.layout]: "stack",
123
+ [UI_SLOT_IDS.titlebar]: "single",
124
+ [UI_SLOT_IDS.titlebarActions]: "stack",
125
+ [UI_SLOT_IDS.menu]: "stack",
126
+ [UI_SLOT_IDS.sidebar]: "stack",
127
+ [UI_SLOT_IDS.main]: "single",
128
+ [UI_SLOT_IDS.view]: "stack",
129
+ [UI_SLOT_IDS.statusbar]: "stack",
130
+ [UI_SLOT_IDS.overlay]: "stack"
131
+ };
132
+ const STANDARD_SLOT_MODES = new Map(Object.entries(UI_SLOT_MODES));
133
+ /** The occupancy mode of a slot; `custom:*` slots are `stack`. */
134
+ function slotModeOf(slot) {
135
+ return STANDARD_SLOT_MODES.get(slot) ?? "stack";
136
+ }
137
+ /** Mounts the `ctx.uiSlots` registry.
138
+ *
139
+ * The facade binds everything to construction-time closures: traceable
140
+ * context proxies rebind `this` on every method access, so `this`-reached
141
+ * state cannot back a service here (mirrors the field seam's external core).
142
+ */
143
+ const slotsPlugin = {
144
+ name: "framework slots",
145
+ apply(ctx) {
146
+ const slots = /* @__PURE__ */ new Map();
147
+ const entryOf = (slot) => {
148
+ let entry = slots.get(slot);
149
+ if (entry === void 0) {
150
+ entry = {
151
+ occupants: /* @__PURE__ */ new Map(),
152
+ nextSeq: 0
153
+ };
154
+ slots.set(slot, entry);
155
+ }
156
+ return entry;
157
+ };
158
+ ctx.provide("uiSlots", {
159
+ list(slot) {
160
+ const ranked = [...entryOf(slot).occupants.values()].sort((a, b) => a.occupant.order - b.occupant.order || a.seq - b.seq);
161
+ if (slotModeOf(slot) === "single" && ranked.length > 0) return [ranked.reduce((best, item) => {
162
+ const byPrecedence = (item.occupant.precedence ?? 0) - (best.occupant.precedence ?? 0);
163
+ return byPrecedence > 0 || byPrecedence === 0 && item.seq > best.seq ? item : best;
164
+ }).occupant];
165
+ return ranked.map((item) => item.occupant);
166
+ },
167
+ register(caller, slot, occupant) {
168
+ const entry = entryOf(slot);
169
+ entry.occupants.set(occupant.id, {
170
+ occupant,
171
+ seq: entry.nextSeq++
172
+ });
173
+ ctx.emit("ui/slot-changed", slot);
174
+ const remove = () => {
175
+ if (entry.occupants.delete(occupant.id)) ctx.emit("ui/slot-changed", slot);
176
+ };
177
+ caller.effect(() => remove);
178
+ return remove;
179
+ }
180
+ });
181
+ }
182
+ };
183
+ //#endregion
184
+ //#region lib/types/ui/utils.js
185
+ /**
186
+ * Class-name combiner: clsx joins, tailwind-merge resolves conflicting
187
+ * Tailwind utilities with the last one winning — the shadcn convention.
188
+ *
189
+ * @module @snap-rail/framework-shadcn/ui/utils
190
+ */
191
+ /**
192
+ * Merge conditional class names into one Tailwind-aware string.
193
+ * @param inputs - class values (strings, arrays, conditional objects).
194
+ * @returns the merged class string.
195
+ */
196
+ function cn(...inputs) {
197
+ return twMerge(clsx(inputs));
198
+ }
199
+ //#endregion
200
+ //#region lib/types/ui/hooks.js
201
+ /**
202
+ * Shared React recipes over the client runtime's event fabric.
203
+ *
204
+ * @module @snap-rail/framework-shadcn/ui/hooks
205
+ */
206
+ /**
207
+ * Re-render the component whenever any of the named ctx events fires —
208
+ * the standard "surface follows the seam" tick. The cordis import is
209
+ * type-only: this module stays a pure React utility at runtime.
210
+ *
211
+ * @param ctx - the plugin context whose events drive the refresh.
212
+ * @param events - event names to listen on (identity is not part of the
213
+ * effect key; a freshly built array literal is fine).
214
+ */
215
+ function useRefresh(ctx, events) {
216
+ const [, setTick] = useState(0);
217
+ const key = events.join("\n");
218
+ useEffect(() => {
219
+ const bump = () => setTick((value) => value + 1);
220
+ const detachers = (key === "" ? [] : key.split("\n")).map((event) => ctx.on(event, bump));
221
+ return () => {
222
+ for (const detach of detachers) detach();
223
+ };
224
+ }, [ctx, key]);
225
+ }
226
+ //#endregion
227
+ //#region lib/types/ui/button.js
228
+ /**
229
+ * Button: cva variants over a native button. Ghost/outline cover chrome
230
+ * surfaces (titlebar, panel toggles); `icon` size squares the padding for
231
+ * glyph-only controls.
232
+ *
233
+ * @module @snap-rail/framework-shadcn/ui/button
234
+ */
235
+ const buttonVariants = cva("inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-base font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0", {
236
+ variants: {
237
+ variant: {
238
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
239
+ destructive: "bg-destructive/90 text-destructive-foreground hover:bg-destructive",
240
+ outline: "border border-border bg-transparent hover:bg-accent hover:text-accent-foreground",
241
+ secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
242
+ ghost: "hover:bg-accent hover:text-accent-foreground",
243
+ link: "text-primary underline-offset-4 hover:underline"
244
+ },
245
+ size: {
246
+ xl: "h-16 px-8 text-lg",
247
+ lg: "h-14 px-6 text-lg",
248
+ default: "h-12 px-5 py-1.5",
249
+ sm: "h-10 px-4 text-sm",
250
+ icon: "h-12 w-12"
251
+ }
252
+ },
253
+ defaultVariants: {
254
+ variant: "default",
255
+ size: "default"
256
+ }
257
+ });
258
+ /** A themed button; `asChild` renders the child element instead. */
259
+ function Button({ className, variant, size, asChild = false, ...props }) {
260
+ return jsx(asChild ? Slot : "button", {
261
+ className: cn(buttonVariants({
262
+ variant,
263
+ size
264
+ }), className),
265
+ ...props
266
+ });
267
+ }
268
+ //#endregion
269
+ //#region lib/types/ui/badge.js
270
+ /**
271
+ * Badge: small status/label chip. `success` carries the online/live state,
272
+ * `warning` the attention state (e.g. partial failure, float type),
273
+ * `destructive` the offline/abnormal one.
274
+ *
275
+ * @module @snap-rail/framework-shadcn/ui/badge
276
+ */
277
+ const badgeVariants = cva("inline-flex items-center gap-1 rounded-md border px-2.5 py-1 text-sm font-medium whitespace-nowrap", {
278
+ variants: { variant: {
279
+ default: "border-transparent bg-primary text-primary-foreground",
280
+ secondary: "border-transparent bg-secondary text-secondary-foreground",
281
+ success: "border-transparent bg-success/15 text-success",
282
+ warning: "border-transparent bg-warning/15 text-warning",
283
+ destructive: "border-transparent bg-destructive/15 text-destructive",
284
+ outline: "text-muted-foreground"
285
+ } },
286
+ defaultVariants: { variant: "default" }
287
+ });
288
+ /** A themed status chip. */
289
+ function Badge({ className, variant, ...props }) {
290
+ return jsx("span", {
291
+ className: cn(badgeVariants({ variant }), className),
292
+ ...props
293
+ });
294
+ }
295
+ //#endregion
296
+ //#region lib/types/ui/switch.js
297
+ /**
298
+ * Switch: the Radix switch with snap-rail chrome — boolean control for
299
+ * toggles like the offline simulation.
300
+ *
301
+ * @module @snap-rail/framework-shadcn/ui/switch
302
+ */
303
+ /** A themed on/off control. */
304
+ function Switch({ className, ...props }) {
305
+ return jsx(SwitchPrimitive.Root, {
306
+ className: cn("peer inline-flex h-6 w-12 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-secondary", className),
307
+ ...props,
308
+ children: jsx(SwitchPrimitive.Thumb, { className: "pointer-events-none block h-5 w-5 rounded-full bg-foreground shadow transition-transform data-[state=checked]:translate-x-6 data-[state=unchecked]:translate-x-1" })
309
+ });
310
+ }
311
+ //#endregion
312
+ //#region lib/types/ui/dialog.js
313
+ /**
314
+ * Dialog: modal overlay + panel over Radix Dialog. Chrome surfaces use it
315
+ * for confirmations (window close, technician id entry); content pages use
316
+ * it for small forms. No built-in close affordance — callers render their
317
+ * own buttons in the footer.
318
+ *
319
+ * @module @snap-rail/framework-shadcn/ui/dialog
320
+ */
321
+ /** Controlled/uncontrolled root; re-exported so callers need one import. */
322
+ const Dialog = DialogPrimitive.Root;
323
+ /** The overlay + centered panel; mounts through a portal into body. */
324
+ function DialogContent({ className, children, ...props }) {
325
+ return jsxs(DialogPrimitive.Portal, { children: [jsx(DialogPrimitive.Overlay, { className: "overlay-in fixed inset-0 z-50 bg-background/80 backdrop-blur-[1px]" }), jsx(DialogPrimitive.Content, {
326
+ className: cn("dialog-in fixed left-1/2 top-1/2 z-50 w-[calc(100%-2.5rem)] max-w-md -translate-x-1/2 -translate-y-1/2", "rounded-lg border border-border bg-card text-card-foreground shadow-xl", "p-5 focus:outline-none", className),
327
+ ...props,
328
+ children
329
+ })] });
330
+ }
331
+ /** The dialog's accessible title. */
332
+ const DialogTitle = DialogPrimitive.Title;
333
+ /** Action row (buttons) at the bottom; defaults to right-aligned. */
334
+ function DialogFooter({ className, ...props }) {
335
+ return jsx("div", {
336
+ className: cn("mt-5 flex justify-end gap-3", className),
337
+ ...props
338
+ });
339
+ }
340
+ //#endregion
341
+ //#region lib/types/ui/input.js
342
+ /** A themed text input. */
343
+ function Input({ className, type = "text", ...props }) {
344
+ return jsx("input", {
345
+ type,
346
+ className: cn("flex h-12 w-full rounded-md border border-input bg-transparent px-4 py-2 text-base", "placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", "disabled:cursor-not-allowed disabled:opacity-50", className),
347
+ ...props
348
+ });
349
+ }
350
+ //#endregion
351
+ //#region lib/types/ui/label.js
352
+ /**
353
+ * Label: form field caption over Radix Label (click focuses the control).
354
+ *
355
+ * @module @snap-rail/framework-shadcn/ui/label
356
+ */
357
+ /** A themed field label. */
358
+ function Label({ className, ...props }) {
359
+ return jsx(LabelPrimitive.Root, {
360
+ className: cn("text-sm font-medium text-muted-foreground select-none", className),
361
+ ...props
362
+ });
363
+ }
364
+ //#endregion
365
+ //#region lib/types/ui/progress.js
366
+ /**
367
+ * Progress: the Radix progress bar with snap-rail chrome — determinate
368
+ * download/scan progress for long-running host work.
369
+ *
370
+ * @module @snap-rail/framework-shadcn/ui/progress
371
+ */
372
+ /** A themed linear progress bar (`value` 0–100, `aria-valuenow` included). */
373
+ function Progress({ className, value, ...props }) {
374
+ const percent = typeof value === "number" ? Math.min(100, Math.max(0, value)) : 0;
375
+ return jsx(ProgressPrimitive.Root, {
376
+ className: cn("relative h-2 w-full overflow-hidden rounded-full bg-secondary", className),
377
+ ...props,
378
+ children: jsx(ProgressPrimitive.Indicator, {
379
+ className: "h-full w-full rounded-full bg-primary transition-transform",
380
+ style: { transform: `translateX(-${100 - percent}%)` }
381
+ })
382
+ });
383
+ }
384
+ //#endregion
385
+ //#region lib/types/ui/drag-scroll.js
386
+ /**
387
+ * DragScroll: an overflow-auto container that also pans when held and
388
+ * dragged with the mouse (both axes). Touch and pen keep Chromium's native
389
+ * pan, so only mouse pointers are intercepted. A 6px threshold separates a
390
+ * pan from a press; the click that follows a pan is swallowed so dragged
391
+ * rows never fire. DragScrolls nest (a table inside a scrolled page); only
392
+ * the innermost container under the pressed target takes the gesture.
393
+ *
394
+ * @module @snap-rail/framework-shadcn/ui/drag-scroll
395
+ */
396
+ /** Movement (px) before a held press turns into a pan. */
397
+ const PAN_THRESHOLD = 6;
398
+ /** An auto-scrolling div that pans under a held mouse drag. */
399
+ function DragScroll({ className, children, ...props }) {
400
+ const container = useRef(null);
401
+ const pan = useRef(null);
402
+ const swallowClick = useRef(false);
403
+ const onPointerDown = (event) => {
404
+ if (event.pointerType !== "mouse" || event.button !== 0) return;
405
+ if (container.current === null) return;
406
+ const innermost = event.target instanceof Element ? event.target.closest("[data-drag-scroll]") : null;
407
+ if (innermost !== null && innermost !== container.current) return;
408
+ pan.current = {
409
+ id: event.pointerId,
410
+ x: event.clientX,
411
+ y: event.clientY,
412
+ moved: false
413
+ };
414
+ };
415
+ const onPointerMove = (event) => {
416
+ const state = pan.current;
417
+ if (state === null || state.id !== event.pointerId) return;
418
+ if (event.buttons === 0) {
419
+ pan.current = null;
420
+ return;
421
+ }
422
+ const el = container.current;
423
+ if (el === null) return;
424
+ const dx = state.x - event.clientX;
425
+ const dy = state.y - event.clientY;
426
+ if (!state.moved) {
427
+ if (Math.hypot(dx, dy) < PAN_THRESHOLD) return;
428
+ state.moved = true;
429
+ el.setPointerCapture(event.pointerId);
430
+ el.dataset.panning = "true";
431
+ }
432
+ el.scrollLeft += dx;
433
+ el.scrollTop += dy;
434
+ state.x = event.clientX;
435
+ state.y = event.clientY;
436
+ };
437
+ const endPan = (event) => {
438
+ const state = pan.current;
439
+ if (state === null || state.id !== event.pointerId) return;
440
+ pan.current = null;
441
+ const el = container.current;
442
+ if (el === null) return;
443
+ if (state.moved) {
444
+ if (el.hasPointerCapture(event.pointerId)) el.releasePointerCapture(event.pointerId);
445
+ delete el.dataset.panning;
446
+ swallowClick.current = true;
447
+ }
448
+ };
449
+ return jsx("div", {
450
+ ref: container,
451
+ "data-drag-scroll": "",
452
+ className: cn("overflow-auto", className),
453
+ onPointerDown,
454
+ onPointerMove,
455
+ onPointerUp: endPan,
456
+ onPointerCancel: endPan,
457
+ onClickCapture: (event) => {
458
+ if (!swallowClick.current) return;
459
+ swallowClick.current = false;
460
+ event.preventDefault();
461
+ event.stopPropagation();
462
+ },
463
+ ...props,
464
+ children
465
+ });
466
+ }
467
+ //#endregion
468
+ //#region lib/types/ui/table.js
469
+ /** The grid container with a hairline bottom rule; the wrapper pans under
470
+ * a held mouse drag (see {@link DragScroll}). */
471
+ function Table({ className, ...props }) {
472
+ return jsx(DragScroll, {
473
+ className: "w-full",
474
+ children: jsx("table", {
475
+ className: cn("w-full caption-bottom text-sm", className),
476
+ ...props
477
+ })
478
+ });
479
+ }
480
+ /** Header group. */
481
+ function TableHeader({ className, ...props }) {
482
+ return jsx("thead", {
483
+ className: cn("[&_tr]:border-b", className),
484
+ ...props
485
+ });
486
+ }
487
+ /** Body group. */
488
+ function TableBody({ className, ...props }) {
489
+ return jsx("tbody", {
490
+ className: cn("[&_tr:last-child]:border-0", className),
491
+ ...props
492
+ });
493
+ }
494
+ /** One row: hairline separators, hover affordance when interactive. */
495
+ function TableRow({ className, ...props }) {
496
+ return jsx("tr", {
497
+ className: cn("border-b border-border transition-colors hover:bg-accent/40 data-[state=selected]:bg-accent", className),
498
+ ...props
499
+ });
500
+ }
501
+ /** One column head: muted, left-aligned unless overridden. */
502
+ function TableHead({ className, ...props }) {
503
+ return jsx("th", {
504
+ className: cn("h-11 px-3 text-left align-middle text-sm font-medium text-muted-foreground", className),
505
+ ...props
506
+ });
507
+ }
508
+ /** One cell. */
509
+ function TableCell({ className, ...props }) {
510
+ return jsx("td", {
511
+ className: cn("px-3 py-3 align-middle", className),
512
+ ...props
513
+ });
514
+ }
515
+ //#endregion
516
+ //#region lib/types/ui/tabs.js
517
+ /**
518
+ * Tabs: themed pager over Radix Tabs (list, trigger, content). Icons are
519
+ * inline strokes so the UI seam needs no icon dependency.
520
+ *
521
+ * @module @snap-rail/framework-shadcn/ui/tabs
522
+ */
523
+ /** Controlled/uncontrolled root; re-exported so callers need one import. */
524
+ const Tabs = TabsPrimitive.Root;
525
+ /** The tab strip; triggers rest on the muted surface, the active one lifts. */
526
+ function TabsList({ className, ...props }) {
527
+ return jsx(TabsPrimitive.List, {
528
+ className: cn("inline-flex h-12 items-center gap-1 rounded-lg bg-muted p-1 text-muted-foreground", className),
529
+ ...props
530
+ });
531
+ }
532
+ /** One tab button; the data attributes carry the active/hover styling. */
533
+ function TabsTrigger({ className, ...props }) {
534
+ return jsx(TabsPrimitive.Trigger, {
535
+ className: cn("inline-flex items-center gap-1.5 whitespace-nowrap rounded-md px-5 py-1.5 text-base font-medium", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", "disabled:pointer-events-none disabled:opacity-50", "data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm", className),
536
+ ...props
537
+ });
538
+ }
539
+ //#endregion
540
+ //#region lib/types/ui/theme.js
541
+ /**
542
+ * Theme settings: the `ui.theme` settings.json row (a mode choice — the
543
+ * cobalt accent is fixed per mode) and its DOM application. The renderer
544
+ * resident that owns the 主题 settings page reads and writes the row; the
545
+ * `settings/changed` frame hot-applies it on every surface without a
546
+ * restart.
547
+ *
548
+ * @module @snap-rail/framework-shadcn/ui/theme
549
+ */
550
+ /** settings.json key carrying the theme row. */
551
+ const THEME_SETTINGS_KEY = "ui.theme";
552
+ /** The theme row; invalid stored rows fall back to {@link DEFAULT_THEME}. */
553
+ const themeSettingsSchema = z.object({ mode: z.enum([
554
+ "system",
555
+ "light",
556
+ "dark"
557
+ ]) });
558
+ /** Applied before any settings arrive; keeps the terminal dark as before. */
559
+ const DEFAULT_THEME = { mode: "dark" };
560
+ /** The mode a setting resolves to (`system` consults the OS preference). */
561
+ function resolveThemeMode(mode, prefersLight) {
562
+ if (mode === "system") return prefersLight ? "light" : "dark";
563
+ return mode;
564
+ }
565
+ /** Apply a theme row: sets `data-mode` on the document element (the token
566
+ * blocks in theme.css key off it). A missing matchMedia — as in tests —
567
+ * resolves `system` to dark. */
568
+ function applyTheme(document, theme) {
569
+ const view = document.defaultView;
570
+ const prefersLight = view !== null && typeof view.matchMedia === "function" && view.matchMedia("(prefers-color-scheme: light)").matches;
571
+ document.documentElement.dataset.mode = resolveThemeMode(theme.mode, prefersLight);
572
+ }
573
+ //#endregion
574
+ //#region lib/types/fallback.js
575
+ /** The settings dialog's occupant id (a Dialog portal, not a layout). */
576
+ const SETTINGS_OCCUPANT_ID = "settings-dialog";
577
+ /** Window-control glyphs; inline so the shell needs no icon dependency. */
578
+ function ControlIcons(props) {
579
+ const base = "flex h-10 w-10 items-center justify-center text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground";
580
+ return jsxs("div", {
581
+ className: "flex items-center",
582
+ children: [
583
+ jsx("button", {
584
+ type: "button",
585
+ "aria-label": "最小化",
586
+ className: base,
587
+ onClick: props.onMinimize,
588
+ children: jsx("svg", {
589
+ viewBox: "0 0 12 12",
590
+ className: "h-3 w-3",
591
+ fill: "none",
592
+ children: jsx("path", {
593
+ d: "M2 6h8",
594
+ stroke: "currentColor",
595
+ strokeWidth: "1.4"
596
+ })
597
+ })
598
+ }),
599
+ jsx("button", {
600
+ type: "button",
601
+ "aria-label": "最大化",
602
+ className: base,
603
+ onClick: props.onToggleMaximize,
604
+ children: jsx("svg", {
605
+ viewBox: "0 0 12 12",
606
+ className: "h-3 w-3",
607
+ fill: "none",
608
+ children: jsx("rect", {
609
+ x: "2.5",
610
+ y: "2.5",
611
+ width: "7",
612
+ height: "7",
613
+ stroke: "currentColor",
614
+ strokeWidth: "1.2"
615
+ })
616
+ })
617
+ }),
618
+ jsx("button", {
619
+ type: "button",
620
+ "aria-label": "关闭",
621
+ className: `${base} hover:bg-destructive hover:text-white`,
622
+ onClick: props.onClose,
623
+ children: jsx("svg", {
624
+ viewBox: "0 0 12 12",
625
+ className: "h-3 w-3",
626
+ fill: "none",
627
+ children: jsx("path", {
628
+ d: "m3 3 6 6M9 3l-6 6",
629
+ stroke: "currentColor",
630
+ strokeWidth: "1.4"
631
+ })
632
+ })
633
+ })
634
+ ]
635
+ });
636
+ }
637
+ /** The fallback body: minimal titlebar + install guidance. */
638
+ function FallbackShell(props) {
639
+ useRefresh(props.ctx, ["ui/slot-changed", "settings/open-changed"]);
640
+ if (props.ctx.uiSlots.list(UI_SLOT_IDS.layout).filter((occupant) => occupant.id !== SETTINGS_OCCUPANT_ID).length > 1) return null;
641
+ const windowControl = (action) => {
642
+ props.link.call("window.control", { action }).catch(() => void 0);
643
+ };
644
+ return jsxs("div", {
645
+ className: "flex h-full flex-col",
646
+ "data-region": "fallback-shell",
647
+ children: [jsxs("div", {
648
+ className: "flex h-10 shrink-0 items-center justify-between border-b border-border pl-4 select-none",
649
+ style: { WebkitAppRegion: "drag" },
650
+ children: [jsx("span", {
651
+ className: "text-sm text-muted-foreground",
652
+ children: "snap-rail"
653
+ }), jsxs("div", {
654
+ className: "flex items-center",
655
+ style: { WebkitAppRegion: "no-drag" },
656
+ children: [
657
+ props.ctx.uiSlots.list(UI_SLOT_IDS.titlebarActions).map((occupant) => jsx(Fragment$1, { children: occupant.render() }, occupant.id)),
658
+ jsx("button", {
659
+ type: "button",
660
+ "aria-label": "设置",
661
+ className: "flex h-10 w-10 items-center justify-center text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground",
662
+ onClick: () => props.ctx.settingsPages.open(),
663
+ children: jsxs("svg", {
664
+ viewBox: "0 0 24 24",
665
+ className: "h-5 w-5",
666
+ fill: "none",
667
+ stroke: "currentColor",
668
+ strokeWidth: "1.6",
669
+ children: [jsx("circle", {
670
+ cx: "12",
671
+ cy: "12",
672
+ r: "3.2"
673
+ }), jsx("path", { d: "M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-1.87-.34 1.7 1.7 0 0 0-1 1.55V21a2 2 0 1 1-4 0v-.09a1.7 1.7 0 0 0-1-1.55 1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.7 1.7 0 0 0 .34-1.87 1.7 1.7 0 0 0-1.55-1H3a2 2 0 1 1 0-4h.09a1.7 1.7 0 0 0 1.55-1 1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.7 1.7 0 0 0 1.87.34h0a1.7 1.7 0 0 0 1-1.55V3a2 2 0 1 1 4 0v.09a1.7 1.7 0 0 0 1 1.55h0a1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.7 1.7 0 0 0-.34 1.87v0a1.7 1.7 0 0 0 1.55 1H21a2 2 0 1 1 0 4h-.09a1.7 1.7 0 0 0-1.55 1Z" })]
674
+ })
675
+ }),
676
+ jsx(ControlIcons, {
677
+ onMinimize: () => windowControl("minimize"),
678
+ onToggleMaximize: () => windowControl("toggle-maximize"),
679
+ onClose: () => windowControl("close")
680
+ })
681
+ ]
682
+ })]
683
+ }), jsx("div", {
684
+ className: "flex min-h-0 flex-1 items-center justify-center p-6",
685
+ children: jsxs("div", {
686
+ className: "grid max-w-md gap-4 text-center",
687
+ children: [
688
+ jsx("div", {
689
+ className: "text-xl font-medium text-foreground",
690
+ children: "尚未激活业务套件"
691
+ }),
692
+ jsx("p", {
693
+ className: "text-sm text-muted-foreground",
694
+ children: "打开设置的「插件管理」页,安装并激活一个业务套件;通讯驱动也在同一处安装。"
695
+ }),
696
+ jsx("div", { children: jsx(Button, {
697
+ "data-fallback-open-settings": true,
698
+ onClick: () => props.ctx.settingsPages.open(),
699
+ children: "打开设置"
700
+ }) })
701
+ ]
702
+ })
703
+ })]
704
+ });
705
+ }
706
+ /** The fallback shell occupant. */
707
+ const fallbackShellPlugin = {
708
+ name: "framework fallback",
709
+ inject: ["uiSlots", "settingsPages"],
710
+ apply(ctx) {
711
+ const client = ctx.get("client");
712
+ ctx.uiSlots.register(ctx, UI_SLOT_IDS.layout, {
713
+ id: "fallback-shell",
714
+ order: 200,
715
+ render() {
716
+ return jsx(FallbackShell, {
717
+ ctx,
718
+ link: client.link
719
+ });
720
+ }
721
+ });
722
+ }
723
+ };
724
+ //#endregion
725
+ //#region lib/types/semantic.js
726
+ /** Mount the semantic contribution registry on top of the slot registry. */
727
+ const semanticPlugin = {
728
+ name: "framework semantic ui contract",
729
+ inject: ["uiSlots"],
730
+ apply(ctx) {
731
+ const slots = ctx.uiSlots;
732
+ const views = /* @__PURE__ */ new Map();
733
+ const layouts = /* @__PURE__ */ new Map();
734
+ const navigation = /* @__PURE__ */ new Map();
735
+ let sequence = 0;
736
+ const ordered = (values) => [...values].sort((a, b) => a.value.order - b.value.order || a.seq - b.seq).map((item) => item.value);
737
+ const register = (table, caller, id, value, disposeSlot) => {
738
+ table.set(id, {
739
+ value,
740
+ seq: sequence++
741
+ });
742
+ const kind = table === views ? "view" : table === layouts ? "layout" : "navigation";
743
+ ctx.emit("ui/semantic-changed", kind);
744
+ const remove = () => {
745
+ if (!table.delete(id)) return;
746
+ disposeSlot?.();
747
+ ctx.emit("ui/semantic-changed", kind);
748
+ };
749
+ caller.effect(() => remove);
750
+ return remove;
751
+ };
752
+ const validateSlot = (kind, id, slot) => {
753
+ if (!isValidUiSlotId(slot)) ctx.emit("ui/semantic-invalid", kind, id, slot);
754
+ };
755
+ ctx.provide("uiContract", {
756
+ registerView(caller, contribution) {
757
+ validateSlot("view", contribution.id, contribution.slot);
758
+ const disposeSlot = slots.register(caller, contribution.slot, contribution);
759
+ return register(views, caller, contribution.id, contribution, disposeSlot);
760
+ },
761
+ registerLayout(caller, contribution) {
762
+ for (const slot of contribution.slots) validateSlot("layout", contribution.id, slot);
763
+ const disposeSlot = slots.register(caller, UI_SLOT_IDS.layout, contribution);
764
+ return register(layouts, caller, contribution.id, contribution, disposeSlot);
765
+ },
766
+ registerNavigation(caller, contribution) {
767
+ return register(navigation, caller, contribution.id, contribution);
768
+ },
769
+ listViews: () => ordered(views.values()),
770
+ listLayouts: () => ordered(layouts.values()),
771
+ listNavigation: () => ordered(navigation.values())
772
+ });
773
+ }
774
+ };
775
+ //#endregion
776
+ //#region ../../node_modules/.pnpm/lucide-react@1.34.0_react@19.2.8/node_modules/lucide-react/dist/esm/shared/src/utils/mergeClasses.mjs
777
+ /**
778
+ * @license lucide-react v1.34.0 - ISC
779
+ *
780
+ * This source code is licensed under the ISC license.
781
+ * See the LICENSE file in the root directory of this source tree.
782
+ */
783
+ const mergeClasses = (...classes) => classes.filter((className, index, array) => {
784
+ return Boolean(className) && className.trim() !== "" && array.indexOf(className) === index;
785
+ }).join(" ").trim();
786
+ //#endregion
787
+ //#region ../../node_modules/.pnpm/lucide-react@1.34.0_react@19.2.8/node_modules/lucide-react/dist/esm/shared/src/utils/toKebabCase.mjs
788
+ /**
789
+ * @license lucide-react v1.34.0 - ISC
790
+ *
791
+ * This source code is licensed under the ISC license.
792
+ * See the LICENSE file in the root directory of this source tree.
793
+ */
794
+ const toKebabCase = (string) => string.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
795
+ //#endregion
796
+ //#region ../../node_modules/.pnpm/lucide-react@1.34.0_react@19.2.8/node_modules/lucide-react/dist/esm/shared/src/utils/toCamelCase.mjs
797
+ /**
798
+ * @license lucide-react v1.34.0 - ISC
799
+ *
800
+ * This source code is licensed under the ISC license.
801
+ * See the LICENSE file in the root directory of this source tree.
802
+ */
803
+ const toCamelCase = (string) => string.replace(/^([A-Z])|[\s-_]+(\w)/g, (match, p1, p2) => p2 ? p2.toUpperCase() : p1.toLowerCase());
804
+ //#endregion
805
+ //#region ../../node_modules/.pnpm/lucide-react@1.34.0_react@19.2.8/node_modules/lucide-react/dist/esm/shared/src/utils/toPascalCase.mjs
806
+ /**
807
+ * @license lucide-react v1.34.0 - ISC
808
+ *
809
+ * This source code is licensed under the ISC license.
810
+ * See the LICENSE file in the root directory of this source tree.
811
+ */
812
+ const toPascalCase = (string) => {
813
+ const camelCase = toCamelCase(string);
814
+ return camelCase.charAt(0).toUpperCase() + camelCase.slice(1);
815
+ };
816
+ //#endregion
817
+ //#region ../../node_modules/.pnpm/lucide-react@1.34.0_react@19.2.8/node_modules/lucide-react/dist/esm/defaultAttributes.mjs
818
+ /**
819
+ * @license lucide-react v1.34.0 - ISC
820
+ *
821
+ * This source code is licensed under the ISC license.
822
+ * See the LICENSE file in the root directory of this source tree.
823
+ */
824
+ var defaultAttributes = {
825
+ xmlns: "http://www.w3.org/2000/svg",
826
+ width: 24,
827
+ height: 24,
828
+ viewBox: "0 0 24 24",
829
+ fill: "none",
830
+ stroke: "currentColor",
831
+ strokeWidth: 2,
832
+ strokeLinecap: "round",
833
+ strokeLinejoin: "round"
834
+ };
835
+ //#endregion
836
+ //#region ../../node_modules/.pnpm/lucide-react@1.34.0_react@19.2.8/node_modules/lucide-react/dist/esm/shared/src/utils/hasA11yProp.mjs
837
+ /**
838
+ * @license lucide-react v1.34.0 - ISC
839
+ *
840
+ * This source code is licensed under the ISC license.
841
+ * See the LICENSE file in the root directory of this source tree.
842
+ */
843
+ const hasA11yProp = (props) => {
844
+ for (const prop in props) if (prop.startsWith("aria-") || prop === "role" || prop === "title") return true;
845
+ return false;
846
+ };
847
+ //#endregion
848
+ //#region ../../node_modules/.pnpm/lucide-react@1.34.0_react@19.2.8/node_modules/lucide-react/dist/esm/context.mjs
849
+ /**
850
+ * @license lucide-react v1.34.0 - ISC
851
+ *
852
+ * This source code is licensed under the ISC license.
853
+ * See the LICENSE file in the root directory of this source tree.
854
+ */
855
+ const LucideContext = createContext({});
856
+ const useLucideContext = () => useContext(LucideContext);
857
+ //#endregion
858
+ //#region ../../node_modules/.pnpm/lucide-react@1.34.0_react@19.2.8/node_modules/lucide-react/dist/esm/Icon.mjs
859
+ /**
860
+ * @license lucide-react v1.34.0 - ISC
861
+ *
862
+ * This source code is licensed under the ISC license.
863
+ * See the LICENSE file in the root directory of this source tree.
864
+ */
865
+ const Icon = forwardRef(({ color, size, strokeWidth, absoluteStrokeWidth, className = "", children, iconNode, ...rest }, ref) => {
866
+ const { size: contextSize = 24, strokeWidth: contextStrokeWidth = 2, absoluteStrokeWidth: contextAbsoluteStrokeWidth = false, color: contextColor = "currentColor", className: contextClass = "" } = useLucideContext() ?? {};
867
+ const calculatedStrokeWidth = absoluteStrokeWidth ?? contextAbsoluteStrokeWidth ? Number(strokeWidth ?? contextStrokeWidth) * 24 / Number(size ?? contextSize) : strokeWidth ?? contextStrokeWidth;
868
+ return createElement("svg", {
869
+ ref,
870
+ ...defaultAttributes,
871
+ width: size ?? contextSize ?? defaultAttributes.width,
872
+ height: size ?? contextSize ?? defaultAttributes.height,
873
+ stroke: color ?? contextColor,
874
+ strokeWidth: calculatedStrokeWidth,
875
+ className: mergeClasses("lucide", contextClass, className),
876
+ ...!children && !hasA11yProp(rest) && { "aria-hidden": "true" },
877
+ ...rest
878
+ }, [...iconNode.map(([tag, attrs]) => createElement(tag, attrs)), ...Array.isArray(children) ? children : [children]]);
879
+ });
880
+ //#endregion
881
+ //#region ../../node_modules/.pnpm/lucide-react@1.34.0_react@19.2.8/node_modules/lucide-react/dist/esm/createLucideIcon.mjs
882
+ /**
883
+ * @license lucide-react v1.34.0 - ISC
884
+ *
885
+ * This source code is licensed under the ISC license.
886
+ * See the LICENSE file in the root directory of this source tree.
887
+ */
888
+ const createLucideIcon = (iconName, iconNode) => {
889
+ const Component = forwardRef(({ className, ...props }, ref) => createElement(Icon, {
890
+ ref,
891
+ iconNode,
892
+ className: mergeClasses(`lucide-${toKebabCase(toPascalCase(iconName))}`, `lucide-${iconName}`, className),
893
+ ...props
894
+ }));
895
+ Component.displayName = toPascalCase(iconName);
896
+ return Component;
897
+ };
898
+ /**
899
+ * @license lucide-react v1.34.0 - ISC
900
+ *
901
+ * This source code is licensed under the ISC license.
902
+ * See the LICENSE file in the root directory of this source tree.
903
+ */
904
+ const RefreshCw = createLucideIcon("refresh-cw", [
905
+ ["path", {
906
+ d: "M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8",
907
+ key: "v9h5vc"
908
+ }],
909
+ ["path", {
910
+ d: "M21 3v5h-5",
911
+ key: "1q7to0"
912
+ }],
913
+ ["path", {
914
+ d: "M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16",
915
+ key: "3uifl3"
916
+ }],
917
+ ["path", {
918
+ d: "M8 16H3v5",
919
+ key: "1cv678"
920
+ }]
921
+ ]);
922
+ /**
923
+ * @license lucide-react v1.34.0 - ISC
924
+ *
925
+ * This source code is licensed under the ISC license.
926
+ * See the LICENSE file in the root directory of this source tree.
927
+ */
928
+ const Rss = createLucideIcon("rss", [
929
+ ["path", {
930
+ d: "M4 11a9 9 0 0 1 9 9",
931
+ key: "pv89mb"
932
+ }],
933
+ ["path", {
934
+ d: "M4 4a16 16 0 0 1 16 16",
935
+ key: "k0647b"
936
+ }],
937
+ ["circle", {
938
+ cx: "5",
939
+ cy: "19",
940
+ r: "1",
941
+ key: "bfqh0e"
942
+ }]
943
+ ]);
944
+ /**
945
+ * @license lucide-react v1.34.0 - ISC
946
+ *
947
+ * This source code is licensed under the ISC license.
948
+ * See the LICENSE file in the root directory of this source tree.
949
+ */
950
+ const Search = createLucideIcon("search", [["path", {
951
+ d: "m21 21-4.34-4.34",
952
+ key: "14j7rj"
953
+ }], ["circle", {
954
+ cx: "11",
955
+ cy: "11",
956
+ r: "8",
957
+ key: "4ej97u"
958
+ }]]);
959
+ /**
960
+ * @license lucide-react v1.34.0 - ISC
961
+ *
962
+ * This source code is licensed under the ISC license.
963
+ * See the LICENSE file in the root directory of this source tree.
964
+ */
965
+ const X = createLucideIcon("x", [["path", {
966
+ d: "M18 6 6 18",
967
+ key: "1bl5f8"
968
+ }], ["path", {
969
+ d: "m6 6 12 12",
970
+ key: "d8bk6v"
971
+ }]]);
972
+ //#endregion
973
+ //#region lib/types/settings-plugins-page.js
974
+ /** The preview's signature row: one line, color carries the verdict. */
975
+ function SignatureRow({ signature, directUrl }) {
976
+ if (signature.state === "verified") return jsxs("div", {
977
+ className: "flex justify-between gap-4",
978
+ "data-signature-row": "verified",
979
+ children: [jsx("span", {
980
+ className: "text-muted-foreground",
981
+ children: "签名"
982
+ }), jsxs("span", { children: ["✓ 已验证", signature.publisher !== void 0 ? ` · ${signature.publisher}` : ` · ${signature.keyId}`] })]
983
+ });
984
+ const detail = signature.keyId !== void 0 ? `未验证 · keyId ${signature.keyId}(未登记)` : "未签名";
985
+ return jsxs("div", {
986
+ className: "flex justify-between gap-4 text-warning",
987
+ "data-signature-row": "unverified",
988
+ children: [jsx("span", {
989
+ className: "text-muted-foreground",
990
+ children: "签名"
991
+ }), jsxs("span", { children: [
992
+ "⚠ ",
993
+ detail,
994
+ directUrl ? " · 直链无完整性校验" : ""
995
+ ] })]
996
+ });
997
+ }
998
+ /** Kind → display label; shared with the search results' rows. */
999
+ const KIND_LABELS = {
1000
+ suite: "业务套件",
1001
+ driver: "通讯驱动",
1002
+ plugin: "插件"
1003
+ };
1004
+ /** The drag-and-drop bridge: turn a dropped `File` back into its host-side
1005
+ * path through the preload's `zipPathOf` (`webUtils.getPathForFile`). Null
1006
+ * when the carrier offers no bridge (test hosts, non-Electron shells). */
1007
+ function droppedZipPath(file) {
1008
+ const zipPathOf = globalThis.snapRail?.zipPathOf;
1009
+ if (typeof zipPathOf !== "function") return null;
1010
+ const path = zipPathOf(file);
1011
+ return typeof path === "string" && path !== "" ? path : null;
1012
+ }
1013
+ /** Does the input name an http(s) address? */
1014
+ const isHttpUrl = (text) => /^https?:\/\//i.test(text);
1015
+ /** Does the input look like a filesystem path (drive letter, UNC, posix
1016
+ * root, or a .zip name with a separator in it)? */
1017
+ const looksLikePath = (text) => /^[a-zA-Z]:[\\/]/.test(text) || text.startsWith("\\\\") || text.startsWith("/") || /\.zip$/i.test(text) && /[/\\]/.test(text);
1018
+ /** The version cell: a muted pill normally; when a feed update is pending,
1019
+ * a success-colored pill carrying the refresh glyph and the feed version —
1020
+ * clicking it updates directly (a key change confirmation intercepts first
1021
+ * when the feed entry is signed by another key). */
1022
+ function VersionPill(props) {
1023
+ const { update } = props;
1024
+ if (update !== void 0) return jsxs(Button, {
1025
+ size: "sm",
1026
+ variant: "outline",
1027
+ "data-update-version": update.name,
1028
+ disabled: props.busy,
1029
+ title: update.keyChanged === true ? `当前 ${update.installedVersion} → ${update.feedVersion},发布者密钥已变更,点击确认更新` : `当前 ${update.installedVersion} → ${update.feedVersion},点击更新`,
1030
+ className: "h-7 bg-success/10 px-2 font-mono text-xs text-success",
1031
+ onClick: () => props.onUpdate(update),
1032
+ children: [jsx(RefreshCw, { className: "h-4 w-4" }), props.busy ? "更新中…" : update.feedVersion]
1033
+ });
1034
+ return jsx("span", {
1035
+ className: "inline-flex h-7 items-center rounded-md border border-border bg-muted px-2 font-mono text-xs text-muted-foreground",
1036
+ children: props.version ?? "—"
1037
+ });
1038
+ }
1039
+ /** The plugins page. */
1040
+ function PluginsPage({ ctx }) {
1041
+ const [rows, setRows] = useState([]);
1042
+ const [failed, setFailed] = useState(false);
1043
+ const [rendererNames, setRendererNames] = useState([]);
1044
+ /** Rows changed since page open — the restart-required surface. */
1045
+ const [touched, setTouched] = useState(/* @__PURE__ */ new Set());
1046
+ const [restartPrompt, setRestartPrompt] = useState(false);
1047
+ const [uninstall, setUninstall] = useState({ phase: "idle" });
1048
+ const [installPhase, setInstallPhase] = useState({ phase: "idle" });
1049
+ const [query, setQuery] = useState("");
1050
+ const [searchResults, setSearchResults] = useState(void 0);
1051
+ const [searching, setSearching] = useState(false);
1052
+ const [actionError, setActionError] = useState(void 0);
1053
+ /** The name whose update click is in flight (version pill busy state). */
1054
+ const [updatingName, setUpdatingName] = useState(void 0);
1055
+ /** The pending update whose click hit a key change — confirmed before the
1056
+ * install fires. */
1057
+ const [keyChange, setKeyChange] = useState(void 0);
1058
+ const [feedDialogOpen, setFeedDialogOpen] = useState(false);
1059
+ const [feedUrls, setFeedUrls] = useState([]);
1060
+ const [feedDraft, setFeedDraft] = useState("");
1061
+ const [feedError, setFeedError] = useState(void 0);
1062
+ /** Pending updates by package name (drives the version pills). */
1063
+ const [updates, setUpdates] = useState(/* @__PURE__ */ new Map());
1064
+ const [dragging, setDragging] = useState(false);
1065
+ const link = ctx.get("client").link;
1066
+ const reload = useCallback(() => {
1067
+ link.call("plugins.list", {}).then((result) => {
1068
+ if (result.ok) setRows(result.value.plugins.map((info) => ({
1069
+ name: info.name,
1070
+ source: info.source,
1071
+ enabled: info.enabled,
1072
+ packageName: info.packageName,
1073
+ ...info.kind !== void 0 ? { kind: info.kind } : {},
1074
+ ...info.version !== void 0 ? { version: info.version } : {},
1075
+ ...info.title !== void 0 ? { title: info.title } : {},
1076
+ ...info.category !== void 0 ? { category: info.category } : {},
1077
+ ...info.clientOnly ? { clientOnly: true } : {}
1078
+ })));
1079
+ else setFailed(true);
1080
+ }).catch(() => setFailed(true));
1081
+ link.call("client-config.list", {}).then((result) => {
1082
+ if (result.ok) setRendererNames(result.value.rows.map((row) => row.name));
1083
+ }).catch(() => void 0);
1084
+ link.call("plugins.updates-check", {}).then((result) => {
1085
+ if (result.ok) setUpdates(new Map(result.value.updates.map((update) => [update.name, update])));
1086
+ }).catch(() => void 0);
1087
+ }, [link]);
1088
+ useEffect(() => {
1089
+ reload();
1090
+ link.call("settings.get", { key: PLUGINS_FEED_URLS_KEY }).then((result) => {
1091
+ if (result.ok && Array.isArray(result.value.value)) setFeedUrls(result.value.value.filter((entry) => typeof entry === "string"));
1092
+ }).catch(() => void 0);
1093
+ }, [reload]);
1094
+ useEffect(() => {
1095
+ const detach = subscribeFrame(link, "plugins/updates-changed", pluginUpdatesChangedSchema, (change) => {
1096
+ setUpdates(new Map(change.updates.map((update) => [update.name, update])));
1097
+ });
1098
+ return () => {
1099
+ detach();
1100
+ };
1101
+ }, [link]);
1102
+ const toggle = (name, enabled) => {
1103
+ setRows((current) => current.map((row) => row.name === name ? {
1104
+ ...row,
1105
+ enabled
1106
+ } : row));
1107
+ if (rendererNames.includes(name)) {
1108
+ setTouched((current) => /* @__PURE__ */ new Set([...current, name]));
1109
+ setRestartPrompt(true);
1110
+ }
1111
+ link.call("plugins.set-enabled", {
1112
+ name,
1113
+ enabled
1114
+ }).then((result) => {
1115
+ if (!result.ok) {
1116
+ setRows((current) => current.map((row) => row.name === name ? {
1117
+ ...row,
1118
+ enabled: !enabled
1119
+ } : row));
1120
+ setTouched((current) => {
1121
+ const next = new Set(current);
1122
+ next.delete(name);
1123
+ return next;
1124
+ });
1125
+ }
1126
+ }).catch(() => {
1127
+ setRows((current) => current.map((row) => row.name === name ? {
1128
+ ...row,
1129
+ enabled: !enabled
1130
+ } : row));
1131
+ setTouched((current) => {
1132
+ const next = new Set(current);
1133
+ next.delete(name);
1134
+ return next;
1135
+ });
1136
+ });
1137
+ };
1138
+ /** The uninstall affordance opens a confirm first — the package and its
1139
+ * data are deleted for real. */
1140
+ const requestUninstall = (pkg) => {
1141
+ setUninstall({
1142
+ phase: "confirm",
1143
+ pkg
1144
+ });
1145
+ };
1146
+ const runUninstall = (pkg) => {
1147
+ link.call("plugins.uninstall", { name: pkg }).then((result) => {
1148
+ if (!result.ok) {
1149
+ setUninstall({
1150
+ phase: "error",
1151
+ message: rpcErrorText(result.error)
1152
+ });
1153
+ return;
1154
+ }
1155
+ setUninstall({ phase: "idle" });
1156
+ reload();
1157
+ if (rendererNames.includes(pkg)) {
1158
+ setTouched((current) => /* @__PURE__ */ new Set([...current, pkg]));
1159
+ setRestartPrompt(true);
1160
+ }
1161
+ }).catch((cause) => setUninstall({
1162
+ phase: "error",
1163
+ message: String(cause)
1164
+ }));
1165
+ };
1166
+ const markRestartIfNeeded = (name, updated) => {
1167
+ if (updated && rendererNames.includes(name)) {
1168
+ setTouched((current) => /* @__PURE__ */ new Set([...current, name]));
1169
+ setRestartPrompt(true);
1170
+ }
1171
+ };
1172
+ /** Inspect a zip the host can already reach (a local path, a picked file,
1173
+ * or a dropped one). */
1174
+ const inspectPath = (zipPath) => {
1175
+ setInstallPhase({ phase: "idle" });
1176
+ link.call("plugins.inspect", { zipPath }).then((result) => {
1177
+ if (!result.ok) {
1178
+ setInstallPhase({
1179
+ phase: "error",
1180
+ message: rpcErrorText(result.error)
1181
+ });
1182
+ return;
1183
+ }
1184
+ setInstallPhase({
1185
+ phase: "confirm",
1186
+ zipPath,
1187
+ archive: result.value.archive,
1188
+ viaUrl: false,
1189
+ risk: false
1190
+ });
1191
+ }).catch((cause) => setInstallPhase({
1192
+ phase: "error",
1193
+ message: String(cause)
1194
+ }));
1195
+ };
1196
+ /** Download and inspect a direct http(s) address — the host owns the
1197
+ * temp file; its path feeds the confirm → install legs. */
1198
+ const inspectFromUrl = (url, source) => {
1199
+ setInstallPhase({ phase: "idle" });
1200
+ link.call("plugins.inspect-from-url", { url }).then((result) => {
1201
+ if (!result.ok) {
1202
+ setInstallPhase({
1203
+ phase: "error",
1204
+ message: rpcErrorText(result.error)
1205
+ });
1206
+ return;
1207
+ }
1208
+ setInstallPhase({
1209
+ phase: "confirm",
1210
+ zipPath: result.value.zipPath,
1211
+ archive: result.value.archive,
1212
+ viaUrl: true,
1213
+ ...source !== void 0 ? { source } : {},
1214
+ risk: false
1215
+ });
1216
+ }).catch((cause) => setInstallPhase({
1217
+ phase: "error",
1218
+ message: String(cause)
1219
+ }));
1220
+ };
1221
+ const runInstall = (zipPath, archive) => {
1222
+ setInstallPhase({ phase: "installing" });
1223
+ if (archive.kind === "combo") {
1224
+ link.call("plugins.install-combo", { zipPath }).then((result) => {
1225
+ if (!result.ok) {
1226
+ setInstallPhase({
1227
+ phase: "error",
1228
+ message: rpcErrorText(result.error)
1229
+ });
1230
+ return;
1231
+ }
1232
+ setInstallPhase({ phase: "idle" });
1233
+ reload();
1234
+ if (archive.combo.hasClient) setRestartPrompt(true);
1235
+ }).catch((cause) => setInstallPhase({
1236
+ phase: "error",
1237
+ message: String(cause)
1238
+ }));
1239
+ return;
1240
+ }
1241
+ link.call("plugins.install", { zipPath }).then((result) => {
1242
+ if (!result.ok) {
1243
+ setInstallPhase({
1244
+ phase: "error",
1245
+ message: rpcErrorText(result.error)
1246
+ });
1247
+ return;
1248
+ }
1249
+ setInstallPhase({ phase: "idle" });
1250
+ reload();
1251
+ markRestartIfNeeded(result.value.installed.name, result.value.installed.updated);
1252
+ }).catch((cause) => setInstallPhase({
1253
+ phase: "error",
1254
+ message: String(cause)
1255
+ }));
1256
+ };
1257
+ const pickZip = () => {
1258
+ link.call("window.pick-zip", { title: "选择插件包或组合包" }).then((result) => {
1259
+ if (!result.ok || result.value === null) return;
1260
+ inspectPath(result.value);
1261
+ }).catch(() => void 0);
1262
+ };
1263
+ /** The tri-form input: address/path → inspect, anything else → feed search. */
1264
+ const submitQuery = () => {
1265
+ const text = query.trim();
1266
+ if (text === "") return;
1267
+ setActionError(void 0);
1268
+ if (isHttpUrl(text)) {
1269
+ inspectFromUrl(text);
1270
+ return;
1271
+ }
1272
+ if (looksLikePath(text)) {
1273
+ inspectPath(text);
1274
+ return;
1275
+ }
1276
+ runSearch(text);
1277
+ };
1278
+ const runSearch = (text) => {
1279
+ setSearching(true);
1280
+ setSearchResults(void 0);
1281
+ link.call("plugins.remote-list", {}).then((result) => {
1282
+ setSearching(false);
1283
+ if (!result.ok) {
1284
+ setActionError(rpcErrorText(result.error));
1285
+ return;
1286
+ }
1287
+ const needle = text.toLowerCase();
1288
+ setSearchResults(result.value.plugins.filter((item) => item.name.toLowerCase().includes(needle) || (item.title ?? "").toLowerCase().includes(needle) || (item.description ?? "").toLowerCase().includes(needle)));
1289
+ }).catch((cause) => {
1290
+ setSearching(false);
1291
+ setActionError(String(cause));
1292
+ });
1293
+ };
1294
+ /** A search-result entry that is not installed yet: preview first. The
1295
+ * entry's own coordinates decide where the zip comes from — a federation
1296
+ * row downloads from the author's release, a same-directory row from its
1297
+ * feed base. */
1298
+ const previewRemote = (item) => {
1299
+ if (item.url !== void 0) {
1300
+ inspectFromUrl(item.url, item.source);
1301
+ return;
1302
+ }
1303
+ if (item.feedUrl === void 0 || item.file === void 0) return;
1304
+ const base = item.feedUrl.endsWith("/") ? item.feedUrl : `${item.feedUrl}/`;
1305
+ inspectFromUrl(`${base}${item.file}`, item.source);
1306
+ };
1307
+ /** The version pill's click: a plain update installs directly; a key
1308
+ * change confirmation intercepts first. */
1309
+ const requestUpdate = (update) => {
1310
+ if (update.keyChanged === true) {
1311
+ setKeyChange(update);
1312
+ return;
1313
+ }
1314
+ updateNow(update.name);
1315
+ };
1316
+ /** The update itself: the feed's entry, no preview. */
1317
+ const updateNow = (name) => {
1318
+ setKeyChange(void 0);
1319
+ setUpdatingName(name);
1320
+ setActionError(void 0);
1321
+ link.call("plugins.remote-install", { name }).then((result) => {
1322
+ setUpdatingName(void 0);
1323
+ if (!result.ok) {
1324
+ setActionError(rpcErrorText(result.error));
1325
+ return;
1326
+ }
1327
+ reload();
1328
+ markRestartIfNeeded(result.value.installed.name, result.value.installed.updated);
1329
+ }).catch(() => setUpdatingName(void 0));
1330
+ };
1331
+ const saveFeedUrls = () => {
1332
+ for (const url of feedUrls) try {
1333
+ const parsed = new URL(url);
1334
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("protocol");
1335
+ } catch {
1336
+ setFeedError("插件源地址需要以 http(s) 开头");
1337
+ return;
1338
+ }
1339
+ setFeedError(void 0);
1340
+ link.call("settings.set", {
1341
+ key: PLUGINS_FEED_URLS_KEY,
1342
+ value: feedUrls
1343
+ }).then((result) => {
1344
+ if (!result.ok) {
1345
+ setFeedError(rpcErrorText(result.error));
1346
+ return;
1347
+ }
1348
+ setFeedDialogOpen(false);
1349
+ }).catch((cause) => setFeedError(String(cause)));
1350
+ };
1351
+ const appendFeedUrl = () => {
1352
+ const value = feedDraft.trim();
1353
+ if (value === "") return;
1354
+ try {
1355
+ const parsed = new URL(value);
1356
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("protocol");
1357
+ } catch {
1358
+ setFeedError("请填写 http(s) 开头的插件源地址");
1359
+ return;
1360
+ }
1361
+ setFeedError(void 0);
1362
+ setFeedDraft("");
1363
+ setFeedUrls((current) => current.includes(value) ? current : [...current, value]);
1364
+ };
1365
+ const onDrop = (event) => {
1366
+ event.preventDefault();
1367
+ setDragging(false);
1368
+ const file = event.dataTransfer.files[0];
1369
+ if (file === void 0) return;
1370
+ if (!file.name.toLowerCase().endsWith(".zip")) {
1371
+ setActionError("只支持 .zip 插件包或组合包");
1372
+ return;
1373
+ }
1374
+ const zipPath = droppedZipPath(file);
1375
+ if (zipPath === null) {
1376
+ setActionError("当前环境不支持拖拽安装(缺少路径桥)");
1377
+ return;
1378
+ }
1379
+ inspectPath(zipPath);
1380
+ };
1381
+ const relaunch = () => {
1382
+ link.call("window.relaunch", {}).catch(() => void 0);
1383
+ };
1384
+ if (failed) return jsx("div", {
1385
+ className: "text-sm text-muted-foreground",
1386
+ children: "插件列表读取失败。"
1387
+ });
1388
+ const needsRestart = (name) => touched.has(name);
1389
+ const listed = rows.filter((row) => row.source !== "builtin");
1390
+ const confirmed = installPhase.phase === "confirm" ? installPhase : void 0;
1391
+ const confirmedPlugin = confirmed?.archive.kind === "plugin" ? confirmed.archive.plugin : void 0;
1392
+ const confirmedCombo = confirmed?.archive.kind === "combo" ? confirmed.archive.combo : void 0;
1393
+ return jsxs("div", {
1394
+ "data-region": "plugins-page",
1395
+ className: "flex min-h-0 flex-1 flex-col gap-4",
1396
+ onDragOver: (event) => {
1397
+ event.preventDefault();
1398
+ setDragging(true);
1399
+ },
1400
+ onDragLeave: () => setDragging(false),
1401
+ onDrop: (event) => onDrop(event),
1402
+ children: [
1403
+ dragging && jsx("div", {
1404
+ className: "pointer-events-none fixed inset-0 z-50 flex items-center justify-center bg-background",
1405
+ children: jsx("div", {
1406
+ className: "rounded-lg border-2 border-dashed border-primary px-8 py-10 text-base text-primary",
1407
+ children: "松开以安装插件包或组合包(.zip)"
1408
+ })
1409
+ }),
1410
+ jsxs("div", {
1411
+ className: "flex items-center gap-2",
1412
+ children: [
1413
+ jsx(Button, {
1414
+ variant: "ghost",
1415
+ size: "icon",
1416
+ "aria-label": "配置插件源",
1417
+ "data-feed-config": true,
1418
+ className: "h-9 w-9 shrink-0 text-muted-foreground",
1419
+ onClick: () => setFeedDialogOpen(true),
1420
+ children: jsx(Rss, { className: "h-4 w-4" })
1421
+ }),
1422
+ jsx(Input, {
1423
+ value: query,
1424
+ placeholder: "粘贴本地/网络地址安装,或输入名称搜索…",
1425
+ "data-plugin-query": true,
1426
+ className: "flex-1",
1427
+ onChange: (event) => setQuery(event.target.value),
1428
+ onKeyDown: (event) => {
1429
+ if (event.key === "Enter") submitQuery();
1430
+ }
1431
+ }),
1432
+ jsxs(Button, {
1433
+ variant: "outline",
1434
+ "data-plugin-search": true,
1435
+ disabled: searching,
1436
+ onClick: submitQuery,
1437
+ children: [jsx(Search, { className: "h-4 w-4" }), searching ? "搜索中…" : "搜索"]
1438
+ }),
1439
+ jsx(Button, {
1440
+ variant: "outline",
1441
+ "data-install-plugin": true,
1442
+ onClick: pickZip,
1443
+ children: "选择文件"
1444
+ })
1445
+ ]
1446
+ }),
1447
+ actionError !== void 0 && jsx("p", {
1448
+ "data-plugin-action-error": true,
1449
+ className: "text-sm text-destructive",
1450
+ children: actionError
1451
+ }),
1452
+ searchResults !== void 0 && jsxs("div", {
1453
+ "data-region": "plugin-search-results",
1454
+ className: "flex flex-col gap-2 rounded-md border border-border p-3",
1455
+ children: [
1456
+ jsxs("div", {
1457
+ className: "flex items-center justify-between",
1458
+ children: [jsxs("span", {
1459
+ className: "text-sm font-medium",
1460
+ children: [
1461
+ "搜索“",
1462
+ query.trim(),
1463
+ "”(",
1464
+ searchResults.length,
1465
+ ")"
1466
+ ]
1467
+ }), jsx(Button, {
1468
+ variant: "ghost",
1469
+ size: "sm",
1470
+ "aria-label": "关闭搜索结果",
1471
+ onClick: () => setSearchResults(void 0),
1472
+ children: "✕"
1473
+ })]
1474
+ }),
1475
+ searchResults.length === 0 && jsx("p", {
1476
+ className: "py-2 text-center text-sm text-muted-foreground",
1477
+ children: "没有匹配的插件(插件源中没有该名称的条目)。"
1478
+ }),
1479
+ searchResults.map((item) => {
1480
+ const pillUpdate = item.action === "update" && item.installed !== void 0 ? {
1481
+ name: item.name,
1482
+ installedVersion: item.installed.version,
1483
+ feedVersion: item.version,
1484
+ ...item.source !== void 0 ? { title: item.source } : {}
1485
+ } : void 0;
1486
+ return jsxs("div", {
1487
+ "data-search-row": item.name,
1488
+ className: "flex items-center justify-between gap-3 rounded-md border border-border px-4 py-3",
1489
+ children: [jsxs("div", {
1490
+ className: "flex min-w-0 flex-col gap-1",
1491
+ children: [
1492
+ jsxs("span", {
1493
+ className: "flex min-w-0 items-center gap-2",
1494
+ children: [
1495
+ jsx("span", {
1496
+ className: "truncate text-sm font-medium",
1497
+ children: item.title ?? item.name
1498
+ }),
1499
+ item.kind !== void 0 && jsx(Badge, {
1500
+ variant: "secondary",
1501
+ children: KIND_LABELS[item.kind] ?? item.kind
1502
+ }),
1503
+ item.source !== void 0 && jsx(Badge, {
1504
+ variant: "outline",
1505
+ "data-search-source": item.name,
1506
+ children: item.source
1507
+ })
1508
+ ]
1509
+ }),
1510
+ jsxs("span", {
1511
+ className: "truncate font-mono text-xs text-muted-foreground",
1512
+ children: [
1513
+ item.name,
1514
+ " · ",
1515
+ item.version
1516
+ ]
1517
+ }),
1518
+ item.description !== void 0 && item.description !== "" && jsx("span", {
1519
+ className: "truncate text-xs text-muted-foreground",
1520
+ children: item.description
1521
+ })
1522
+ ]
1523
+ }), item.action === "install" ? jsx(Button, {
1524
+ size: "sm",
1525
+ "data-search-install": item.name,
1526
+ onClick: () => previewRemote(item),
1527
+ children: "安装"
1528
+ }) : pillUpdate !== void 0 ? jsx(VersionPill, {
1529
+ update: pillUpdate,
1530
+ busy: updatingName === item.name,
1531
+ onUpdate: requestUpdate
1532
+ }) : jsx(Badge, {
1533
+ variant: "secondary",
1534
+ children: item.action === "current" ? "已是最新" : "本地更新"
1535
+ })]
1536
+ }, item.name);
1537
+ })
1538
+ ]
1539
+ }),
1540
+ jsxs("div", {
1541
+ className: "shrink-0 text-sm font-medium text-muted-foreground",
1542
+ children: [
1543
+ "已安装插件(",
1544
+ listed.length,
1545
+ ")"
1546
+ ]
1547
+ }),
1548
+ jsx("div", {
1549
+ className: "min-h-0 flex-1 overflow-auto rounded-md border border-border",
1550
+ children: jsxs(Table, {
1551
+ "data-region": "plugins-table",
1552
+ children: [jsx(TableHeader, { children: jsxs(TableRow, { children: [
1553
+ jsx(TableHead, {
1554
+ className: "w-14",
1555
+ children: "启用"
1556
+ }),
1557
+ jsx(TableHead, { children: "名称" }),
1558
+ jsx(TableHead, { children: "标识" }),
1559
+ jsx(TableHead, {
1560
+ className: "w-24",
1561
+ children: "分类"
1562
+ }),
1563
+ jsx(TableHead, {
1564
+ className: "w-36",
1565
+ children: "版本"
1566
+ }),
1567
+ jsx(TableHead, {
1568
+ className: "w-24",
1569
+ children: "操作"
1570
+ })
1571
+ ] }) }), jsxs(TableBody, { children: [listed.map((row) => {
1572
+ const label = row.title ?? row.name;
1573
+ return jsxs(TableRow, {
1574
+ "data-plugin-row": row.name,
1575
+ children: [
1576
+ jsx(TableCell, { children: jsx(Switch, {
1577
+ "aria-label": `启用 ${label}`,
1578
+ checked: row.enabled,
1579
+ onCheckedChange: (checked) => toggle(row.name, checked)
1580
+ }) }),
1581
+ jsx(TableCell, { children: jsxs("span", {
1582
+ className: "flex items-center gap-2 truncate text-sm font-medium",
1583
+ children: [label, needsRestart(row.name) && jsx(Badge, {
1584
+ variant: "destructive",
1585
+ children: "待重启"
1586
+ })]
1587
+ }) }),
1588
+ jsx(TableCell, {
1589
+ className: "font-mono text-xs text-muted-foreground",
1590
+ children: row.name
1591
+ }),
1592
+ jsx(TableCell, {
1593
+ className: "text-sm text-muted-foreground",
1594
+ children: row.category ?? "其他"
1595
+ }),
1596
+ jsx(TableCell, { children: jsx(VersionPill, {
1597
+ version: row.version,
1598
+ update: updates.get(row.packageName),
1599
+ busy: updatingName === row.packageName,
1600
+ onUpdate: requestUpdate
1601
+ }) }),
1602
+ jsx(TableCell, { children: row.source === "pool" ? jsx(Button, {
1603
+ "data-uninstall-plugin": row.packageName,
1604
+ variant: "ghost",
1605
+ size: "sm",
1606
+ className: "text-destructive hover:text-destructive",
1607
+ onClick: () => requestUninstall(row.packageName),
1608
+ children: "卸载"
1609
+ }) : jsx("span", {
1610
+ className: "text-xs text-muted-foreground",
1611
+ children: "—"
1612
+ }) })
1613
+ ]
1614
+ }, row.name);
1615
+ }), listed.length === 0 && jsx(TableRow, { children: jsx(TableCell, {
1616
+ colSpan: 6,
1617
+ className: "py-8 text-center text-sm text-muted-foreground",
1618
+ children: "暂无已安装插件 —— 粘贴地址、拖入插件/组合 .zip,或输入名称从插件源搜索安装。"
1619
+ }) })] })]
1620
+ })
1621
+ }),
1622
+ restartPrompt && jsx(Dialog, {
1623
+ open: true,
1624
+ onOpenChange: (next) => {
1625
+ if (!next) setRestartPrompt(false);
1626
+ },
1627
+ children: jsxs(DialogContent, {
1628
+ "aria-describedby": void 0,
1629
+ className: "max-w-sm",
1630
+ children: [
1631
+ jsx(DialogTitle, {
1632
+ className: "text-base font-medium",
1633
+ children: "变更待重启生效"
1634
+ }),
1635
+ jsx("p", {
1636
+ className: "text-sm text-muted-foreground",
1637
+ children: "业务套件与页面类插件的启用变更将在下次启动时生效;插件更新或卸载后的界面残留也会随之消失。"
1638
+ }),
1639
+ jsxs(DialogFooter, { children: [jsx(Button, {
1640
+ variant: "outline",
1641
+ onClick: () => setRestartPrompt(false),
1642
+ children: "稍后"
1643
+ }), jsx(Button, {
1644
+ "data-restart-now": true,
1645
+ onClick: () => relaunch(),
1646
+ children: "立即重启"
1647
+ })] })
1648
+ ]
1649
+ })
1650
+ }),
1651
+ confirmed !== void 0 && jsx(Dialog, {
1652
+ open: true,
1653
+ onOpenChange: (next) => {
1654
+ if (!next) setInstallPhase({ phase: "idle" });
1655
+ },
1656
+ children: jsx(DialogContent, {
1657
+ "aria-describedby": void 0,
1658
+ className: "max-w-sm",
1659
+ children: confirmed.risk ? jsxs(Fragment, { children: [
1660
+ jsx(DialogTitle, {
1661
+ className: "text-base font-medium",
1662
+ "data-install-risk": true,
1663
+ children: confirmedCombo !== void 0 ? "⚠ 此组合含未经发布者验证的插件" : "⚠ 此插件未经发布者验证"
1664
+ }),
1665
+ jsxs("div", {
1666
+ className: "grid gap-2 text-sm",
1667
+ children: [
1668
+ confirmedPlugin !== void 0 && jsxs("div", {
1669
+ className: "rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-sm",
1670
+ children: [jsx("p", {
1671
+ "data-risk-package": true,
1672
+ className: "font-mono",
1673
+ children: confirmedPlugin.name
1674
+ }), jsx("p", {
1675
+ className: "mt-1 text-muted-foreground",
1676
+ children: confirmedPlugin.signature.keyId !== void 0 ? `签名 keyId ${confirmedPlugin.signature.keyId} 不在本机信任名单中,无法验证发布者。` : "这个包没有携带任何签名,也没有任何完整性校验。"
1677
+ })]
1678
+ }),
1679
+ confirmedCombo !== void 0 && jsxs("div", {
1680
+ className: "rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-sm",
1681
+ children: [
1682
+ jsxs("p", {
1683
+ "data-risk-package": true,
1684
+ className: "font-mono",
1685
+ children: [
1686
+ confirmedCombo.title,
1687
+ " · ",
1688
+ confirmedCombo.id
1689
+ ]
1690
+ }),
1691
+ jsx("p", {
1692
+ className: "mt-1 text-muted-foreground",
1693
+ children: "组合包本身没有签名,以下成员无法验证发布者:"
1694
+ }),
1695
+ jsx("ul", {
1696
+ className: "mt-1 grid gap-1",
1697
+ children: confirmedCombo.plugins.filter((plugin) => plugin.signature.state === "unverified").map((plugin) => jsxs("li", {
1698
+ className: "font-mono text-xs",
1699
+ children: [
1700
+ plugin.name,
1701
+ " · ",
1702
+ plugin.signature.keyId ?? "未签名"
1703
+ ]
1704
+ }, plugin.name))
1705
+ })
1706
+ ]
1707
+ }),
1708
+ jsx("p", {
1709
+ className: "text-muted-foreground",
1710
+ children: "· 请确认插件来源可信后再继续;安装后可在本机信任文件登记其公钥。"
1711
+ })
1712
+ ]
1713
+ }),
1714
+ jsxs(DialogFooter, { children: [jsx(Button, {
1715
+ variant: "outline",
1716
+ onClick: () => setInstallPhase({
1717
+ ...confirmed,
1718
+ risk: false
1719
+ }),
1720
+ children: "返回"
1721
+ }), jsx(Button, {
1722
+ "data-confirm-risk-install": true,
1723
+ onClick: () => runInstall(confirmed.zipPath, confirmed.archive),
1724
+ children: "我知晓风险,仍要安装"
1725
+ })] })
1726
+ ] }) : confirmedCombo !== void 0 ? jsxs(Fragment, { children: [
1727
+ jsx(DialogTitle, {
1728
+ className: "text-base font-medium",
1729
+ children: "安装组合套件"
1730
+ }),
1731
+ jsxs("div", {
1732
+ className: "grid gap-2 text-sm",
1733
+ children: [
1734
+ jsxs("div", {
1735
+ className: "flex justify-between gap-4",
1736
+ children: [jsx("span", {
1737
+ className: "text-muted-foreground",
1738
+ children: "组合名"
1739
+ }), jsx("span", {
1740
+ className: "truncate",
1741
+ children: confirmedCombo.title
1742
+ })]
1743
+ }),
1744
+ jsxs("div", {
1745
+ className: "flex justify-between gap-4",
1746
+ children: [jsx("span", {
1747
+ className: "text-muted-foreground",
1748
+ children: "组合 ID"
1749
+ }), jsx("span", {
1750
+ className: "truncate font-mono",
1751
+ children: confirmedCombo.id
1752
+ })]
1753
+ }),
1754
+ jsxs("div", {
1755
+ className: "flex justify-between gap-4",
1756
+ children: [jsx("span", {
1757
+ className: "text-muted-foreground",
1758
+ children: "版本"
1759
+ }), jsx("span", {
1760
+ className: "font-mono",
1761
+ children: confirmedCombo.version
1762
+ })]
1763
+ }),
1764
+ confirmedCombo.description !== void 0 && jsx("p", {
1765
+ className: "text-xs text-muted-foreground",
1766
+ children: confirmedCombo.description
1767
+ }),
1768
+ confirmed.source !== void 0 && jsxs("div", {
1769
+ className: "flex justify-between gap-4",
1770
+ children: [jsx("span", {
1771
+ className: "text-muted-foreground",
1772
+ children: "来源"
1773
+ }), jsx("span", { children: confirmed.source })]
1774
+ }),
1775
+ jsx("div", {
1776
+ className: "grid max-h-48 gap-1 overflow-auto rounded-md border border-border px-3 py-2",
1777
+ children: confirmedCombo.plugins.map((plugin) => {
1778
+ const action = plugin.action === "install" ? "安装" : plugin.action === "update" ? "更新" : "保留";
1779
+ const unverified = plugin.signature.state === "unverified";
1780
+ return jsxs("div", {
1781
+ "data-combo-member": plugin.name,
1782
+ className: "flex items-baseline justify-between gap-3 text-xs",
1783
+ children: [jsx("span", {
1784
+ className: "truncate",
1785
+ children: plugin.title ?? plugin.name
1786
+ }), jsxs("span", {
1787
+ className: `shrink-0 font-mono ${unverified ? "text-warning" : "text-muted-foreground"}`,
1788
+ children: [
1789
+ plugin.version,
1790
+ " · ",
1791
+ action,
1792
+ unverified ? " · ⚠ 未验证" : ""
1793
+ ]
1794
+ })]
1795
+ }, plugin.name);
1796
+ })
1797
+ }),
1798
+ jsx("p", {
1799
+ className: "text-xs text-muted-foreground",
1800
+ children: "组合包本身不签名;每个成员都经与单插件相同的签名校验。"
1801
+ }),
1802
+ confirmedCombo.missing.length > 0 && jsxs("p", {
1803
+ "data-combo-missing": true,
1804
+ className: "rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive",
1805
+ children: [
1806
+ "缺少依赖:",
1807
+ confirmedCombo.missing.join("、"),
1808
+ "——组合会立即启用全部行,装齐后再安装。"
1809
+ ]
1810
+ }),
1811
+ confirmedCombo.hasClient && jsx("p", {
1812
+ className: "text-xs text-muted-foreground",
1813
+ children: "组合含界面插件;装好后重启一次让界面生效。"
1814
+ })
1815
+ ]
1816
+ }),
1817
+ jsxs(DialogFooter, { children: [jsx(Button, {
1818
+ variant: "outline",
1819
+ onClick: () => setInstallPhase({ phase: "idle" }),
1820
+ children: "取消"
1821
+ }), jsx(Button, {
1822
+ "data-confirm-install-combo": true,
1823
+ disabled: confirmedCombo.missing.length > 0,
1824
+ onClick: () => {
1825
+ if (confirmedCombo.plugins.some((plugin) => plugin.signature.state === "unverified")) {
1826
+ setInstallPhase({
1827
+ ...confirmed,
1828
+ risk: true
1829
+ });
1830
+ return;
1831
+ }
1832
+ runInstall(confirmed.zipPath, confirmed.archive);
1833
+ },
1834
+ children: "安装组合"
1835
+ })] })
1836
+ ] }) : confirmedPlugin !== void 0 ? jsxs(Fragment, { children: [
1837
+ jsx(DialogTitle, {
1838
+ className: "text-base font-medium",
1839
+ children: confirmedPlugin.action === "update" ? "更新插件" : "安装插件"
1840
+ }),
1841
+ jsxs("div", {
1842
+ className: "grid gap-2 text-sm",
1843
+ children: [
1844
+ confirmedPlugin.title !== void 0 && jsxs("div", {
1845
+ className: "flex justify-between gap-4",
1846
+ children: [jsx("span", {
1847
+ className: "text-muted-foreground",
1848
+ children: "名称"
1849
+ }), jsx("span", {
1850
+ className: "truncate",
1851
+ children: confirmedPlugin.title
1852
+ })]
1853
+ }),
1854
+ jsxs("div", {
1855
+ className: "flex justify-between gap-4",
1856
+ children: [jsx("span", {
1857
+ className: "text-muted-foreground",
1858
+ children: "包名"
1859
+ }), jsx("span", {
1860
+ className: "truncate font-mono",
1861
+ children: confirmedPlugin.name
1862
+ })]
1863
+ }),
1864
+ confirmedPlugin.action === "update" ? jsxs(Fragment, { children: [jsxs("div", {
1865
+ className: "flex justify-between gap-4",
1866
+ children: [jsx("span", {
1867
+ className: "text-muted-foreground",
1868
+ children: "已安装版本"
1869
+ }), jsx("span", {
1870
+ className: "font-mono",
1871
+ children: confirmedPlugin.installed?.version
1872
+ })]
1873
+ }), jsxs("div", {
1874
+ className: "flex justify-between gap-4",
1875
+ children: [jsx("span", {
1876
+ className: "text-muted-foreground",
1877
+ children: "将更新至"
1878
+ }), jsx("span", {
1879
+ className: "font-mono",
1880
+ children: confirmedPlugin.version
1881
+ })]
1882
+ })] }) : jsxs("div", {
1883
+ className: "flex justify-between gap-4",
1884
+ children: [jsx("span", {
1885
+ className: "text-muted-foreground",
1886
+ children: "版本"
1887
+ }), jsx("span", {
1888
+ className: "font-mono",
1889
+ children: confirmedPlugin.version
1890
+ })]
1891
+ }),
1892
+ jsxs("div", {
1893
+ className: "flex justify-between gap-4",
1894
+ children: [jsx("span", {
1895
+ className: "text-muted-foreground",
1896
+ children: "分类"
1897
+ }), jsx("span", { children: confirmedPlugin.category ?? "其他" })]
1898
+ }),
1899
+ confirmed.source !== void 0 && jsxs("div", {
1900
+ className: "flex justify-between gap-4",
1901
+ children: [jsx("span", {
1902
+ className: "text-muted-foreground",
1903
+ children: "来源"
1904
+ }), jsx("span", { children: confirmed.source })]
1905
+ }),
1906
+ jsx(SignatureRow, {
1907
+ signature: confirmedPlugin.signature,
1908
+ directUrl: confirmed.viaUrl
1909
+ }),
1910
+ confirmedPlugin.hasClient && jsxs("div", {
1911
+ className: "flex justify-between gap-4",
1912
+ children: [jsx("span", {
1913
+ className: "text-muted-foreground",
1914
+ children: "界面"
1915
+ }), jsx("span", { children: "含页面" })]
1916
+ }),
1917
+ jsxs("div", {
1918
+ className: "flex justify-between gap-4",
1919
+ children: [jsx("span", {
1920
+ className: "text-muted-foreground",
1921
+ children: "权限"
1922
+ }), jsx("span", { children: confirmedPlugin.permissions.length === 0 ? "无" : confirmedPlugin.permissions.join("、") })]
1923
+ }),
1924
+ confirmedPlugin.missing.length > 0 && jsxs("p", {
1925
+ className: "rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive",
1926
+ children: [
1927
+ "尚未安装的依赖:",
1928
+ confirmedPlugin.missing.join("、"),
1929
+ "——可先安装本包,启用前需装齐。"
1930
+ ]
1931
+ }),
1932
+ confirmedPlugin.action === "update" && jsx("p", {
1933
+ className: "text-xs text-muted-foreground",
1934
+ children: "覆盖后保留该插件的配置与数据;运行中的界面需重启后生效。"
1935
+ }),
1936
+ confirmedPlugin.action === "blocked" && jsxs("p", {
1937
+ "data-install-blocked": true,
1938
+ className: "text-sm text-destructive",
1939
+ children: [
1940
+ "已安装 v",
1941
+ confirmedPlugin.installed?.version,
1942
+ ",安装包 v",
1943
+ confirmedPlugin.version,
1944
+ " 不高于已装版本,无法覆盖(卸载后可降级安装)。"
1945
+ ]
1946
+ })
1947
+ ]
1948
+ }),
1949
+ jsxs(DialogFooter, { children: [jsx(Button, {
1950
+ variant: "outline",
1951
+ onClick: () => setInstallPhase({ phase: "idle" }),
1952
+ children: "取消"
1953
+ }), jsx(Button, {
1954
+ "data-confirm-install": true,
1955
+ disabled: confirmedPlugin.action === "blocked",
1956
+ onClick: () => {
1957
+ if (confirmedPlugin.signature.state === "unverified") {
1958
+ setInstallPhase({
1959
+ ...confirmed,
1960
+ risk: true
1961
+ });
1962
+ return;
1963
+ }
1964
+ runInstall(confirmed.zipPath, confirmed.archive);
1965
+ },
1966
+ children: confirmedPlugin.action === "update" ? "更新" : "安装"
1967
+ })] })
1968
+ ] }) : null
1969
+ })
1970
+ }),
1971
+ keyChange !== void 0 && jsx(Dialog, {
1972
+ open: true,
1973
+ onOpenChange: (next) => {
1974
+ if (!next) setKeyChange(void 0);
1975
+ },
1976
+ children: jsxs(DialogContent, {
1977
+ "aria-describedby": void 0,
1978
+ className: "max-w-sm",
1979
+ children: [
1980
+ jsx(DialogTitle, {
1981
+ className: "text-base font-medium",
1982
+ children: "发布者密钥已变更"
1983
+ }),
1984
+ jsxs("div", {
1985
+ className: "grid gap-2 text-sm",
1986
+ children: [jsxs("p", {
1987
+ className: "text-muted-foreground",
1988
+ children: [
1989
+ jsx("span", {
1990
+ className: "font-mono text-foreground",
1991
+ children: keyChange.name
1992
+ }),
1993
+ " ",
1994
+ "的新版本由不同的密钥签名。若发布者确实轮换了密钥,更新是安全的;否则不要继续。"
1995
+ ]
1996
+ }), jsxs("div", {
1997
+ className: "rounded-md border border-warning/30 bg-warning/10 px-3 py-2",
1998
+ children: [jsxs("p", { children: [
1999
+ "已安装 v",
2000
+ keyChange.installedVersion,
2001
+ " → 源上 v",
2002
+ keyChange.feedVersion
2003
+ ] }), jsx("p", {
2004
+ className: "mt-1 text-muted-foreground",
2005
+ children: "请核对发布者公告中的新 keyId 后再决定。"
2006
+ })]
2007
+ })]
2008
+ }),
2009
+ jsxs(DialogFooter, { children: [jsx(Button, {
2010
+ variant: "outline",
2011
+ onClick: () => setKeyChange(void 0),
2012
+ children: "取消"
2013
+ }), jsx(Button, {
2014
+ "data-confirm-key-change": true,
2015
+ onClick: () => updateNow(keyChange.name),
2016
+ children: "仍要更新"
2017
+ })] })
2018
+ ]
2019
+ })
2020
+ }),
2021
+ installPhase.phase === "error" && jsx(Dialog, {
2022
+ open: true,
2023
+ onOpenChange: (next) => {
2024
+ if (!next) setInstallPhase({ phase: "idle" });
2025
+ },
2026
+ children: jsxs(DialogContent, {
2027
+ "aria-describedby": void 0,
2028
+ className: "max-w-sm",
2029
+ children: [
2030
+ jsx(DialogTitle, {
2031
+ className: "text-base font-medium",
2032
+ children: "安装失败"
2033
+ }),
2034
+ jsx("p", {
2035
+ "data-install-error": true,
2036
+ className: "text-sm text-destructive",
2037
+ children: installPhase.message
2038
+ }),
2039
+ jsx(DialogFooter, { children: jsx(Button, {
2040
+ onClick: () => setInstallPhase({ phase: "idle" }),
2041
+ children: "知道了"
2042
+ }) })
2043
+ ]
2044
+ })
2045
+ }),
2046
+ uninstall.phase === "confirm" && jsx(Dialog, {
2047
+ open: true,
2048
+ onOpenChange: (next) => {
2049
+ if (!next) setUninstall({ phase: "idle" });
2050
+ },
2051
+ children: jsxs(DialogContent, {
2052
+ "aria-describedby": void 0,
2053
+ className: "max-w-sm",
2054
+ children: [
2055
+ jsx(DialogTitle, {
2056
+ className: "text-base font-medium",
2057
+ children: "卸载插件"
2058
+ }),
2059
+ jsxs("p", {
2060
+ className: "text-sm text-muted-foreground",
2061
+ children: [
2062
+ "将从插件池移除 ",
2063
+ jsx("span", {
2064
+ className: "font-mono text-foreground",
2065
+ children: uninstall.pkg
2066
+ }),
2067
+ ",其数据一并删除,不可恢复。"
2068
+ ]
2069
+ }),
2070
+ jsxs(DialogFooter, { children: [jsx(Button, {
2071
+ variant: "outline",
2072
+ onClick: () => setUninstall({ phase: "idle" }),
2073
+ children: "取消"
2074
+ }), jsx(Button, {
2075
+ variant: "destructive",
2076
+ "data-confirm-uninstall": true,
2077
+ onClick: () => runUninstall(uninstall.pkg),
2078
+ children: "卸载"
2079
+ })] })
2080
+ ]
2081
+ })
2082
+ }),
2083
+ uninstall.phase === "error" && jsx(Dialog, {
2084
+ open: true,
2085
+ onOpenChange: (next) => {
2086
+ if (!next) setUninstall({ phase: "idle" });
2087
+ },
2088
+ children: jsxs(DialogContent, {
2089
+ "aria-describedby": void 0,
2090
+ className: "max-w-sm",
2091
+ children: [
2092
+ jsx(DialogTitle, {
2093
+ className: "text-base font-medium",
2094
+ children: "卸载失败"
2095
+ }),
2096
+ jsx("p", {
2097
+ "data-uninstall-error": true,
2098
+ className: "text-sm text-destructive",
2099
+ children: uninstall.message
2100
+ }),
2101
+ jsx(DialogFooter, { children: jsx(Button, {
2102
+ onClick: () => setUninstall({ phase: "idle" }),
2103
+ children: "知道了"
2104
+ }) })
2105
+ ]
2106
+ })
2107
+ }),
2108
+ feedDialogOpen && jsx(Dialog, {
2109
+ open: true,
2110
+ onOpenChange: (next) => {
2111
+ if (!next) setFeedDialogOpen(false);
2112
+ },
2113
+ children: jsxs(DialogContent, {
2114
+ "aria-describedby": void 0,
2115
+ className: "max-w-md",
2116
+ children: [
2117
+ jsx(DialogTitle, {
2118
+ className: "text-base font-medium",
2119
+ children: "插件源"
2120
+ }),
2121
+ jsxs("div", {
2122
+ className: "flex flex-col gap-2",
2123
+ children: [
2124
+ jsx("label", {
2125
+ className: "text-sm text-muted-foreground",
2126
+ htmlFor: "plugin-feed-url",
2127
+ children: "搜索与在线更新使用的插件源列表:"
2128
+ }),
2129
+ feedUrls.length === 0 && jsx("p", {
2130
+ className: "text-xs text-muted-foreground",
2131
+ children: "尚未添加自定义源(当前使用出厂默认源)。"
2132
+ }),
2133
+ feedUrls.map((url, index) => jsxs("div", {
2134
+ "data-feed-row": url,
2135
+ className: "flex items-center gap-2 rounded-md border border-border px-3 py-2",
2136
+ children: [jsx("span", {
2137
+ className: "min-w-0 flex-1 truncate font-mono text-xs",
2138
+ children: url
2139
+ }), jsx(Button, {
2140
+ variant: "ghost",
2141
+ size: "sm",
2142
+ "aria-label": `移除 ${url}`,
2143
+ "data-feed-remove": index,
2144
+ onClick: () => setFeedUrls((current) => current.filter((_, at) => at !== index)),
2145
+ children: "✕"
2146
+ })]
2147
+ }, url)),
2148
+ jsxs("div", {
2149
+ className: "flex items-center gap-2",
2150
+ children: [jsx(Input, {
2151
+ id: "plugin-feed-url",
2152
+ value: feedDraft,
2153
+ placeholder: "https://…(指向含 index.json 的目录)",
2154
+ "data-feed-url-input": true,
2155
+ onChange: (event) => setFeedDraft(event.target.value),
2156
+ onKeyDown: (event) => {
2157
+ if (event.key === "Enter") appendFeedUrl();
2158
+ }
2159
+ }), jsx(Button, {
2160
+ variant: "outline",
2161
+ "data-feed-add": true,
2162
+ onClick: appendFeedUrl,
2163
+ children: "添加源"
2164
+ })]
2165
+ }),
2166
+ feedError !== void 0 && jsx("p", {
2167
+ className: "text-sm text-destructive",
2168
+ children: feedError
2169
+ }),
2170
+ jsx("p", {
2171
+ className: "text-xs text-muted-foreground",
2172
+ children: "未修改时使用出厂默认源;修改后保存在本机,仅影响本机。社区插件源就是插件作者自己仓库的 Release 地址。"
2173
+ })
2174
+ ]
2175
+ }),
2176
+ jsxs(DialogFooter, { children: [jsx(Button, {
2177
+ variant: "outline",
2178
+ onClick: () => setFeedDialogOpen(false),
2179
+ children: "取消"
2180
+ }), jsx(Button, {
2181
+ "data-feed-save": true,
2182
+ onClick: saveFeedUrls,
2183
+ children: "保存"
2184
+ })] })
2185
+ ]
2186
+ })
2187
+ })
2188
+ ]
2189
+ });
2190
+ }
2191
+ //#endregion
2192
+ //#region lib/types/settings-update-page.js
2193
+ /** The phase's one-line copy; `error`/`unconfigured` carry their own message. */
2194
+ function phaseHint(status) {
2195
+ switch (status.phase) {
2196
+ case "unsupported": return "当前为开发模式,无法检查更新。";
2197
+ case "unconfigured": return status.message ?? "未配置更新源。";
2198
+ case "idle": return "就绪,可手动检查更新。";
2199
+ case "checking": return "正在检查更新…";
2200
+ case "available": return "发现新版本,正在后台自动下载。";
2201
+ case "none": return "已是最新版本。";
2202
+ case "downloading": return `正在下载新版本… ${Math.round(status.percent ?? 0)}%`;
2203
+ case "ready": return "新版本已就绪,重启后自动完成安装。";
2204
+ case "error": return `更新失败:${status.message ?? "未知错误"}`;
2205
+ }
2206
+ }
2207
+ /** The 软件更新 page. */
2208
+ function UpdatePage({ ctx }) {
2209
+ const [status, setStatus] = useState(void 0);
2210
+ const [failed, setFailed] = useState(false);
2211
+ const [busy, setBusy] = useState(false);
2212
+ const [actionError, setActionError] = useState(void 0);
2213
+ const [feedUrl, setFeedUrl] = useState("");
2214
+ const [feedError, setFeedError] = useState(void 0);
2215
+ const [autoCheck, setAutoCheck] = useState(true);
2216
+ const link = ctx.get("client").link;
2217
+ useEffect(() => {
2218
+ link.call("update.state", {}).then((result) => {
2219
+ if (result.ok) setStatus(result.value.status);
2220
+ else setFailed(true);
2221
+ }).catch(() => setFailed(true));
2222
+ link.call("settings.get", { key: UPDATE_AUTO_CHECK_KEY }).then((result) => {
2223
+ if (result.ok && typeof result.value.value === "boolean") setAutoCheck(result.value.value);
2224
+ }).catch(() => void 0);
2225
+ link.call("settings.get", { key: UPDATE_FEED_URL_KEY }).then((result) => {
2226
+ if (result.ok && typeof result.value.value === "string") setFeedUrl(result.value.value);
2227
+ }).catch(() => void 0);
2228
+ const offStatus = subscribeFrame(link, "update/status", updateStatusSchema, (next) => setStatus(next));
2229
+ const offSettings = subscribeFrame(link, "settings/changed", settingsChangedSchema, (change) => {
2230
+ if (change.key === UPDATE_FEED_URL_KEY) setFeedUrl(typeof change.value === "string" ? change.value : "");
2231
+ if (change.key === UPDATE_AUTO_CHECK_KEY) setAutoCheck(change.value !== false);
2232
+ });
2233
+ return () => {
2234
+ offStatus();
2235
+ offSettings();
2236
+ };
2237
+ }, [ctx]);
2238
+ /** Run a snapshot-returning update method; failures surface next to the buttons. */
2239
+ const run = (method) => {
2240
+ setBusy(true);
2241
+ setActionError(void 0);
2242
+ link.call(method, {}).then((result) => {
2243
+ if (result.ok) setStatus(result.value.status);
2244
+ else setActionError(rpcErrorText(result.error));
2245
+ setBusy(false);
2246
+ }).catch(() => setBusy(false));
2247
+ };
2248
+ const install = () => {
2249
+ setBusy(true);
2250
+ setActionError(void 0);
2251
+ link.call("update.install", {}).then((result) => {
2252
+ if (!result.ok) {
2253
+ setActionError(rpcErrorText(result.error));
2254
+ setBusy(false);
2255
+ }
2256
+ }).catch(() => setBusy(false));
2257
+ };
2258
+ const saveFeedUrl = () => {
2259
+ const value = feedUrl.trim();
2260
+ if (value !== "") try {
2261
+ const parsed = new URL(value);
2262
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("protocol");
2263
+ } catch {
2264
+ setFeedError("请填写 http(s) 开头的更新源地址");
2265
+ return;
2266
+ }
2267
+ setFeedError(void 0);
2268
+ link.call("settings.set", {
2269
+ key: UPDATE_FEED_URL_KEY,
2270
+ value
2271
+ });
2272
+ };
2273
+ if (failed) return jsx("div", {
2274
+ className: "text-sm text-muted-foreground",
2275
+ children: "更新状态读取失败。"
2276
+ });
2277
+ if (status === void 0) return jsx("div", {
2278
+ className: "text-sm text-muted-foreground",
2279
+ children: "正在连接更新通道…"
2280
+ });
2281
+ const phase = status.phase;
2282
+ const showCheck = phase === "idle" || phase === "none" || phase === "error" || phase === "available";
2283
+ const percent = Math.min(100, Math.max(0, Math.round(status.percent ?? 0)));
2284
+ return jsxs("div", {
2285
+ "data-region": "update-page",
2286
+ className: "flex max-w-xl flex-col gap-4",
2287
+ children: [jsxs("div", {
2288
+ "data-update-status": phase,
2289
+ className: "flex flex-col gap-3 rounded-md border border-border px-4 py-4",
2290
+ children: [
2291
+ jsxs("div", {
2292
+ className: "flex items-center justify-between",
2293
+ children: [jsx("span", {
2294
+ className: "text-sm text-muted-foreground",
2295
+ children: "当前版本"
2296
+ }), jsx("span", {
2297
+ className: "font-mono text-sm",
2298
+ children: status.currentVersion
2299
+ })]
2300
+ }),
2301
+ status.version !== void 0 && jsxs("div", {
2302
+ className: "flex items-center justify-between",
2303
+ children: [jsx("span", {
2304
+ className: "text-sm text-muted-foreground",
2305
+ children: "新版本"
2306
+ }), jsx("span", {
2307
+ className: "font-mono text-sm",
2308
+ children: status.version
2309
+ })]
2310
+ }),
2311
+ jsx("p", {
2312
+ "data-update-hint": true,
2313
+ className: "text-sm text-muted-foreground",
2314
+ children: phaseHint(status)
2315
+ }),
2316
+ phase === "downloading" && jsx(Progress, {
2317
+ "data-update-progress": percent,
2318
+ "aria-label": "下载进度",
2319
+ value: percent
2320
+ }),
2321
+ actionError !== void 0 && jsx("p", {
2322
+ "data-update-error": true,
2323
+ className: "text-sm text-destructive",
2324
+ children: actionError
2325
+ }),
2326
+ jsxs("div", {
2327
+ className: "flex items-center gap-2",
2328
+ children: [
2329
+ showCheck && jsx(Button, {
2330
+ "data-update-check": true,
2331
+ disabled: busy,
2332
+ onClick: () => run("update.check"),
2333
+ children: phase === "error" ? "重新检查" : "检查更新"
2334
+ }),
2335
+ phase === "available" && jsx(Button, {
2336
+ "data-update-download": true,
2337
+ variant: "outline",
2338
+ disabled: busy,
2339
+ onClick: () => run("update.download"),
2340
+ children: "立即下载"
2341
+ }),
2342
+ phase === "ready" && jsx(Button, {
2343
+ "data-update-install": true,
2344
+ disabled: busy,
2345
+ onClick: install,
2346
+ children: "重启并安装"
2347
+ })
2348
+ ]
2349
+ })
2350
+ ]
2351
+ }), jsxs("div", {
2352
+ className: "flex flex-col gap-3 rounded-md border border-border px-4 py-4",
2353
+ children: [jsxs("div", {
2354
+ className: "flex items-center justify-between gap-4",
2355
+ children: [jsxs("div", {
2356
+ className: "flex flex-col gap-0.5",
2357
+ children: [jsx("span", {
2358
+ className: "text-sm",
2359
+ children: "启动时自动检查更新"
2360
+ }), jsx("span", {
2361
+ className: "text-xs text-muted-foreground",
2362
+ children: "发现新版本后自动后台下载;安装仍需手动确认重启。"
2363
+ })]
2364
+ }), jsx(Switch, {
2365
+ "aria-label": "启动时自动检查更新",
2366
+ checked: autoCheck,
2367
+ onCheckedChange: (next) => {
2368
+ setAutoCheck(next);
2369
+ link.call("settings.set", {
2370
+ key: UPDATE_AUTO_CHECK_KEY,
2371
+ value: next
2372
+ });
2373
+ }
2374
+ })]
2375
+ }), jsxs("div", {
2376
+ className: "flex flex-col gap-1.5",
2377
+ children: [
2378
+ jsx(Label, {
2379
+ htmlFor: "update-feed-url",
2380
+ children: "更新源地址"
2381
+ }),
2382
+ jsxs("div", {
2383
+ className: "flex items-center gap-2",
2384
+ children: [jsx(Input, {
2385
+ id: "update-feed-url",
2386
+ value: feedUrl,
2387
+ placeholder: "https://updates.example.com/snap-rail/",
2388
+ onChange: (event) => setFeedUrl(event.target.value)
2389
+ }), jsx(Button, {
2390
+ variant: "outline",
2391
+ "data-update-save-feed": true,
2392
+ onClick: saveFeedUrl,
2393
+ children: "保存"
2394
+ })]
2395
+ }),
2396
+ feedError !== void 0 && jsx("p", {
2397
+ className: "text-sm text-destructive",
2398
+ children: feedError
2399
+ }),
2400
+ jsx("p", {
2401
+ className: "text-xs text-muted-foreground",
2402
+ children: "指向存放安装包与 latest.yml 的目录;留空时使用安装包内置配置。保存后再点「检查更新」立即生效。"
2403
+ })
2404
+ ]
2405
+ })]
2406
+ })]
2407
+ });
2408
+ }
2409
+ //#endregion
2410
+ //#region lib/types/settings-dialog.js
2411
+ /** The 主题 settings page: one mode choice (跟随系统/白天/黑夜). The write
2412
+ * hot-applies everywhere via the `settings/changed` frame — no restart. */
2413
+ function ThemePage(props) {
2414
+ const [mode, setMode] = useState(props.initial.mode);
2415
+ return jsxs("div", {
2416
+ "data-region": "theme-page",
2417
+ className: "flex max-w-xl flex-col gap-4",
2418
+ children: [jsx("p", {
2419
+ className: "text-sm text-muted-foreground",
2420
+ children: "模式立即生效并写入配置文件;跟随系统时随操作系统的日夜间设置自动切换。"
2421
+ }), jsx(Tabs, {
2422
+ value: mode,
2423
+ onValueChange: (next) => {
2424
+ const choice = next;
2425
+ setMode(choice);
2426
+ props.onChoose({ mode: choice });
2427
+ },
2428
+ children: jsxs(TabsList, {
2429
+ "aria-label": "主题模式",
2430
+ children: [
2431
+ jsx(TabsTrigger, {
2432
+ value: "system",
2433
+ "data-mode-option": "system",
2434
+ children: "跟随系统"
2435
+ }),
2436
+ jsx(TabsTrigger, {
2437
+ value: "light",
2438
+ "data-mode-option": "light",
2439
+ children: "白天"
2440
+ }),
2441
+ jsx(TabsTrigger, {
2442
+ value: "dark",
2443
+ "data-mode-option": "dark",
2444
+ children: "黑夜"
2445
+ })
2446
+ ]
2447
+ })
2448
+ })]
2449
+ });
2450
+ }
2451
+ /** The settings shell: a fullscreen surface that renders the registered
2452
+ * pages as a menu on the left and the active page's content on the right.
2453
+ * Pages come from any plugin via the settings seam; the shell itself
2454
+ * contributes the first-party plugin page (management and market fused)
2455
+ * plus theme and app-update pages. */
2456
+ function SettingsDialog({ ctx }) {
2457
+ useRefresh(ctx, ["settings/open-changed", "settings/pages-changed"]);
2458
+ const open = ctx.settingsPages.isOpen();
2459
+ const pages = ctx.settingsPages.list();
2460
+ const activeId = ctx.settingsPages.active();
2461
+ const active = pages.find((page) => page.id === activeId);
2462
+ useEffect(() => {
2463
+ if (!open) return;
2464
+ const onKey = (event) => {
2465
+ if (event.key === "Escape") ctx.settingsPages.close();
2466
+ };
2467
+ window.addEventListener("keydown", onKey);
2468
+ return () => {
2469
+ window.removeEventListener("keydown", onKey);
2470
+ };
2471
+ }, [open]);
2472
+ if (!open) return null;
2473
+ return jsxs("div", {
2474
+ "data-region": "settings-shell",
2475
+ "data-active-page": activeId,
2476
+ className: "fixed inset-0 z-50 flex flex-col bg-background",
2477
+ children: [jsxs("div", {
2478
+ className: "flex items-center justify-between border-b border-border px-5 py-3",
2479
+ children: [jsx("span", {
2480
+ className: "text-lg font-medium",
2481
+ children: "设置"
2482
+ }), jsx(Button, {
2483
+ variant: "ghost",
2484
+ size: "icon",
2485
+ "aria-label": "关闭设置",
2486
+ onClick: () => ctx.settingsPages.close(),
2487
+ children: jsx(X, { className: "h-5 w-5" })
2488
+ })]
2489
+ }), jsxs("div", {
2490
+ className: "flex min-h-0 flex-1",
2491
+ children: [jsx(DragScroll, {
2492
+ "data-region": "settings-menu",
2493
+ className: "flex w-60 shrink-0 flex-col gap-1 border-r border-border p-2",
2494
+ children: pages.map((page) => jsx("button", {
2495
+ type: "button",
2496
+ "data-settings-page": page.id,
2497
+ "aria-current": page.active || void 0,
2498
+ className: `flex h-12 w-full items-center rounded-md px-4 text-left text-base transition-colors hover:bg-accent ${page.active ? "bg-accent font-medium text-accent-foreground channel-keyline" : "text-muted-foreground"}`,
2499
+ onClick: () => ctx.settingsPages.open(page.id),
2500
+ children: page.title
2501
+ }, page.id))
2502
+ }), jsx("div", {
2503
+ "data-region": "settings-content",
2504
+ "data-active-page": activeId,
2505
+ className: "flex min-w-0 flex-1 flex-col overflow-auto p-6",
2506
+ children: active?.render()
2507
+ })]
2508
+ })]
2509
+ });
2510
+ }
2511
+ /** The settings dialog occupant. */
2512
+ const settingsDialogPlugin = {
2513
+ name: "framework settings",
2514
+ inject: ["uiContract", "settingsPages"],
2515
+ apply(ctx) {
2516
+ const client = ctx.get("client");
2517
+ let theme = DEFAULT_THEME;
2518
+ const adoptTheme = (value) => {
2519
+ const parsed = themeSettingsSchema.safeParse(value);
2520
+ if (!parsed.success) return;
2521
+ theme = parsed.data;
2522
+ applyTheme(document, theme);
2523
+ };
2524
+ applyTheme(document, theme);
2525
+ client.link.call("settings.get", { key: THEME_SETTINGS_KEY }).then((result) => {
2526
+ if (result.ok) adoptTheme(result.value.value);
2527
+ }).catch(() => {});
2528
+ ctx.effect(() => subscribeFrame(client.link, "settings/changed", settingsChangedSchema, (change) => {
2529
+ if (change.key === "ui.theme") adoptTheme(change.value);
2530
+ }));
2531
+ const osPreference = window.matchMedia("(prefers-color-scheme: light)");
2532
+ ctx.effect(() => {
2533
+ const sync = () => {
2534
+ if (theme.mode === "system") applyTheme(document, theme);
2535
+ };
2536
+ osPreference.addEventListener("change", sync);
2537
+ return () => {
2538
+ osPreference.removeEventListener("change", sync);
2539
+ };
2540
+ });
2541
+ const chooseTheme = (next) => {
2542
+ theme = next;
2543
+ applyTheme(document, next);
2544
+ client.link.call("settings.set", {
2545
+ key: THEME_SETTINGS_KEY,
2546
+ value: next
2547
+ });
2548
+ };
2549
+ ctx.settingsPages.register(ctx, {
2550
+ id: "plugins",
2551
+ title: "插件",
2552
+ order: 0,
2553
+ render() {
2554
+ return jsx(PluginsPage, { ctx });
2555
+ }
2556
+ });
2557
+ ctx.settingsPages.register(ctx, {
2558
+ id: "theme",
2559
+ title: "主题",
2560
+ order: 1,
2561
+ render() {
2562
+ return jsx(ThemePage, {
2563
+ initial: theme,
2564
+ onChoose: chooseTheme
2565
+ });
2566
+ }
2567
+ });
2568
+ ctx.settingsPages.register(ctx, {
2569
+ id: "update",
2570
+ title: "软件更新",
2571
+ order: 2,
2572
+ render() {
2573
+ return jsx(UpdatePage, { ctx });
2574
+ }
2575
+ });
2576
+ ctx.uiContract.registerLayout(ctx, {
2577
+ id: "settings-dialog",
2578
+ order: 100,
2579
+ slots: [UI_SLOT_IDS.overlay],
2580
+ render() {
2581
+ return jsx(SettingsDialog, { ctx });
2582
+ }
2583
+ });
2584
+ }
2585
+ };
2586
+ //#endregion
2587
+ //#region lib/types/session.js
2588
+ /** Mounts the `ctx.session` mirror; state lives in the apply closure. */
2589
+ const sessionPlugin = {
2590
+ name: "client-session",
2591
+ inject: ["client"],
2592
+ apply(ctx) {
2593
+ const { link } = ctx.client;
2594
+ let operator = null;
2595
+ let restored = false;
2596
+ const sync = (next) => {
2597
+ operator = next;
2598
+ ctx.emit("session/changed", operator);
2599
+ };
2600
+ link.call("session.current", {}).then((result) => {
2601
+ if (result.ok) sync(result.value.operator);
2602
+ }).catch(() => {}).finally(() => {
2603
+ restored = true;
2604
+ ctx.emit("session/restored");
2605
+ });
2606
+ ctx.effect(() => subscribeFrame(link, "settings/changed", settingsChangedSchema, (change) => {
2607
+ if (change.key !== SESSION_OPERATOR_KEY) return;
2608
+ link.call("session.current", {}).then((result) => {
2609
+ if (result.ok && result.value.operator !== operator) sync(result.value.operator);
2610
+ }).catch(() => {});
2611
+ }));
2612
+ ctx.provide("session", {
2613
+ current: () => operator,
2614
+ restored: () => restored,
2615
+ async login(raw) {
2616
+ const trimmed = raw.trim();
2617
+ if (trimmed === "") throw new Error("工号不能为空");
2618
+ const result = await link.call("session.login", { operator: trimmed });
2619
+ if (!result.ok) throw new Error(`登录失败(${result.error.code})`);
2620
+ sync(trimmed);
2621
+ },
2622
+ async logout() {
2623
+ const result = await link.call("session.logout", {});
2624
+ if (!result.ok) throw new Error(`退出失败(${result.error.code})`);
2625
+ sync(null);
2626
+ }
2627
+ });
2628
+ }
2629
+ };
2630
+ //#endregion
2631
+ //#region lib/types/variables.js
2632
+ /**
2633
+ * Mounts the `ctx.variables` registry. All state lives in construction-time
2634
+ * closures: traceable context proxies rebind `this` on every method access,
2635
+ * so `this`-reached state cannot back a service here (the uiSlots lesson).
2636
+ */
2637
+ const variablesPlugin = {
2638
+ name: "client-variables",
2639
+ apply(ctx) {
2640
+ const variables = /* @__PURE__ */ new Map();
2641
+ ctx.provide("variables", {
2642
+ list() {
2643
+ return [...variables.values()];
2644
+ },
2645
+ register(caller, defs) {
2646
+ const keys = defs.map((def) => pointKey(def));
2647
+ if (new Set(keys).size !== keys.length) throw new Error(`variables: duplicate addresses in one registration: ${keys.join(", ")}`);
2648
+ const installed = [];
2649
+ for (const def of defs) {
2650
+ const entry = {
2651
+ ...def,
2652
+ source: "plugin"
2653
+ };
2654
+ const key = pointKey(def);
2655
+ installed.push({
2656
+ key,
2657
+ entry
2658
+ });
2659
+ variables.set(key, entry);
2660
+ }
2661
+ ctx.emit("variables/changed");
2662
+ const remove = () => {
2663
+ let changed = false;
2664
+ for (const { key, entry } of installed) if (variables.get(key) === entry) {
2665
+ variables.delete(key);
2666
+ changed = true;
2667
+ }
2668
+ if (changed) ctx.emit("variables/changed");
2669
+ };
2670
+ caller.effect(() => remove);
2671
+ return remove;
2672
+ }
2673
+ });
2674
+ }
2675
+ };
2676
+ /**
2677
+ * Resolve a binding against the generic mapping document. The device must
2678
+ * exist, the group entity must exist on it, and the point form must find
2679
+ * the point in exactly that group — an unknown group name must read as
2680
+ * unresolved, never as "all normal". A group resolves with at least one
2681
+ * member, in the document's order (the driver's projection defines it; the
2682
+ * first active member is a stable "primary").
2683
+ */
2684
+ function resolveBinding(doc, binding) {
2685
+ if (!doc.devices.some((device) => device.id === binding.device)) return void 0;
2686
+ if (!doc.groups.some((group) => group.deviceId === binding.device && group.name === binding.group)) return void 0;
2687
+ if ("name" in binding) {
2688
+ if (!doc.points.some((point) => point.deviceId === binding.device && point.group === binding.group && point.name === binding.name)) return void 0;
2689
+ return {
2690
+ kind: "point",
2691
+ ref: {
2692
+ device: binding.device,
2693
+ group: binding.group,
2694
+ name: binding.name
2695
+ },
2696
+ name: binding.name
2697
+ };
2698
+ }
2699
+ const members = doc.points.filter((point) => point.deviceId === binding.device && point.group === binding.group).map((point) => ({
2700
+ ref: {
2701
+ device: point.deviceId,
2702
+ group: point.group,
2703
+ name: point.name
2704
+ },
2705
+ name: point.name
2706
+ }));
2707
+ if (members.length === 0) return void 0;
2708
+ return {
2709
+ kind: "group",
2710
+ device: binding.device,
2711
+ group: binding.group,
2712
+ members
2713
+ };
2714
+ }
2715
+ /** Any-active truthiness: `true`, a nonzero number/bigint, a non-empty string. */
2716
+ function isActiveValue(value) {
2717
+ if (value === null) return false;
2718
+ if (typeof value === "boolean") return value;
2719
+ if (typeof value === "bigint") return value !== 0n;
2720
+ if (typeof value === "number") return value !== 0;
2721
+ return value.length > 0;
2722
+ }
2723
+ /**
2724
+ * Watch one binding and emit a `BindingView` on every change. The watcher
2725
+ * resolves the generic mapping document itself, seeds current values with
2726
+ * one `field.points.read`, follows `field/point-updated` frames, and
2727
+ * re-resolves on a `field/mappings-changed` frame — re-mapping takes effect
2728
+ * without remounting the consumer.
2729
+ *
2730
+ * The returned stop function drops this watcher's subscription references;
2731
+ * the host counts references per address, so overlapping watchers of one
2732
+ * address never starve each other.
2733
+ */
2734
+ function watchBinding(ctx, binding, listener) {
2735
+ const { link } = ctx.client;
2736
+ let live = true;
2737
+ let resolved;
2738
+ let refs = [];
2739
+ const samples = /* @__PURE__ */ new Map();
2740
+ const compute = () => {
2741
+ if (resolved === void 0) {
2742
+ listener({ kind: "unresolved" });
2743
+ return;
2744
+ }
2745
+ if (resolved.kind === "point") {
2746
+ listener({
2747
+ kind: "point",
2748
+ ref: resolved.ref,
2749
+ name: resolved.name,
2750
+ sample: samples.get(pointKey(resolved.ref))
2751
+ });
2752
+ return;
2753
+ }
2754
+ const active = [];
2755
+ const abnormal = [];
2756
+ const pending = [];
2757
+ for (const member of resolved.members) {
2758
+ const sample = samples.get(pointKey(member.ref));
2759
+ if (sample === void 0 || sample.value === null && sample.time === 0) pending.push(member.name);
2760
+ else if (sample.value === null) abnormal.push(member.name);
2761
+ else if (isActiveValue(sample.value)) active.push({
2762
+ name: member.name,
2763
+ value: sample.value,
2764
+ time: sample.time
2765
+ });
2766
+ }
2767
+ listener({
2768
+ kind: "group",
2769
+ device: resolved.device,
2770
+ group: resolved.group,
2771
+ status: active.length > 0 ? "active" : "normal",
2772
+ active,
2773
+ abnormal,
2774
+ pending
2775
+ });
2776
+ };
2777
+ const resubscribe = () => {
2778
+ link.call("equipment.mappings.list", {}).then((result) => {
2779
+ if (!live || !result.ok) return;
2780
+ const next = resolveBinding(result.value.mappings, binding);
2781
+ const nextRefs = next === void 0 ? [] : next.kind === "point" ? [next.ref] : next.members.map((member) => member.ref);
2782
+ const wanted = new Set(nextRefs.map(pointKey));
2783
+ for (const ref of refs) if (!wanted.has(pointKey(ref))) link.call("equipment.points.unsubscribe", { points: [ref] }).catch(() => {});
2784
+ for (const ref of nextRefs) if (!refs.some((current) => pointKey(current) === pointKey(ref))) link.call("equipment.points.subscribe", { points: [ref] }).catch(() => {});
2785
+ refs = nextRefs;
2786
+ resolved = next;
2787
+ samples.clear();
2788
+ if (nextRefs.length === 0) {
2789
+ compute();
2790
+ return;
2791
+ }
2792
+ link.call("equipment.points.read", { points: [...nextRefs] }).then((read) => {
2793
+ if (!live) return;
2794
+ if (read.ok) for (const sample of read.value.samples) samples.set(pointKey(sample), {
2795
+ value: sample.value,
2796
+ time: sample.time
2797
+ });
2798
+ compute();
2799
+ }).catch(() => {
2800
+ if (live) compute();
2801
+ });
2802
+ }).catch(() => {});
2803
+ };
2804
+ const detachUpdates = subscribeFrame(link, "equipment/point-updated", equipmentFrameSchemas["equipment/point-updated"], (frame) => {
2805
+ const ref = {
2806
+ device: frame.device,
2807
+ group: frame.group,
2808
+ name: frame.name
2809
+ };
2810
+ if (!refs.some((current) => pointKey(current) === pointKey(ref))) return;
2811
+ samples.set(pointKey(ref), {
2812
+ value: frame.value,
2813
+ time: frame.time
2814
+ });
2815
+ compute();
2816
+ });
2817
+ const detachConfig = link.subscribe("equipment/mappings-changed", () => {
2818
+ resubscribe();
2819
+ });
2820
+ resubscribe();
2821
+ return () => {
2822
+ live = false;
2823
+ detachUpdates();
2824
+ detachConfig();
2825
+ if (refs.length > 0) link.call("equipment.points.unsubscribe", { points: [...refs] }).catch(() => {});
2826
+ };
2827
+ }
2828
+ /** Stable identity of a binding for hook dependency lists. */
2829
+ function bindingKey(binding) {
2830
+ return "name" in binding ? `${binding.device}/${binding.group}/${binding.name}` : `${binding.device}/${binding.group}`;
2831
+ }
2832
+ /** The React recipe over `watchBinding`; `unresolved` until first observation. */
2833
+ function useBinding(ctx, binding) {
2834
+ const [view, setView] = useState({ kind: "unresolved" });
2835
+ const { link } = ctx.client;
2836
+ const key = bindingKey(binding);
2837
+ useEffect(() => watchBinding(ctx, binding, setView), [link, key]);
2838
+ return view;
2839
+ }
2840
+ /**
2841
+ * The consume recipe for demand plugins: seed the current value with a
2842
+ * `field.points.read`, then follow `field/point-updated` increments. Returns
2843
+ * `undefined` until the first observation — an unmapped declaration stays
2844
+ * undefined.
2845
+ *
2846
+ * The hook holds one subscription reference for its lifetime; the matching
2847
+ * `field.points.unsubscribe` fires on unmount, and the host's reference
2848
+ * counting keeps other watchers of the same address live.
2849
+ */
2850
+ function usePoint(ctx, binding) {
2851
+ const [sample, setSample] = useState(void 0);
2852
+ const { link } = ctx.client;
2853
+ const key = pointKey(binding);
2854
+ useEffect(() => {
2855
+ let live = true;
2856
+ const detach = subscribeFrame(link, "equipment/point-updated", equipmentFrameSchemas["equipment/point-updated"], (frame) => {
2857
+ const frameRef = {
2858
+ device: frame.device,
2859
+ group: frame.group,
2860
+ name: frame.name
2861
+ };
2862
+ if (pointKey(frameRef) !== key) return;
2863
+ if (live) setSample({
2864
+ value: frame.value,
2865
+ time: frame.time
2866
+ });
2867
+ });
2868
+ link.call("equipment.points.subscribe", { points: [binding] }).catch(() => {});
2869
+ link.call("equipment.points.read", { points: [binding] }).then((result) => {
2870
+ if (!live || !result.ok) return;
2871
+ const first = result.value.samples[0];
2872
+ if (first !== void 0) setSample({
2873
+ value: first.value,
2874
+ time: first.time
2875
+ });
2876
+ }).catch(() => {});
2877
+ return () => {
2878
+ live = false;
2879
+ detach();
2880
+ link.call("equipment.points.unsubscribe", { points: [binding] }).catch(() => {});
2881
+ };
2882
+ }, [link, key]);
2883
+ return sample;
2884
+ }
2885
+ //#endregion
2886
+ //#region lib/types/workflows.js
2887
+ /** Whether two epoch-ms times fall on the same local calendar day. */
2888
+ function sameLocalDay(a, b) {
2889
+ return new Date(a).toDateString() === new Date(b).toDateString();
2890
+ }
2891
+ /**
2892
+ * Mounts the `ctx.workflows` registry. All state lives in construction-time
2893
+ * closures: traceable context proxies rebind `this` on every method access,
2894
+ * so `this`-reached state cannot back a service here (the uiSlots lesson).
2895
+ */
2896
+ const workflowsPlugin = {
2897
+ name: "client-workflows",
2898
+ apply(ctx) {
2899
+ const workflows = /* @__PURE__ */ new Map();
2900
+ let nextSeq = 0;
2901
+ let activeId;
2902
+ let operator = null;
2903
+ let events = [];
2904
+ const satisfies = (requirement) => events.some((event) => event.action === requirement.action && (requirement.scope === "day" ? sameLocalDay(event.time, Date.now()) : event.actor === operator && sameLocalDay(event.time, Date.now())));
2905
+ const changed = () => {
2906
+ ctx.emit("workflow/changed");
2907
+ };
2908
+ ctx.provide("workflows", {
2909
+ list() {
2910
+ return [...workflows.values()].sort((a, b) => a.def.order - b.def.order || a.seq - b.seq).map(({ def, alert }) => ({
2911
+ ...def,
2912
+ unlocked: def.requires.every(satisfies),
2913
+ active: def.id === activeId,
2914
+ alert
2915
+ }));
2916
+ },
2917
+ register(caller, def) {
2918
+ workflows.set(def.id, {
2919
+ def,
2920
+ alert: null,
2921
+ seq: nextSeq++
2922
+ });
2923
+ changed();
2924
+ const remove = () => {
2925
+ if (workflows.delete(def.id)) {
2926
+ if (activeId === def.id) activeId = void 0;
2927
+ changed();
2928
+ }
2929
+ };
2930
+ caller.effect(() => remove);
2931
+ return remove;
2932
+ },
2933
+ setActive(id) {
2934
+ if (activeId === id) return;
2935
+ activeId = id;
2936
+ changed();
2937
+ },
2938
+ setAlert(id, alert) {
2939
+ const entry = workflows.get(id);
2940
+ if (entry === void 0 || entry.alert === alert) return;
2941
+ if (alert !== null && alert.kind === "halo") alert = {
2942
+ kind: "halo",
2943
+ progress: Math.min(1, Math.max(0, alert.progress))
2944
+ };
2945
+ entry.alert = alert;
2946
+ changed();
2947
+ },
2948
+ setOperator(next) {
2949
+ if (operator === next) return;
2950
+ operator = next;
2951
+ changed();
2952
+ },
2953
+ feedEvents(next) {
2954
+ events = [...next].sort((a, b) => a.time - b.time);
2955
+ changed();
2956
+ },
2957
+ announce(action, actor) {
2958
+ events = [...events, {
2959
+ action,
2960
+ actor: actor ?? operator ?? "client",
2961
+ time: Date.now()
2962
+ }];
2963
+ changed();
2964
+ }
2965
+ });
2966
+ }
2967
+ };
2968
+ //#endregion
2969
+ //#region lib/types/Shell.js
2970
+ /** Shell root; mounted once by {@link ../index.tsx | createClientRuntime}. */
2971
+ function Shell(props) {
2972
+ const [tick, setTick] = useState(0);
2973
+ useEffect(() => {
2974
+ const detach = props.ctx.on("ui/slot-changed", () => setTick((value) => value + 1));
2975
+ return () => {
2976
+ detach();
2977
+ };
2978
+ }, [props.ctx]);
2979
+ const layouts = props.ctx.uiSlots.list(UI_SLOT_IDS.layout);
2980
+ const content = layouts.length > 0 ? layouts.map((occupant) => jsx(Fragment$1, { children: occupant.render() }, occupant.id)) : jsx("div", {
2981
+ className: "m-auto text-muted-foreground",
2982
+ children: "没有已加载的布局插件。"
2983
+ });
2984
+ return jsx("div", {
2985
+ className: "flex h-full flex-col",
2986
+ "data-tick": tick,
2987
+ children: content
2988
+ });
2989
+ }
2990
+ //#endregion
2991
+ //#region lib/types/runtime.js
2992
+ /**
2993
+ * The client renderer host: takes over the React root from the kernel,
2994
+ * mounts the phase-1 occupant plugins into one client Cordis context, and
2995
+ * renders the slot-driven shell that re-renders whenever any occupied slot
2996
+ * changes. When no layout plugin registered, the shell degrades to a notice
2997
+ * instead of an empty window.
2998
+ *
2999
+ * @module @snap-rail/framework-shadcn/runtime
3000
+ */
3001
+ /**
3002
+ * Take over the booted root: stop the startup page, mount the client plugin
3003
+ * tree, render the shell.
3004
+ *
3005
+ * @param handle - the kernel's boot result.
3006
+ * @param options - see {@link RuntimeOptions}.
3007
+ */
3008
+ async function createClientRuntime(handle, options) {
3009
+ const ctx = new Context();
3010
+ ctx.provide("client", { link: handle.link });
3011
+ await ctx.plugin(TimerService);
3012
+ await ctx.plugin(slotsPlugin);
3013
+ await ctx.plugin(semanticPlugin);
3014
+ await ctx.plugin(settingsPlugin);
3015
+ await ctx.plugin(settingsDialogPlugin);
3016
+ await ctx.plugin(variablesPlugin);
3017
+ await ctx.plugin(sessionPlugin);
3018
+ await ctx.plugin(workflowsPlugin);
3019
+ for (const seat of options.plugins) {
3020
+ const spec = "plugin" in seat ? seat : { plugin: seat };
3021
+ const plugin = spec.plugin;
3022
+ await ctx.plugin(plugin, spec.config === void 0 ? {} : spec.config);
3023
+ }
3024
+ await ctx.plugin(fallbackShellPlugin);
3025
+ handle.root.unmount();
3026
+ const shellRoot = createRoot(handle.element);
3027
+ shellRoot.render(jsx(Shell, { ctx }));
3028
+ return {
3029
+ ctx,
3030
+ dispose: () => ctx.fiber.dispose().then(() => {
3031
+ shellRoot.unmount();
3032
+ })
3033
+ };
3034
+ }
3035
+ //#endregion
3036
+ export { CUSTOM_UI_SLOT_PATTERN, STANDARD_UI_SLOT_IDS, UI_SLOT_IDS, UI_SLOT_MODES, createClientRuntime, isValidUiSlotId, resolveBinding, slotModeOf, useBinding, usePoint, watchBinding };