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