@caido-utils/components 0.1.0 → 0.2.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/README.md ADDED
@@ -0,0 +1,416 @@
1
+ # @caido-utils/components
2
+
3
+ Vue 3 components for Caido plugin frontends.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pnpm add @caido-utils/components
9
+ ```
10
+
11
+ **Peer dependencies:** `vue`, `primevue`, `@caido/sdk-frontend`, `@codemirror/view`
12
+
13
+ ```ts
14
+ import { Dialog, Table, HttpqlInput, Card } from "@caido-utils/components";
15
+ ```
16
+
17
+ ---
18
+
19
+ ### ButtonGroup
20
+
21
+ Toggle button group. Always requires a selection (no empty state).
22
+
23
+ ```vue
24
+ <ButtonGroup v-model="view" :options="options" />
25
+ ```
26
+
27
+ #### Props
28
+
29
+ | Name | Type | Default | Description |
30
+ | ------------ | ------------------------------------ | --------- | ------------------------ |
31
+ | `modelValue` | `string` | required | Currently selected value |
32
+ | `options` | `{ label: string; value: string }[]` | required | Available options |
33
+ | `size` | `"small" \| "large"` | `"small"` | Button size |
34
+
35
+ #### Emits
36
+
37
+ | Event | Payload | Description |
38
+ | ------------------- | -------- | ---------------------- |
39
+ | `update:modelValue` | `string` | Selected value changed |
40
+
41
+ ---
42
+
43
+ ### Card
44
+
45
+ Styled container card. Passes through all PrimeVue Card props and slots.
46
+
47
+ ```vue
48
+ <Card>
49
+ <template #content>
50
+ <p>Card body</p>
51
+ </template>
52
+ </Card>
53
+ ```
54
+
55
+ #### Slots
56
+
57
+ All PrimeVue Card slots: `#header`, `#title`, `#subtitle`, `#content`, `#footer`.
58
+
59
+ ---
60
+
61
+ ### ContextMenu
62
+
63
+ Right-click context menu with dark styling.
64
+
65
+ ```vue
66
+ <div @contextmenu.prevent="ctxRef?.show($event)">
67
+ Right-click here
68
+ </div>
69
+ <ContextMenu ref="ctxRef" :items="menuItems" />
70
+ ```
71
+
72
+ #### Props
73
+
74
+ | Name | Type | Default | Description |
75
+ | ------- | ------------ | -------- | ------------------------ |
76
+ | `items` | `MenuItem[]` | required | PrimeVue menu item array |
77
+
78
+ #### Exposed Methods
79
+
80
+ | Method | Signature | Description |
81
+ | -------- | ----------------------------- | ---------------------- |
82
+ | `show` | `(event: MouseEvent) => void` | Show at mouse position |
83
+ | `hide` | `() => void` | Hide menu |
84
+ | `toggle` | `(event: MouseEvent) => void` | Toggle visibility |
85
+
86
+ ---
87
+
88
+ ### DataTable
89
+
90
+ PrimeVue DataTable with virtual scrolling, compact styling, resizable columns, and scroll position memory.
91
+
92
+ ```vue
93
+ <DataTable
94
+ v-model:selection="selected"
95
+ v-model:active-row="activeRow"
96
+ :items="rows"
97
+ :columns="columns"
98
+ selectable="multiple"
99
+ scroll-key="my-table"
100
+ data-key="id"
101
+ @row-select="onSelect"
102
+ />
103
+ ```
104
+
105
+ #### Props
106
+
107
+ | Name | Type | Default | Description |
108
+ | ------------ | --------------------------------------------------------- | -------- | ----------------------------------- |
109
+ | `items` | `Record<string, unknown>[]` | required | Row data |
110
+ | `columns` | `{ field: string; header: string; sortable?: boolean }[]` | required | Column definitions |
111
+ | `rowHeight` | `number` | `33` | Row height in pixels |
112
+ | `selectable` | `false \| "single" \| "multiple"` | `false` | Selection mode |
113
+ | `selection` | `Record<string, unknown>[]` | `[]` | Selected rows (v-model) |
114
+ | `activeRow` | `Record<string, unknown> \| null` | `null` | Highlighted row (v-model) |
115
+ | `scrollKey` | `string` | - | Key for scroll position persistence |
116
+
117
+ Additional PrimeVue DataTable props are forwarded via `$attrs` (e.g. `dataKey`).
118
+
119
+ #### Emits
120
+
121
+ | Event | Payload | Description |
122
+ | ------------------ | ----------------------------------- | ------------------ |
123
+ | `update:selection` | `Record<string, unknown>[]` | Selection changed |
124
+ | `update:activeRow` | `Record<string, unknown> \| null` | Active row changed |
125
+ | `row-select` | `{ data: Record<string, unknown> }` | Row selected |
126
+ | `row-unselect` | `{ data: Record<string, unknown> }` | Row unselected |
127
+ | `row-click` | `{ data: Record<string, unknown> }` | Row clicked |
128
+
129
+ #### Slots
130
+
131
+ | Slot | Scope | Description |
132
+ | -------------- | ----------------- | ------------------------------- |
133
+ | `cell-{field}` | `{ item, value }` | Custom cell renderer per column |
134
+
135
+ ---
136
+
137
+ ### Dialog
138
+
139
+ Modal dialog with header, optional footer with action/cancel buttons.
140
+
141
+ ```vue
142
+ <Dialog
143
+ v-if="visible"
144
+ title="Confirm Action"
145
+ width="500px"
146
+ action-label="Save"
147
+ action-icon="fas fa-save"
148
+ @close="visible = false"
149
+ @action="handleSave"
150
+ >
151
+ <p>Dialog content here</p>
152
+ </Dialog>
153
+ ```
154
+
155
+ #### Props
156
+
157
+ | Name | Type | Default | Description |
158
+ | ---------------- | --------- | ----------- | ------------------------------------ |
159
+ | `title` | `string` | required | Header text |
160
+ | `width` | `string` | required | CSS width (e.g. `"500px"`) |
161
+ | `showBack` | `boolean` | `false` | Show back arrow in header |
162
+ | `showFooter` | `boolean` | `true` | Show footer with buttons |
163
+ | `actionLabel` | `string` | `"Confirm"` | Primary button label |
164
+ | `actionIcon` | `string` | - | Font Awesome class for action button |
165
+ | `actionDisabled` | `boolean` | `false` | Disable action button |
166
+ | `actionLoading` | `boolean` | `false` | Show loading state on action button |
167
+ | `cancelLabel` | `string` | `"Cancel"` | Cancel button label |
168
+ | `contentClass` | `string` | - | CSS class for dialog content area |
169
+
170
+ #### Emits
171
+
172
+ | Event | Description |
173
+ | -------- | ----------------------------- |
174
+ | `close` | X button or cancel clicked |
175
+ | `back` | Back arrow clicked |
176
+ | `action` | Primary action button clicked |
177
+
178
+ #### Slots
179
+
180
+ | Slot | Description |
181
+ | --------- | ----------------------------------- |
182
+ | `default` | Dialog body content |
183
+ | `footer` | Override the default footer buttons |
184
+
185
+ ---
186
+
187
+ ### HttpqlInput
188
+
189
+ HTTPQL query input with syntax highlighting and context-aware autocomplete.
190
+
191
+ ```vue
192
+ <HttpqlInput
193
+ v-model="query"
194
+ :loading="isSearching"
195
+ placeholder="Filter requests..."
196
+ @submit="onSearch"
197
+ />
198
+ ```
199
+
200
+ #### Props
201
+
202
+ | Name | Type | Default | Description |
203
+ | ------------- | --------- | ---------------------------- | ----------------- |
204
+ | `modelValue` | `string` | required | Query string |
205
+ | `placeholder` | `string` | `"Enter an HTTPQL query..."` | Placeholder text |
206
+ | `loading` | `boolean` | `false` | Show spinner icon |
207
+
208
+ #### Emits
209
+
210
+ | Event | Description |
211
+ | ------------------- | ---------------------------------------------- |
212
+ | `update:modelValue` | Query text changed |
213
+ | `submit` | Enter pressed (when no suggestion is selected) |
214
+
215
+ #### Autocomplete
216
+
217
+ The suggestion engine provides context-aware completions:
218
+
219
+ | Context | Suggestions |
220
+ | ------------------------------------ | ------------------------------------------------- |
221
+ | Empty input / after logical operator | Namespaces: `req`, `resp`, `row`, `filter` |
222
+ | After namespace (e.g. `req.`) | Fields for that namespace |
223
+ | After field (e.g. `req.method.`) | Operators based on field type |
224
+ | After `filter:` | `inscope`, `recent`, `1hr`, `6hr`, `12hr`, `24hr` |
225
+ | After a completed value | `AND`, `OR` |
226
+
227
+ Syntax uses dot/colon notation: `req.method.eq:"GET"`
228
+
229
+ ---
230
+
231
+ ### Menu
232
+
233
+ Popup tiered menu with dark styling.
234
+
235
+ ```vue
236
+ <Menu ref="menuRef" :items="menuItems" />
237
+ ```
238
+
239
+ #### Props
240
+
241
+ | Name | Type | Default | Description |
242
+ | ------- | ------------ | -------- | ---------------------------------------------- |
243
+ | `items` | `MenuItem[]` | required | PrimeVue menu items (supports nested submenus) |
244
+
245
+ #### Exposed Methods
246
+
247
+ | Method | Signature | Description |
248
+ | -------- | ------------------------ | -------------------- |
249
+ | `show` | `(event: Event) => void` | Show at event target |
250
+ | `hide` | `() => void` | Hide menu |
251
+ | `toggle` | `(event: Event) => void` | Toggle visibility |
252
+
253
+ ---
254
+
255
+ ### MenuButton
256
+
257
+ Button that opens a popup menu on click.
258
+
259
+ ```vue
260
+ <MenuButton :items="menuItems" label="Actions" icon="fas fa-ellipsis-v" />
261
+ ```
262
+
263
+ #### Props
264
+
265
+ | Name | Type | Default | Description |
266
+ | ---------- | -------------------- | ------------ | ------------------------ |
267
+ | `items` | `MenuItem[]` | required | Menu items |
268
+ | `label` | `string` | - | Button label |
269
+ | `icon` | `string` | - | Font Awesome icon class |
270
+ | `severity` | `string` | `"contrast"` | PrimeVue button severity |
271
+ | `size` | `"small" \| "large"` | `"small"` | Button size |
272
+ | `disabled` | `boolean` | `false` | Disable button |
273
+
274
+ ---
275
+
276
+ ### MultiSelect
277
+
278
+ Compact multi-select dropdown with chip display.
279
+
280
+ ```vue
281
+ <MultiSelect
282
+ v-model="selectedProfiles"
283
+ :options="profiles"
284
+ option-label="name"
285
+ option-value="id"
286
+ placeholder="Select profiles..."
287
+ />
288
+ ```
289
+
290
+ #### Props
291
+
292
+ | Name | Type | Default | Description |
293
+ | --------------- | ------------------- | ------------- | -------------------------------- |
294
+ | `modelValue` | `unknown[]` | required | Selected values |
295
+ | `options` | `unknown[]` | required | Available options |
296
+ | `optionLabel` | `string` | `"label"` | Property name for display text |
297
+ | `optionValue` | `string` | `"value"` | Property name for value |
298
+ | `placeholder` | `string` | `"Select..."` | Placeholder text |
299
+ | `display` | `"comma" \| "chip"` | `"chip"` | How selected items are displayed |
300
+ | `filter` | `boolean` | `false` | Show search input |
301
+ | `showToggleAll` | `boolean` | `false` | Show select all checkbox |
302
+ | `disabled` | `boolean` | `false` | Disable component |
303
+
304
+ Additional PrimeVue MultiSelect props are forwarded via `$attrs`.
305
+
306
+ #### Emits
307
+
308
+ | Event | Payload | Description |
309
+ | ------------------- | ----------- | ----------------- |
310
+ | `update:modelValue` | `unknown[]` | Selection changed |
311
+
312
+ ---
313
+
314
+ ### RequestEditor
315
+
316
+ Caido's native HTTP request editor.
317
+
318
+ ```vue
319
+ <RequestEditor :sdk="sdk" :content="rawRequest" />
320
+ ```
321
+
322
+ #### Props
323
+
324
+ | Name | Type | Default | Description |
325
+ | -------------- | --------------------- | ------------------------ | --------------------------------- |
326
+ | `sdk` | `any` | required | Caido frontend SDK instance |
327
+ | `content` | `string \| undefined` | required | Raw HTTP request text |
328
+ | `emptyMessage` | `string` | `"No request available"` | Message when content is undefined |
329
+
330
+ Use `request.getRaw().toText()` for the `content` prop.
331
+
332
+ ---
333
+
334
+ ### ResponseEditor
335
+
336
+ Caido's native HTTP response editor.
337
+
338
+ ```vue
339
+ <ResponseEditor :sdk="sdk" :content="rawResponse" />
340
+ ```
341
+
342
+ #### Props
343
+
344
+ | Name | Type | Default | Description |
345
+ | -------------- | --------------------- | ------------------------- | --------------------------------- |
346
+ | `sdk` | `any` | required | Caido frontend SDK instance |
347
+ | `content` | `string \| undefined` | required | Raw HTTP response text |
348
+ | `emptyMessage` | `string` | `"No response available"` | Message when content is undefined |
349
+
350
+ Use `response.getRaw().toText()` for the `content` prop.
351
+
352
+ ---
353
+
354
+ ### Table
355
+
356
+ High-performance sortable table with virtual scrolling (auto-enabled at 100+ rows), selection, context menu, and scroll position memory.
357
+
358
+ ```vue
359
+ <Table
360
+ :items="rows"
361
+ :selected="selected"
362
+ :columns="columns"
363
+ :row-key="(r) => r.id"
364
+ :active-key="activeId"
365
+ selectable
366
+ scroll-key="my-table"
367
+ :context-menu="menuItems"
368
+ @update:selected="selected = $event"
369
+ @row-click="onRowClick"
370
+ >
371
+ <template #cell-status="{ value }">
372
+ <span :class="value === '200' ? 'text-green-400' : 'text-red-400'">{{ value }}</span>
373
+ </template>
374
+ </Table>
375
+ ```
376
+
377
+ #### Props
378
+
379
+ | Name | Type | Default | Description |
380
+ | -------------- | --------------------- | ----------- | ----------------------------------- |
381
+ | `items` | `T[]` | required | Row data |
382
+ | `selected` | `T[]` | required | Selected items |
383
+ | `columns` | `Column[]` | required | Column definitions (see below) |
384
+ | `rowKey` | `(item: T) => string` | required | Unique key extractor |
385
+ | `activeKey` | `string` | - | Row key of the highlighted row |
386
+ | `emptyMessage` | `string` | `"No data"` | Message when empty |
387
+ | `selectable` | `boolean` | `false` | Show checkbox column |
388
+ | `scrollKey` | `string` | - | Key for scroll position persistence |
389
+ | `contextMenu` | `MenuItem[]` | - | Right-click menu items |
390
+ | `resizable` | `boolean` | `false` | Enable column resizing |
391
+
392
+ #### Column Definition
393
+
394
+ ```ts
395
+ type Column = {
396
+ key: string; // property name on row object
397
+ header: string; // column header text
398
+ width: string; // CSS width ("150px", "1fr", "auto")
399
+ sortable?: boolean;
400
+ sortType?: "string" | "number" | "date"; // default: "string"
401
+ };
402
+ ```
403
+
404
+ #### Emits
405
+
406
+ | Event | Payload | Description |
407
+ | ----------------- | ----------------- | ----------------- |
408
+ | `update:selected` | `T[]` | Selection changed |
409
+ | `row-click` | `T` | Row clicked |
410
+ | `row-contextmenu` | `[T, MouseEvent]` | Row right-clicked |
411
+
412
+ #### Slots
413
+
414
+ | Slot | Scope | Description |
415
+ | ------------ | ----------------------------- | ------------------------------- |
416
+ | `cell-{key}` | `{ item: T, value: unknown }` | Custom cell renderer per column |
@@ -1,7 +1,9 @@
1
1
  <script setup lang="ts">
2
- import { nextTick, ref, watch } from "vue";
2
+ import { nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
3
3
 
4
+ import ButtonGroup from "../ButtonGroup/Container.vue";
4
5
  import Card from "../Card/Container.vue";
6
+ import { formatHttpBody } from "../editors/format";
5
7
 
6
8
  const props = withDefaults(
7
9
  defineProps<{
@@ -14,54 +16,139 @@ const props = withDefaults(
14
16
  },
15
17
  );
16
18
 
19
+ const viewMode = ref("pretty");
20
+ const viewOptions = [
21
+ { label: "Pretty", value: "pretty" },
22
+ { label: "Raw", value: "raw" },
23
+ ];
24
+
25
+ const cardRef = ref<HTMLDivElement>();
17
26
  const container = ref<HTMLDivElement>();
18
27
  let editor: HTTPRequestEditor | undefined;
28
+ let intersectionObserver: IntersectionObserver | undefined;
29
+ let wasHidden = false;
19
30
 
20
- function initEditor() {
21
- if (container.value === undefined) return;
22
- if (editor !== undefined) return;
31
+ function injectStyles(el: HTMLElement) {
32
+ const shadow = el.shadowRoot;
33
+ if (!shadow) return;
34
+ const style = document.createElement("style");
35
+ style.textContent = `.c-card__header, .c-card__footer { display: none !important; }`;
36
+ shadow.appendChild(style);
37
+ }
23
38
 
24
- editor = props.sdk.ui.httpRequestEditor();
25
- const el = editor.getElement();
39
+ function createEditor(): HTTPRequestEditor {
40
+ const instance = props.sdk.ui.httpRequestEditor();
41
+ const el = instance.getElement();
26
42
  el.style.height = "100%";
27
43
  el.style.width = "100%";
28
- container.value.appendChild(el);
44
+ injectStyles(el);
45
+ return instance;
46
+ }
47
+
48
+ function mountEditor() {
49
+ if (!container.value) return;
50
+ if (!editor) {
51
+ editor = createEditor();
52
+ }
53
+ const el = editor.getElement();
54
+ if (el.parentElement !== container.value) {
55
+ container.value.appendChild(el);
56
+ }
29
57
  }
30
58
 
31
59
  function updateEditor(content: string) {
32
- if (editor === undefined) return;
60
+ if (!editor) return;
33
61
  const view = editor.getEditorView();
34
- if (view === undefined) return;
62
+ if (!view) return;
35
63
  view.dispatch({
36
64
  changes: { from: 0, to: view.state.doc.length, insert: content },
37
65
  });
38
66
  }
39
67
 
68
+ async function setContent(content: string) {
69
+ mountEditor();
70
+ if (viewMode.value === "pretty") {
71
+ const formatted = await formatHttpBody(content);
72
+ updateEditor(formatted);
73
+ } else {
74
+ updateEditor(content);
75
+ }
76
+ }
77
+
78
+ function onVisibilityChange(entries: IntersectionObserverEntry[]) {
79
+ const entry = entries[0];
80
+ if (!entry) return;
81
+
82
+ if (!entry.isIntersecting) {
83
+ wasHidden = true;
84
+ return;
85
+ }
86
+
87
+ if (wasHidden && props.content !== undefined) {
88
+ wasHidden = false;
89
+ nextTick(() => setContent(props.content!));
90
+ }
91
+ }
92
+
93
+ onMounted(async () => {
94
+ await nextTick();
95
+ mountEditor();
96
+ if (props.content !== undefined) {
97
+ await setContent(props.content);
98
+ }
99
+
100
+ if (cardRef.value) {
101
+ intersectionObserver = new IntersectionObserver(onVisibilityChange);
102
+ intersectionObserver.observe(cardRef.value);
103
+ }
104
+ });
105
+
106
+ onBeforeUnmount(() => {
107
+ intersectionObserver?.disconnect();
108
+ if (editor) {
109
+ editor.getElement().remove();
110
+ editor = undefined;
111
+ }
112
+ });
113
+
40
114
  watch(
41
115
  () => props.content,
42
116
  async (content) => {
43
117
  if (content === undefined) return;
44
118
  await nextTick();
45
- initEditor();
46
- updateEditor(content);
119
+ await setContent(content);
47
120
  },
48
121
  );
122
+
123
+ watch(viewMode, async () => {
124
+ if (props.content !== undefined) {
125
+ await setContent(props.content);
126
+ }
127
+ });
49
128
  </script>
50
129
 
51
130
  <template>
52
- <Card class="h-full">
53
- <template #content>
54
- <div
55
- v-show="content === undefined"
56
- class="h-full flex items-center justify-center text-surface-500 text-xs"
57
- >
58
- {{ emptyMessage }}
59
- </div>
60
- <div
61
- v-show="content !== undefined"
62
- ref="container"
63
- class="h-full w-full overflow-hidden"
64
- />
65
- </template>
66
- </Card>
131
+ <div ref="cardRef" class="h-full">
132
+ <Card class="h-full">
133
+ <template #content>
134
+ <div
135
+ class="flex items-center justify-between px-3 py-1.5 border-b border-surface-700 shrink-0"
136
+ >
137
+ <span class="text-xs text-surface-400 font-medium">Request</span>
138
+ <ButtonGroup v-model="viewMode" :options="viewOptions" size="small" />
139
+ </div>
140
+ <div
141
+ v-show="content === undefined"
142
+ class="h-full flex items-center justify-center text-surface-500 text-xs"
143
+ >
144
+ {{ emptyMessage }}
145
+ </div>
146
+ <div
147
+ v-show="content !== undefined"
148
+ ref="container"
149
+ class="h-full w-full overflow-hidden"
150
+ />
151
+ </template>
152
+ </Card>
153
+ </div>
67
154
  </template>
@@ -1,6 +1,7 @@
1
1
  type __VLS_Props = {
2
2
  sdk: any;
3
3
  content: string | undefined;
4
+ render?: string;
4
5
  emptyMessage?: string;
5
6
  };
6
7
  declare const _default: import("vue").DefineComponent<__VLS_WithDefaults<__VLS_TypePropsToOption<__VLS_Props>, {
@@ -1,12 +1,15 @@
1
1
  <script setup lang="ts">
2
- import { nextTick, ref, watch } from "vue";
2
+ import { nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
3
3
 
4
+ import ButtonGroup from "../ButtonGroup/Container.vue";
4
5
  import Card from "../Card/Container.vue";
6
+ import { formatHttpBody } from "../editors/format";
5
7
 
6
8
  const props = withDefaults(
7
9
  defineProps<{
8
10
  sdk: any;
9
11
  content: string | undefined;
12
+ render?: string;
10
13
  emptyMessage?: string;
11
14
  }>(),
12
15
  {
@@ -14,54 +17,154 @@ const props = withDefaults(
14
17
  },
15
18
  );
16
19
 
20
+ const viewMode = ref("pretty");
21
+ const viewOptions = [
22
+ { label: "Pretty", value: "pretty" },
23
+ { label: "Raw", value: "raw" },
24
+ { label: "Preview", value: "preview" },
25
+ ];
26
+
27
+ const cardRef = ref<HTMLDivElement>();
17
28
  const container = ref<HTMLDivElement>();
18
29
  let editor: HTTPResponseEditor | undefined;
30
+ let intersectionObserver: IntersectionObserver | undefined;
31
+ let wasHidden = false;
19
32
 
20
- function initEditor() {
21
- if (container.value === undefined) return;
22
- if (editor !== undefined) return;
33
+ function injectStyles(el: HTMLElement) {
34
+ const shadow = el.shadowRoot;
35
+ if (!shadow) return;
36
+ const style = document.createElement("style");
37
+ style.textContent = `.c-card__header, .c-card__footer { display: none !important; }`;
38
+ shadow.appendChild(style);
39
+ }
23
40
 
24
- editor = props.sdk.ui.httpResponseEditor();
25
- const el = editor.getElement();
41
+ function createEditor(): HTTPResponseEditor {
42
+ const instance = props.sdk.ui.httpResponseEditor();
43
+ const el = instance.getElement();
26
44
  el.style.height = "100%";
27
45
  el.style.width = "100%";
28
- container.value.appendChild(el);
46
+ injectStyles(el);
47
+ return instance;
48
+ }
49
+
50
+ function mountEditor() {
51
+ if (!container.value) return;
52
+ if (!editor) {
53
+ editor = createEditor();
54
+ }
55
+ const el = editor.getElement();
56
+ if (el.parentElement !== container.value) {
57
+ container.value.appendChild(el);
58
+ }
29
59
  }
30
60
 
31
61
  function updateEditor(content: string) {
32
- if (editor === undefined) return;
62
+ if (!editor) return;
33
63
  const view = editor.getEditorView();
34
- if (view === undefined) return;
64
+ if (!view) return;
35
65
  view.dispatch({
36
66
  changes: { from: 0, to: view.state.doc.length, insert: content },
37
67
  });
38
68
  }
39
69
 
70
+ async function setContent(content: string) {
71
+ mountEditor();
72
+ if (viewMode.value === "pretty") {
73
+ const formatted = await formatHttpBody(content);
74
+ updateEditor(formatted);
75
+ } else {
76
+ updateEditor(content);
77
+ }
78
+ }
79
+
80
+ function onVisibilityChange(entries: IntersectionObserverEntry[]) {
81
+ const entry = entries[0];
82
+ if (!entry) return;
83
+
84
+ if (!entry.isIntersecting) {
85
+ wasHidden = true;
86
+ return;
87
+ }
88
+
89
+ if (wasHidden && props.content !== undefined) {
90
+ wasHidden = false;
91
+ nextTick(() => setContent(props.content!));
92
+ }
93
+ }
94
+
95
+ onMounted(async () => {
96
+ await nextTick();
97
+ mountEditor();
98
+ if (props.content !== undefined) {
99
+ await setContent(props.content);
100
+ }
101
+
102
+ if (cardRef.value) {
103
+ intersectionObserver = new IntersectionObserver(onVisibilityChange);
104
+ intersectionObserver.observe(cardRef.value);
105
+ }
106
+ });
107
+
108
+ onBeforeUnmount(() => {
109
+ intersectionObserver?.disconnect();
110
+ if (editor) {
111
+ editor.getElement().remove();
112
+ editor = undefined;
113
+ }
114
+ });
115
+
40
116
  watch(
41
117
  () => props.content,
42
118
  async (content) => {
43
119
  if (content === undefined) return;
44
120
  await nextTick();
45
- initEditor();
46
- updateEditor(content);
121
+ await setContent(content);
47
122
  },
48
123
  );
124
+
125
+ watch(viewMode, async () => {
126
+ if (props.content !== undefined) {
127
+ await setContent(props.content);
128
+ }
129
+ });
49
130
  </script>
50
131
 
51
132
  <template>
52
- <Card class="h-full">
53
- <template #content>
54
- <div
55
- v-show="content === undefined"
56
- class="h-full flex items-center justify-center text-surface-500 text-xs"
57
- >
58
- {{ emptyMessage }}
59
- </div>
60
- <div
61
- v-show="content !== undefined"
62
- ref="container"
63
- class="h-full w-full overflow-hidden"
64
- />
65
- </template>
66
- </Card>
133
+ <div ref="cardRef" class="h-full">
134
+ <Card class="h-full">
135
+ <template #content>
136
+ <div
137
+ class="flex items-center justify-between px-3 py-1.5 border-b border-surface-700 shrink-0"
138
+ >
139
+ <span class="text-xs text-surface-400 font-medium">Response</span>
140
+ <ButtonGroup v-model="viewMode" :options="viewOptions" size="small" />
141
+ </div>
142
+ <div
143
+ v-show="content === undefined && viewMode !== 'preview'"
144
+ class="h-full flex items-center justify-center text-surface-500 text-xs"
145
+ >
146
+ {{ emptyMessage }}
147
+ </div>
148
+ <div
149
+ v-show="content !== undefined && viewMode !== 'preview'"
150
+ ref="container"
151
+ class="h-full w-full overflow-hidden"
152
+ />
153
+ <div v-show="viewMode === 'preview'" class="flex-1 min-h-0 w-full">
154
+ <iframe
155
+ v-if="render"
156
+ :srcdoc="render"
157
+ sandbox=""
158
+ class="h-full w-full border-0 bg-white"
159
+ />
160
+ <div
161
+ v-else
162
+ class="h-full flex items-center justify-center text-surface-500 text-xs"
163
+ >
164
+ No preview available
165
+ </div>
166
+ </div>
167
+ </template>
168
+ </Card>
169
+ </div>
67
170
  </template>
@@ -1,6 +1,7 @@
1
1
  type __VLS_Props = {
2
2
  sdk: any;
3
3
  content: string | undefined;
4
+ render?: string;
4
5
  emptyMessage?: string;
5
6
  };
6
7
  declare const _default: import("vue").DefineComponent<__VLS_WithDefaults<__VLS_TypePropsToOption<__VLS_Props>, {
@@ -0,0 +1 @@
1
+ export declare function formatHttpBody(raw: string): Promise<string>;
@@ -0,0 +1,25 @@
1
+ import { prettify } from "@caido-utils/frontend";
2
+ function splitHttpMessage(raw) {
3
+ let idx = raw.indexOf("\r\n\r\n");
4
+ let sepLen = 4;
5
+ if (idx === -1) {
6
+ idx = raw.indexOf("\n\n");
7
+ sepLen = 2;
8
+ }
9
+ if (idx === -1) return null;
10
+ return {
11
+ headers: raw.substring(0, idx + sepLen),
12
+ body: raw.substring(idx + sepLen)
13
+ };
14
+ }
15
+ function extractContentType(headers) {
16
+ const match = headers.match(/^content-type:\s*([^\r\n]+)/im);
17
+ return match?.[1]?.trim();
18
+ }
19
+ export async function formatHttpBody(raw) {
20
+ const parts = splitHttpMessage(raw);
21
+ if (!parts || !parts.body.trim()) return raw;
22
+ const contentType = extractContentType(parts.headers);
23
+ const formatted = await prettify(parts.body, contentType);
24
+ return parts.headers + formatted;
25
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caido-utils/components",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist"
@@ -14,15 +14,16 @@
14
14
  "scripts": {
15
15
  "build": "unbuild"
16
16
  },
17
+ "dependencies": {
18
+ "@caido-utils/frontend": "^0.19.0"
19
+ },
17
20
  "peerDependencies": {
18
21
  "@caido/sdk-frontend": ">=0.54.0",
19
- "@codemirror/view": ">=6.0.0",
20
22
  "primevue": ">=4.0.0",
21
23
  "vue": ">=3.4.0"
22
24
  },
23
25
  "devDependencies": {
24
26
  "@caido/sdk-frontend": "0.54.2-beta.15",
25
- "@codemirror/view": "6.39.6",
26
27
  "primevue": "4.1.0",
27
28
  "typescript": "^5.5.4",
28
29
  "unbuild": "^3.6.1",