@nwire/studio 0.9.1

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 (107) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +72 -0
  3. package/components.json +19 -0
  4. package/index.html +12 -0
  5. package/package.json +66 -0
  6. package/src/App.vue +305 -0
  7. package/src/components/EmptyState.stories.ts +53 -0
  8. package/src/components/EmptyState.vue +28 -0
  9. package/src/components/ErrorBoundary.vue +60 -0
  10. package/src/components/FilterInput.stories.ts +32 -0
  11. package/src/components/FilterInput.vue +33 -0
  12. package/src/components/JsonView.stories.ts +38 -0
  13. package/src/components/JsonView.vue +34 -0
  14. package/src/components/KindBadge.stories.ts +72 -0
  15. package/src/components/KindBadge.vue +59 -0
  16. package/src/components/ListRow.stories.ts +56 -0
  17. package/src/components/ListRow.vue +48 -0
  18. package/src/components/MasterDetail.stories.ts +74 -0
  19. package/src/components/MasterDetail.vue +35 -0
  20. package/src/components/MonacoViewer.vue +143 -0
  21. package/src/components/PageHeader.stories.ts +45 -0
  22. package/src/components/PageHeader.vue +46 -0
  23. package/src/components/SchemaNode.vue +208 -0
  24. package/src/components/SchemaTree.vue +65 -0
  25. package/src/components/SourceDrawer.vue +136 -0
  26. package/src/components/SourcePill.vue +103 -0
  27. package/src/components/__tests__/EmptyState.test.ts +28 -0
  28. package/src/components/__tests__/ErrorBoundary.test.ts +52 -0
  29. package/src/components/__tests__/FilterInput.test.ts +38 -0
  30. package/src/components/__tests__/JsonView.test.ts +33 -0
  31. package/src/components/__tests__/KindBadge.test.ts +39 -0
  32. package/src/components/__tests__/ListRow.test.ts +39 -0
  33. package/src/components/__tests__/MasterDetail.test.ts +40 -0
  34. package/src/components/__tests__/PageHeader.test.ts +42 -0
  35. package/src/components/index.ts +17 -0
  36. package/src/components/ui/badge/Badge.vue +17 -0
  37. package/src/components/ui/badge/index.ts +25 -0
  38. package/src/components/ui/button/Button.vue +28 -0
  39. package/src/components/ui/button/index.ts +34 -0
  40. package/src/components/ui/card/Card.vue +14 -0
  41. package/src/components/ui/card/CardContent.vue +14 -0
  42. package/src/components/ui/card/CardDescription.vue +14 -0
  43. package/src/components/ui/card/CardFooter.vue +14 -0
  44. package/src/components/ui/card/CardHeader.vue +14 -0
  45. package/src/components/ui/card/CardTitle.vue +14 -0
  46. package/src/components/ui/card/index.ts +6 -0
  47. package/src/components/ui/dialog/Dialog.vue +15 -0
  48. package/src/components/ui/dialog/DialogClose.vue +12 -0
  49. package/src/components/ui/dialog/DialogContent.vue +47 -0
  50. package/src/components/ui/dialog/DialogDescription.vue +22 -0
  51. package/src/components/ui/dialog/DialogFooter.vue +12 -0
  52. package/src/components/ui/dialog/DialogHeader.vue +14 -0
  53. package/src/components/ui/dialog/DialogScrollContent.vue +60 -0
  54. package/src/components/ui/dialog/DialogTitle.vue +22 -0
  55. package/src/components/ui/dialog/DialogTrigger.vue +12 -0
  56. package/src/components/ui/dialog/index.ts +9 -0
  57. package/src/components/ui/input/Input.vue +32 -0
  58. package/src/components/ui/input/index.ts +1 -0
  59. package/src/components/ui/scroll-area/ScrollArea.vue +22 -0
  60. package/src/components/ui/scroll-area/ScrollBar.vue +32 -0
  61. package/src/components/ui/scroll-area/index.ts +2 -0
  62. package/src/components/ui/separator/Separator.vue +27 -0
  63. package/src/components/ui/separator/index.ts +1 -0
  64. package/src/components/ui/skeleton/Skeleton.vue +14 -0
  65. package/src/components/ui/skeleton/index.ts +1 -0
  66. package/src/components/ui/tabs/Tabs.vue +15 -0
  67. package/src/components/ui/tabs/TabsContent.vue +25 -0
  68. package/src/components/ui/tabs/TabsList.vue +25 -0
  69. package/src/components/ui/tabs/TabsTrigger.vue +29 -0
  70. package/src/components/ui/tabs/index.ts +4 -0
  71. package/src/components/ui/tooltip/Tooltip.vue +15 -0
  72. package/src/components/ui/tooltip/TooltipContent.vue +40 -0
  73. package/src/components/ui/tooltip/TooltipProvider.vue +12 -0
  74. package/src/components/ui/tooltip/TooltipTrigger.vue +12 -0
  75. package/src/components/ui/tooltip/index.ts +4 -0
  76. package/src/composables/useCopy.ts +31 -0
  77. package/src/lib/__tests__/normalize-cache.test.ts +104 -0
  78. package/src/lib/cache.ts +334 -0
  79. package/src/lib/normalize-cache.ts +92 -0
  80. package/src/lib/project-catalog.ts +125 -0
  81. package/src/lib/utils.ts +6 -0
  82. package/src/main.ts +112 -0
  83. package/src/pages/Actions.vue +180 -0
  84. package/src/pages/Commands.vue +262 -0
  85. package/src/pages/Dispatch.vue +431 -0
  86. package/src/pages/Events.vue +166 -0
  87. package/src/pages/Home.stories.ts +47 -0
  88. package/src/pages/Home.vue +485 -0
  89. package/src/pages/Hooks.vue +297 -0
  90. package/src/pages/Live.vue +249 -0
  91. package/src/pages/Modules.vue +174 -0
  92. package/src/pages/Overview.vue +159 -0
  93. package/src/pages/Plugins.stories.ts +44 -0
  94. package/src/pages/Plugins.vue +403 -0
  95. package/src/pages/Projects.vue +272 -0
  96. package/src/pages/Run.vue +479 -0
  97. package/src/pages/Topology.vue +164 -0
  98. package/src/pages/Trace.vue +511 -0
  99. package/src/pages/TraceNode.vue +166 -0
  100. package/src/pages/Workflows.vue +191 -0
  101. package/src/pages/__tests__/Actions.test.ts +98 -0
  102. package/src/pages/__tests__/Home.test.ts +98 -0
  103. package/src/pages/__tests__/Hooks.test.ts +119 -0
  104. package/src/pages/__tests__/Plugins.test.ts +80 -0
  105. package/src/style.css +40 -0
  106. package/tsconfig.json +20 -0
  107. package/vite.config.ts +892 -0
@@ -0,0 +1,403 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Plugins — every plugin AND every module-as-plugin registered against the
4
+ * Nwire app, with live boot/shutdown timing pulled from the lifecycle
5
+ * telemetry stream. The cache emits `plugins.json` at scan time; the wire
6
+ * emits `kind: "lifecycle"` (e.g. `nwire.plugin.booted`) and
7
+ * `kind: "hook.step"` records under `plugin.boot:<name>` /
8
+ * `plugin.shutdown:<name>` at runtime. We fuse the two views.
9
+ */
10
+ import { computed, onMounted, onUnmounted, ref, watch } from "vue";
11
+ import { useRoute, useRouter } from "vue-router";
12
+ import { useCache, type PluginEntry, type HookEntry } from "@/lib/cache";
13
+ import { Activity, Anchor, Boxes, Puzzle, RefreshCw, Search } from "lucide-vue-next";
14
+ import { SourcePill, SourceDrawer, KindBadge } from "@/components";
15
+
16
+ // ── Telemetry record shapes (loose — payloads come from the wire) ─────
17
+ interface LifecycleRecord {
18
+ readonly kind: "lifecycle";
19
+ readonly event: string;
20
+ readonly ts: string;
21
+ readonly payload?: { pluginName?: string; durationMs?: number; [k: string]: unknown };
22
+ }
23
+
24
+ interface HookStepRecord {
25
+ readonly kind: "hook.step";
26
+ readonly hookName: string;
27
+ readonly hookId: string;
28
+ readonly runId: string;
29
+ readonly stepId: number;
30
+ readonly stepKind: "chain" | "listener";
31
+ readonly stepName?: string;
32
+ readonly phase: "start" | "end" | "error";
33
+ readonly durationMs?: number;
34
+ readonly ts: string;
35
+ }
36
+
37
+ type PluginTelemetry = LifecycleRecord | HookStepRecord;
38
+
39
+ const route = useRoute();
40
+ const router = useRouter();
41
+ const { cache } = useCache();
42
+ const filter = ref("");
43
+ const kindFilter = ref<"all" | "plugin" | "module">("all");
44
+ const selected = ref<string | null>(null);
45
+ const sourcePreview = ref<{ file: string; line: number; column?: number } | null>(null);
46
+
47
+ function applyQueryPreselect(): void {
48
+ const name = route.query.name;
49
+ if (typeof name !== "string" || name.length === 0) return;
50
+ const found = cache.value?.plugins.find((p) => p.name === name);
51
+ if (found) selected.value = `${found.app}::${found.kind}::${found.name}`;
52
+ }
53
+
54
+ onMounted(applyQueryPreselect);
55
+ watch(() => route.query.name, applyQueryPreselect);
56
+ watch(() => cache.value, applyQueryPreselect);
57
+
58
+ // ── Live tap stream ───────────────────────────────────────────────────
59
+ const liveRecords = ref<PluginTelemetry[]>([]);
60
+ const streamStatus = ref<"connecting" | "open" | "closed" | "error">("connecting");
61
+ let es: EventSource | null = null;
62
+
63
+ onMounted(() => {
64
+ connect();
65
+ });
66
+ onUnmounted(() => {
67
+ es?.close();
68
+ });
69
+
70
+ function connect(): void {
71
+ streamStatus.value = "connecting";
72
+ es?.close();
73
+ es = new EventSource("/_nwire/telemetry/stream");
74
+ es.onopen = () => {
75
+ streamStatus.value = "open";
76
+ };
77
+ es.onerror = () => {
78
+ streamStatus.value = "error";
79
+ };
80
+ es.onmessage = (m) => {
81
+ try {
82
+ const rec = JSON.parse(m.data) as { kind?: string };
83
+ if (rec.kind === "lifecycle" || rec.kind === "hook.step") {
84
+ liveRecords.value.push(rec as PluginTelemetry);
85
+ if (liveRecords.value.length > 1000) liveRecords.value.splice(0, 500);
86
+ }
87
+ } catch {
88
+ /* ignore non-JSON frames */
89
+ }
90
+ };
91
+ }
92
+
93
+ // ── Helpers ───────────────────────────────────────────────────────────
94
+ const key = (p: PluginEntry) => `${p.app}::${p.kind}::${p.name}`;
95
+
96
+ function pluginNameFromHook(hookName: string): string | null {
97
+ const colon = hookName.indexOf(":");
98
+ if (colon === -1) return null;
99
+ const prefix = hookName.slice(0, colon);
100
+ if (prefix !== "plugin.boot" && prefix !== "plugin.shutdown") return null;
101
+ return hookName.slice(colon + 1);
102
+ }
103
+
104
+ function recordPluginName(rec: PluginTelemetry): string | null {
105
+ if (rec.kind === "lifecycle") return rec.payload?.pluginName ?? null;
106
+ return pluginNameFromHook(rec.hookName);
107
+ }
108
+
109
+ function recordPhase(rec: PluginTelemetry): string {
110
+ if (rec.kind === "lifecycle") {
111
+ // e.g. nwire.plugin.booted → "booted"
112
+ const parts = rec.event.split(".");
113
+ return parts[parts.length - 1] ?? rec.event;
114
+ }
115
+ const colon = rec.hookName.indexOf(":");
116
+ const head = colon === -1 ? rec.hookName : rec.hookName.slice(0, colon);
117
+ return `${head.replace(/^plugin\./, "")} ${rec.phase}`;
118
+ }
119
+
120
+ function recordDuration(rec: PluginTelemetry): number | undefined {
121
+ if (rec.kind === "lifecycle") {
122
+ const d = rec.payload?.durationMs;
123
+ return typeof d === "number" ? d : undefined;
124
+ }
125
+ return rec.durationMs;
126
+ }
127
+
128
+ /** Last observed boot duration per plugin (lifecycle preferred over hook). */
129
+ const bootDurations = computed<Record<string, number>>(() => {
130
+ const out: Record<string, number> = {};
131
+ for (const rec of liveRecords.value) {
132
+ const name = recordPluginName(rec);
133
+ if (!name) continue;
134
+ if (rec.kind === "lifecycle" && rec.event.endsWith(".booted")) {
135
+ const d = recordDuration(rec);
136
+ if (typeof d === "number") out[name] = d;
137
+ }
138
+ }
139
+ return out;
140
+ });
141
+
142
+ // ── Filtered list ─────────────────────────────────────────────────────
143
+ const filteredPlugins = computed<PluginEntry[]>(() => {
144
+ if (!cache.value) return [];
145
+ const q = filter.value.toLowerCase();
146
+ return cache.value.plugins.filter((p) => {
147
+ if (kindFilter.value !== "all" && p.kind !== kindFilter.value) return false;
148
+ if (!q) return true;
149
+ return p.name.toLowerCase().includes(q) || p.app.toLowerCase().includes(q);
150
+ });
151
+ });
152
+
153
+ const detail = computed<PluginEntry | null>(() => {
154
+ if (!cache.value) return null;
155
+ return cache.value.plugins.find((p) => key(p) === selected.value) ?? null;
156
+ });
157
+
158
+ // ── Contributed hooks for the selected plugin ─────────────────────────
159
+ const contributedHooks = computed<HookEntry[]>(() => {
160
+ if (!detail.value || !cache.value) return [];
161
+ const name = detail.value.name;
162
+ const bootName = `plugin.boot:${name}`;
163
+ const downName = `plugin.shutdown:${name}`;
164
+ return cache.value.hooks.filter((h) => h.name === bootName || h.name === downName);
165
+ });
166
+
167
+ // ── Lifecycle timeline for the selected plugin (last 50) ──────────────
168
+ const detailRecords = computed<PluginTelemetry[]>(() => {
169
+ if (!detail.value) return [];
170
+ const name = detail.value.name;
171
+ return liveRecords.value
172
+ .filter((r) => recordPluginName(r) === name)
173
+ .slice(-50)
174
+ .reverse();
175
+ });
176
+
177
+ function openHook(hookName: string): void {
178
+ void router.push({ path: "/hooks", query: { name: hookName } });
179
+ }
180
+ </script>
181
+
182
+ <template>
183
+ <div v-if="cache" class="h-full flex flex-col" data-testid="plugins-page">
184
+ <!-- Header -->
185
+ <div class="p-6 pb-3 border-b border-zinc-800 flex items-center justify-between">
186
+ <div>
187
+ <div class="flex items-center gap-2">
188
+ <Puzzle class="w-4 h-4 text-fuchsia-400" />
189
+ <h1 class="text-lg font-medium">Plugins</h1>
190
+ <span class="text-[10px] text-zinc-500">
191
+ {{ filteredPlugins.length }} / {{ cache.plugins.length }}
192
+ </span>
193
+ <span
194
+ class="ml-2 text-[9px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-zinc-900 text-zinc-400"
195
+ data-testid="stream-status"
196
+ >
197
+ stream {{ streamStatus }}
198
+ </span>
199
+ </div>
200
+ <p class="text-xs text-zinc-500 mt-1">
201
+ Plugins and modules-as-plugins composed into the app. Boot and shutdown timing streams
202
+ live from the running wire.
203
+ </p>
204
+ </div>
205
+ <button
206
+ class="p-1 rounded hover:bg-zinc-800 text-zinc-400"
207
+ title="Reconnect stream"
208
+ @click="connect"
209
+ >
210
+ <RefreshCw class="w-3.5 h-3.5" />
211
+ </button>
212
+ </div>
213
+
214
+ <!-- Empty (no plugins at all) -->
215
+ <div
216
+ v-if="cache.plugins.length === 0"
217
+ class="flex-1 flex items-center justify-center p-12"
218
+ data-testid="plugins-empty"
219
+ >
220
+ <div class="text-center max-w-md">
221
+ <Puzzle class="w-8 h-8 text-zinc-700 mx-auto mb-3" />
222
+ <div class="text-sm text-zinc-400 font-medium">No plugins in cache</div>
223
+ <div class="text-xs text-zinc-500 mt-2">
224
+ Plugins and modules are emitted to <span class="font-mono">.nwire/plugins.json</span> at
225
+ scan time. Run <span class="font-mono">nwire cache</span> after registering one.
226
+ </div>
227
+ </div>
228
+ </div>
229
+
230
+ <!-- Master / detail -->
231
+ <div v-else class="flex-1 flex min-h-0">
232
+ <!-- Master -->
233
+ <aside class="w-[280px] border-r border-zinc-800 flex flex-col shrink-0">
234
+ <div class="px-3 py-2 border-b border-zinc-800 space-y-2">
235
+ <div class="relative">
236
+ <Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-zinc-500" />
237
+ <input
238
+ v-model="filter"
239
+ placeholder="filter name / app…"
240
+ class="w-full bg-zinc-900 border border-zinc-800 rounded pl-7 pr-2 py-1 text-xs placeholder:text-zinc-600 focus:outline-none focus:border-zinc-600"
241
+ />
242
+ </div>
243
+ <div class="flex gap-1">
244
+ <button
245
+ v-for="opt in ['all', 'plugin', 'module'] as const"
246
+ :key="opt"
247
+ class="flex-1 px-2 py-0.5 text-[10px] uppercase tracking-wide rounded border transition-colors"
248
+ :class="
249
+ kindFilter === opt
250
+ ? 'bg-zinc-800 border-zinc-700 text-zinc-100'
251
+ : 'bg-zinc-950 border-zinc-900 text-zinc-500 hover:text-zinc-300'
252
+ "
253
+ @click="kindFilter = opt"
254
+ >
255
+ {{ opt === "all" ? "All" : opt + "s" }}
256
+ </button>
257
+ </div>
258
+ </div>
259
+
260
+ <div v-if="filteredPlugins.length === 0" class="p-4 text-xs text-zinc-500">
261
+ No plugins match the filter.
262
+ </div>
263
+ <ul v-else class="flex-1 overflow-auto" data-testid="plugins-list">
264
+ <li v-for="p in filteredPlugins" :key="key(p)">
265
+ <button
266
+ class="w-full text-left px-3 py-2 border-b border-zinc-900 hover:bg-zinc-900/50 transition-colors"
267
+ :class="{ 'bg-zinc-900/70': key(p) === selected }"
268
+ @click="selected = key(p)"
269
+ >
270
+ <div class="flex items-center gap-2">
271
+ <component
272
+ :is="p.kind === 'plugin' ? Anchor : Boxes"
273
+ class="w-3 h-3 shrink-0"
274
+ :class="p.kind === 'plugin' ? 'text-fuchsia-400' : 'text-orange-400'"
275
+ />
276
+ <span class="font-mono text-xs text-zinc-100 truncate flex-1">{{ p.name }}</span>
277
+ <span
278
+ v-if="bootDurations[p.name] !== undefined"
279
+ class="text-[10px] text-emerald-400 tabular-nums shrink-0"
280
+ title="last observed boot duration"
281
+ >
282
+ {{ bootDurations[p.name]!.toFixed(1) }}ms
283
+ </span>
284
+ </div>
285
+ <div class="text-[10px] text-zinc-500 mt-0.5 pl-5">
286
+ {{ p.app }}
287
+ </div>
288
+ </button>
289
+ </li>
290
+ </ul>
291
+ </aside>
292
+
293
+ <!-- Detail -->
294
+ <main class="flex-1 flex flex-col min-w-0" data-testid="plugins-detail">
295
+ <div v-if="!detail" class="p-8 text-sm text-zinc-500 italic">
296
+ Select a plugin to view its contributed hooks + live lifecycle timeline.
297
+ </div>
298
+ <div v-else class="flex-1 flex flex-col overflow-hidden">
299
+ <!-- Header -->
300
+ <div class="px-6 py-5 border-b border-zinc-800">
301
+ <div class="flex items-center gap-3 flex-wrap">
302
+ <component
303
+ :is="detail.kind === 'plugin' ? Anchor : Boxes"
304
+ class="w-4 h-4"
305
+ :class="detail.kind === 'plugin' ? 'text-fuchsia-400' : 'text-orange-400'"
306
+ />
307
+ <h2 class="font-mono text-xl">{{ detail.name }}</h2>
308
+ <KindBadge :variant="detail.kind === 'plugin' ? 'info' : 'warning'">
309
+ {{ detail.kind }}
310
+ </KindBadge>
311
+ <span class="text-[10px] text-zinc-500 font-mono">{{ detail.app }}</span>
312
+ <button
313
+ v-if="detail.source"
314
+ type="button"
315
+ class="ml-auto inline-flex items-center"
316
+ @click="sourcePreview = detail.source!"
317
+ >
318
+ <SourcePill :source="detail.source" />
319
+ </button>
320
+ </div>
321
+ </div>
322
+
323
+ <div class="flex-1 overflow-auto">
324
+ <!-- Contributed hooks -->
325
+ <section class="px-6 py-4 border-b border-zinc-900">
326
+ <h3 class="text-xs uppercase tracking-wide text-zinc-400 mb-3">
327
+ Contributed hooks
328
+ <span class="text-zinc-600 normal-case tracking-normal ml-1">
329
+ ({{ contributedHooks.length }})
330
+ </span>
331
+ </h3>
332
+ <div v-if="contributedHooks.length === 0" class="text-xs text-zinc-500 italic">
333
+ No <span class="font-mono">plugin.boot:{{ detail.name }}</span> or
334
+ <span class="font-mono">plugin.shutdown:{{ detail.name }}</span>
335
+ hooks registered.
336
+ </div>
337
+ <ul v-else class="space-y-1">
338
+ <li
339
+ v-for="h in contributedHooks"
340
+ :key="h.id"
341
+ class="flex items-center gap-3 px-3 py-1.5 rounded hover:bg-zinc-900/50 cursor-pointer"
342
+ :data-testid="`hook-link-${h.name}`"
343
+ @click="openHook(h.name)"
344
+ >
345
+ <Anchor class="w-3 h-3 text-zinc-500 shrink-0" />
346
+ <span class="font-mono text-xs flex-1 truncate">{{ h.name }}</span>
347
+ <span class="text-[10px] text-zinc-500 tabular-nums shrink-0">
348
+ chain {{ h.chain }} · {{ h.listeners }} listener{{
349
+ h.listeners === 1 ? "" : "s"
350
+ }}
351
+ </span>
352
+ </li>
353
+ </ul>
354
+ </section>
355
+
356
+ <!-- Lifecycle timeline -->
357
+ <section class="px-6 py-4">
358
+ <div class="flex items-center gap-2 mb-3">
359
+ <Activity class="w-3.5 h-3.5 text-emerald-400" />
360
+ <h3 class="text-xs uppercase tracking-wide text-zinc-400">Lifecycle timeline</h3>
361
+ <span class="text-[10px] text-zinc-500 tabular-nums">
362
+ {{ detailRecords.length }} recent
363
+ </span>
364
+ </div>
365
+ <div v-if="detailRecords.length === 0" class="text-xs text-zinc-500 italic">
366
+ No lifecycle data yet. Boot or restart the wire and events for
367
+ <span class="font-mono">{{ detail.name }}</span> will stream here.
368
+ </div>
369
+ <ul v-else class="divide-y divide-zinc-900" data-testid="plugins-tap-list">
370
+ <li
371
+ v-for="(r, i) in detailRecords"
372
+ :key="i"
373
+ class="px-3 py-1.5 flex items-center gap-3 font-mono text-[11px] hover:bg-zinc-900/30"
374
+ >
375
+ <span class="text-zinc-600 tabular-nums w-20 shrink-0">
376
+ {{ new Date(r.ts).toLocaleTimeString(undefined, { hour12: false }) }}
377
+ </span>
378
+ <span
379
+ class="text-[10px] uppercase tracking-wide shrink-0"
380
+ :class="{
381
+ 'text-emerald-400': r.kind === 'lifecycle',
382
+ 'text-cyan-400': r.kind === 'hook.step',
383
+ }"
384
+ >{{ r.kind === "lifecycle" ? "event" : "hook" }}</span
385
+ >
386
+ <span class="text-zinc-200 truncate flex-1">{{ recordPhase(r) }}</span>
387
+ <span
388
+ v-if="recordDuration(r) !== undefined"
389
+ class="text-zinc-500 tabular-nums shrink-0"
390
+ >
391
+ {{ recordDuration(r)!.toFixed(1) }} ms
392
+ </span>
393
+ </li>
394
+ </ul>
395
+ </section>
396
+ </div>
397
+ </div>
398
+ </main>
399
+ </div>
400
+
401
+ <SourceDrawer :source="sourcePreview" @close="sourcePreview = null" />
402
+ </div>
403
+ </template>
@@ -0,0 +1,272 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Projects catalog — every Nwire project this browser has opened
4
+ * Studio for. Each card shows composition + live running status; click
5
+ * a card to navigate to its Home if it's the current Studio's project,
6
+ * or copy a `cd && nwire studio` snippet for the others.
7
+ *
8
+ * Snapshots live in localStorage under `nwire.projects` (one per cwd).
9
+ * Live status comes from `/__nwire/projects/status` which walks each
10
+ * project's `.nwire/processes/` registry — no per-project Studio needs
11
+ * to be open for the dots to be accurate.
12
+ */
13
+ import { computed, onMounted, ref } from "vue";
14
+ import { FolderOpen, Folder, Copy, Trash2, Activity, Circle, RefreshCw } from "lucide-vue-next";
15
+ import { useRouter } from "vue-router";
16
+ import {
17
+ loadCatalog,
18
+ forgetProject,
19
+ setActiveProjectCwd,
20
+ type ProjectSnapshot,
21
+ } from "@/lib/project-catalog";
22
+
23
+ interface ProcessRec {
24
+ id: string;
25
+ port?: number;
26
+ pid: number;
27
+ status: string;
28
+ startedAt: string;
29
+ }
30
+ type StatusMap = Record<string, { hasManifest: boolean; processes: ProcessRec[] }>;
31
+
32
+ const router = useRouter();
33
+ const catalog = ref<Record<string, ProjectSnapshot>>({});
34
+ const status = ref<StatusMap>({});
35
+ const currentCwd = ref<string | null>(null);
36
+ const copiedCwd = ref<string | null>(null);
37
+ /** cwd → human label while a per-project `nwire cache` is in flight. */
38
+ const rescanState = ref<Record<string, "running" | "done" | "error">>({});
39
+
40
+ const projects = computed(() => {
41
+ return Object.values(catalog.value).sort((a, b) => b.lastVisited.localeCompare(a.lastVisited));
42
+ });
43
+
44
+ async function refresh() {
45
+ catalog.value = loadCatalog();
46
+ try {
47
+ const proj = await fetch("/__nwire/project");
48
+ if (proj.ok) {
49
+ const body = (await proj.json()) as { cwd: string };
50
+ currentCwd.value = body.cwd;
51
+ }
52
+ } catch {
53
+ /* current project unknown — fine */
54
+ }
55
+ const cwds = Object.keys(catalog.value);
56
+ if (cwds.length === 0) {
57
+ status.value = {};
58
+ return;
59
+ }
60
+ try {
61
+ const res = await fetch("/__nwire/projects/status", {
62
+ method: "POST",
63
+ headers: { "Content-Type": "application/json" },
64
+ body: JSON.stringify({ cwds }),
65
+ });
66
+ if (res.ok) status.value = (await res.json()) as StatusMap;
67
+ } catch {
68
+ status.value = {};
69
+ }
70
+ }
71
+
72
+ function copyStartCommand(cwd: string) {
73
+ const cmd = `cd "${cwd}" && nwire studio`;
74
+ void navigator.clipboard.writeText(cmd);
75
+ copiedCwd.value = cwd;
76
+ setTimeout(() => {
77
+ if (copiedCwd.value === cwd) copiedCwd.value = null;
78
+ }, 1500);
79
+ }
80
+
81
+ function openProject(cwd: string) {
82
+ if (cwd === currentCwd.value) {
83
+ void router.push("/");
84
+ } else {
85
+ // Shape A — set the active project and hard-reload so every page
86
+ // re-fetches against the new cwd. SSE streams + cached state are
87
+ // tied to the previous project, so a fresh load is the clean pivot.
88
+ setActiveProjectCwd(cwd);
89
+ window.location.assign("/");
90
+ }
91
+ }
92
+
93
+ function forget(cwd: string) {
94
+ forgetProject(cwd);
95
+ catalog.value = loadCatalog();
96
+ }
97
+
98
+ /**
99
+ * Fire `nwire cache` against this project's cwd via the Studio supervisor.
100
+ * The supervisor runs it in the background; we poll briefly to surface the
101
+ * "done" state. The composition snapshot updates the next time that project
102
+ * is opened (Studio reads the rebuilt manifest on load).
103
+ */
104
+ async function rescan(cwd: string) {
105
+ rescanState.value = { ...rescanState.value, [cwd]: "running" };
106
+ try {
107
+ const res = await fetch("/__nwire/run/exec", {
108
+ method: "POST",
109
+ headers: { "Content-Type": "application/json" },
110
+ body: JSON.stringify({ command: "cache", cwd }),
111
+ });
112
+ if (!res.ok) throw new Error(`exec failed: ${res.status}`);
113
+ // Optimistic — supervisor returned 200 means the child spawned;
114
+ // surface the result in the UI for a beat then revert.
115
+ rescanState.value = { ...rescanState.value, [cwd]: "done" };
116
+ setTimeout(() => {
117
+ const { [cwd]: _, ...rest } = rescanState.value;
118
+ void _;
119
+ rescanState.value = rest;
120
+ }, 2_000);
121
+ } catch {
122
+ rescanState.value = { ...rescanState.value, [cwd]: "error" };
123
+ setTimeout(() => {
124
+ const { [cwd]: _, ...rest } = rescanState.value;
125
+ void _;
126
+ rescanState.value = rest;
127
+ }, 3_000);
128
+ }
129
+ }
130
+
131
+ onMounted(() => {
132
+ void refresh();
133
+ });
134
+ </script>
135
+
136
+ <template>
137
+ <div class="h-full overflow-auto">
138
+ <div class="max-w-5xl mx-auto px-8 py-6 space-y-6">
139
+ <header class="flex items-center justify-between">
140
+ <div>
141
+ <h1 class="text-xl font-semibold tracking-tight">Projects</h1>
142
+ <p class="text-sm text-zinc-500 mt-1">
143
+ Every Nwire project this browser has opened. Snapshots are stored locally; live status
144
+ reads each project's
145
+ <code class="text-zinc-400">.nwire/processes/</code>.
146
+ </p>
147
+ </div>
148
+ <button
149
+ class="text-xs text-zinc-500 hover:text-zinc-200 px-2 py-1 rounded border border-zinc-800 hover:border-zinc-700"
150
+ @click="refresh"
151
+ >
152
+ Refresh
153
+ </button>
154
+ </header>
155
+
156
+ <div v-if="projects.length === 0" class="text-sm text-zinc-500 py-12 text-center">
157
+ No projects yet. Each time you run
158
+ <code class="text-zinc-300">nwire studio</code> from a project, it gets added here.
159
+ </div>
160
+
161
+ <div v-else class="grid grid-cols-1 md:grid-cols-2 gap-3">
162
+ <article
163
+ v-for="p in projects"
164
+ :key="p.cwd"
165
+ class="border border-zinc-800 rounded-lg p-4 bg-zinc-950/40 hover:border-zinc-700 transition-colors group"
166
+ >
167
+ <div class="flex items-start justify-between gap-3">
168
+ <div class="min-w-0">
169
+ <div class="flex items-center gap-2">
170
+ <FolderOpen v-if="p.cwd === currentCwd" class="w-4 h-4 text-emerald-400 shrink-0" />
171
+ <Folder v-else class="w-4 h-4 text-zinc-500 shrink-0" />
172
+ <h2 class="font-semibold text-sm tracking-tight truncate">
173
+ {{ p.name }}
174
+ </h2>
175
+ <span
176
+ v-if="p.cwd === currentCwd"
177
+ class="text-[10px] uppercase tracking-wider text-emerald-400 px-1.5 py-0.5 rounded bg-emerald-950/40 border border-emerald-900"
178
+ >
179
+ active
180
+ </span>
181
+ </div>
182
+ <div class="text-[11px] font-mono text-zinc-600 mt-1 truncate" :title="p.cwd">
183
+ {{ p.cwd }}
184
+ </div>
185
+ </div>
186
+ <button
187
+ class="opacity-0 group-hover:opacity-100 text-zinc-600 hover:text-rose-400 transition-opacity p-1"
188
+ :title="`Forget ${p.name}`"
189
+ @click="forget(p.cwd)"
190
+ >
191
+ <Trash2 class="w-3.5 h-3.5" />
192
+ </button>
193
+ </div>
194
+
195
+ <!-- Composition -->
196
+ <div v-if="p.composition" class="grid grid-cols-4 gap-2 mt-4 text-xs text-zinc-400">
197
+ <div>
198
+ <div class="text-zinc-600 text-[10px] uppercase tracking-wide">apps</div>
199
+ <div>{{ p.composition.apps }}</div>
200
+ </div>
201
+ <div>
202
+ <div class="text-zinc-600 text-[10px] uppercase tracking-wide">modules</div>
203
+ <div>{{ p.composition.modules }}</div>
204
+ </div>
205
+ <div>
206
+ <div class="text-zinc-600 text-[10px] uppercase tracking-wide">actions</div>
207
+ <div>{{ p.composition.actions }}</div>
208
+ </div>
209
+ <div>
210
+ <div class="text-zinc-600 text-[10px] uppercase tracking-wide">events</div>
211
+ <div>{{ p.composition.events }}</div>
212
+ </div>
213
+ </div>
214
+ <div v-else class="mt-4 text-[11px] text-zinc-600">No composition snapshot yet.</div>
215
+
216
+ <!-- Running status -->
217
+ <div class="flex items-center justify-between mt-4 pt-3 border-t border-zinc-900">
218
+ <div class="flex items-center gap-2 text-[11px]">
219
+ <template v-if="status[p.cwd]?.processes.length">
220
+ <Activity class="w-3 h-3 text-emerald-400" />
221
+ <span class="text-emerald-300"> {{ status[p.cwd].processes.length }} running </span>
222
+ <span class="text-zinc-600 font-mono">
223
+ ({{ status[p.cwd].processes.map((x) => x.port ?? "?").join(", ") }})
224
+ </span>
225
+ </template>
226
+ <template v-else>
227
+ <Circle class="w-3 h-3 text-zinc-700" />
228
+ <span class="text-zinc-600">idle</span>
229
+ </template>
230
+ </div>
231
+ <div class="flex items-center gap-2">
232
+ <button
233
+ class="text-[11px] text-zinc-500 hover:text-zinc-200 flex items-center gap-1.5 px-2 py-1 rounded border border-zinc-800 hover:border-zinc-700 disabled:opacity-50"
234
+ :disabled="rescanState[p.cwd] === 'running'"
235
+ @click="rescan(p.cwd)"
236
+ :title="`Run nwire cache against ${p.cwd}`"
237
+ data-testid="project-rescan"
238
+ >
239
+ <RefreshCw
240
+ class="w-3 h-3"
241
+ :class="{ 'animate-spin': rescanState[p.cwd] === 'running' }"
242
+ />
243
+ <template v-if="rescanState[p.cwd] === 'running'">Scanning…</template>
244
+ <template v-else-if="rescanState[p.cwd] === 'done'">Rescanned</template>
245
+ <template v-else-if="rescanState[p.cwd] === 'error'">Failed</template>
246
+ <template v-else>Re-scan</template>
247
+ </button>
248
+ <button
249
+ v-if="p.cwd !== currentCwd"
250
+ class="text-[11px] text-zinc-500 hover:text-zinc-200 flex items-center gap-1.5 px-2 py-1 rounded border border-zinc-800 hover:border-zinc-700"
251
+ @click="copyStartCommand(p.cwd)"
252
+ title="Copy `cd … && nwire studio` to clipboard"
253
+ >
254
+ <Copy class="w-3 h-3" />
255
+ {{ copiedCwd === p.cwd ? "Copied!" : "Copy cmd" }}
256
+ </button>
257
+ <button
258
+ class="text-[11px] text-zinc-100 bg-emerald-700 hover:bg-emerald-600 px-2 py-1 rounded"
259
+ @click="openProject(p.cwd)"
260
+ >
261
+ {{ p.cwd === currentCwd ? "Open" : "Switch" }}
262
+ </button>
263
+ </div>
264
+ </div>
265
+ <div class="text-[10px] text-zinc-700 mt-2">
266
+ last visited {{ new Date(p.lastVisited).toLocaleString() }}
267
+ </div>
268
+ </article>
269
+ </div>
270
+ </div>
271
+ </div>
272
+ </template>