@lapis-notes/workspace 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/LICENSE.md +4 -0
  3. package/README.md +36 -0
  4. package/dist/WorkspaceShell.svelte +59 -0
  5. package/dist/WorkspaceShell.svelte.d.ts +14 -0
  6. package/dist/index.d.ts +6 -0
  7. package/dist/index.js +5 -0
  8. package/dist/plugin-management/CommunityPluginsSettingsPage.svelte +182 -0
  9. package/dist/plugin-management/CommunityPluginsSettingsPage.svelte.d.ts +5 -0
  10. package/dist/plugin-management/CorePluginsSettingsPage.svelte +111 -0
  11. package/dist/plugin-management/CorePluginsSettingsPage.svelte.d.ts +5 -0
  12. package/dist/plugin-management/PluginReadmeRenderer.svelte +47 -0
  13. package/dist/plugin-management/PluginReadmeRenderer.svelte.d.ts +9 -0
  14. package/dist/plugin-management/PluginRegistrySettingsPage.svelte +751 -0
  15. package/dist/plugin-management/PluginRegistrySettingsPage.svelte.d.ts +5 -0
  16. package/dist/plugin-management/PluginSettingsRow.svelte +152 -0
  17. package/dist/plugin-management/PluginSettingsRow.svelte.d.ts +24 -0
  18. package/dist/plugin-management/plugin-management-context.svelte.d.ts +15 -0
  19. package/dist/plugin-management/plugin-management-context.svelte.js +24 -0
  20. package/dist/plugin-management/plugin-management.css +477 -0
  21. package/dist/plugin-management/plugin-readme.d.ts +26 -0
  22. package/dist/plugin-management/plugin-readme.js +34 -0
  23. package/dist/plugin-management/plugin-settings-navigation.d.ts +8 -0
  24. package/dist/plugin-management/plugin-settings-navigation.js +37 -0
  25. package/dist/plugin-management/register-plugin-management-settings.d.ts +3 -0
  26. package/dist/plugin-management/register-plugin-management-settings.js +95 -0
  27. package/dist/workspace-shell.css +18 -0
  28. package/package.json +69 -0
@@ -0,0 +1,751 @@
1
+ <script lang="ts">
2
+ import AlertTriangle from "@lucide/svelte/icons/alert-triangle";
3
+ import ArrowUpCircle from "@lucide/svelte/icons/arrow-up-circle";
4
+ import BookOpen from "@lucide/svelte/icons/book-open";
5
+ import CalendarClock from "@lucide/svelte/icons/calendar-clock";
6
+ import Check from "@lucide/svelte/icons/check";
7
+ import Download from "@lucide/svelte/icons/download";
8
+ import PackageIcon from "@lucide/svelte/icons/package";
9
+ import RefreshCw from "@lucide/svelte/icons/refresh-cw";
10
+ import ShieldAlert from "@lucide/svelte/icons/shield-alert";
11
+ import Trash2 from "@lucide/svelte/icons/trash-2";
12
+ import Upload from "@lucide/svelte/icons/upload";
13
+ import {
14
+ DEFAULT_OFFICIAL_PLUGIN_REGISTRY_SOURCE,
15
+ isAbortError,
16
+ Notice,
17
+ PluginDistributionError,
18
+ withPluginInstallProgress,
19
+ type InstalledPluginRecord,
20
+ type PluginCatalogDetail,
21
+ type PluginCatalogEntry,
22
+ type PluginUpdateInfo,
23
+ } from "@lapis-notes/api";
24
+ import * as Alert from "@lapismd/design-core/shadcn/alert";
25
+ import * as AlertDialog from "@lapismd/design-core/shadcn/alert-dialog";
26
+ import * as Badge from "@lapismd/design-core/shadcn/badge";
27
+ import * as Button from "@lapismd/design-core/shadcn/button";
28
+ import * as Dialog from "@lapismd/design-core/shadcn/dialog";
29
+ import * as Input from "@lapismd/design-core/shadcn/input";
30
+ import * as Resizable from "@lapismd/design-core/shadcn/resizable";
31
+ import * as Separator from "@lapismd/design-core/shadcn/separator";
32
+ import * as Tabs from "@lapismd/design-core/shadcn/tabs";
33
+ import type { WorkspaceSettingsPageProps } from "@lapismd/design-core/workspace/settings";
34
+ import { onMount, tick, untrack } from "svelte";
35
+ import { getPluginManagementContext } from "./plugin-management-context.svelte";
36
+ import "./plugin-management.css";
37
+ import {
38
+ fetchPluginReadmeMarkdown,
39
+ type PluginReadmeLoadState,
40
+ } from "./plugin-readme";
41
+ import PluginReadmeRenderer from "./PluginReadmeRenderer.svelte";
42
+
43
+ type PluginsRegistryTabId = "installed" | "browse" | "updates" | "sources";
44
+
45
+ let { controller }: WorkspaceSettingsPageProps = $props();
46
+ const context = untrack(() => getPluginManagementContext(controller));
47
+ const app = context.app;
48
+
49
+ let activeTab = $state<PluginsRegistryTabId>("installed");
50
+ let searchText = $state("");
51
+ let catalogEntries = $state<PluginCatalogEntry[]>([]);
52
+ let installed = $state<InstalledPluginRecord[]>([]);
53
+ let updates = $state<PluginUpdateInfo[]>([]);
54
+ let lastError = $state<Error | null>(null);
55
+ let refreshing = $state(false);
56
+ let runningAction = $state<string | null>(null);
57
+ let detailDialogOpen = $state(false);
58
+ let uninstallConfirmOpen = $state(false);
59
+ let uninstallTarget = $state<InstalledPluginRecord | null>(null);
60
+ let detailPluginId = $state<string | null>(null);
61
+ let detailPluginDetail = $state<PluginCatalogDetail | null>(null);
62
+ let detailLoading = $state(false);
63
+ let readmeState = $state<PluginReadmeLoadState>({ status: "idle" });
64
+ let latestReleaseDates = $state<Record<string, string | null>>({});
65
+ let latestReleaseSizes = $state<Record<string, number | null>>({});
66
+ let bundleFileInput = $state<HTMLInputElement | null>(null);
67
+
68
+ let installedIds = $derived(new Set(installed.map((record) => record.pluginId)));
69
+ let staticPluginIds = $derived(
70
+ new Set(app.plugins.corePluginEntries.map((entry) => entry.manifest.id)),
71
+ );
72
+ let availablePluginIds = $derived(
73
+ new Set([...installedIds, ...staticPluginIds]),
74
+ );
75
+ let communityPlugins = $derived(
76
+ app.plugins.communityPlugins.filter(
77
+ (plugin) => !installedIds.has(plugin.manifest.id),
78
+ ),
79
+ );
80
+ let filteredCatalogEntries = $derived(
81
+ catalogEntries.filter((entry) => {
82
+ const text = searchText.trim().toLowerCase();
83
+ if (!text) return true;
84
+ return `${entry.name} ${entry.id} ${entry.description} ${entry.author}`
85
+ .toLowerCase()
86
+ .includes(text);
87
+ }),
88
+ );
89
+ let detailPluginEntry = $derived(
90
+ catalogEntries.find((entry) => entry.id === detailPluginId) ?? null,
91
+ );
92
+
93
+ $effect(() => {
94
+ const revealRevision = context.reveal.revision;
95
+ if (
96
+ !revealRevision ||
97
+ context.reveal.sectionId !== "plugin-registry" ||
98
+ !context.reveal.entryId.startsWith("registry:")
99
+ ) {
100
+ return;
101
+ }
102
+ const pluginId = context.reveal.entryId.slice("registry:".length);
103
+ activeTab = "browse";
104
+ void tick().then(async () => {
105
+ const entry = catalogEntries.find((candidate) => candidate.id === pluginId);
106
+ if (entry) await openDetails(entry);
107
+ });
108
+ });
109
+
110
+ onMount(() => {
111
+ void refresh(false);
112
+ });
113
+
114
+ async function refresh(force: boolean): Promise<void> {
115
+ refreshing = true;
116
+ try {
117
+ lastError = null;
118
+ if (force || !catalogEntries.length) {
119
+ await app.pluginDistribution.refreshCatalog({ force });
120
+ if (force) {
121
+ latestReleaseDates = {};
122
+ latestReleaseSizes = {};
123
+ }
124
+ }
125
+ catalogEntries = app.pluginDistribution.search({ channel: "all" });
126
+ installed = await app.pluginDistribution.listInstalled();
127
+ updates = await app.pluginDistribution.listUpdates();
128
+ void loadLatestReleaseDates(catalogEntries);
129
+ } catch (error) {
130
+ lastError = error instanceof Error ? error : new Error(String(error));
131
+ new Notice(`Plugin registry unavailable: ${errorMessage(lastError)}`);
132
+ } finally {
133
+ refreshing = false;
134
+ }
135
+ }
136
+
137
+ async function runAction(
138
+ actionId: string,
139
+ action: () => Promise<unknown>,
140
+ ): Promise<void> {
141
+ runningAction = actionId;
142
+ try {
143
+ lastError = null;
144
+ await action();
145
+ await refresh(false);
146
+ } catch (error) {
147
+ lastError = error instanceof Error ? error : new Error(String(error));
148
+ new Notice(`Plugin registry action failed: ${errorMessage(lastError)}`);
149
+ } finally {
150
+ runningAction = null;
151
+ }
152
+ }
153
+
154
+ async function runPluginProgressAction(
155
+ actionId: string,
156
+ pluginId: string,
157
+ title: string,
158
+ action: (signal: AbortSignal) => Promise<unknown>,
159
+ ): Promise<void> {
160
+ runningAction = actionId;
161
+ try {
162
+ lastError = null;
163
+ await withPluginInstallProgress(app, { pluginId, title }, action);
164
+ await refresh(false);
165
+ } catch (error) {
166
+ if (isAbortError(error)) {
167
+ await refresh(false);
168
+ return;
169
+ }
170
+ lastError = error instanceof Error ? error : new Error(String(error));
171
+ new Notice(`Plugin registry action failed: ${errorMessage(lastError)}`);
172
+ } finally {
173
+ runningAction = null;
174
+ }
175
+ }
176
+
177
+ async function openDetails(entry: PluginCatalogEntry): Promise<void> {
178
+ detailDialogOpen = true;
179
+ await selectDetail(entry);
180
+ }
181
+
182
+ function requestUninstall(record: InstalledPluginRecord): void {
183
+ uninstallTarget = record;
184
+ uninstallConfirmOpen = true;
185
+ }
186
+
187
+ async function confirmUninstall(): Promise<void> {
188
+ const record = uninstallTarget;
189
+ if (!record) return;
190
+ uninstallConfirmOpen = false;
191
+ uninstallTarget = null;
192
+ await runAction(`uninstall:${record.pluginId}`, () =>
193
+ app.pluginDistribution.uninstall(record.pluginId),
194
+ );
195
+ }
196
+
197
+ async function selectDetail(entry: PluginCatalogEntry): Promise<void> {
198
+ detailPluginId = entry.id;
199
+ detailPluginDetail = null;
200
+ detailLoading = true;
201
+ readmeState = { status: "loading" };
202
+ try {
203
+ const detail = await app.pluginDistribution.getPluginDetail(entry.id);
204
+ if (detailPluginId !== entry.id) return;
205
+ detailPluginDetail = detail;
206
+ latestReleaseDates = {
207
+ ...latestReleaseDates,
208
+ [entry.id]: latestReleaseDate(detail),
209
+ };
210
+ latestReleaseSizes = {
211
+ ...latestReleaseSizes,
212
+ [entry.id]: latestBundleSize(detail),
213
+ };
214
+ const readmeUrl = detail?.readmeUrl ?? entry.readmeUrl;
215
+ if (!readmeUrl) {
216
+ readmeState = { status: "missing" };
217
+ return;
218
+ }
219
+ const markdown = await fetchPluginReadmeMarkdown(readmeUrl, {
220
+ pluginId: entry.id,
221
+ detailUrl: entry.detail,
222
+ });
223
+ if (detailPluginId !== entry.id) return;
224
+ readmeState = { status: "loaded", url: readmeUrl, markdown };
225
+ } catch (error) {
226
+ if (detailPluginId !== entry.id) return;
227
+ readmeState = {
228
+ status: "error",
229
+ message: error instanceof Error ? error.message : String(error),
230
+ };
231
+ } finally {
232
+ if (detailPluginId === entry.id) detailLoading = false;
233
+ }
234
+ }
235
+
236
+ function errorMessage(error: Error): string {
237
+ return error instanceof PluginDistributionError
238
+ ? `${error.code}: ${error.message}`
239
+ : error.message;
240
+ }
241
+
242
+ function badge(value: string): string {
243
+ return value
244
+ .split(/[\s-]+/)
245
+ .filter(Boolean)
246
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
247
+ .join(" ");
248
+ }
249
+
250
+ async function loadLatestReleaseDates(
251
+ entries: PluginCatalogEntry[],
252
+ ): Promise<void> {
253
+ const missing = entries.filter(
254
+ (entry) =>
255
+ !Object.prototype.hasOwnProperty.call(latestReleaseDates, entry.id) ||
256
+ !Object.prototype.hasOwnProperty.call(latestReleaseSizes, entry.id),
257
+ );
258
+ if (!missing.length) return;
259
+ const summaries = await Promise.all(
260
+ missing.map(async (entry) => {
261
+ try {
262
+ const detail = await app.pluginDistribution.getPluginDetail(entry.id);
263
+ return [
264
+ entry.id,
265
+ { releasedAt: latestReleaseDate(detail), size: latestBundleSize(detail) },
266
+ ] as const;
267
+ } catch {
268
+ return [entry.id, { releasedAt: null, size: null }] as const;
269
+ }
270
+ }),
271
+ );
272
+ latestReleaseDates = {
273
+ ...latestReleaseDates,
274
+ ...Object.fromEntries(summaries.map(([id, value]) => [id, value.releasedAt])),
275
+ };
276
+ latestReleaseSizes = {
277
+ ...latestReleaseSizes,
278
+ ...Object.fromEntries(summaries.map(([id, value]) => [id, value.size])),
279
+ };
280
+ }
281
+
282
+ function latestReleaseDate(detail: PluginCatalogDetail | null): string | null {
283
+ return detail?.versions[detail.latestVersion]?.releasedAt ?? null;
284
+ }
285
+
286
+ function latestBundleSize(detail: PluginCatalogDetail | null): number | null {
287
+ return detail?.versions[detail.latestVersion]?.bundle.size ?? null;
288
+ }
289
+
290
+ function installedSize(record: InstalledPluginRecord): number | null {
291
+ const detailSize = latestReleaseSizes[record.pluginId];
292
+ if (typeof detailSize === "number") return detailSize;
293
+ return record.files.length
294
+ ? record.files.reduce((total, file) => total + file.size, 0)
295
+ : null;
296
+ }
297
+
298
+ function formatReleaseDate(value: string): string | null {
299
+ const date = new Date(value);
300
+ if (Number.isNaN(date.getTime())) return null;
301
+ return new Intl.DateTimeFormat(undefined, {
302
+ month: "short",
303
+ day: "numeric",
304
+ year: "numeric",
305
+ }).format(date);
306
+ }
307
+
308
+ function updateDescription(update: PluginUpdateInfo): string {
309
+ return [
310
+ `${update.currentVersion} -> ${update.targetVersion}`,
311
+ typeof update.bundleSize === "number"
312
+ ? `Size ${formatByteSize(update.bundleSize)}`
313
+ : "",
314
+ update.targetVersion !== update.latestVersion
315
+ ? `Latest ${update.latestVersion}`
316
+ : "",
317
+ update.reasons.length ? `Blocked: ${update.reasons.join(", ")}` : "",
318
+ ]
319
+ .filter(Boolean)
320
+ .join(" | ");
321
+ }
322
+
323
+ function formatByteSize(bytes: number): string {
324
+ if (bytes < 1024) return `${bytes} B`;
325
+ const units = ["KiB", "MiB", "GiB"];
326
+ let value = bytes / 1024;
327
+ let unitIndex = 0;
328
+ while (value >= 1024 && unitIndex < units.length - 1) {
329
+ value /= 1024;
330
+ unitIndex += 1;
331
+ }
332
+ return `${new Intl.NumberFormat(undefined, {
333
+ maximumFractionDigits: value >= 10 ? 0 : 1,
334
+ }).format(value)} ${units[unitIndex]}`;
335
+ }
336
+
337
+ function openBundleFilePicker(): void {
338
+ bundleFileInput?.click();
339
+ }
340
+
341
+ async function installSelectedBundle(event: Event): Promise<void> {
342
+ const input = event.currentTarget as HTMLInputElement;
343
+ const file = input.files?.[0];
344
+ input.value = "";
345
+ if (!file) return;
346
+ if (!file.name.endsWith(".lapis-plugin")) {
347
+ new Notice("Select a .lapis-plugin bundle.");
348
+ return;
349
+ }
350
+ await runPluginProgressAction(
351
+ "install-bundle",
352
+ "*",
353
+ `Installing ${file.name}`,
354
+ async (signal) =>
355
+ app.pluginDistribution.installBundle(await file.arrayBuffer(), {
356
+ enable: true,
357
+ signal,
358
+ }),
359
+ );
360
+ }
361
+ </script>
362
+
363
+ <section
364
+ class="lapis-plugin-management lapis-plugin-registry"
365
+ data-ui-component="lapis-plugin-management"
366
+ data-ui-part="plugin-registry"
367
+ >
368
+ <header class="lapis-plugin-management__header">
369
+ <div>
370
+ <h1>Plugin registry</h1>
371
+ <p>Browse verified plugins, manage installed provenance, and review updates.</p>
372
+ </div>
373
+ <div class="lapis-plugin-management__actions">
374
+ <input
375
+ bind:this={bundleFileInput}
376
+ class="sr-only"
377
+ type="file"
378
+ accept=".lapis-plugin"
379
+ aria-label="Choose .lapis-plugin file"
380
+ onchange={(event) => void installSelectedBundle(event)}
381
+ />
382
+ <Button.Root
383
+ variant="outline"
384
+ size="sm"
385
+ disabled={runningAction === "install-bundle"}
386
+ onclick={openBundleFilePicker}
387
+ >
388
+ <Upload class="lapis-plugin-management__icon" />
389
+ Install from .lapis-plugin
390
+ </Button.Root>
391
+ <Button.Root
392
+ variant="outline"
393
+ size="sm"
394
+ disabled={refreshing}
395
+ aria-busy={refreshing}
396
+ aria-label={refreshing ? "Refreshing plugin registry" : "Refresh plugin registry"}
397
+ onclick={() => void refresh(true)}
398
+ >
399
+ <RefreshCw
400
+ class="lapis-plugin-management__icon"
401
+ data-spinning={refreshing || undefined}
402
+ />
403
+ Refresh
404
+ </Button.Root>
405
+ </div>
406
+ </header>
407
+
408
+ {#if lastError}
409
+ <Alert.Root variant="destructive">
410
+ <AlertTriangle class="lapis-plugin-management__icon" />
411
+ <Alert.Title>Plugin registry unavailable</Alert.Title>
412
+ <Alert.Description>{errorMessage(lastError)}</Alert.Description>
413
+ </Alert.Root>
414
+ {/if}
415
+
416
+ <Tabs.Root bind:value={activeTab} class="lapis-plugin-registry__tabs">
417
+ <Tabs.List class="lapis-plugin-registry__tab-list">
418
+ <Tabs.Trigger class="lapis-plugin-registry__tab" value="installed"
419
+ >Installed</Tabs.Trigger
420
+ >
421
+ <Tabs.Trigger class="lapis-plugin-registry__tab" value="browse"
422
+ >Browse</Tabs.Trigger
423
+ >
424
+ <Tabs.Trigger class="lapis-plugin-registry__tab" value="updates"
425
+ >Updates</Tabs.Trigger
426
+ >
427
+ <Tabs.Trigger class="lapis-plugin-registry__tab" value="sources"
428
+ >Sources</Tabs.Trigger
429
+ >
430
+ </Tabs.List>
431
+
432
+ <Tabs.Content value="installed" class="lapis-plugin-management__rows">
433
+ {#if !installed.length && !communityPlugins.length}
434
+ <p class="lapis-plugin-management__empty">
435
+ No installed registry or community plugins.
436
+ </p>
437
+ {/if}
438
+ {#each installed as record (record.pluginId)}
439
+ {@const runtimePlugin = app.plugins.plugins.get(record.pluginId)}
440
+ {@const size = installedSize(record)}
441
+ {@const update = updates.find((candidate) => candidate.id === record.pluginId)}
442
+ <article class="lapis-plugin-card" data-plugin-id={record.pluginId}>
443
+ <div class="lapis-plugin-card__content">
444
+ <h2>{record.pluginId}</h2>
445
+ <p>
446
+ Version {record.installedVersion} | {runtimePlugin?.enabled ? "Enabled" : "Disabled"}{typeof size === "number" ? ` | Size ${formatByteSize(size)}` : ""}
447
+ </p>
448
+ <div class="lapis-plugin-card__badges">
449
+ <Badge.Badge variant="outline">{badge(record.provenance)}</Badge.Badge>
450
+ {#if record.revoked}<Badge.Badge variant="destructive">Revoked</Badge.Badge>{/if}
451
+ {#if record.restartRequired}<Badge.Badge variant="outline">Restart required</Badge.Badge>{/if}
452
+ </div>
453
+ </div>
454
+ <div class="lapis-plugin-card__actions">
455
+ {#if update?.canUpdate}
456
+ <Button.Root
457
+ variant="outline"
458
+ size="sm"
459
+ disabled={runningAction === `update:${record.pluginId}`}
460
+ onclick={() =>
461
+ void runPluginProgressAction(
462
+ `update:${record.pluginId}`,
463
+ record.pluginId,
464
+ `Updating ${update.name}`,
465
+ (signal) =>
466
+ app.pluginDistribution.update(
467
+ update.id,
468
+ update.targetVersion,
469
+ { signal },
470
+ ),
471
+ )}
472
+ >
473
+ <ArrowUpCircle class="lapis-plugin-management__icon" />Update
474
+ </Button.Root>
475
+ {/if}
476
+ <Button.Root
477
+ variant="outline"
478
+ size="sm"
479
+ disabled={runningAction === `toggle:${record.pluginId}`}
480
+ onclick={() =>
481
+ void runAction(`toggle:${record.pluginId}`, () =>
482
+ runtimePlugin?.enabled
483
+ ? app.plugins.disablePlugin(record.pluginId)
484
+ : app.plugins.enablePlugin(record.pluginId),
485
+ )}
486
+ >{runtimePlugin?.enabled ? "Disable" : "Enable"}</Button.Root>
487
+ <Button.Root
488
+ variant="outline"
489
+ size="icon"
490
+ aria-label={`Uninstall ${record.pluginId}`}
491
+ title="Uninstall"
492
+ disabled={runningAction === `uninstall:${record.pluginId}`}
493
+ onclick={() => requestUninstall(record)}
494
+ ><Trash2 class="lapis-plugin-management__icon" /></Button.Root>
495
+ </div>
496
+ </article>
497
+ {#if record.revoked}
498
+ <Alert.Root variant="destructive">
499
+ <ShieldAlert class="lapis-plugin-management__icon" />
500
+ <Alert.Title>{record.pluginId} was revoked</Alert.Title>
501
+ <Alert.Description>{record.revoked.message ?? record.revoked.reason}</Alert.Description>
502
+ </Alert.Root>
503
+ {/if}
504
+ {/each}
505
+ {#each communityPlugins as plugin (plugin.manifest.id)}
506
+ <article class="lapis-plugin-card">
507
+ <div class="lapis-plugin-card__content">
508
+ <h2>{plugin.manifest.name}</h2>
509
+ <p>ID {plugin.manifest.id} | {plugin.enabled ? "Enabled" : "Disabled"}</p>
510
+ <div class="lapis-plugin-card__badges">
511
+ <Badge.Badge variant="outline">Manual</Badge.Badge>
512
+ </div>
513
+ </div>
514
+ </article>
515
+ {/each}
516
+ </Tabs.Content>
517
+
518
+ <Tabs.Content value="browse" class="lapis-plugin-registry__browse">
519
+ <Input.Input bind:value={searchText} placeholder="Search plugins" aria-label="Search plugins" />
520
+ {#if !filteredCatalogEntries.length}
521
+ <p class="lapis-plugin-management__empty">No registry entries loaded.</p>
522
+ {/if}
523
+ <div class="lapis-plugin-registry__grid">
524
+ {#each filteredCatalogEntries as entry (entry.id)}
525
+ {@const isInstalled = availablePluginIds.has(entry.id)}
526
+ {@const updatedAt = latestReleaseDates[entry.id]}
527
+ {@const size = latestReleaseSizes[entry.id]}
528
+ {@const updatedLabel = updatedAt ? formatReleaseDate(updatedAt) : null}
529
+ <article class="lapis-plugin-registry-card">
530
+ <div class="lapis-plugin-registry-card__icon" aria-hidden="true">
531
+ <PackageIcon class="lapis-plugin-management__icon" />
532
+ </div>
533
+ <div class="lapis-plugin-registry-card__content">
534
+ <div class="lapis-plugin-registry-card__title">
535
+ <h2>{entry.name}</h2>
536
+ {#if entry.channel === "official"}<Badge.Badge variant="outline">Official</Badge.Badge>{/if}
537
+ </div>
538
+ <p>{entry.description}</p>
539
+ <div class="lapis-plugin-registry-card__metadata">
540
+ <span>Version {entry.latestVersion}</span>
541
+ {#if typeof size === "number"}<span>Size {formatByteSize(size)}</span>{/if}
542
+ {#if updatedLabel}<span><CalendarClock class="lapis-plugin-management__small-icon" />Updated {updatedLabel}</span>{/if}
543
+ </div>
544
+ <div class="lapis-plugin-card__actions">
545
+ <Button.Root
546
+ variant="outline"
547
+ size="sm"
548
+ disabled={detailLoading && detailPluginId === entry.id}
549
+ onclick={() => void openDetails(entry)}
550
+ ><BookOpen class="lapis-plugin-management__icon" />Details</Button.Root>
551
+ <Button.Root
552
+ variant={isInstalled ? "outline" : "default"}
553
+ size="sm"
554
+ disabled={isInstalled || runningAction === `install:${entry.id}`}
555
+ onclick={() =>
556
+ void runPluginProgressAction(
557
+ `install:${entry.id}`,
558
+ entry.id,
559
+ `Installing ${entry.name}`,
560
+ (signal) =>
561
+ app.pluginDistribution.install(entry.id, {
562
+ enable: true,
563
+ requireOfficial: entry.channel === "official",
564
+ signal,
565
+ }),
566
+ )}
567
+ >
568
+ {#if isInstalled}<Check class="lapis-plugin-management__icon" />Installed{:else}<Download class="lapis-plugin-management__icon" />Install{/if}
569
+ </Button.Root>
570
+ </div>
571
+ </div>
572
+ </article>
573
+ {/each}
574
+ </div>
575
+ </Tabs.Content>
576
+
577
+ <Tabs.Content value="updates" class="lapis-plugin-management__rows">
578
+ {#if !updates.length}<p class="lapis-plugin-management__empty">No plugin updates available.</p>{/if}
579
+ {#each updates as update (update.id)}
580
+ {#if update.status === "revoked"}
581
+ <Alert.Root variant="destructive">
582
+ <ShieldAlert class="lapis-plugin-management__icon" />
583
+ <Alert.Title>{update.name} was revoked</Alert.Title>
584
+ <Alert.Description>{update.revoked?.message ?? update.revoked?.reason ?? "This installed version was revoked by the official registry."}</Alert.Description>
585
+ </Alert.Root>
586
+ {:else if !update.canUpdate}
587
+ <Alert.Root>
588
+ <AlertTriangle class="lapis-plugin-management__icon" />
589
+ <Alert.Title>{update.name} update is unavailable</Alert.Title>
590
+ <Alert.Description>{update.reasons.length ? update.reasons.join(", ") : "The latest version is not compatible with this runtime."}</Alert.Description>
591
+ </Alert.Root>
592
+ {/if}
593
+ <article class="lapis-plugin-card">
594
+ <div class="lapis-plugin-card__content">
595
+ <h2>{update.name}</h2>
596
+ <p>{updateDescription(update)}</p>
597
+ <div class="lapis-plugin-card__badges">
598
+ <Badge.Badge variant="outline">{badge(update.provenance)}</Badge.Badge>
599
+ <Badge.Badge variant={update.status === "revoked" ? "destructive" : "outline"}>{badge(update.status.replace(/-/g, " "))}</Badge.Badge>
600
+ </div>
601
+ </div>
602
+ <div class="lapis-plugin-card__actions">
603
+ <Button.Root
604
+ variant="outline"
605
+ size="sm"
606
+ disabled={!update.canUpdate || runningAction === `update:${update.id}`}
607
+ onclick={() =>
608
+ void runPluginProgressAction(
609
+ `update:${update.id}`,
610
+ update.id,
611
+ `Updating ${update.name}`,
612
+ (signal) =>
613
+ app.pluginDistribution.update(
614
+ update.id,
615
+ update.targetVersion,
616
+ { signal },
617
+ ),
618
+ )}
619
+ ><ArrowUpCircle class="lapis-plugin-management__icon" />Update</Button.Root>
620
+ </div>
621
+ </article>
622
+ {/each}
623
+ </Tabs.Content>
624
+
625
+ <Tabs.Content value="sources" class="lapis-plugin-management__rows">
626
+ <article class="lapis-plugin-card">
627
+ <div class="lapis-plugin-card__content">
628
+ <h2>{DEFAULT_OFFICIAL_PLUGIN_REGISTRY_SOURCE.name}</h2>
629
+ <p>{DEFAULT_OFFICIAL_PLUGIN_REGISTRY_SOURCE.url}</p>
630
+ <div class="lapis-plugin-card__badges">
631
+ <Badge.Badge variant="outline">Official</Badge.Badge>
632
+ <Badge.Badge variant="outline">Locked</Badge.Badge>
633
+ </div>
634
+ </div>
635
+ </article>
636
+ <Separator.Separator />
637
+ <p class="lapis-plugin-management__empty">Additional third-party registry sources are outside Full Registry V1.</p>
638
+ </Tabs.Content>
639
+ </Tabs.Root>
640
+
641
+ <Dialog.Root bind:open={detailDialogOpen}>
642
+ <Dialog.Content class="lapis-plugin-detail-dialog">
643
+ <Dialog.Header class="sr-only">
644
+ <Dialog.Title>{detailPluginEntry?.name ?? "Plugin details"}</Dialog.Title>
645
+ <Dialog.Description>Registry plugin details and README content.</Dialog.Description>
646
+ </Dialog.Header>
647
+ <div class="lapis-plugin-detail-dialog__layout">
648
+ <header class="lapis-plugin-detail-dialog__header">
649
+ <div>
650
+ <h2>{detailPluginEntry?.name ?? "Plugin details"}</h2>
651
+ <p>{detailPluginDetail?.description ?? detailPluginEntry?.description ?? "Select a plugin to review its registry details."}</p>
652
+ </div>
653
+ {#if detailPluginEntry}
654
+ {@const isInstalled = availablePluginIds.has(detailPluginEntry.id)}
655
+ <Button.Root
656
+ variant={isInstalled ? "outline" : "default"}
657
+ size="sm"
658
+ disabled={isInstalled || runningAction === `install:${detailPluginEntry.id}`}
659
+ onclick={() =>
660
+ void runPluginProgressAction(
661
+ `install:${detailPluginEntry.id}`,
662
+ detailPluginEntry.id,
663
+ `Installing ${detailPluginEntry.name}`,
664
+ (signal) =>
665
+ app.pluginDistribution.install(detailPluginEntry.id, {
666
+ enable: true,
667
+ requireOfficial: detailPluginEntry.channel === "official",
668
+ signal,
669
+ }),
670
+ )}
671
+ >{isInstalled ? "Installed" : "Install"}</Button.Root>
672
+ {/if}
673
+ </header>
674
+ <Resizable.PaneGroup direction="horizontal" class="lapis-plugin-detail-dialog__panes">
675
+ <Resizable.Pane defaultSize={30} minSize={22} maxSize={42}>
676
+ <aside class="lapis-plugin-detail-dialog__results">
677
+ <header>
678
+ <strong>Browse results</strong>
679
+ <span>{filteredCatalogEntries.length} {filteredCatalogEntries.length === 1 ? "plugin" : "plugins"}</span>
680
+ </header>
681
+ <div class="lapis-plugin-detail-dialog__result-list">
682
+ {#each filteredCatalogEntries as entry (entry.id)}
683
+ <button
684
+ type="button"
685
+ data-active={entry.id === detailPluginId || undefined}
686
+ onclick={() => void selectDetail(entry)}
687
+ >
688
+ <strong>{entry.name}</strong>
689
+ <span>{entry.description}</span>
690
+ <small>{entry.latestVersion}</small>
691
+ </button>
692
+ {/each}
693
+ {#if !filteredCatalogEntries.length}<p>No plugins match the current Browse search.</p>{/if}
694
+ </div>
695
+ </aside>
696
+ </Resizable.Pane>
697
+ <Resizable.Handle />
698
+ <Resizable.Pane defaultSize={70} minSize={45}>
699
+ <div class="lapis-plugin-detail-dialog__detail">
700
+ {#if detailPluginEntry}
701
+ {@const updatedAt = latestReleaseDates[detailPluginEntry.id]}
702
+ {@const size = detailPluginDetail ? latestBundleSize(detailPluginDetail) : latestReleaseSizes[detailPluginEntry.id]}
703
+ {@const updatedLabel = updatedAt ? formatReleaseDate(updatedAt) : null}
704
+ <div class="lapis-plugin-detail-dialog__metadata">
705
+ <div>
706
+ <span>Version {detailPluginEntry.latestVersion}</span>
707
+ {#if typeof size === "number"}<span>Size {formatByteSize(size)}</span>{/if}
708
+ <span>{detailPluginEntry.platforms.map(badge).join(", ")}</span>
709
+ {#if updatedLabel}<span>Updated {updatedLabel}</span>{/if}
710
+ </div>
711
+ <div class="lapis-plugin-card__badges">
712
+ <Badge.Badge variant="outline">{badge(detailPluginEntry.channel)}</Badge.Badge>
713
+ {#if availablePluginIds.has(detailPluginEntry.id)}<Badge.Badge variant="outline">Installed</Badge.Badge>{/if}
714
+ {#each detailPluginEntry.badges ?? [] as entryBadge}<Badge.Badge variant={entryBadge === "revoked" ? "destructive" : "outline"}>{badge(entryBadge)}</Badge.Badge>{/each}
715
+ {#each detailPluginEntry.categories as category}<Badge.Badge variant="outline">{badge(category)}</Badge.Badge>{/each}
716
+ </div>
717
+ </div>
718
+ <div class="lapis-plugin-detail-dialog__readme" data-plugin-readme-url={readmeState.status === "loaded" ? readmeState.url : undefined}>
719
+ {#if detailLoading && readmeState.status === "loading"}
720
+ <p>Loading README...</p>
721
+ {:else if readmeState.status === "loaded"}
722
+ <PluginReadmeRenderer app={app} markdown={readmeState.markdown} sourcePath={readmeState.url} />
723
+ {:else if readmeState.status === "error"}
724
+ <Alert.Root><AlertTriangle class="lapis-plugin-management__icon" /><Alert.Title>README unavailable</Alert.Title><Alert.Description>{readmeState.message}</Alert.Description></Alert.Root>
725
+ {:else}
726
+ <p>No README is available for this registry entry.</p>
727
+ {/if}
728
+ </div>
729
+ {:else}
730
+ <p class="lapis-plugin-management__empty">Select a plugin from the Browse results.</p>
731
+ {/if}
732
+ </div>
733
+ </Resizable.Pane>
734
+ </Resizable.PaneGroup>
735
+ </div>
736
+ </Dialog.Content>
737
+ </Dialog.Root>
738
+
739
+ <AlertDialog.Root bind:open={uninstallConfirmOpen}>
740
+ <AlertDialog.Content>
741
+ <AlertDialog.Header>
742
+ <AlertDialog.Title>Uninstall plugin</AlertDialog.Title>
743
+ <AlertDialog.Description>{uninstallTarget ? `Uninstall ${uninstallTarget.pluginId}? This removes the plugin from the vault.` : ""}</AlertDialog.Description>
744
+ </AlertDialog.Header>
745
+ <AlertDialog.Footer>
746
+ <AlertDialog.Cancel onclick={() => (uninstallTarget = null)}>Cancel</AlertDialog.Cancel>
747
+ <AlertDialog.Action onclick={() => void confirmUninstall()}>Uninstall</AlertDialog.Action>
748
+ </AlertDialog.Footer>
749
+ </AlertDialog.Content>
750
+ </AlertDialog.Root>
751
+ </section>