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

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