@lapis-notes/workspace 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/LICENSE.md +3 -3
  3. package/dist/index.d.ts +3 -0
  4. package/dist/index.js +3 -0
  5. package/dist/plugin-management/CommunityPluginsSettingsPage.svelte +152 -73
  6. package/dist/plugin-management/PluginBrowser.svelte +517 -0
  7. package/dist/plugin-management/PluginBrowser.svelte.d.ts +17 -0
  8. package/dist/plugin-management/PluginBrowserCard.svelte +230 -0
  9. package/dist/plugin-management/PluginBrowserCard.svelte.d.ts +9 -0
  10. package/dist/plugin-management/PluginBrowserDetail.svelte +1168 -0
  11. package/dist/plugin-management/PluginBrowserDetail.svelte.d.ts +20 -0
  12. package/dist/plugin-management/PluginBrowserDirectory.svelte +1069 -0
  13. package/dist/plugin-management/PluginBrowserDirectory.svelte.d.ts +21 -0
  14. package/dist/plugin-management/PluginBrowserLanding.svelte +341 -0
  15. package/dist/plugin-management/PluginBrowserLanding.svelte.d.ts +12 -0
  16. package/dist/plugin-management/PluginRegistryBadge.svelte +41 -0
  17. package/dist/plugin-management/PluginRegistryBadge.svelte.d.ts +8 -0
  18. package/dist/plugin-management/PluginRegistryContentState.svelte +80 -0
  19. package/dist/plugin-management/PluginRegistryContentState.svelte.d.ts +14 -0
  20. package/dist/plugin-management/PluginRegistryImage.svelte +64 -0
  21. package/dist/plugin-management/PluginRegistryImage.svelte.d.ts +9 -0
  22. package/dist/plugin-management/PluginRegistryMedia.svelte +223 -0
  23. package/dist/plugin-management/PluginRegistryMedia.svelte.d.ts +8 -0
  24. package/dist/plugin-management/PluginRegistryMediaViewer.svelte +155 -0
  25. package/dist/plugin-management/PluginRegistryMediaViewer.svelte.d.ts +13 -0
  26. package/dist/plugin-management/PluginRegistryRow.svelte +120 -0
  27. package/dist/plugin-management/PluginRegistryRow.svelte.d.ts +25 -0
  28. package/dist/plugin-management/PluginRegistrySettingsPage.svelte +1009 -512
  29. package/dist/plugin-management/PluginRegistryStatus.svelte +33 -0
  30. package/dist/plugin-management/PluginRegistryStatus.svelte.d.ts +8 -0
  31. package/dist/plugin-management/PluginRegistryToolbar.svelte +159 -0
  32. package/dist/plugin-management/PluginRegistryToolbar.svelte.d.ts +21 -0
  33. package/dist/plugin-management/community-presentation/CommunityChip.svelte +229 -0
  34. package/dist/plugin-management/community-presentation/CommunityChip.svelte.d.ts +4 -0
  35. package/dist/plugin-management/community-presentation/CommunityMemberAvatar.svelte +42 -0
  36. package/dist/plugin-management/community-presentation/CommunityMemberAvatar.svelte.d.ts +13 -0
  37. package/dist/plugin-management/community-presentation/RegistryMediaGallery.svelte +282 -0
  38. package/dist/plugin-management/community-presentation/RegistryMediaGallery.svelte.d.ts +8 -0
  39. package/dist/plugin-management/community-presentation/RegistryMediaViewer.css +179 -0
  40. package/dist/plugin-management/community-presentation/RegistryMediaViewer.svelte +241 -0
  41. package/dist/plugin-management/community-presentation/RegistryMediaViewer.svelte.d.ts +14 -0
  42. package/dist/plugin-management/community-presentation/RegistryPluginChangelog.svelte +216 -0
  43. package/dist/plugin-management/community-presentation/RegistryPluginChangelog.svelte.d.ts +8 -0
  44. package/dist/plugin-management/community-presentation/RegistryVerifiedImage.svelte +179 -0
  45. package/dist/plugin-management/community-presentation/RegistryVerifiedImage.svelte.d.ts +15 -0
  46. package/dist/plugin-management/community-presentation/avatar-model.d.ts +14 -0
  47. package/dist/plugin-management/community-presentation/avatar-model.js +21 -0
  48. package/dist/plugin-management/community-presentation/community-member-avatar.css +137 -0
  49. package/dist/plugin-management/community-presentation/community-typography.css +30 -0
  50. package/dist/plugin-management/community-presentation/model-adapter.d.ts +54 -0
  51. package/dist/plugin-management/community-presentation/model-adapter.js +90 -0
  52. package/dist/plugin-management/community-presentation/model.d.ts +9 -0
  53. package/dist/plugin-management/community-presentation/model.js +40 -0
  54. package/dist/plugin-management/community-presentation/props.d.ts +21 -0
  55. package/dist/plugin-management/community-presentation/props.js +1 -0
  56. package/dist/plugin-management/community-presentation/registry-filter-icons.d.ts +3 -0
  57. package/dist/plugin-management/community-presentation/registry-filter-icons.js +70 -0
  58. package/dist/plugin-management/community-presentation/registry-markdown.d.ts +1 -0
  59. package/dist/plugin-management/community-presentation/registry-markdown.js +57 -0
  60. package/dist/plugin-management/community-presentation/registry-plugin-icons.d.ts +2 -0
  61. package/dist/plugin-management/community-presentation/registry-plugin-icons.js +31 -0
  62. package/dist/plugin-management/plugin-browser-model.d.ts +43 -0
  63. package/dist/plugin-management/plugin-browser-model.js +95 -0
  64. package/dist/plugin-management/plugin-browser-navigation.d.ts +3 -0
  65. package/dist/plugin-management/plugin-browser-navigation.js +36 -0
  66. package/dist/plugin-management/plugin-download-stats.d.ts +1 -0
  67. package/dist/plugin-management/plugin-download-stats.js +15 -0
  68. package/dist/plugin-management/plugin-management.css +1088 -117
  69. package/dist/plugin-management/plugin-registry-controller.svelte.d.ts +29 -0
  70. package/dist/plugin-management/plugin-registry-controller.svelte.js +154 -0
  71. package/dist/plugin-management/plugin-registry-detail-session.svelte.d.ts +18 -0
  72. package/dist/plugin-management/plugin-registry-detail-session.svelte.js +106 -0
  73. package/dist/plugin-management/plugin-registry-detail.d.ts +17 -0
  74. package/dist/plugin-management/plugin-registry-detail.js +1 -0
  75. package/dist/plugin-management/plugins-view.svelte.d.ts +40 -0
  76. package/dist/plugin-management/plugins-view.svelte.js +105 -0
  77. package/package.json +5 -5
@@ -1,71 +1,71 @@
1
1
  <script lang="ts">
2
- import AlertTriangle from "@lucide/svelte/icons/alert-triangle";
3
2
  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
3
  import Check from "@lucide/svelte/icons/check";
7
4
  import Download from "@lucide/svelte/icons/download";
8
- import PackageIcon from "@lucide/svelte/icons/package";
5
+ import Power from "@lucide/svelte/icons/power";
9
6
  import RefreshCw from "@lucide/svelte/icons/refresh-cw";
10
- import ShieldAlert from "@lucide/svelte/icons/shield-alert";
11
7
  import Trash2 from "@lucide/svelte/icons/trash-2";
12
8
  import Upload from "@lucide/svelte/icons/upload";
13
9
  import {
14
- DEFAULT_OFFICIAL_PLUGIN_REGISTRY_SOURCE,
15
- isAbortError,
16
10
  Notice,
11
+ pluginDownloadCounts,
17
12
  PluginDistributionError,
18
- withPluginInstallProgress,
19
13
  type InstalledPluginRecord,
20
- type PluginCatalogDetail,
21
14
  type PluginCatalogEntry,
15
+ type PluginRegistrySourceState,
22
16
  type PluginUpdateInfo,
23
17
  } from "@lapis-notes/api";
24
- import * as Alert from "@lapismd/design-core/shadcn/alert";
25
18
  import * as AlertDialog from "@lapismd/design-core/shadcn/alert-dialog";
26
- import * as Badge from "@lapismd/design-core/shadcn/badge";
27
19
  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";
20
+ import * as Field from "@lapismd/design-core/shadcn/field";
21
+ import * as Switch from "@lapismd/design-core/shadcn/switch";
32
22
  import * as Tabs from "@lapismd/design-core/shadcn/tabs";
23
+ import * as Tooltip from "@lapismd/design-core/shadcn/tooltip";
33
24
  import type { WorkspaceSettingsPageProps } from "@lapismd/design-core/workspace/settings";
34
25
  import { onMount, tick, untrack } from "svelte";
26
+ import { getPluginRegistryController } from "./plugin-registry-controller.svelte";
27
+ import { openPluginBrowser } from "./plugin-browser-navigation";
35
28
  import { getPluginManagementContext } from "./plugin-management-context.svelte";
36
29
  import "./plugin-management.css";
37
- import {
38
- fetchPluginReadmeMarkdown,
39
- type PluginReadmeLoadState,
40
- } from "./plugin-readme";
41
- import PluginReadmeRenderer from "./PluginReadmeRenderer.svelte";
30
+ import PluginRegistryContentState from "./PluginRegistryContentState.svelte";
31
+ import type { PluginRegistryBadgeTone } from "./PluginRegistryBadge.svelte";
32
+ import PluginRegistryRow, {
33
+ type RegistryRowBadge,
34
+ } from "./PluginRegistryRow.svelte";
35
+ import PluginRegistryToolbar from "./PluginRegistryToolbar.svelte";
36
+ import { formatApproximateDownloadCount } from "./plugin-download-stats";
42
37
 
43
38
  type PluginsRegistryTabId = "installed" | "browse" | "updates" | "sources";
44
39
 
45
40
  let { controller }: WorkspaceSettingsPageProps = $props();
46
41
  const context = untrack(() => getPluginManagementContext(controller));
47
42
  const app = context.app;
43
+ const registry = getPluginRegistryController(app);
48
44
 
49
45
  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
46
  let uninstallConfirmOpen = $state(false);
59
47
  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>>({});
48
+ let uninstallDisableInstead = $state(false);
66
49
  let bundleFileInput = $state<HTMLInputElement | null>(null);
67
50
 
68
- let installedIds = $derived(new Set(installed.map((record) => record.pluginId)));
51
+ let installedSearch = $state("");
52
+ let installedStatus = $state("all");
53
+ let installedProvenance = $state("all");
54
+ let installedSort = $state("name");
55
+ let browseSearch = $state("");
56
+ let browsePlatform = $state("all");
57
+ let browseChannel = $state("all");
58
+ let browseCategory = $state("all");
59
+ let browseInstallState = $state("all");
60
+ let browseCompatibleOnly = $state(false);
61
+ let browseSort = $state("name");
62
+ let updatesSearch = $state("");
63
+ let updatesStatus = $state("all");
64
+ let updatesSort = $state("status");
65
+
66
+ let installedIds = $derived(
67
+ new Set(registry.installed.map((record) => record.pluginId)),
68
+ );
69
69
  let staticPluginIds = $derived(
70
70
  new Set(app.plugins.corePluginEntries.map((entry) => entry.manifest.id)),
71
71
  );
@@ -77,17 +77,183 @@
77
77
  (plugin) => !installedIds.has(plugin.manifest.id),
78
78
  ),
79
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
- }),
80
+ let categories = $derived(
81
+ [
82
+ ...new Set(registry.catalogEntries.flatMap((entry) => entry.categories)),
83
+ ].sort(),
84
+ );
85
+ let compatiblePluginIds = $derived(
86
+ new Set(
87
+ app.pluginDistribution
88
+ .search({ compatibleOnly: true })
89
+ .map((entry) => entry.id),
90
+ ),
91
+ );
92
+ let filteredCatalogEntries = $derived.by(() => {
93
+ const text = browseSearch.trim().toLowerCase();
94
+ return registry.catalogEntries
95
+ .filter((entry) => {
96
+ if (
97
+ text &&
98
+ !`${entry.name} ${entry.id} ${entry.description} ${entry.author}`
99
+ .toLowerCase()
100
+ .includes(text)
101
+ ) {
102
+ return false;
103
+ }
104
+ if (
105
+ browsePlatform !== "all" &&
106
+ !entry.platforms.includes(
107
+ browsePlatform as PluginCatalogEntry["platforms"][number],
108
+ )
109
+ ) {
110
+ return false;
111
+ }
112
+ if (browseChannel !== "all" && entry.channel !== browseChannel) {
113
+ return false;
114
+ }
115
+ if (
116
+ browseCategory !== "all" &&
117
+ !entry.categories.includes(browseCategory)
118
+ ) {
119
+ return false;
120
+ }
121
+ if (
122
+ browseInstallState === "installed" &&
123
+ !availablePluginIds.has(entry.id)
124
+ ) {
125
+ return false;
126
+ }
127
+ if (
128
+ browseInstallState === "available" &&
129
+ availablePluginIds.has(entry.id)
130
+ ) {
131
+ return false;
132
+ }
133
+ if (browseCompatibleOnly && !compatiblePluginIds.has(entry.id)) {
134
+ return false;
135
+ }
136
+ return true;
137
+ })
138
+ .sort((left, right) =>
139
+ browseSort === "downloads"
140
+ ? downloadCount(right.id) - downloadCount(left.id) ||
141
+ left.name.localeCompare(right.name)
142
+ : browseSort === "recent"
143
+ ? (right.latestRelease?.releasedAt ?? "").localeCompare(
144
+ left.latestRelease?.releasedAt ?? "",
145
+ ) || left.name.localeCompare(right.name)
146
+ : left.name.localeCompare(right.name),
147
+ );
148
+ });
149
+ let filteredInstalled = $derived.by(() => {
150
+ const text = installedSearch.trim().toLowerCase();
151
+ return registry.installed
152
+ .filter((record) => {
153
+ const entry = catalogEntry(record.pluginId);
154
+ const runtime = app.plugins.plugins.get(record.pluginId);
155
+ const name = entry?.name ?? runtime?.manifest.name ?? record.pluginId;
156
+ const description =
157
+ entry?.description ?? runtime?.manifest.description ?? "";
158
+ if (
159
+ text &&
160
+ !`${name} ${record.pluginId} ${description}`
161
+ .toLowerCase()
162
+ .includes(text)
163
+ ) {
164
+ return false;
165
+ }
166
+ if (
167
+ installedProvenance !== "all" &&
168
+ record.provenance !== installedProvenance
169
+ ) {
170
+ return false;
171
+ }
172
+ if (installedStatus === "enabled" && !runtime?.enabled) return false;
173
+ if (installedStatus === "disabled" && runtime?.enabled) return false;
174
+ if (installedStatus === "revoked" && !record.revoked) return false;
175
+ if (installedStatus === "restart" && !record.restartRequired)
176
+ return false;
177
+ return true;
178
+ })
179
+ .sort((left, right) =>
180
+ installedSort === "recent"
181
+ ? right.updatedAt.localeCompare(left.updatedAt)
182
+ : installedName(left).localeCompare(installedName(right)),
183
+ );
184
+ });
185
+ let filteredCommunityPlugins = $derived.by(() => {
186
+ const text = installedSearch.trim().toLowerCase();
187
+ if (installedProvenance !== "all" && installedProvenance !== "manual") {
188
+ return [];
189
+ }
190
+ return communityPlugins.filter((plugin) => {
191
+ if (
192
+ text &&
193
+ !`${plugin.manifest.name} ${plugin.manifest.id} ${plugin.manifest.description}`
194
+ .toLowerCase()
195
+ .includes(text)
196
+ ) {
197
+ return false;
198
+ }
199
+ if (installedStatus === "enabled" && !plugin.enabled) return false;
200
+ if (installedStatus === "disabled" && plugin.enabled) return false;
201
+ if (installedStatus === "revoked" || installedStatus === "restart") {
202
+ return false;
203
+ }
204
+ return true;
205
+ });
206
+ });
207
+ let filteredUpdates = $derived.by(() => {
208
+ const text = updatesSearch.trim().toLowerCase();
209
+ const priority = (update: PluginUpdateInfo) =>
210
+ update.canUpdate ? 0 : update.status === "revoked" ? 2 : 1;
211
+ return registry.updates
212
+ .filter((update) => {
213
+ if (
214
+ text &&
215
+ !`${update.name} ${update.id}`.toLowerCase().includes(text)
216
+ ) {
217
+ return false;
218
+ }
219
+ if (updatesStatus === "ready" && !update.canUpdate) return false;
220
+ if (
221
+ updatesStatus === "incompatible" &&
222
+ (update.canUpdate || update.status === "revoked")
223
+ ) {
224
+ return false;
225
+ }
226
+ if (updatesStatus === "revoked" && update.status !== "revoked") {
227
+ return false;
228
+ }
229
+ return true;
230
+ })
231
+ .sort((left, right) =>
232
+ updatesSort === "name"
233
+ ? left.name.localeCompare(right.name)
234
+ : priority(left) - priority(right) ||
235
+ left.name.localeCompare(right.name),
236
+ );
237
+ });
238
+ let browseFilterCount = $derived(
239
+ Number(Boolean(browseSearch.trim())) +
240
+ Number(browsePlatform !== "all") +
241
+ Number(browseChannel !== "all") +
242
+ Number(browseCategory !== "all") +
243
+ Number(browseInstallState !== "all") +
244
+ Number(browseCompatibleOnly) +
245
+ Number(browseSort !== "name"),
246
+ );
247
+ let installedFilterCount = $derived(
248
+ Number(Boolean(installedSearch.trim())) +
249
+ Number(installedStatus !== "all") +
250
+ Number(installedProvenance !== "all") +
251
+ Number(installedSort !== "name"),
88
252
  );
89
- let detailPluginEntry = $derived(
90
- catalogEntries.find((entry) => entry.id === detailPluginId) ?? null,
253
+ let updatesFilterCount = $derived(
254
+ Number(Boolean(updatesSearch.trim())) +
255
+ Number(updatesStatus !== "all") +
256
+ Number(updatesSort !== "status"),
91
257
  );
92
258
 
93
259
  $effect(() => {
@@ -102,135 +268,119 @@
102
268
  const pluginId = context.reveal.entryId.slice("registry:".length);
103
269
  activeTab = "browse";
104
270
  void tick().then(async () => {
105
- const entry = catalogEntries.find((candidate) => candidate.id === pluginId);
271
+ const entry = registry.catalogEntries.find(
272
+ (candidate) => candidate.id === pluginId,
273
+ );
106
274
  if (entry) await openDetails(entry);
107
275
  });
108
276
  });
109
277
 
110
278
  onMount(() => {
279
+ const release = registry.retain();
111
280
  void refresh(false);
281
+ return () => {
282
+ release();
283
+ };
112
284
  });
113
-
114
285
  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
- }
286
+ await registry.refresh(force);
135
287
  }
136
288
 
137
289
  async function runAction(
138
290
  actionId: string,
139
291
  action: () => Promise<unknown>,
140
292
  ): Promise<void> {
141
- runningAction = actionId;
293
+ if (registry.runningAction) return;
294
+ registry.runningAction = actionId;
142
295
  try {
143
- lastError = null;
296
+ registry.lastError = null;
144
297
  await action();
145
298
  await refresh(false);
146
299
  } catch (error) {
147
- lastError = error instanceof Error ? error : new Error(String(error));
148
- new Notice(`Plugin registry action failed: ${errorMessage(lastError)}`);
300
+ registry.lastError =
301
+ error instanceof Error ? error : new Error(String(error));
302
+ new Notice(
303
+ `Plugin registry action failed: ${errorMessage(registry.lastError!)}`,
304
+ undefined,
305
+ app,
306
+ );
149
307
  } finally {
150
- runningAction = null;
308
+ registry.runningAction = null;
151
309
  }
152
310
  }
153
311
 
154
- async function runPluginProgressAction(
155
- actionId: string,
156
- pluginId: string,
157
- title: string,
158
- action: (signal: AbortSignal) => Promise<unknown>,
312
+ async function setRegistrySourceEnabled(
313
+ sourceState: PluginRegistrySourceState,
314
+ enabled: boolean,
159
315
  ): Promise<void> {
160
- runningAction = actionId;
316
+ if (registry.runningAction) return;
317
+ const source = sourceState.source;
318
+ registry.runningAction = `source:${source.id}`;
161
319
  try {
162
- lastError = null;
163
- await withPluginInstallProgress(app, { pluginId, title }, action);
164
- await refresh(false);
320
+ registry.lastError = null;
321
+ await app.pluginDistribution.setSourceEnabled(source.id, enabled);
322
+ registry.catalogEntries = [];
323
+ await refresh(true);
165
324
  } 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)}`);
325
+ registry.lastError =
326
+ error instanceof Error ? error : new Error(String(error));
327
+ new Notice(
328
+ `Plugin registry source update failed: ${errorMessage(registry.lastError!)}`,
329
+ undefined,
330
+ app,
331
+ );
332
+ registry.registrySources = app.pluginDistribution.listSources();
172
333
  } finally {
173
- runningAction = null;
334
+ registry.runningAction = null;
174
335
  }
175
336
  }
176
337
 
338
+ async function installEntry(entry: PluginCatalogEntry) {
339
+ await registry.install(entry);
340
+ }
341
+ async function updatePlugin(update: PluginUpdateInfo) {
342
+ await registry.update(update);
343
+ }
344
+
177
345
  async function openDetails(entry: PluginCatalogEntry): Promise<void> {
178
- detailDialogOpen = true;
179
- await selectDetail(entry);
346
+ try {
347
+ await openPluginBrowser(app, entry.id);
348
+ controller.close();
349
+ } catch (error) {
350
+ registry.lastError =
351
+ error instanceof Error ? error : new Error(String(error));
352
+ new Notice(
353
+ `Could not open plugin details: ${registry.lastError.message}`,
354
+ 0,
355
+ app,
356
+ );
357
+ }
180
358
  }
181
359
 
182
360
  function requestUninstall(record: InstalledPluginRecord): void {
361
+ uninstallDisableInstead = false;
183
362
  uninstallTarget = record;
184
363
  uninstallConfirmOpen = true;
185
364
  }
186
365
 
187
- async function confirmUninstall(): Promise<void> {
188
- const record = uninstallTarget;
189
- if (!record) return;
366
+ function closeUninstallDialog(): void {
190
367
  uninstallConfirmOpen = false;
368
+ uninstallDisableInstead = false;
191
369
  uninstallTarget = null;
192
- await runAction(`uninstall:${record.pluginId}`, () =>
193
- app.pluginDistribution.uninstall(record.pluginId),
194
- );
195
370
  }
196
371
 
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
- }
372
+ async function confirmPluginRemovalChoice(): Promise<void> {
373
+ const record = uninstallTarget;
374
+ if (!record) return;
375
+ const disableInstead = uninstallDisableInstead;
376
+ closeUninstallDialog();
377
+ await runAction(
378
+ `${disableInstead ? "toggle" : "uninstall"}:${record.pluginId}`,
379
+ () =>
380
+ disableInstead
381
+ ? app.plugins.disablePlugin(record.pluginId)
382
+ : app.pluginDistribution.uninstall(record.pluginId),
383
+ );
234
384
  }
235
385
 
236
386
  function errorMessage(error: Error): string {
@@ -239,7 +389,7 @@
239
389
  : error.message;
240
390
  }
241
391
 
242
- function badge(value: string): string {
392
+ function titleCase(value: string): string {
243
393
  return value
244
394
  .split(/[\s-]+/)
245
395
  .filter(Boolean)
@@ -247,55 +397,116 @@
247
397
  .join(" ");
248
398
  }
249
399
 
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),
400
+ function provenanceBadgeTone(value: string): PluginRegistryBadgeTone {
401
+ if (value === "official" || value === "community") return value;
402
+ return "neutral";
403
+ }
404
+
405
+ function catalogEntry(pluginId: string): PluginCatalogEntry | undefined {
406
+ return registry.catalogEntries.find((entry) => entry.id === pluginId);
407
+ }
408
+
409
+ function installedName(record: InstalledPluginRecord): string {
410
+ return (
411
+ catalogEntry(record.pluginId)?.name ??
412
+ app.plugins.plugins.get(record.pluginId)?.manifest.name ??
413
+ record.pluginId
257
414
  );
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
- }),
415
+ }
416
+
417
+ function installedDescription(record: InstalledPluginRecord): string {
418
+ return (
419
+ catalogEntry(record.pluginId)?.description ??
420
+ app.plugins.plugins.get(record.pluginId)?.manifest.description ??
421
+ `Installed plugin ${record.pluginId}`
271
422
  );
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
423
  }
281
424
 
282
- function latestReleaseDate(detail: PluginCatalogDetail | null): string | null {
283
- return detail?.versions[detail.latestVersion]?.releasedAt ?? null;
425
+ function pluginToggleAction(enabled: boolean, name: string): string {
426
+ return `${enabled ? "Disable" : "Enable"} ${name}`;
284
427
  }
285
428
 
286
- function latestBundleSize(detail: PluginCatalogDetail | null): number | null {
287
- return detail?.versions[detail.latestVersion]?.bundle.size ?? null;
429
+ function resolveRuntimePlugin(pluginId: string) {
430
+ void registry.pluginLifecycleRevision;
431
+ return app.plugins.plugins.get(pluginId);
432
+ }
433
+
434
+ function pluginDescription(pluginId: string, fallback: string): string {
435
+ return (
436
+ catalogEntry(pluginId)?.description ??
437
+ app.plugins.plugins.get(pluginId)?.manifest.description ??
438
+ fallback
439
+ );
440
+ }
441
+
442
+ function registryRowBadges(
443
+ entry: PluginCatalogEntry | undefined,
444
+ provenance: string,
445
+ ): RegistryRowBadge[] {
446
+ return [
447
+ {
448
+ label: titleCase(provenance),
449
+ tone: provenanceBadgeTone(provenance),
450
+ },
451
+ ...(entry?.platforms.map((platform) => ({
452
+ label: titleCase(platform),
453
+ tone: platform === "web" ? ("web" as const) : ("desktop" as const),
454
+ })) ?? []),
455
+ ...(entry?.categories.slice(0, 2).map((category) => ({
456
+ label: titleCase(category),
457
+ tone: "category" as const,
458
+ })) ?? []),
459
+ ...(entry?.status === "revoked"
460
+ ? [{ label: "Revoked", tone: "danger" as const }]
461
+ : []),
462
+ ];
288
463
  }
289
464
 
290
465
  function installedSize(record: InstalledPluginRecord): number | null {
291
- const detailSize = latestReleaseSizes[record.pluginId];
292
- if (typeof detailSize === "number") return detailSize;
293
466
  return record.files.length
294
467
  ? record.files.reduce((total, file) => total + file.size, 0)
295
468
  : null;
296
469
  }
297
470
 
298
- function formatReleaseDate(value: string): string | null {
471
+ function downloadCount(pluginId: string): number {
472
+ return registry.downloadStats
473
+ ? pluginDownloadCounts(registry.downloadStats, pluginId).recent
474
+ : 0;
475
+ }
476
+
477
+ function downloadLabel(pluginId: string): string | null {
478
+ if (!registry.downloadStats) return null;
479
+ const count = pluginDownloadCounts(registry.downloadStats, pluginId).recent;
480
+ return `~${formatApproximateDownloadCount(count)} downloads (30d)`;
481
+ }
482
+
483
+ function browseActionStatus(
484
+ pluginId: string,
485
+ installedRecord: InstalledPluginRecord | undefined,
486
+ ) {
487
+ if (registry.actionFeedback?.pluginId === pluginId) {
488
+ if (registry.actionFeedback.state === "running") {
489
+ return {
490
+ label: "Installing",
491
+ reason: registry.actionFeedback.message,
492
+ tone: "neutral" as const,
493
+ };
494
+ }
495
+ if (registry.actionFeedback.state === "error") {
496
+ return {
497
+ label: "Install failed",
498
+ reason: registry.actionFeedback.message,
499
+ tone: "danger" as const,
500
+ };
501
+ }
502
+ }
503
+ return installedRecord
504
+ ? { label: "Installed", tone: "success" as const }
505
+ : undefined;
506
+ }
507
+
508
+ function formatDate(value?: string): string | null {
509
+ if (!value) return null;
299
510
  const date = new Date(value);
300
511
  if (Number.isNaN(date.getTime())) return null;
301
512
  return new Intl.DateTimeFormat(undefined, {
@@ -305,22 +516,8 @@
305
516
  }).format(date);
306
517
  }
307
518
 
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 {
519
+ function formatByteSize(bytes?: number | null): string | null {
520
+ if (typeof bytes !== "number") return null;
324
521
  if (bytes < 1024) return `${bytes} B`;
325
522
  const units = ["KiB", "MiB", "GiB"];
326
523
  let value = bytes / 1024;
@@ -334,6 +531,45 @@
334
531
  }).format(value)} ${units[unitIndex]}`;
335
532
  }
336
533
 
534
+ function updateReason(update: PluginUpdateInfo): string | undefined {
535
+ if (update.status === "revoked") {
536
+ return (
537
+ update.revoked?.message ??
538
+ update.revoked?.reason ??
539
+ "This installed version was revoked by the official registry."
540
+ );
541
+ }
542
+ if (!update.canUpdate) {
543
+ return update.reasons.length
544
+ ? update.reasons.join(", ")
545
+ : "The latest version is not compatible with this runtime.";
546
+ }
547
+ return undefined;
548
+ }
549
+
550
+ function resetInstalledFilters(): void {
551
+ installedSearch = "";
552
+ installedStatus = "all";
553
+ installedProvenance = "all";
554
+ installedSort = "name";
555
+ }
556
+
557
+ function resetBrowseFilters(): void {
558
+ browseSearch = "";
559
+ browsePlatform = "all";
560
+ browseChannel = "all";
561
+ browseCategory = "all";
562
+ browseInstallState = "all";
563
+ browseCompatibleOnly = false;
564
+ browseSort = "name";
565
+ }
566
+
567
+ function resetUpdateFilters(): void {
568
+ updatesSearch = "";
569
+ updatesStatus = "all";
570
+ updatesSort = "status";
571
+ }
572
+
337
573
  function openBundleFilePicker(): void {
338
574
  bundleFileInput?.click();
339
575
  }
@@ -347,16 +583,26 @@
347
583
  new Notice("Select a .lapis-plugin bundle.");
348
584
  return;
349
585
  }
350
- await runPluginProgressAction(
586
+ let installedRecord: InstalledPluginRecord | undefined;
587
+ await registry.runPluginAction(
351
588
  "install-bundle",
352
589
  "*",
353
590
  `Installing ${file.name}`,
354
- async (signal) =>
355
- app.pluginDistribution.installBundle(await file.arrayBuffer(), {
356
- enable: true,
357
- signal,
358
- }),
591
+ async (signal) => {
592
+ installedRecord = await app.pluginDistribution.installBundle(
593
+ await file.arrayBuffer(),
594
+ {
595
+ enable: true,
596
+ signal,
597
+ },
598
+ );
599
+ },
359
600
  );
601
+ if (installedRecord?.nostrProof?.requiresRelayRefresh) {
602
+ new Notice(
603
+ "Plugin installed from a valid Nostr proof. Connect and refresh its registry source to check for newer revocations.",
604
+ );
605
+ }
360
606
  }
361
607
  </script>
362
608
 
@@ -368,7 +614,10 @@
368
614
  <header class="lapis-plugin-management__header">
369
615
  <div>
370
616
  <h1>Plugin registry</h1>
371
- <p>Browse verified plugins, manage installed provenance, and review updates.</p>
617
+ <p>
618
+ Browse verified plugins, manage installed provenance, and review
619
+ updates.
620
+ </p>
372
621
  </div>
373
622
  <div class="lapis-plugin-management__actions">
374
623
  <input
@@ -382,37 +631,27 @@
382
631
  <Button.Root
383
632
  variant="outline"
384
633
  size="sm"
385
- disabled={runningAction === "install-bundle"}
634
+ disabled={registry.runningAction === "install-bundle"}
386
635
  onclick={openBundleFilePicker}
636
+ ><Upload />Install from .lapis-plugin</Button.Root
387
637
  >
388
- <Upload class="lapis-plugin-management__icon" />
389
- Install from .lapis-plugin
390
- </Button.Root>
391
638
  <Button.Root
639
+ class="lapis-plugin-registry__refresh"
392
640
  variant="outline"
393
641
  size="sm"
394
- disabled={refreshing}
395
- aria-busy={refreshing}
396
- aria-label={refreshing ? "Refreshing plugin registry" : "Refresh plugin registry"}
642
+ disabled={registry.refreshing}
643
+ aria-busy={registry.refreshing}
644
+ aria-label={registry.refreshing
645
+ ? "Refreshing plugin registry"
646
+ : "Refresh plugin registry"}
397
647
  onclick={() => void refresh(true)}
398
648
  >
399
- <RefreshCw
400
- class="lapis-plugin-management__icon"
401
- data-spinning={refreshing || undefined}
402
- />
403
- Refresh
649
+ <RefreshCw data-spinning={registry.refreshing || undefined} />
650
+ <span>Refresh</span>
404
651
  </Button.Root>
405
652
  </div>
406
653
  </header>
407
654
 
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
655
  <Tabs.Root bind:value={activeTab} class="lapis-plugin-registry__tabs">
417
656
  <Tabs.List class="lapis-plugin-registry__tab-list">
418
657
  <Tabs.Trigger class="lapis-plugin-registry__tab" value="installed"
@@ -429,322 +668,580 @@
429
668
  >
430
669
  </Tabs.List>
431
670
 
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>
671
+ <Tabs.Content value="installed" class="lapis-plugin-registry__content">
672
+ <PluginRegistryToolbar
673
+ tab="installed"
674
+ bind:search={installedSearch}
675
+ bind:enabledState={installedStatus}
676
+ bind:provenance={installedProvenance}
677
+ bind:sort={installedSort}
678
+ filterCount={installedFilterCount}
679
+ resultCount={filteredInstalled.length + filteredCommunityPlugins.length}
680
+ onreset={resetInstalledFilters}
681
+ />
682
+ {#if registry.initialLoading}
683
+ <PluginRegistryContentState
684
+ kind="loading"
685
+ heading="Loading installed plugins"
686
+ description="Reading installed plugin state and registry provenance…"
687
+ />
688
+ {:else if registry.lastError && !registry.installed.length && !communityPlugins.length}
689
+ <PluginRegistryContentState
690
+ kind="error"
691
+ heading="Installed plugins unavailable"
692
+ description={errorMessage(registry.lastError)}
693
+ actionLabel="Retry"
694
+ onaction={() => void refresh(true)}
695
+ />
696
+ {:else if !registry.installed.length && !communityPlugins.length}
697
+ <PluginRegistryContentState
698
+ part="registry-installed-empty-state"
699
+ kind="empty"
700
+ heading="No plugins installed"
701
+ description="No installed registry or community plugins."
702
+ actionLabel="Browse plugins"
703
+ onaction={() => (activeTab = "browse")}
704
+ />
705
+ {:else if !filteredInstalled.length && !filteredCommunityPlugins.length}
706
+ <PluginRegistryContentState
707
+ kind="filtered"
708
+ heading="No installed plugins match"
709
+ description="Try changing the search or installed-plugin filters."
710
+ actionLabel="Reset filters"
711
+ onaction={resetInstalledFilters}
712
+ />
713
+ {:else}
714
+ <div class="lapis-plugin-registry__rows">
715
+ {#each filteredInstalled as record (record.pluginId)}
716
+ {@const runtimePlugin = resolveRuntimePlugin(record.pluginId)}
717
+ {@const entry = catalogEntry(record.pluginId)}
718
+ {@const size = installedSize(record)}
719
+ {@const update = registry.updates.find(
720
+ (candidate) => candidate.id === record.pluginId,
721
+ )}
722
+ {@const toggleAction = pluginToggleAction(
723
+ Boolean(runtimePlugin?.enabled),
724
+ installedName(record),
725
+ )}
726
+ <PluginRegistryRow
727
+ id={record.pluginId}
728
+ name={installedName(record)}
729
+ description={installedDescription(record)}
730
+ version={record.installedVersion}
731
+ metadata={[
732
+ ...(size === null ? [] : [`Size ${formatByteSize(size)}`]),
733
+ `Updated ${formatDate(record.updatedAt) ?? record.updatedAt}`,
734
+ ...(entry && downloadLabel(entry.id)
735
+ ? [downloadLabel(entry.id)!]
736
+ : []),
737
+ ]}
738
+ badges={[
739
+ ...registryRowBadges(entry, record.provenance),
740
+ ...(record.nostrProof
741
+ ? [{ label: "Nostr proof", tone: "community" as const }]
742
+ : []),
743
+ ...(record.restartRequired
744
+ ? [{ label: "Restart required" }]
745
+ : []),
746
+ ]}
747
+ status={record.revoked
748
+ ? {
749
+ label: "Revoked",
750
+ reason: record.revoked.message ?? record.revoked.reason,
751
+ tone: "danger",
752
+ }
753
+ : update?.canUpdate
754
+ ? { label: "Update available", tone: "warning" }
755
+ : record.nostrProof?.requiresRelayRefresh
756
+ ? {
757
+ label: "Revocation check needed",
758
+ reason: "Refresh the Nostr registry source when online",
759
+ tone: "warning",
760
+ }
761
+ : undefined}
762
+ >
763
+ {#snippet actions()}
764
+ {#if update?.canUpdate}
765
+ <Tooltip.Provider>
766
+ <Tooltip.Root>
767
+ <Tooltip.Trigger>
768
+ {#snippet child({ props })}
769
+ <Button.Root
770
+ {...props}
771
+ size="icon"
772
+ variant="outline"
773
+ aria-label={`Update ${installedName(record)}`}
774
+ disabled={registry.runningAction ===
775
+ `update:${record.pluginId}`}
776
+ onclick={() => void updatePlugin(update)}
777
+ ><ArrowUpCircle /></Button.Root
778
+ >
779
+ {/snippet}
780
+ </Tooltip.Trigger>
781
+ <Tooltip.Content
782
+ >Update {installedName(record)}</Tooltip.Content
783
+ >
784
+ </Tooltip.Root>
785
+ </Tooltip.Provider>
786
+ {/if}
787
+ {#key toggleAction}
788
+ <Tooltip.Provider>
789
+ <Tooltip.Root>
790
+ <Tooltip.Trigger>
791
+ {#snippet child({ props })}
792
+ <Switch.Root
793
+ {...props}
794
+ checked={Boolean(runtimePlugin?.enabled)}
795
+ disabled={!runtimePlugin ||
796
+ registry.runningAction ===
797
+ `toggle:${record.pluginId}`}
798
+ aria-label={toggleAction}
799
+ data-tooltip-action={toggleAction}
800
+ onCheckedChange={(checked) =>
801
+ void runAction(`toggle:${record.pluginId}`, () =>
802
+ checked
803
+ ? app.plugins.enablePlugin(record.pluginId)
804
+ : app.plugins.disablePlugin(record.pluginId),
805
+ )}
806
+ />
807
+ {/snippet}
808
+ </Tooltip.Trigger>
809
+ <Tooltip.Content>{toggleAction}</Tooltip.Content>
810
+ </Tooltip.Root>
811
+ </Tooltip.Provider>
812
+ {/key}
551
813
  <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
- )}
814
+ size="icon"
815
+ variant="ghost"
816
+ aria-label={`Uninstall ${record.pluginId}`}
817
+ title="Uninstall"
818
+ disabled={registry.runningAction ===
819
+ `uninstall:${record.pluginId}`}
820
+ onclick={() => requestUninstall(record)}
821
+ ><Trash2 /></Button.Root
567
822
  >
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>
823
+ {/snippet}
824
+ </PluginRegistryRow>
825
+ {/each}
826
+ {#each filteredCommunityPlugins as plugin (plugin.manifest.id)}
827
+ <PluginRegistryRow
828
+ id={plugin.manifest.id}
829
+ name={plugin.manifest.name}
830
+ description={plugin.manifest.description}
831
+ version={plugin.manifest.version}
832
+ badges={[{ label: "Manual" }]}
833
+ />
834
+ {/each}
835
+ </div>
836
+ {/if}
575
837
  </Tabs.Content>
576
838
 
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}
839
+ <Tabs.Content value="browse" class="lapis-plugin-registry__content">
840
+ <PluginRegistryToolbar
841
+ tab="browse"
842
+ bind:search={browseSearch}
843
+ bind:platform={browsePlatform}
844
+ bind:channel={browseChannel}
845
+ bind:category={browseCategory}
846
+ bind:installedState={browseInstallState}
847
+ bind:compatibleOnly={browseCompatibleOnly}
848
+ bind:sort={browseSort}
849
+ {categories}
850
+ filterCount={browseFilterCount}
851
+ resultCount={filteredCatalogEntries.length}
852
+ onreset={resetBrowseFilters}
853
+ />
854
+ {#if registry.initialLoading}
855
+ <PluginRegistryContentState
856
+ kind="loading"
857
+ heading="Loading plugin registry"
858
+ description="Verifying the official catalog and revocation metadata…"
859
+ />
860
+ {:else if registry.lastError && !registry.catalogEntries.length}
861
+ <PluginRegistryContentState
862
+ kind="error"
863
+ heading="Plugin registry unavailable"
864
+ description={errorMessage(registry.lastError)}
865
+ actionLabel="Retry"
866
+ onaction={() => void refresh(true)}
867
+ />
868
+ {:else if !registry.catalogEntries.length}
869
+ <PluginRegistryContentState
870
+ kind="empty"
871
+ heading="No registry entries"
872
+ description="The configured registry did not return any plugin entries."
873
+ actionLabel="Refresh"
874
+ onaction={() => void refresh(true)}
875
+ />
876
+ {:else if !filteredCatalogEntries.length}
877
+ <PluginRegistryContentState
878
+ kind="filtered"
879
+ heading="No plugins match"
880
+ description="Try changing the search or Browse filters."
881
+ actionLabel="Reset filters"
882
+ onaction={resetBrowseFilters}
883
+ />
884
+ {:else}
885
+ <div class="lapis-plugin-registry__rows">
886
+ {#each filteredCatalogEntries as entry (entry.id)}
887
+ {@const installedRecord = registry.installed.find(
888
+ (record) => record.pluginId === entry.id,
889
+ )}
890
+ {@const isBundled = staticPluginIds.has(entry.id)}
891
+ {@const releaseDate = formatDate(entry.latestRelease?.releasedAt)}
892
+ {@const releaseSize = formatByteSize(
893
+ entry.latestRelease?.bundleSize,
894
+ )}
895
+ <PluginRegistryRow
896
+ id={entry.id}
897
+ name={entry.name}
898
+ description={entry.description}
899
+ version={entry.latestVersion}
900
+ metadata={[
901
+ ...(releaseDate ? [`Released ${releaseDate}`] : []),
902
+ ...(releaseSize ? [`Size ${releaseSize}`] : []),
903
+ ...(downloadLabel(entry.id) ? [downloadLabel(entry.id)!] : []),
904
+ ]}
905
+ badges={registryRowBadges(entry, entry.channel)}
906
+ status={browseActionStatus(entry.id, installedRecord)}
907
+ onopen={() => void openDetails(entry)}
908
+ >
909
+ {#snippet actions()}
910
+ {#if installedRecord}
911
+ <Button.Root
912
+ size="sm"
913
+ variant="outline"
914
+ aria-label={`Uninstall ${entry.name}`}
915
+ disabled={registry.runningAction ===
916
+ `uninstall:${entry.id}`}
917
+ onclick={() => requestUninstall(installedRecord)}
918
+ ><Trash2 data-icon="inline-start" />Uninstall</Button.Root
919
+ >
920
+ {:else if isBundled}
921
+ <Button.Root size="sm" variant="outline" disabled
922
+ ><Check data-icon="inline-start" />Bundled</Button.Root
923
+ >
924
+ {:else}
925
+ {@const installing =
926
+ registry.runningAction === `install:${entry.id}`}
927
+ <Button.Root
928
+ size="sm"
929
+ aria-label={installing
930
+ ? `Installing ${entry.name}`
931
+ : undefined}
932
+ aria-busy={installing}
933
+ disabled={installing}
934
+ onclick={() => void installEntry(entry)}
935
+ >{#if installing}<RefreshCw
936
+ data-icon="inline-start"
937
+ data-spinning="true"
938
+ />Installing…{:else}<Download
939
+ data-icon="inline-start"
940
+ />Install{/if}</Button.Root
941
+ >
942
+ {/if}
943
+ {/snippet}
944
+ </PluginRegistryRow>
945
+ {/each}
946
+ </div>
947
+ {/if}
623
948
  </Tabs.Content>
624
949
 
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>
950
+ <Tabs.Content value="updates" class="lapis-plugin-registry__content">
951
+ <PluginRegistryToolbar
952
+ tab="updates"
953
+ bind:search={updatesSearch}
954
+ bind:updateState={updatesStatus}
955
+ bind:sort={updatesSort}
956
+ filterCount={updatesFilterCount}
957
+ resultCount={filteredUpdates.length}
958
+ onreset={resetUpdateFilters}
959
+ />
960
+ {#if registry.initialLoading}
961
+ <PluginRegistryContentState
962
+ kind="loading"
963
+ heading="Checking for updates"
964
+ description="Comparing installed plugins with verified registry releases…"
965
+ />
966
+ {:else if registry.lastError && !registry.updates.length}
967
+ <PluginRegistryContentState
968
+ kind="error"
969
+ heading="Updates unavailable"
970
+ description={errorMessage(registry.lastError)}
971
+ actionLabel="Retry"
972
+ onaction={() => void refresh(true)}
973
+ />
974
+ {:else if !registry.updates.length}
975
+ <PluginRegistryContentState
976
+ part="registry-updates-empty-state"
977
+ kind="success"
978
+ heading="You’re up to date"
979
+ description="No plugin updates available."
980
+ actionLabel="Check for updates"
981
+ onaction={() => void refresh(true)}
982
+ />
983
+ {:else if !filteredUpdates.length}
984
+ <PluginRegistryContentState
985
+ kind="filtered"
986
+ heading="No updates match"
987
+ description="Try changing the search or update-status filter."
988
+ actionLabel="Reset filters"
989
+ onaction={resetUpdateFilters}
990
+ />
991
+ {:else}
992
+ <div class="lapis-plugin-registry__rows">
993
+ {#each filteredUpdates as update (update.id)}
994
+ {@const entry = catalogEntry(update.id)}
995
+ {@const releaseDate = formatDate(entry?.latestRelease?.releasedAt)}
996
+ <PluginRegistryRow
997
+ id={update.id}
998
+ onopen={entry ? () => void openDetails(entry) : undefined}
999
+ name={update.name}
1000
+ description={pluginDescription(
1001
+ update.id,
1002
+ `Update available for ${update.name}.`,
1003
+ )}
1004
+ version={`${update.currentVersion} → ${update.targetVersion}`}
1005
+ metadata={[
1006
+ ...(releaseDate ? [`Released ${releaseDate}`] : []),
1007
+ ...(typeof update.bundleSize === "number"
1008
+ ? [`Size ${formatByteSize(update.bundleSize)}`]
1009
+ : []),
1010
+ ...(update.targetVersion !== update.latestVersion
1011
+ ? [`Latest ${update.latestVersion}`]
1012
+ : []),
1013
+ ...(entry && downloadLabel(entry.id)
1014
+ ? [downloadLabel(entry.id)!]
1015
+ : []),
1016
+ ]}
1017
+ badges={registryRowBadges(entry, update.provenance)}
1018
+ status={{
1019
+ label: update.canUpdate
1020
+ ? "Ready"
1021
+ : update.status === "revoked"
1022
+ ? "Revoked"
1023
+ : "Incompatible",
1024
+ reason: updateReason(update),
1025
+ tone:
1026
+ update.status === "revoked"
1027
+ ? "danger"
1028
+ : update.canUpdate
1029
+ ? "success"
1030
+ : "warning",
1031
+ }}
1032
+ >
1033
+ {#snippet actions()}
1034
+ <Tooltip.Provider>
1035
+ <Tooltip.Root>
1036
+ <Tooltip.Trigger>
1037
+ {#snippet child({ props })}
1038
+ <Button.Root
1039
+ {...props}
1040
+ size="icon"
1041
+ variant="outline"
1042
+ aria-label={`Update ${update.name}`}
1043
+ disabled={!update.canUpdate ||
1044
+ registry.runningAction === `update:${update.id}`}
1045
+ onclick={() => void updatePlugin(update)}
1046
+ ><ArrowUpCircle /></Button.Root
1047
+ >
1048
+ {/snippet}
1049
+ </Tooltip.Trigger>
1050
+ <Tooltip.Content>Update {update.name}</Tooltip.Content>
1051
+ </Tooltip.Root>
1052
+ </Tooltip.Provider>
1053
+ {/snippet}
1054
+ </PluginRegistryRow>
1055
+ {/each}
634
1056
  </div>
635
- </article>
636
- <Separator.Separator />
637
- <p class="lapis-plugin-management__empty">Additional third-party registry sources are outside Full Registry V1.</p>
1057
+ {/if}
638
1058
  </Tabs.Content>
639
- </Tabs.Root>
640
1059
 
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>
1060
+ <Tabs.Content value="sources" class="lapis-plugin-registry__content">
1061
+ <div class="lapis-plugin-registry__rows">
1062
+ {#each registry.registrySources as sourceState (sourceState.source.id)}
1063
+ {@const source = sourceState.source}
1064
+ {@const isNostr = source.kind === "nostr"}
1065
+ <PluginRegistryRow
1066
+ id={source.id}
1067
+ name={source.name}
1068
+ description={source.url}
1069
+ metadata={[
1070
+ ...(source.builtin ? ["Built-in source"] : []),
1071
+ isNostr
1072
+ ? `${source.relays?.length ?? 0} relay${source.relays?.length === 1 ? "" : "s"}`
1073
+ : "HTTPS registry",
1074
+ source.trustTier === "official"
1075
+ ? "Official source"
1076
+ : "Community source",
1077
+ ]}
1078
+ badges={[
1079
+ { label: source.enabled ? "Enabled" : "Disabled" },
1080
+ ...(isNostr
1081
+ ? [{ label: "Nostr", tone: "community" as const }]
1082
+ : []),
1083
+ {
1084
+ label:
1085
+ source.trustTier === "official" ? "Official" : "Community",
1086
+ tone:
1087
+ source.trustTier === "official"
1088
+ ? ("official" as const)
1089
+ : ("community" as const),
1090
+ },
1091
+ ...(source.builtin ? [{ label: "Locked" }] : []),
1092
+ ]}
1093
+ status={!source.enabled
1094
+ ? { label: "Disabled" }
1095
+ : sourceState.phase === "error"
1096
+ ? {
1097
+ label: "Source problem",
1098
+ reason: sourceState.message,
1099
+ tone: "warning",
1100
+ }
1101
+ : isNostr && !sourceState.installReady
1102
+ ? {
1103
+ label: "Browse only",
1104
+ reason:
1105
+ sourceState.message ??
1106
+ "Relay install quorum has not been reached",
1107
+ tone: "warning",
1108
+ }
1109
+ : {
1110
+ label: isNostr ? "Install quorum ready" : "Available",
1111
+ tone: "success",
1112
+ }}
1113
+ >
1114
+ {#snippet actions()}
1115
+ {@const sourceToggleAction = `${source.enabled ? "Disable" : "Enable"} ${source.name}`}
1116
+ {#key sourceToggleAction}
1117
+ <Tooltip.Provider>
1118
+ <Tooltip.Root>
1119
+ <Tooltip.Trigger>
1120
+ {#snippet child({ props })}
1121
+ <Switch.Root
1122
+ {...props}
1123
+ checked={source.enabled}
1124
+ disabled={registry.runningAction ===
1125
+ `source:${source.id}`}
1126
+ aria-label={sourceToggleAction}
1127
+ data-tooltip-action={sourceToggleAction}
1128
+ onCheckedChange={(checked) =>
1129
+ void setRegistrySourceEnabled(sourceState, checked)}
1130
+ />
1131
+ {/snippet}
1132
+ </Tooltip.Trigger>
1133
+ <Tooltip.Content>{sourceToggleAction}</Tooltip.Content>
1134
+ </Tooltip.Root>
1135
+ </Tooltip.Provider>
1136
+ {/key}
1137
+ {#if source.enabled && (sourceState.phase === "error" || !sourceState.installReady)}
1138
+ <Button.Root
1139
+ size="sm"
1140
+ variant="outline"
1141
+ onclick={() => void refresh(true)}
1142
+ ><RefreshCw />Retry</Button.Root
1143
+ >
731
1144
  {/if}
732
- </div>
733
- </Resizable.Pane>
734
- </Resizable.PaneGroup>
1145
+ {/snippet}
1146
+ </PluginRegistryRow>
1147
+ {/each}
735
1148
  </div>
736
- </Dialog.Content>
737
- </Dialog.Root>
1149
+ </Tabs.Content>
1150
+ </Tabs.Root>
738
1151
 
739
1152
  <AlertDialog.Root bind:open={uninstallConfirmOpen}>
740
- <AlertDialog.Content>
1153
+ <AlertDialog.Content
1154
+ class="lapis-plugin-uninstall-dialog"
1155
+ data-action-mode={uninstallDisableInstead ? "disable" : "uninstall"}
1156
+ >
741
1157
  <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>
1158
+ <AlertDialog.Media class="lapis-plugin-uninstall-dialog__media">
1159
+ <Trash2 aria-hidden="true" />
1160
+ </AlertDialog.Media>
1161
+ <AlertDialog.Title>Uninstall plugin?</AlertDialog.Title>
1162
+ <AlertDialog.Description
1163
+ >{uninstallDisableInstead
1164
+ ? "The plugin will stay installed in this vault and can be enabled again later."
1165
+ : "This removes the installed plugin from this vault. You can reinstall it from the registry later."}</AlertDialog.Description
1166
+ >
744
1167
  </AlertDialog.Header>
1168
+ {#if uninstallTarget}
1169
+ {@const entry = catalogEntry(uninstallTarget.pluginId)}
1170
+ {@const targetSize = installedSize(uninstallTarget)}
1171
+ <div
1172
+ class="lapis-plugin-uninstall-dialog__target"
1173
+ data-ui-component="plugin-uninstall-target"
1174
+ >
1175
+ <PluginRegistryRow
1176
+ id={uninstallTarget.pluginId}
1177
+ name={installedName(uninstallTarget)}
1178
+ description={installedDescription(uninstallTarget)}
1179
+ version={uninstallTarget.installedVersion}
1180
+ metadata={[
1181
+ ...(targetSize === null
1182
+ ? []
1183
+ : [`Size ${formatByteSize(targetSize)}`]),
1184
+ ]}
1185
+ badges={registryRowBadges(entry, uninstallTarget.provenance)}
1186
+ status={uninstallTarget.revoked
1187
+ ? { label: "Revoked", tone: "danger" }
1188
+ : undefined}
1189
+ />
1190
+ </div>
1191
+ {#if resolveRuntimePlugin(uninstallTarget.pluginId)?.enabled}
1192
+ {@const alternateAction = uninstallDisableInstead
1193
+ ? `Choose Uninstall instead for ${installedName(uninstallTarget)}`
1194
+ : `Choose Disable instead for ${installedName(uninstallTarget)}`}
1195
+ <Field.Field
1196
+ orientation="horizontal"
1197
+ class="lapis-plugin-uninstall-dialog__alternative"
1198
+ >
1199
+ <Field.Content>
1200
+ <Field.Title>Disable instead</Field.Title>
1201
+ <Field.Description
1202
+ >Keep the plugin installed and turn it off.</Field.Description
1203
+ >
1204
+ </Field.Content>
1205
+ <Tooltip.Provider>
1206
+ <Tooltip.Root>
1207
+ <Tooltip.Trigger>
1208
+ {#snippet child({ props })}
1209
+ <Switch.Root
1210
+ {...props}
1211
+ bind:checked={uninstallDisableInstead}
1212
+ aria-label={alternateAction}
1213
+ data-tooltip-action={alternateAction}
1214
+ />
1215
+ {/snippet}
1216
+ </Tooltip.Trigger>
1217
+ <Tooltip.Content>{alternateAction}</Tooltip.Content>
1218
+ </Tooltip.Root>
1219
+ </Tooltip.Provider>
1220
+ </Field.Field>
1221
+ {/if}
1222
+ {/if}
745
1223
  <AlertDialog.Footer>
746
- <AlertDialog.Cancel onclick={() => (uninstallTarget = null)}>Cancel</AlertDialog.Cancel>
747
- <AlertDialog.Action onclick={() => void confirmUninstall()}>Uninstall</AlertDialog.Action>
1224
+ <AlertDialog.Cancel onclick={closeUninstallDialog}
1225
+ >Cancel</AlertDialog.Cancel
1226
+ >
1227
+ <AlertDialog.Action
1228
+ class={uninstallDisableInstead
1229
+ ? "lapis-plugin-uninstall-dialog__confirm--disable"
1230
+ : "lapis-plugin-uninstall-dialog__confirm--uninstall"}
1231
+ variant={uninstallDisableInstead ? "default" : "destructive"}
1232
+ data-action-mode={uninstallDisableInstead ? "disable" : "uninstall"}
1233
+ aria-label={uninstallTarget
1234
+ ? `${uninstallDisableInstead ? "Disable" : "Uninstall"} ${installedName(uninstallTarget)}`
1235
+ : `${uninstallDisableInstead ? "Disable" : "Uninstall"} plugin`}
1236
+ onclick={() => void confirmPluginRemovalChoice()}
1237
+ >{#if uninstallDisableInstead}<Power
1238
+ data-icon="inline-start"
1239
+ aria-hidden="true"
1240
+ />Disable plugin{:else}<Trash2
1241
+ data-icon="inline-start"
1242
+ aria-hidden="true"
1243
+ />Uninstall plugin{/if}</AlertDialog.Action
1244
+ >
748
1245
  </AlertDialog.Footer>
749
1246
  </AlertDialog.Content>
750
1247
  </AlertDialog.Root>