@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.
- package/LICENSE +21 -0
- package/README.md +72 -0
- package/components.json +19 -0
- package/index.html +12 -0
- package/package.json +66 -0
- package/src/App.vue +305 -0
- package/src/components/EmptyState.stories.ts +53 -0
- package/src/components/EmptyState.vue +28 -0
- package/src/components/ErrorBoundary.vue +60 -0
- package/src/components/FilterInput.stories.ts +32 -0
- package/src/components/FilterInput.vue +33 -0
- package/src/components/JsonView.stories.ts +38 -0
- package/src/components/JsonView.vue +34 -0
- package/src/components/KindBadge.stories.ts +72 -0
- package/src/components/KindBadge.vue +59 -0
- package/src/components/ListRow.stories.ts +56 -0
- package/src/components/ListRow.vue +48 -0
- package/src/components/MasterDetail.stories.ts +74 -0
- package/src/components/MasterDetail.vue +35 -0
- package/src/components/MonacoViewer.vue +143 -0
- package/src/components/PageHeader.stories.ts +45 -0
- package/src/components/PageHeader.vue +46 -0
- package/src/components/SchemaNode.vue +208 -0
- package/src/components/SchemaTree.vue +65 -0
- package/src/components/SourceDrawer.vue +136 -0
- package/src/components/SourcePill.vue +103 -0
- package/src/components/__tests__/EmptyState.test.ts +28 -0
- package/src/components/__tests__/ErrorBoundary.test.ts +52 -0
- package/src/components/__tests__/FilterInput.test.ts +38 -0
- package/src/components/__tests__/JsonView.test.ts +33 -0
- package/src/components/__tests__/KindBadge.test.ts +39 -0
- package/src/components/__tests__/ListRow.test.ts +39 -0
- package/src/components/__tests__/MasterDetail.test.ts +40 -0
- package/src/components/__tests__/PageHeader.test.ts +42 -0
- package/src/components/index.ts +17 -0
- package/src/components/ui/badge/Badge.vue +17 -0
- package/src/components/ui/badge/index.ts +25 -0
- package/src/components/ui/button/Button.vue +28 -0
- package/src/components/ui/button/index.ts +34 -0
- package/src/components/ui/card/Card.vue +14 -0
- package/src/components/ui/card/CardContent.vue +14 -0
- package/src/components/ui/card/CardDescription.vue +14 -0
- package/src/components/ui/card/CardFooter.vue +14 -0
- package/src/components/ui/card/CardHeader.vue +14 -0
- package/src/components/ui/card/CardTitle.vue +14 -0
- package/src/components/ui/card/index.ts +6 -0
- package/src/components/ui/dialog/Dialog.vue +15 -0
- package/src/components/ui/dialog/DialogClose.vue +12 -0
- package/src/components/ui/dialog/DialogContent.vue +47 -0
- package/src/components/ui/dialog/DialogDescription.vue +22 -0
- package/src/components/ui/dialog/DialogFooter.vue +12 -0
- package/src/components/ui/dialog/DialogHeader.vue +14 -0
- package/src/components/ui/dialog/DialogScrollContent.vue +60 -0
- package/src/components/ui/dialog/DialogTitle.vue +22 -0
- package/src/components/ui/dialog/DialogTrigger.vue +12 -0
- package/src/components/ui/dialog/index.ts +9 -0
- package/src/components/ui/input/Input.vue +32 -0
- package/src/components/ui/input/index.ts +1 -0
- package/src/components/ui/scroll-area/ScrollArea.vue +22 -0
- package/src/components/ui/scroll-area/ScrollBar.vue +32 -0
- package/src/components/ui/scroll-area/index.ts +2 -0
- package/src/components/ui/separator/Separator.vue +27 -0
- package/src/components/ui/separator/index.ts +1 -0
- package/src/components/ui/skeleton/Skeleton.vue +14 -0
- package/src/components/ui/skeleton/index.ts +1 -0
- package/src/components/ui/tabs/Tabs.vue +15 -0
- package/src/components/ui/tabs/TabsContent.vue +25 -0
- package/src/components/ui/tabs/TabsList.vue +25 -0
- package/src/components/ui/tabs/TabsTrigger.vue +29 -0
- package/src/components/ui/tabs/index.ts +4 -0
- package/src/components/ui/tooltip/Tooltip.vue +15 -0
- package/src/components/ui/tooltip/TooltipContent.vue +40 -0
- package/src/components/ui/tooltip/TooltipProvider.vue +12 -0
- package/src/components/ui/tooltip/TooltipTrigger.vue +12 -0
- package/src/components/ui/tooltip/index.ts +4 -0
- package/src/composables/useCopy.ts +31 -0
- package/src/lib/__tests__/normalize-cache.test.ts +104 -0
- package/src/lib/cache.ts +334 -0
- package/src/lib/normalize-cache.ts +92 -0
- package/src/lib/project-catalog.ts +125 -0
- package/src/lib/utils.ts +6 -0
- package/src/main.ts +112 -0
- package/src/pages/Actions.vue +180 -0
- package/src/pages/Commands.vue +262 -0
- package/src/pages/Dispatch.vue +431 -0
- package/src/pages/Events.vue +166 -0
- package/src/pages/Home.stories.ts +47 -0
- package/src/pages/Home.vue +485 -0
- package/src/pages/Hooks.vue +297 -0
- package/src/pages/Live.vue +249 -0
- package/src/pages/Modules.vue +174 -0
- package/src/pages/Overview.vue +159 -0
- package/src/pages/Plugins.stories.ts +44 -0
- package/src/pages/Plugins.vue +403 -0
- package/src/pages/Projects.vue +272 -0
- package/src/pages/Run.vue +479 -0
- package/src/pages/Topology.vue +164 -0
- package/src/pages/Trace.vue +511 -0
- package/src/pages/TraceNode.vue +166 -0
- package/src/pages/Workflows.vue +191 -0
- package/src/pages/__tests__/Actions.test.ts +98 -0
- package/src/pages/__tests__/Home.test.ts +98 -0
- package/src/pages/__tests__/Hooks.test.ts +119 -0
- package/src/pages/__tests__/Plugins.test.ts +80 -0
- package/src/style.css +40 -0
- package/tsconfig.json +20 -0
- 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
|
+
});
|