@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,208 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Recursive renderer for `SchemaTree`. One node per call; nested objects/
4
+ * arrays/anyOf are rendered by re-entering this component on their
5
+ * children. Indentation comes from the `depth` prop.
6
+ */
7
+ import { computed, ref } from "vue";
8
+ import { ChevronRight, ChevronDown } from "lucide-vue-next";
9
+
10
+ interface JsonSchemaNode {
11
+ type?: string | string[];
12
+ properties?: Record<string, JsonSchemaNode>;
13
+ required?: readonly string[];
14
+ items?: JsonSchemaNode | readonly JsonSchemaNode[];
15
+ enum?: readonly unknown[];
16
+ const?: unknown;
17
+ anyOf?: readonly JsonSchemaNode[];
18
+ oneOf?: readonly JsonSchemaNode[];
19
+ description?: string;
20
+ format?: string;
21
+ pattern?: string;
22
+ minLength?: number;
23
+ maxLength?: number;
24
+ minimum?: number;
25
+ maximum?: number;
26
+ default?: unknown;
27
+ $ref?: string;
28
+ [k: string]: unknown;
29
+ }
30
+
31
+ const props = defineProps<{
32
+ /** The schema node to render. */
33
+ node: JsonSchemaNode | undefined;
34
+ /** Root schema — used for $ref resolution. */
35
+ root: JsonSchemaNode | undefined;
36
+ /** Path of keys leading to this node — used for display + collapse state. */
37
+ path: readonly string[];
38
+ /** Render depth — controls indentation. */
39
+ depth: number;
40
+ /** True when this node is a required field of its parent object. */
41
+ required?: boolean;
42
+ /** Field name (when this node is a property of an object). */
43
+ fieldName?: string;
44
+ }>();
45
+
46
+ const expanded = ref(props.depth < 2);
47
+
48
+ // Resolve $ref against the root schema's definitions.
49
+ const resolved = computed<JsonSchemaNode | undefined>(() => {
50
+ let n = props.node;
51
+ if (!n) return undefined;
52
+ if (n.$ref && typeof n.$ref === "string" && props.root) {
53
+ const refName = n.$ref.replace(/^#\/definitions\//, "");
54
+ const defs = (props.root.definitions ?? {}) as Record<string, JsonSchemaNode>;
55
+ n = defs[refName] ?? n;
56
+ }
57
+ return n;
58
+ });
59
+
60
+ const kind = computed<string>(() => {
61
+ const n = resolved.value;
62
+ if (!n) return "unknown";
63
+ if (n.enum) return "enum";
64
+ if (n.anyOf) return "anyOf";
65
+ if (n.oneOf) return "oneOf";
66
+ if (n.const !== undefined) return "const";
67
+ if (Array.isArray(n.type)) return n.type.join(" | ");
68
+ return n.type ?? "any";
69
+ });
70
+
71
+ const isObject = computed(() => kind.value === "object" && resolved.value?.properties);
72
+ const isArray = computed(() => kind.value === "array" && resolved.value?.items);
73
+ const isExpandable = computed(
74
+ () =>
75
+ isObject.value ||
76
+ isArray.value ||
77
+ Boolean(resolved.value?.anyOf?.length) ||
78
+ Boolean(resolved.value?.oneOf?.length),
79
+ );
80
+
81
+ const constraints = computed<string[]>(() => {
82
+ const n = resolved.value;
83
+ if (!n) return [];
84
+ const out: string[] = [];
85
+ if (n.format) out.push(n.format);
86
+ if (n.pattern) out.push(`pattern: /${n.pattern}/`);
87
+ if (typeof n.minLength === "number") out.push(`min ${n.minLength}`);
88
+ if (typeof n.maxLength === "number") out.push(`max ${n.maxLength}`);
89
+ if (typeof n.minimum === "number") out.push(`≥ ${n.minimum}`);
90
+ if (typeof n.maximum === "number") out.push(`≤ ${n.maximum}`);
91
+ if (n.default !== undefined) out.push(`default: ${JSON.stringify(n.default)}`);
92
+ return out;
93
+ });
94
+
95
+ const enumValues = computed<readonly unknown[]>(() => resolved.value?.enum ?? []);
96
+
97
+ const propertyEntries = computed(() => {
98
+ const n = resolved.value;
99
+ if (!n?.properties) return [];
100
+ const required = new Set(n.required ?? []);
101
+ return Object.entries(n.properties).map(([key, child]) => ({
102
+ key,
103
+ child: child as JsonSchemaNode,
104
+ required: required.has(key),
105
+ }));
106
+ });
107
+
108
+ const itemSchema = computed<JsonSchemaNode | undefined>(() => {
109
+ const n = resolved.value;
110
+ if (!n) return undefined;
111
+ if (Array.isArray(n.items)) return undefined; // tuple shape — render later
112
+ return n.items as JsonSchemaNode | undefined;
113
+ });
114
+
115
+ const unionOptions = computed<readonly JsonSchemaNode[]>(() => {
116
+ return resolved.value?.anyOf ?? resolved.value?.oneOf ?? [];
117
+ });
118
+
119
+ const toggle = () => {
120
+ if (isExpandable.value) expanded.value = !expanded.value;
121
+ };
122
+ </script>
123
+
124
+ <template>
125
+ <div :style="{ paddingLeft: depth > 0 ? '0.75rem' : '0' }">
126
+ <!-- Header row — field name + type + chips -->
127
+ <div
128
+ class="flex items-baseline gap-2 leading-snug"
129
+ :class="
130
+ isExpandable ? 'cursor-pointer hover:bg-zinc-800/50 rounded-sm px-1 -mx-1' : 'px-1 -mx-1'
131
+ "
132
+ @click="toggle"
133
+ >
134
+ <ChevronDown v-if="isExpandable && expanded" class="h-3 w-3 mt-0.5 text-zinc-500 shrink-0" />
135
+ <ChevronRight v-else-if="isExpandable" class="h-3 w-3 mt-0.5 text-zinc-500 shrink-0" />
136
+ <span v-else class="w-3 shrink-0" />
137
+
138
+ <span v-if="fieldName" class="text-zinc-200">
139
+ {{ fieldName }}<span v-if="required" class="text-orange-400 ml-0.5">*</span>
140
+ </span>
141
+ <span class="text-violet-400 text-[11px]">{{ kind }}</span>
142
+ <span
143
+ v-for="c in constraints"
144
+ :key="c"
145
+ class="text-[10px] text-zinc-500 bg-zinc-800/60 rounded px-1.5 py-0.5"
146
+ >
147
+ {{ c }}
148
+ </span>
149
+ <span
150
+ v-if="resolved?.description"
151
+ class="text-zinc-500 italic font-sans text-[11px] truncate"
152
+ >
153
+ — {{ resolved.description }}
154
+ </span>
155
+ </div>
156
+
157
+ <!-- Body — expand for object/array/enum/union -->
158
+ <div v-if="expanded && isExpandable" class="mt-1 border-l border-zinc-800/60 ml-1.5">
159
+ <!-- Object properties -->
160
+ <template v-if="isObject">
161
+ <SchemaNode
162
+ v-for="entry in propertyEntries"
163
+ :key="entry.key"
164
+ :node="entry.child"
165
+ :root="root"
166
+ :path="[...path, entry.key]"
167
+ :depth="depth + 1"
168
+ :field-name="entry.key"
169
+ :required="entry.required"
170
+ />
171
+ </template>
172
+
173
+ <!-- Array item -->
174
+ <SchemaNode
175
+ v-else-if="isArray && itemSchema"
176
+ :node="itemSchema"
177
+ :root="root"
178
+ :path="[...path, '[]']"
179
+ :depth="depth + 1"
180
+ field-name="(item)"
181
+ />
182
+
183
+ <!-- Union (anyOf / oneOf) -->
184
+ <template v-else-if="unionOptions.length">
185
+ <SchemaNode
186
+ v-for="(opt, i) in unionOptions"
187
+ :key="i"
188
+ :node="opt"
189
+ :root="root"
190
+ :path="[...path, `#${i}`]"
191
+ :depth="depth + 1"
192
+ :field-name="`option ${i + 1}`"
193
+ />
194
+ </template>
195
+ </div>
196
+
197
+ <!-- Enum values (collapse-friendly, but always visible) -->
198
+ <div v-if="enumValues.length" class="ml-4 mt-1 flex flex-wrap gap-1">
199
+ <span
200
+ v-for="(v, i) in enumValues"
201
+ :key="i"
202
+ class="text-[10px] bg-zinc-800/60 border border-zinc-700/60 text-emerald-400 rounded px-1.5 py-0.5"
203
+ >
204
+ {{ typeof v === "string" ? `"${v}"` : String(v) }}
205
+ </span>
206
+ </div>
207
+ </div>
208
+ </template>
@@ -0,0 +1,65 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * `SchemaTree` — render a Zod-derived JSON Schema as a typed structured
4
+ * tree. Replaces raw `<pre>{JSON.stringify(schema)}</pre>` blocks.
5
+ *
6
+ * <SchemaTree :schema="action.schema" />
7
+ *
8
+ * Recognizes the schema shapes our scanner emits via
9
+ * `zod-to-json-schema`:
10
+ * - `type: "object"` with `properties` + `required` → expandable rows
11
+ * - `type: "array"` with `items` → wraps the item tree
12
+ * - `type: "string" | "number" | "boolean" | "null"` → leaf
13
+ * - `enum: […]` → enum chip
14
+ * - `anyOf` / `oneOf` → "one of" panel with each option as a sub-tree
15
+ * - `$ref` → resolves against the top-level schema's `definitions`
16
+ *
17
+ * Designed for read-only inspection. The companion `SchemaForm` (Try page)
18
+ * builds editors from the same shape.
19
+ */
20
+ import { computed } from "vue";
21
+ import SchemaNode from "./SchemaNode.vue";
22
+
23
+ const props = defineProps<{
24
+ /** A JSON Schema (the scanner already converted Zod → JSON Schema). */
25
+ schema: unknown;
26
+ /** Optional heading. */
27
+ label?: string;
28
+ }>();
29
+
30
+ // The scanner sometimes wraps top-level types under `definitions`; pull the
31
+ // root in if so.
32
+ const rootSchema = computed(() => {
33
+ const s = props.schema as Record<string, unknown> | undefined;
34
+ if (!s) return undefined;
35
+ if (s.$ref && typeof s.$ref === "string") {
36
+ const refName = s.$ref.replace(/^#\/definitions\//, "");
37
+ const defs = (s.definitions ?? {}) as Record<string, unknown>;
38
+ return (defs[refName] ?? s) as Record<string, unknown>;
39
+ }
40
+ return s;
41
+ });
42
+
43
+ const empty = computed(() => {
44
+ if (!rootSchema.value) return true;
45
+ if (typeof rootSchema.value !== "object") return true;
46
+ return Object.keys(rootSchema.value as Record<string, unknown>).length === 0;
47
+ });
48
+ </script>
49
+
50
+ <template>
51
+ <div class="space-y-2">
52
+ <h3 v-if="label" class="text-xs uppercase tracking-wide text-zinc-500">
53
+ {{ label }}
54
+ </h3>
55
+ <div
56
+ v-if="empty"
57
+ class="rounded-md border border-zinc-800 bg-zinc-900/30 px-3 py-2 text-xs italic text-zinc-500"
58
+ >
59
+ No schema declared.
60
+ </div>
61
+ <div v-else class="rounded-md border border-zinc-800 bg-zinc-900/30 p-3 font-mono text-xs">
62
+ <SchemaNode :node="rootSchema" :root="rootSchema" :path="[]" :depth="0" />
63
+ </div>
64
+ </div>
65
+ </template>
@@ -0,0 +1,136 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * `SourceDrawer` — right-side panel that fetches a source file (via the
4
+ * `/__nwire/source` Studio middleware) and displays it in a `MonacoViewer`
5
+ * with the relevant line highlighted.
6
+ *
7
+ * <SourceDrawer :source="selectedSource" @close="selectedSource = null" />
8
+ *
9
+ * The fetch goes through Studio's Vite middleware so we never need a
10
+ * separate dev server; the middleware reads the file from disk and
11
+ * returns `{ content, language }`. Permission scope is the repo root
12
+ * — the middleware refuses paths outside the cwd.
13
+ */
14
+ import { ref, watch } from "vue";
15
+ import { X } from "lucide-vue-next";
16
+ import MonacoViewer from "./MonacoViewer.vue";
17
+ import SourcePill from "./SourcePill.vue";
18
+
19
+ const props = defineProps<{
20
+ source?: { file: string; line: number; column?: number } | null;
21
+ }>();
22
+
23
+ const emit = defineEmits<{ (e: "close"): void }>();
24
+
25
+ const content = ref<string>("");
26
+ const language = ref<string>("typescript");
27
+ const loading = ref(false);
28
+ const error = ref<string | undefined>();
29
+
30
+ async function load(file: string) {
31
+ loading.value = true;
32
+ error.value = undefined;
33
+ try {
34
+ const res = await fetch(`/__nwire/source?path=${encodeURIComponent(file)}`);
35
+ if (!res.ok) {
36
+ error.value = `${res.status} ${res.statusText}`;
37
+ content.value = "";
38
+ return;
39
+ }
40
+ const body = (await res.json()) as { content: string; language?: string };
41
+ content.value = body.content;
42
+ language.value = body.language ?? "typescript";
43
+ } catch (err) {
44
+ error.value = (err as Error).message;
45
+ content.value = "";
46
+ } finally {
47
+ loading.value = false;
48
+ }
49
+ }
50
+
51
+ watch(
52
+ () => props.source?.file,
53
+ (file) => {
54
+ if (file) void load(file);
55
+ else content.value = "";
56
+ },
57
+ { immediate: true },
58
+ );
59
+ </script>
60
+
61
+ <template>
62
+ <Teleport to="body">
63
+ <Transition
64
+ enter-active-class="transition-transform duration-200"
65
+ enter-from-class="translate-x-full"
66
+ enter-to-class="translate-x-0"
67
+ leave-active-class="transition-transform duration-150"
68
+ leave-from-class="translate-x-0"
69
+ leave-to-class="translate-x-full"
70
+ >
71
+ <aside
72
+ v-if="source"
73
+ class="fixed top-0 right-0 z-50 h-screen w-[min(720px,80vw)] bg-zinc-950 border-l border-zinc-800 shadow-2xl flex flex-col"
74
+ >
75
+ <header class="flex items-center justify-between border-b border-zinc-800 px-4 py-3">
76
+ <div class="flex items-center gap-3 min-w-0">
77
+ <h2 class="text-sm font-medium text-zinc-200 truncate">Source</h2>
78
+ <SourcePill :source="source" compact />
79
+ </div>
80
+ <button
81
+ type="button"
82
+ class="text-zinc-500 hover:text-zinc-300 transition-colors"
83
+ @click="emit('close')"
84
+ >
85
+ <X class="h-4 w-4" />
86
+ </button>
87
+ </header>
88
+
89
+ <div class="flex-1 overflow-hidden p-3">
90
+ <div v-if="loading" class="flex h-full items-center justify-center text-xs text-zinc-500">
91
+ Loading source…
92
+ </div>
93
+ <div
94
+ v-else-if="error"
95
+ class="rounded-md border border-rose-900/40 bg-rose-950/30 p-4 text-xs text-rose-300"
96
+ >
97
+ <p class="font-medium">Could not load source.</p>
98
+ <p class="mt-1 text-rose-400/80">{{ error }}</p>
99
+ <p class="mt-2 text-zinc-500">
100
+ The file is outside the Studio process's working directory, or the path doesn't exist
101
+ on this machine. Try the
102
+ <a
103
+ :href="`vscode://file${source.file}:${source.line}`"
104
+ class="text-orange-400 underline"
105
+ >
106
+ IDE link
107
+ </a>
108
+ instead.
109
+ </p>
110
+ </div>
111
+ <MonacoViewer
112
+ v-else
113
+ :content="content"
114
+ :language="language"
115
+ :highlight-line="source.line"
116
+ />
117
+ </div>
118
+ </aside>
119
+ </Transition>
120
+
121
+ <Transition
122
+ enter-active-class="transition-opacity duration-150"
123
+ enter-from-class="opacity-0"
124
+ enter-to-class="opacity-100"
125
+ leave-active-class="transition-opacity duration-100"
126
+ leave-from-class="opacity-100"
127
+ leave-to-class="opacity-0"
128
+ >
129
+ <div
130
+ v-if="source"
131
+ class="fixed inset-0 z-40 bg-black/40 backdrop-blur-sm"
132
+ @click="emit('close')"
133
+ />
134
+ </Transition>
135
+ </Teleport>
136
+ </template>
@@ -0,0 +1,103 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * `SourcePill` — chip that shows a `file:line` source location with a copy
4
+ * button and an "open in IDE" button. Backed by the `source` field every
5
+ * `.nwire/*.json` entry now carries (set at `defineX` call time by
6
+ * `captureSourceLocation()`).
7
+ *
8
+ * <SourcePill :source="action.source" />
9
+ *
10
+ * The IDE-open URI scheme is picked from `localStorage["nwire.ide"]`
11
+ * (default: `vscode`). Supported values: `vscode`, `cursor`, `zed`,
12
+ * `idea`, `webstorm`. Users can change theirs from the Studio Overview
13
+ * "Settings" pane (see `pages/Overview.vue`).
14
+ */
15
+ import { computed } from "vue";
16
+ import { ExternalLink, Copy, Check } from "lucide-vue-next";
17
+ import { useCopy } from "../composables/useCopy";
18
+
19
+ const props = defineProps<{
20
+ source?: { file: string; line: number; column?: number };
21
+ /** Compact mode — no label, just the path:line. Default false. */
22
+ compact?: boolean;
23
+ }>();
24
+
25
+ const { copy, copied } = useCopy();
26
+
27
+ const fileSegment = computed(() => {
28
+ if (!props.source) return "";
29
+ const parts = props.source.file.split("/");
30
+ // Last 2 path segments give enough context without filling the chip.
31
+ return parts.slice(-2).join("/");
32
+ });
33
+
34
+ const idePrefix = (): string => {
35
+ if (typeof localStorage === "undefined") return "vscode://file";
36
+ const ide = localStorage.getItem("nwire.ide") ?? "vscode";
37
+ switch (ide) {
38
+ case "cursor":
39
+ return "cursor://file";
40
+ case "zed":
41
+ return "zed://file";
42
+ case "idea":
43
+ case "webstorm":
44
+ case "jetbrains":
45
+ return `jetbrains://idea/navigate/reference?path=`;
46
+ default:
47
+ return "vscode://file";
48
+ }
49
+ };
50
+
51
+ const ideHref = computed(() => {
52
+ if (!props.source) return undefined;
53
+ const { file, line, column } = props.source;
54
+ const prefix = idePrefix();
55
+ // JetBrains uses a query-string scheme; everything else uses path:line:col.
56
+ if (prefix.includes("?path=")) {
57
+ return `${prefix}${encodeURIComponent(file)}:${line}`;
58
+ }
59
+ return `${prefix}${file}:${line}${column ? `:${column}` : ""}`;
60
+ });
61
+
62
+ const copyText = computed(() => {
63
+ if (!props.source) return "";
64
+ return `${props.source.file}:${props.source.line}${props.source.column ? `:${props.source.column}` : ""}`;
65
+ });
66
+
67
+ const onCopy = (e: Event) => {
68
+ e.preventDefault();
69
+ e.stopPropagation();
70
+ copy(copyText.value);
71
+ };
72
+ </script>
73
+
74
+ <template>
75
+ <div
76
+ v-if="source"
77
+ class="inline-flex items-center gap-1.5 rounded-md border border-zinc-800 bg-zinc-900/50 px-2 py-1 text-xs font-mono text-zinc-400 hover:border-zinc-700 hover:text-zinc-300 transition-colors"
78
+ >
79
+ <span
80
+ v-if="!compact"
81
+ class="text-zinc-600 text-[10px] uppercase tracking-wider font-sans pr-0.5"
82
+ >
83
+ source
84
+ </span>
85
+ <a
86
+ :href="ideHref"
87
+ :title="`Open ${copyText} in IDE`"
88
+ class="inline-flex items-center gap-1 hover:text-orange-400"
89
+ >
90
+ {{ fileSegment }}:{{ source.line }}
91
+ <ExternalLink class="h-3 w-3 opacity-60" />
92
+ </a>
93
+ <button
94
+ type="button"
95
+ :title="copied ? 'Copied!' : `Copy ${copyText}`"
96
+ class="ml-1 inline-flex items-center text-zinc-500 hover:text-zinc-300"
97
+ @click="onCopy"
98
+ >
99
+ <Check v-if="copied" class="h-3 w-3 text-emerald-400" />
100
+ <Copy v-else class="h-3 w-3" />
101
+ </button>
102
+ </div>
103
+ </template>
@@ -0,0 +1,28 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { mount } from "@vue/test-utils";
3
+ import { Network } from "lucide-vue-next";
4
+ import EmptyState from "../EmptyState.vue";
5
+
6
+ describe("EmptyState", () => {
7
+ it("renders title + hint + icon", () => {
8
+ const wrapper = mount(EmptyState, {
9
+ props: { title: "No resolvers", hint: "Run nwire cache", icon: Network },
10
+ });
11
+ expect(wrapper.get("[data-testid=empty-state]").text()).toContain("No resolvers");
12
+ expect(wrapper.get("[data-testid=empty-state]").text()).toContain("Run nwire cache");
13
+ expect(wrapper.findComponent(Network).exists()).toBe(true);
14
+ });
15
+
16
+ it("renders without icon + hint", () => {
17
+ const wrapper = mount(EmptyState, { props: { title: "Empty" } });
18
+ expect(wrapper.get("[data-testid=empty-state]").text()).toContain("Empty");
19
+ });
20
+
21
+ it("renders the actions slot", () => {
22
+ const wrapper = mount(EmptyState, {
23
+ props: { title: "Empty" },
24
+ slots: { actions: '<button class="btn">Reload</button>' },
25
+ });
26
+ expect(wrapper.find("button.btn").exists()).toBe(true);
27
+ });
28
+ });
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { mount, flushPromises } from "@vue/test-utils";
3
+ import { defineComponent, h, nextTick, ref } from "vue";
4
+ import ErrorBoundary from "../ErrorBoundary.vue";
5
+
6
+ describe("ErrorBoundary", () => {
7
+ it("renders children when nothing throws", () => {
8
+ const Child = defineComponent({ template: "<p data-testid='ok'>hello</p>" });
9
+ const wrapper = mount(ErrorBoundary, { slots: { default: Child } });
10
+ expect(wrapper.find("[data-testid=ok]").text()).toBe("hello");
11
+ expect(wrapper.find("[data-testid=error-boundary]").exists()).toBe(false);
12
+ });
13
+
14
+ it("catches a render-time error and renders the fallback", async () => {
15
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
16
+ const Boom = defineComponent({
17
+ render() {
18
+ throw new Error("kaboom");
19
+ },
20
+ });
21
+
22
+ const wrapper = mount(ErrorBoundary, { slots: { default: () => h(Boom) } });
23
+ await nextTick();
24
+ expect(wrapper.find("[data-testid=error-boundary]").exists()).toBe(true);
25
+ expect(wrapper.find("[data-testid=error-boundary]").text()).toContain("kaboom");
26
+ consoleError.mockRestore();
27
+ });
28
+
29
+ it("reset clears the captured error", async () => {
30
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
31
+ const shouldThrow = ref(true);
32
+ const Maybe = defineComponent({
33
+ render() {
34
+ if (shouldThrow.value) throw new Error("once");
35
+ return h("p", { "data-testid": "ok" }, "fine");
36
+ },
37
+ });
38
+
39
+ const wrapper = mount(ErrorBoundary, { slots: { default: () => h(Maybe) } });
40
+ await nextTick();
41
+ expect(wrapper.find("[data-testid=error-boundary]").exists()).toBe(true);
42
+
43
+ shouldThrow.value = false;
44
+ await wrapper.get("button").trigger("click");
45
+ await flushPromises();
46
+ await nextTick();
47
+
48
+ expect(wrapper.find("[data-testid=error-boundary]").exists()).toBe(false);
49
+ expect(wrapper.find("[data-testid=ok]").exists()).toBe(true);
50
+ consoleError.mockRestore();
51
+ });
52
+ });
@@ -0,0 +1,38 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { mount } from "@vue/test-utils";
3
+ import FilterInput from "../FilterInput.vue";
4
+
5
+ describe("FilterInput", () => {
6
+ it("renders the placeholder", () => {
7
+ const wrapper = mount(FilterInput, {
8
+ props: { modelValue: "", placeholder: "filter by name" },
9
+ });
10
+ expect(wrapper.get("[data-testid=filter-input]").attributes("placeholder")).toBe(
11
+ "filter by name",
12
+ );
13
+ });
14
+
15
+ it("emits update:modelValue when user types", async () => {
16
+ const wrapper = mount(FilterInput, { props: { modelValue: "" } });
17
+ await wrapper.get("[data-testid=filter-input]").setValue("moderation");
18
+ const emitted = wrapper.emitted("update:modelValue");
19
+ expect(emitted).toBeTruthy();
20
+ expect(emitted![0]).toEqual(["moderation"]);
21
+ });
22
+
23
+ it("reflects modelValue prop", () => {
24
+ const wrapper = mount(FilterInput, { props: { modelValue: "queue" } });
25
+ expect((wrapper.get("[data-testid=filter-input]").element as HTMLInputElement).value).toBe(
26
+ "queue",
27
+ );
28
+ });
29
+
30
+ it("applies compact width modifier", () => {
31
+ const wrapper = mount(FilterInput, {
32
+ props: { modelValue: "", compact: true },
33
+ });
34
+ expect(wrapper.get("[data-testid=filter-input]").attributes("class")).toContain("w-full");
35
+ // Compact mode shrinks the wrapper, not the input itself
36
+ expect(wrapper.html()).toContain("w-72");
37
+ });
38
+ });
@@ -0,0 +1,33 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { mount } from "@vue/test-utils";
3
+ import JsonView from "../JsonView.vue";
4
+
5
+ describe("JsonView", () => {
6
+ it("pretty-prints the value with 2-space indent", () => {
7
+ const wrapper = mount(JsonView, {
8
+ props: { value: { a: 1, b: "two" } },
9
+ });
10
+ const text = wrapper.get("[data-testid=json-view]").text();
11
+ expect(text).toContain('"a": 1');
12
+ expect(text).toContain('"b": "two"');
13
+ });
14
+
15
+ it("renders an empty pre when value is undefined", () => {
16
+ const wrapper = mount(JsonView, { props: { value: undefined } });
17
+ expect(wrapper.get("[data-testid=json-view]").text()).toBe("");
18
+ });
19
+
20
+ it("shows the label header when provided", () => {
21
+ const wrapper = mount(JsonView, {
22
+ props: { value: {}, label: "Body schema" },
23
+ });
24
+ expect(wrapper.text()).toContain("Body schema");
25
+ });
26
+
27
+ it("applies max-height style when maxLines is set", () => {
28
+ const wrapper = mount(JsonView, {
29
+ props: { value: {}, maxLines: 5 },
30
+ });
31
+ expect(wrapper.get("[data-testid=json-view]").attributes("style")).toContain("max-height");
32
+ });
33
+ });