@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.
- package/CHANGELOG.md +8 -0
- package/LICENSE.md +4 -0
- package/README.md +36 -0
- package/dist/WorkspaceShell.svelte +59 -0
- package/dist/WorkspaceShell.svelte.d.ts +14 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5 -0
- package/dist/plugin-management/CommunityPluginsSettingsPage.svelte +182 -0
- package/dist/plugin-management/CommunityPluginsSettingsPage.svelte.d.ts +5 -0
- package/dist/plugin-management/CorePluginsSettingsPage.svelte +111 -0
- package/dist/plugin-management/CorePluginsSettingsPage.svelte.d.ts +5 -0
- package/dist/plugin-management/PluginReadmeRenderer.svelte +47 -0
- package/dist/plugin-management/PluginReadmeRenderer.svelte.d.ts +9 -0
- package/dist/plugin-management/PluginRegistrySettingsPage.svelte +751 -0
- package/dist/plugin-management/PluginRegistrySettingsPage.svelte.d.ts +5 -0
- package/dist/plugin-management/PluginSettingsRow.svelte +152 -0
- package/dist/plugin-management/PluginSettingsRow.svelte.d.ts +24 -0
- package/dist/plugin-management/plugin-management-context.svelte.d.ts +15 -0
- package/dist/plugin-management/plugin-management-context.svelte.js +24 -0
- package/dist/plugin-management/plugin-management.css +477 -0
- package/dist/plugin-management/plugin-readme.d.ts +26 -0
- package/dist/plugin-management/plugin-readme.js +34 -0
- package/dist/plugin-management/plugin-settings-navigation.d.ts +8 -0
- package/dist/plugin-management/plugin-settings-navigation.js +37 -0
- package/dist/plugin-management/register-plugin-management-settings.d.ts +3 -0
- package/dist/plugin-management/register-plugin-management-settings.js +95 -0
- package/dist/workspace-shell.css +18 -0
- 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>
|