Added multiple components, added CodeMirror for body editor and added variable substitution for params and headers
This commit is contained in:
@@ -13,8 +13,12 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/lang-html": "^6.4.11",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/lang-xml": "^6.1.0",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2"
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"svelte-codemirror-editor": "^2.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internationalized/date": "^3.8.1",
|
||||
|
||||
103
src/lib/components/code-editor.svelte
Normal file
103
src/lib/components/code-editor.svelte
Normal file
@@ -0,0 +1,103 @@
|
||||
<script lang="ts">
|
||||
import CodeMirror from "svelte-codemirror-editor";
|
||||
import { json } from "@codemirror/lang-json";
|
||||
import { xml } from "@codemirror/lang-xml";
|
||||
import { html } from "@codemirror/lang-html";
|
||||
import type { BodyType } from "$lib/types/request";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
language: BodyType;
|
||||
readonly?: boolean;
|
||||
placeholder?: string;
|
||||
class?: string;
|
||||
onchange?: (value: string) => void;
|
||||
};
|
||||
|
||||
let {
|
||||
value = "",
|
||||
language = "json",
|
||||
readonly = false,
|
||||
placeholder = "",
|
||||
class: className = "",
|
||||
onchange,
|
||||
}: Props = $props();
|
||||
|
||||
function getLanguageSupport(lang: BodyType) {
|
||||
switch (lang) {
|
||||
case "json":
|
||||
return json();
|
||||
case "xml":
|
||||
return xml();
|
||||
case "html":
|
||||
return html();
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function handleChange(newValue: string) {
|
||||
if (onchange) {
|
||||
onchange(newValue);
|
||||
}
|
||||
}
|
||||
|
||||
let lang = $derived(getLanguageSupport(language));
|
||||
</script>
|
||||
|
||||
<div class="code-editor-wrapper {className}">
|
||||
<CodeMirror
|
||||
bind:value
|
||||
{lang}
|
||||
{readonly}
|
||||
{placeholder}
|
||||
lineNumbers={true}
|
||||
tabSize={2}
|
||||
lineWrapping={true}
|
||||
onchange={handleChange}
|
||||
styles={{
|
||||
"&": {
|
||||
height: "100%",
|
||||
fontSize: "13px",
|
||||
},
|
||||
".cm-scroller": {
|
||||
fontFamily:
|
||||
"ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace",
|
||||
},
|
||||
".cm-gutters": {
|
||||
backgroundColor: "hsl(var(--muted))",
|
||||
borderRight: "1px solid hsl(var(--border))",
|
||||
},
|
||||
".cm-activeLineGutter": {
|
||||
backgroundColor: "hsl(var(--accent))",
|
||||
},
|
||||
".cm-activeLine": {
|
||||
backgroundColor: "hsl(var(--accent) / 0.3)",
|
||||
},
|
||||
"&.cm-focused": {
|
||||
outline: "none",
|
||||
},
|
||||
".cm-content": {
|
||||
caretColor: "hsl(var(--foreground))",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.code-editor-wrapper {
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.code-editor-wrapper :global(.cm-editor) {
|
||||
height: 100%;
|
||||
background-color: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.code-editor-wrapper :global(.cm-placeholder) {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
</style>
|
||||
@@ -2,20 +2,26 @@
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
import { Input } from "$lib/components/ui/input/index.js";
|
||||
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
|
||||
import * as Select from "$lib/components/ui/select/index.js";
|
||||
import * as Tabs from "$lib/components/ui/tabs/index.js";
|
||||
import SendIcon from "@lucide/svelte/icons/send";
|
||||
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||
import type { Request, HttpMethod } from "$lib/types/request";
|
||||
import XIcon from "@lucide/svelte/icons/x";
|
||||
import CodeEditor from "./code-editor.svelte";
|
||||
import VariableInput from "./variable-input.svelte";
|
||||
import type { Request, HttpMethod, BodyType } from "$lib/types/request";
|
||||
import type { ResolvedVariable } from "$lib/types/variable";
|
||||
|
||||
type Props = {
|
||||
request: Request;
|
||||
variables?: ResolvedVariable[];
|
||||
onSend: (request: Request) => void;
|
||||
onUpdate: (request: Request) => void;
|
||||
};
|
||||
|
||||
let { request, onSend, onUpdate }: Props = $props();
|
||||
let { request, variables = [], onSend, onUpdate }: Props = $props();
|
||||
|
||||
let localRequest = $state<Request>({ ...request });
|
||||
let activeTab = $state<"params" | "headers" | "body">("params");
|
||||
|
||||
$effect(() => {
|
||||
localRequest = { ...request };
|
||||
@@ -30,6 +36,15 @@
|
||||
"HEAD",
|
||||
"OPTIONS",
|
||||
];
|
||||
const bodyTypes: { value: BodyType; label: string }[] = [
|
||||
{ value: "none", label: "None" },
|
||||
{ value: "json", label: "JSON" },
|
||||
{ value: "xml", label: "XML" },
|
||||
{ value: "text", label: "Text" },
|
||||
{ value: "html", label: "HTML" },
|
||||
{ value: "form-data", label: "Form Data" },
|
||||
{ value: "x-www-form-urlencoded", label: "URL Encoded" },
|
||||
];
|
||||
|
||||
function getMethodColor(method: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
@@ -49,15 +64,39 @@
|
||||
onUpdate(localRequest);
|
||||
}
|
||||
|
||||
function handleUrlChange(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
localRequest.url = target.value;
|
||||
function handleUrlChange(value: string) {
|
||||
localRequest.url = value;
|
||||
onUpdate(localRequest);
|
||||
}
|
||||
|
||||
function handleBodyTypeChange(value: BodyType) {
|
||||
localRequest.bodyType = value;
|
||||
onUpdate(localRequest);
|
||||
}
|
||||
|
||||
function handleBodyChange(value: string) {
|
||||
localRequest.body = value;
|
||||
onUpdate(localRequest);
|
||||
}
|
||||
|
||||
function handleSend() {
|
||||
onSend(localRequest);
|
||||
}
|
||||
|
||||
function formatBody() {
|
||||
if (localRequest.bodyType === "json") {
|
||||
try {
|
||||
localRequest.body = JSON.stringify(
|
||||
JSON.parse(localRequest.body),
|
||||
null,
|
||||
2
|
||||
);
|
||||
onUpdate(localRequest);
|
||||
} catch {
|
||||
// Invalid JSON, don't format
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full">
|
||||
@@ -89,10 +128,11 @@
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<Input
|
||||
class="flex-1 font-mono text-sm"
|
||||
placeholder="Enter request URL"
|
||||
<VariableInput
|
||||
class="flex-1"
|
||||
placeholder={"Enter request URL (use {{VAR_NAME}} for variables)"}
|
||||
value={localRequest.url}
|
||||
{variables}
|
||||
oninput={handleUrlChange}
|
||||
/>
|
||||
|
||||
@@ -103,159 +143,229 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 flex flex-col overflow-hidden">
|
||||
<div class="border-b">
|
||||
<div class="flex">
|
||||
<button
|
||||
class={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === "params"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
onclick={() => (activeTab = "params")}
|
||||
>
|
||||
Params
|
||||
</button>
|
||||
<button
|
||||
class={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === "headers"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
onclick={() => (activeTab = "headers")}
|
||||
>
|
||||
Headers
|
||||
</button>
|
||||
<button
|
||||
class={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === "body"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
onclick={() => (activeTab = "body")}
|
||||
>
|
||||
Body
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Tabs.Root value="params" class="flex-1 flex flex-col overflow-hidden">
|
||||
<Tabs.List
|
||||
class="w-full justify-start rounded-none border-b bg-transparent p-0 h-auto"
|
||||
>
|
||||
<Tabs.Trigger
|
||||
value="params"
|
||||
class="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
|
||||
>
|
||||
Params
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger
|
||||
value="headers"
|
||||
class="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
|
||||
>
|
||||
Headers
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger
|
||||
value="body"
|
||||
class="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
|
||||
>
|
||||
Body
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<div class="flex-1 p-4 overflow-auto">
|
||||
{#if activeTab === "params"}
|
||||
<div class="space-y-2">
|
||||
{#each localRequest.params as param, i (i)}
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
class="flex-1"
|
||||
placeholder="Key"
|
||||
value={param.key}
|
||||
oninput={(e) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
localRequest.params[i].key = target.value;
|
||||
onUpdate(localRequest);
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
class="flex-1"
|
||||
placeholder="Value"
|
||||
value={param.value}
|
||||
oninput={(e) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
localRequest.params[i].value = target.value;
|
||||
onUpdate(localRequest);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => {
|
||||
localRequest.params = localRequest.params.filter(
|
||||
(_, idx) => idx !== i
|
||||
);
|
||||
onUpdate(localRequest);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => {
|
||||
localRequest.params = [
|
||||
...localRequest.params,
|
||||
{ key: "", value: "", enabled: true },
|
||||
];
|
||||
onUpdate(localRequest);
|
||||
}}
|
||||
>
|
||||
Add Parameter
|
||||
</Button>
|
||||
</div>
|
||||
{:else if activeTab === "headers"}
|
||||
<div class="space-y-2">
|
||||
{#each localRequest.headers as header, i (i)}
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
class="flex-1"
|
||||
placeholder="Key"
|
||||
value={header.key}
|
||||
oninput={(e) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
localRequest.headers[i].key = target.value;
|
||||
onUpdate(localRequest);
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
class="flex-1"
|
||||
placeholder="Value"
|
||||
value={header.value}
|
||||
oninput={(e) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
localRequest.headers[i].value = target.value;
|
||||
onUpdate(localRequest);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => {
|
||||
localRequest.headers = localRequest.headers.filter(
|
||||
(_, idx) => idx !== i
|
||||
);
|
||||
onUpdate(localRequest);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => {
|
||||
localRequest.headers = [
|
||||
...localRequest.headers,
|
||||
{ key: "", value: "", enabled: true },
|
||||
];
|
||||
onUpdate(localRequest);
|
||||
}}
|
||||
>
|
||||
Add Header
|
||||
</Button>
|
||||
</div>
|
||||
{:else if activeTab === "body"}
|
||||
<textarea
|
||||
class="w-full h-64 p-3 font-mono text-sm border rounded-md bg-background resize-none focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="Request body (JSON, XML, etc.)"
|
||||
value={localRequest.body}
|
||||
oninput={(e) => {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
localRequest.body = target.value;
|
||||
<Tabs.Content value="params" class="flex-1 m-0 p-4 overflow-auto">
|
||||
<div class="space-y-2">
|
||||
{#each localRequest.params as param, i (i)}
|
||||
<div class="flex items-center gap-2">
|
||||
<VariableInput
|
||||
class="flex-1"
|
||||
placeholder="Key"
|
||||
value={param.key}
|
||||
{variables}
|
||||
oninput={(value) => {
|
||||
localRequest.params[i].key = value;
|
||||
onUpdate(localRequest);
|
||||
}}
|
||||
/>
|
||||
<VariableInput
|
||||
class="flex-1"
|
||||
placeholder="Value"
|
||||
value={param.value}
|
||||
{variables}
|
||||
oninput={(value) => {
|
||||
localRequest.params[i].value = value;
|
||||
onUpdate(localRequest);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={() => {
|
||||
localRequest.params = localRequest.params.filter(
|
||||
(_, idx) => idx !== i
|
||||
);
|
||||
onUpdate(localRequest);
|
||||
}}
|
||||
>
|
||||
<XIcon class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => {
|
||||
localRequest.params = [
|
||||
...localRequest.params,
|
||||
{ key: "", value: "", enabled: true },
|
||||
];
|
||||
onUpdate(localRequest);
|
||||
}}
|
||||
></textarea>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
>
|
||||
Add Parameter
|
||||
</Button>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="headers" class="flex-1 m-0 p-4 overflow-auto">
|
||||
<div class="space-y-2">
|
||||
{#each localRequest.headers as header, i (i)}
|
||||
<div class="flex items-center gap-2">
|
||||
<VariableInput
|
||||
class="flex-1"
|
||||
placeholder="Key"
|
||||
value={header.key}
|
||||
{variables}
|
||||
oninput={(value) => {
|
||||
localRequest.headers[i].key = value;
|
||||
onUpdate(localRequest);
|
||||
}}
|
||||
/>
|
||||
<VariableInput
|
||||
class="flex-1"
|
||||
placeholder="Value"
|
||||
value={header.value}
|
||||
{variables}
|
||||
oninput={(value) => {
|
||||
localRequest.headers[i].value = value;
|
||||
onUpdate(localRequest);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={() => {
|
||||
localRequest.headers = localRequest.headers.filter(
|
||||
(_, idx) => idx !== i
|
||||
);
|
||||
onUpdate(localRequest);
|
||||
}}
|
||||
>
|
||||
<XIcon class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => {
|
||||
localRequest.headers = [
|
||||
...localRequest.headers,
|
||||
{ key: "", value: "", enabled: true },
|
||||
];
|
||||
onUpdate(localRequest);
|
||||
}}
|
||||
>
|
||||
Add Header
|
||||
</Button>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="body" class="flex-1 m-0 flex flex-col overflow-hidden">
|
||||
<div class="flex items-center gap-2 p-2 border-b bg-muted/30">
|
||||
<span class="text-sm text-muted-foreground">Content Type:</span>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={localRequest.bodyType}
|
||||
onValueChange={(value) => handleBodyTypeChange(value as BodyType)}
|
||||
>
|
||||
<Select.Trigger class="w-40 h-8">
|
||||
{bodyTypes.find((t) => t.value === localRequest.bodyType)?.label ||
|
||||
"None"}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each bodyTypes as bodyType (bodyType.value)}
|
||||
<Select.Item value={bodyType.value}>{bodyType.label}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{#if localRequest.bodyType === "json"}
|
||||
<Button variant="ghost" size="sm" onclick={formatBody}>Format</Button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex-1 p-2 overflow-hidden">
|
||||
{#if localRequest.bodyType === "none"}
|
||||
<div
|
||||
class="flex items-center justify-center h-full text-muted-foreground text-sm"
|
||||
>
|
||||
This request does not have a body
|
||||
</div>
|
||||
{:else if localRequest.bodyType === "form-data" || localRequest.bodyType === "x-www-form-urlencoded"}
|
||||
<div class="space-y-2 p-2">
|
||||
{#each localRequest.formData as item, i (i)}
|
||||
<div class="flex items-center gap-2">
|
||||
<VariableInput
|
||||
class="flex-1"
|
||||
placeholder="Key"
|
||||
value={item.key}
|
||||
{variables}
|
||||
oninput={(value) => {
|
||||
localRequest.formData[i].key = value;
|
||||
onUpdate(localRequest);
|
||||
}}
|
||||
/>
|
||||
<VariableInput
|
||||
class="flex-1"
|
||||
placeholder="Value"
|
||||
value={item.value}
|
||||
{variables}
|
||||
oninput={(value) => {
|
||||
localRequest.formData[i].value = value;
|
||||
onUpdate(localRequest);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={() => {
|
||||
localRequest.formData = localRequest.formData.filter(
|
||||
(_, idx) => idx !== i
|
||||
);
|
||||
onUpdate(localRequest);
|
||||
}}
|
||||
>
|
||||
<XIcon class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => {
|
||||
localRequest.formData = [
|
||||
...localRequest.formData,
|
||||
{ key: "", value: "", type: "text", enabled: true },
|
||||
];
|
||||
onUpdate(localRequest);
|
||||
}}
|
||||
>
|
||||
Add Field
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<CodeEditor
|
||||
value={localRequest.body}
|
||||
language={localRequest.bodyType}
|
||||
placeholder="Enter request body..."
|
||||
class="h-full"
|
||||
onchange={handleBodyChange}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</div>
|
||||
|
||||
176
src/lib/components/response-panel.svelte
Normal file
176
src/lib/components/response-panel.svelte
Normal file
@@ -0,0 +1,176 @@
|
||||
<script lang="ts">
|
||||
import * as Tabs from "$lib/components/ui/tabs/index.js";
|
||||
import { Badge } from "$lib/components/ui/badge/index.js";
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
import { Spinner } from "$lib/components/ui/spinner/index.js";
|
||||
import CodeEditor from "./code-editor.svelte";
|
||||
import CopyIcon from "@lucide/svelte/icons/copy";
|
||||
import CheckIcon from "@lucide/svelte/icons/check";
|
||||
import type { Response } from "$lib/types/response";
|
||||
import type { BodyType } from "$lib/types/request";
|
||||
|
||||
type Props = {
|
||||
response: Response | null;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
let { response, loading = false }: Props = $props();
|
||||
|
||||
let copied = $state(false);
|
||||
|
||||
function getStatusColor(status: number): string {
|
||||
if (status >= 200 && status < 300) return "bg-green-500/10 text-green-500";
|
||||
if (status >= 300 && status < 400) return "bg-blue-500/10 text-blue-500";
|
||||
if (status >= 400 && status < 500)
|
||||
return "bg-yellow-500/10 text-yellow-500";
|
||||
if (status >= 500) return "bg-red-500/10 text-red-500";
|
||||
return "bg-gray-500/10 text-gray-500";
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms} ms`;
|
||||
return `${(ms / 1000).toFixed(2)} s`;
|
||||
}
|
||||
|
||||
function getBodyType(): BodyType {
|
||||
if (!response) return "text";
|
||||
const ct = response.contentType.toLowerCase();
|
||||
if (ct.includes("json")) return "json";
|
||||
if (ct.includes("xml")) return "xml";
|
||||
if (ct.includes("html")) return "html";
|
||||
return "text";
|
||||
}
|
||||
|
||||
function formatBody(body: string, type: BodyType): string {
|
||||
if (type === "json") {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(body), null, 2);
|
||||
} catch {
|
||||
return body;
|
||||
}
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
async function copyToClipboard() {
|
||||
if (!response) return;
|
||||
await navigator.clipboard.writeText(response.body);
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 2000);
|
||||
}
|
||||
|
||||
let bodyType = $derived(getBodyType());
|
||||
let formattedBody = $derived(
|
||||
response ? formatBody(response.body, bodyType) : ""
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full border-t">
|
||||
{#if loading}
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<Spinner class="size-8" />
|
||||
<span class="text-sm text-muted-foreground">Sending request...</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else if response}
|
||||
<div class="flex items-center gap-4 px-4 py-2 border-b bg-muted/30">
|
||||
<Badge class={getStatusColor(response.status)}>
|
||||
{response.status}
|
||||
{response.statusText}
|
||||
</Badge>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{formatDuration(response.duration)}
|
||||
</span>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{formatSize(response.size)}
|
||||
</span>
|
||||
<div class="flex-1"></div>
|
||||
<Button variant="ghost" size="sm" onclick={copyToClipboard}>
|
||||
{#if copied}
|
||||
<CheckIcon class="size-4 mr-1" />
|
||||
Copied
|
||||
{:else}
|
||||
<CopyIcon class="size-4 mr-1" />
|
||||
Copy
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs.Root value="body" class="flex-1 flex flex-col">
|
||||
<Tabs.List
|
||||
class="w-full justify-start rounded-none border-b bg-transparent p-0"
|
||||
>
|
||||
<Tabs.Trigger
|
||||
value="body"
|
||||
class="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent"
|
||||
>
|
||||
Body
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger
|
||||
value="headers"
|
||||
class="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent"
|
||||
>
|
||||
Headers ({response.headers.length})
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content
|
||||
value="body"
|
||||
class="flex-1 m-0 p-0 data-[state=active]:flex data-[state=active]:flex-col"
|
||||
>
|
||||
<div class="flex-1 p-2">
|
||||
<CodeEditor
|
||||
value={formattedBody}
|
||||
language={bodyType}
|
||||
readonly={true}
|
||||
class="h-full"
|
||||
/>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content
|
||||
value="headers"
|
||||
class="flex-1 m-0 data-[state=active]:flex data-[state=active]:flex-col overflow-auto"
|
||||
>
|
||||
<div class="p-4">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b">
|
||||
<th
|
||||
class="text-left py-2 pr-4 font-medium text-muted-foreground"
|
||||
>Name</th
|
||||
>
|
||||
<th class="text-left py-2 font-medium text-muted-foreground"
|
||||
>Value</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each response.headers as header (header.key)}
|
||||
<tr class="border-b last:border-0">
|
||||
<td class="py-2 pr-4 font-mono text-xs">{header.key}</td>
|
||||
<td class="py-2 font-mono text-xs break-all"
|
||||
>{header.value}</td
|
||||
>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
{:else}
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<span class="text-sm text-muted-foreground"
|
||||
>Send a request to see the response</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
50
src/lib/components/ui/badge/badge.svelte
Normal file
50
src/lib/components/ui/badge/badge.svelte
Normal file
@@ -0,0 +1,50 @@
|
||||
<script lang="ts" module>
|
||||
import { type VariantProps, tv } from "tailwind-variants";
|
||||
|
||||
export const badgeVariants = tv({
|
||||
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-full border px-2 py-0.5 text-xs font-medium transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3",
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent",
|
||||
destructive:
|
||||
"bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white",
|
||||
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
});
|
||||
|
||||
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { HTMLAnchorAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
href,
|
||||
class: className,
|
||||
variant = "default",
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAnchorAttributes> & {
|
||||
variant?: BadgeVariant;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<svelte:element
|
||||
this={href ? "a" : "span"}
|
||||
bind:this={ref}
|
||||
data-slot="badge"
|
||||
{href}
|
||||
class={cn(badgeVariants({ variant }), className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</svelte:element>
|
||||
2
src/lib/components/ui/badge/index.ts
Normal file
2
src/lib/components/ui/badge/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as Badge } from "./badge.svelte";
|
||||
export { badgeVariants, type BadgeVariant } from "./badge.svelte";
|
||||
10
src/lib/components/ui/scroll-area/index.ts
Normal file
10
src/lib/components/ui/scroll-area/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import Scrollbar from "./scroll-area-scrollbar.svelte";
|
||||
import Root from "./scroll-area.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Scrollbar,
|
||||
//,
|
||||
Root as ScrollArea,
|
||||
Scrollbar as ScrollAreaScrollbar,
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
orientation = "vertical",
|
||||
children,
|
||||
...restProps
|
||||
}: WithoutChild<ScrollAreaPrimitive.ScrollbarProps> = $props();
|
||||
</script>
|
||||
|
||||
<ScrollAreaPrimitive.Scrollbar
|
||||
bind:ref
|
||||
data-slot="scroll-area-scrollbar"
|
||||
{orientation}
|
||||
class={cn(
|
||||
"flex touch-none select-none p-px transition-colors",
|
||||
orientation === "vertical" && "h-full w-2.5 border-s border-s-transparent",
|
||||
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
data-slot="scroll-area-thumb"
|
||||
class="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
43
src/lib/components/ui/scroll-area/scroll-area.svelte
Normal file
43
src/lib/components/ui/scroll-area/scroll-area.svelte
Normal file
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "bits-ui";
|
||||
import { Scrollbar } from "./index.js";
|
||||
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
viewportRef = $bindable(null),
|
||||
class: className,
|
||||
orientation = "vertical",
|
||||
scrollbarXClasses = "",
|
||||
scrollbarYClasses = "",
|
||||
children,
|
||||
...restProps
|
||||
}: WithoutChild<ScrollAreaPrimitive.RootProps> & {
|
||||
orientation?: "vertical" | "horizontal" | "both" | undefined;
|
||||
scrollbarXClasses?: string | undefined;
|
||||
scrollbarYClasses?: string | undefined;
|
||||
viewportRef?: HTMLElement | null;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<ScrollAreaPrimitive.Root
|
||||
bind:ref
|
||||
data-slot="scroll-area"
|
||||
class={cn("relative", className)}
|
||||
{...restProps}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
bind:ref={viewportRef}
|
||||
data-slot="scroll-area-viewport"
|
||||
class="ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] focus-visible:outline-1 focus-visible:ring-4"
|
||||
>
|
||||
{@render children?.()}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
{#if orientation === "vertical" || orientation === "both"}
|
||||
<Scrollbar orientation="vertical" class={scrollbarYClasses} />
|
||||
{/if}
|
||||
{#if orientation === "horizontal" || orientation === "both"}
|
||||
<Scrollbar orientation="horizontal" class={scrollbarXClasses} />
|
||||
{/if}
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
37
src/lib/components/ui/select/index.ts
Normal file
37
src/lib/components/ui/select/index.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
|
||||
import Group from "./select-group.svelte";
|
||||
import Label from "./select-label.svelte";
|
||||
import Item from "./select-item.svelte";
|
||||
import Content from "./select-content.svelte";
|
||||
import Trigger from "./select-trigger.svelte";
|
||||
import Separator from "./select-separator.svelte";
|
||||
import ScrollDownButton from "./select-scroll-down-button.svelte";
|
||||
import ScrollUpButton from "./select-scroll-up-button.svelte";
|
||||
import GroupHeading from "./select-group-heading.svelte";
|
||||
|
||||
const Root = SelectPrimitive.Root;
|
||||
|
||||
export {
|
||||
Root,
|
||||
Group,
|
||||
Label,
|
||||
Item,
|
||||
Content,
|
||||
Trigger,
|
||||
Separator,
|
||||
ScrollDownButton,
|
||||
ScrollUpButton,
|
||||
GroupHeading,
|
||||
//
|
||||
Root as Select,
|
||||
Group as SelectGroup,
|
||||
Label as SelectLabel,
|
||||
Item as SelectItem,
|
||||
Content as SelectContent,
|
||||
Trigger as SelectTrigger,
|
||||
Separator as SelectSeparator,
|
||||
ScrollDownButton as SelectScrollDownButton,
|
||||
ScrollUpButton as SelectScrollUpButton,
|
||||
GroupHeading as SelectGroupHeading,
|
||||
};
|
||||
42
src/lib/components/ui/select/select-content.svelte
Normal file
42
src/lib/components/ui/select/select-content.svelte
Normal file
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
import SelectScrollUpButton from "./select-scroll-up-button.svelte";
|
||||
import SelectScrollDownButton from "./select-scroll-down-button.svelte";
|
||||
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
sideOffset = 4,
|
||||
portalProps,
|
||||
children,
|
||||
preventScroll = true,
|
||||
...restProps
|
||||
}: WithoutChild<SelectPrimitive.ContentProps> & {
|
||||
portalProps?: SelectPrimitive.PortalProps;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.Portal {...portalProps}>
|
||||
<SelectPrimitive.Content
|
||||
bind:ref
|
||||
{sideOffset}
|
||||
{preventScroll}
|
||||
data-slot="select-content"
|
||||
class={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 max-h-(--bits-select-content-available-height) origin-(--bits-select-content-transform-origin) relative z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
class={cn(
|
||||
"h-(--bits-select-anchor-height) min-w-(--bits-select-anchor-width) w-full scroll-my-1 p-1"
|
||||
)}
|
||||
>
|
||||
{@render children?.()}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
21
src/lib/components/ui/select/select-group-heading.svelte
Normal file
21
src/lib/components/ui/select/select-group-heading.svelte
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { ComponentProps } from "svelte";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: ComponentProps<typeof SelectPrimitive.GroupHeading> = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.GroupHeading
|
||||
bind:ref
|
||||
data-slot="select-group-heading"
|
||||
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</SelectPrimitive.GroupHeading>
|
||||
7
src/lib/components/ui/select/select-group.svelte
Normal file
7
src/lib/components/ui/select/select-group.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: SelectPrimitive.GroupProps = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.Group data-slot="select-group" {...restProps} />
|
||||
38
src/lib/components/ui/select/select-item.svelte
Normal file
38
src/lib/components/ui/select/select-item.svelte
Normal file
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import CheckIcon from "@lucide/svelte/icons/check";
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
value,
|
||||
label,
|
||||
children: childrenProp,
|
||||
...restProps
|
||||
}: WithoutChild<SelectPrimitive.ItemProps> = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.Item
|
||||
bind:ref
|
||||
{value}
|
||||
data-slot="select-item"
|
||||
class={cn(
|
||||
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 pe-8 ps-2 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ selected, highlighted })}
|
||||
<span class="absolute end-2 flex size-3.5 items-center justify-center">
|
||||
{#if selected}
|
||||
<CheckIcon class="size-4" />
|
||||
{/if}
|
||||
</span>
|
||||
{#if childrenProp}
|
||||
{@render childrenProp({ selected, highlighted })}
|
||||
{:else}
|
||||
{label || value}
|
||||
{/if}
|
||||
{/snippet}
|
||||
</SelectPrimitive.Item>
|
||||
20
src/lib/components/ui/select/select-label.svelte
Normal file
20
src/lib/components/ui/select/select-label.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="select-label"
|
||||
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
bind:ref
|
||||
data-slot="select-scroll-down-button"
|
||||
class={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...restProps}
|
||||
>
|
||||
<ChevronDownIcon class="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
20
src/lib/components/ui/select/select-scroll-up-button.svelte
Normal file
20
src/lib/components/ui/select/select-scroll-up-button.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import ChevronUpIcon from "@lucide/svelte/icons/chevron-up";
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
bind:ref
|
||||
data-slot="select-scroll-up-button"
|
||||
class={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...restProps}
|
||||
>
|
||||
<ChevronUpIcon class="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
18
src/lib/components/ui/select/select-separator.svelte
Normal file
18
src/lib/components/ui/select/select-separator.svelte
Normal file
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import type { Separator as SeparatorPrimitive } from "bits-ui";
|
||||
import { Separator } from "$lib/components/ui/separator/index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: SeparatorPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<Separator
|
||||
bind:ref
|
||||
data-slot="select-separator"
|
||||
class={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
29
src/lib/components/ui/select/select-trigger.svelte
Normal file
29
src/lib/components/ui/select/select-trigger.svelte
Normal file
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
size = "default",
|
||||
...restProps
|
||||
}: WithoutChild<SelectPrimitive.TriggerProps> & {
|
||||
size?: "sm" | "default";
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.Trigger
|
||||
bind:ref
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
class={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 shadow-xs flex w-fit select-none items-center justify-between gap-2 whitespace-nowrap rounded-md border bg-transparent px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
<ChevronDownIcon class="size-4 opacity-50" />
|
||||
</SelectPrimitive.Trigger>
|
||||
1
src/lib/components/ui/spinner/index.ts
Normal file
1
src/lib/components/ui/spinner/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Spinner } from "./spinner.svelte";
|
||||
14
src/lib/components/ui/spinner/spinner.svelte
Normal file
14
src/lib/components/ui/spinner/spinner.svelte
Normal file
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import Loader2Icon from "@lucide/svelte/icons/loader-2";
|
||||
import type { ComponentProps } from "svelte";
|
||||
|
||||
let { class: className, ...restProps }: ComponentProps<typeof Loader2Icon> = $props();
|
||||
</script>
|
||||
|
||||
<Loader2Icon
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
class={cn("size-4 animate-spin", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
16
src/lib/components/ui/tabs/index.ts
Normal file
16
src/lib/components/ui/tabs/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import Root from "./tabs.svelte";
|
||||
import Content from "./tabs-content.svelte";
|
||||
import List from "./tabs-list.svelte";
|
||||
import Trigger from "./tabs-trigger.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
List,
|
||||
Trigger,
|
||||
//
|
||||
Root as Tabs,
|
||||
Content as TabsContent,
|
||||
List as TabsList,
|
||||
Trigger as TabsTrigger,
|
||||
};
|
||||
17
src/lib/components/ui/tabs/tabs-content.svelte
Normal file
17
src/lib/components/ui/tabs/tabs-content.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Tabs as TabsPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: TabsPrimitive.ContentProps = $props();
|
||||
</script>
|
||||
|
||||
<TabsPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="tabs-content"
|
||||
class={cn("flex-1 outline-none", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
20
src/lib/components/ui/tabs/tabs-list.svelte
Normal file
20
src/lib/components/ui/tabs/tabs-list.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Tabs as TabsPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: TabsPrimitive.ListProps = $props();
|
||||
</script>
|
||||
|
||||
<TabsPrimitive.List
|
||||
bind:ref
|
||||
data-slot="tabs-list"
|
||||
class={cn(
|
||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
20
src/lib/components/ui/tabs/tabs-trigger.svelte
Normal file
20
src/lib/components/ui/tabs/tabs-trigger.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Tabs as TabsPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: TabsPrimitive.TriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<TabsPrimitive.Trigger
|
||||
bind:ref
|
||||
data-slot="tabs-trigger"
|
||||
class={cn(
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-2 py-1 text-sm font-medium transition-[color,box-shadow] focus-visible:outline-1 focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
19
src/lib/components/ui/tabs/tabs.svelte
Normal file
19
src/lib/components/ui/tabs/tabs.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { Tabs as TabsPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(""),
|
||||
class: className,
|
||||
...restProps
|
||||
}: TabsPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<TabsPrimitive.Root
|
||||
bind:ref
|
||||
bind:value
|
||||
data-slot="tabs"
|
||||
class={cn("flex flex-col gap-2", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
7
src/lib/components/ui/textarea/index.ts
Normal file
7
src/lib/components/ui/textarea/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from "./textarea.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Textarea,
|
||||
};
|
||||
23
src/lib/components/ui/textarea/textarea.svelte
Normal file
23
src/lib/components/ui/textarea/textarea.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
|
||||
import type { HTMLTextareaAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(),
|
||||
class: className,
|
||||
"data-slot": dataSlot = "textarea",
|
||||
...restProps
|
||||
}: WithoutChildren<WithElementRef<HTMLTextareaAttributes>> = $props();
|
||||
</script>
|
||||
|
||||
<textarea
|
||||
bind:this={ref}
|
||||
data-slot={dataSlot}
|
||||
class={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 field-sizing-content shadow-xs flex min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
bind:value
|
||||
{...restProps}
|
||||
></textarea>
|
||||
167
src/lib/components/variable-input.svelte
Normal file
167
src/lib/components/variable-input.svelte
Normal file
@@ -0,0 +1,167 @@
|
||||
<script lang="ts">
|
||||
import { Input } from "$lib/components/ui/input/index.js";
|
||||
import type { ResolvedVariable } from "$lib/types/variable";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
class?: string;
|
||||
variables: ResolvedVariable[];
|
||||
oninput?: (value: string) => void;
|
||||
};
|
||||
|
||||
let {
|
||||
value = "",
|
||||
placeholder = "",
|
||||
class: className = "",
|
||||
variables = [],
|
||||
oninput,
|
||||
}: Props = $props();
|
||||
|
||||
let showSuggestions = $state(false);
|
||||
let filteredVariables = $state<ResolvedVariable[]>([]);
|
||||
let selectedIndex = $state(0);
|
||||
let inputElement: HTMLInputElement | null = $state(null);
|
||||
let cursorPosition = $state(0);
|
||||
|
||||
function findVariableContext(
|
||||
text: string,
|
||||
position: number
|
||||
): { start: number; query: string } | null {
|
||||
const beforeCursor = text.substring(0, position);
|
||||
const match = beforeCursor.match(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)?$/);
|
||||
if (match) {
|
||||
return {
|
||||
start: match.index! + 2,
|
||||
query: (match[1] || "").toLowerCase(),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function handleInput(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
cursorPosition = target.selectionStart || 0;
|
||||
|
||||
const context = findVariableContext(target.value, cursorPosition);
|
||||
if (context) {
|
||||
filteredVariables = variables.filter((v) =>
|
||||
v.name.toLowerCase().includes(context.query)
|
||||
);
|
||||
showSuggestions = filteredVariables.length > 0;
|
||||
selectedIndex = 0;
|
||||
} else {
|
||||
showSuggestions = false;
|
||||
}
|
||||
|
||||
if (oninput) {
|
||||
oninput(target.value);
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (!showSuggestions) return;
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
selectedIndex = Math.min(
|
||||
selectedIndex + 1,
|
||||
filteredVariables.length - 1
|
||||
);
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
selectedIndex = Math.max(selectedIndex - 1, 0);
|
||||
break;
|
||||
case "Enter":
|
||||
case "Tab":
|
||||
if (filteredVariables.length > 0) {
|
||||
e.preventDefault();
|
||||
insertVariable(filteredVariables[selectedIndex]);
|
||||
}
|
||||
break;
|
||||
case "Escape":
|
||||
showSuggestions = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function insertVariable(variable: ResolvedVariable) {
|
||||
const context = findVariableContext(value, cursorPosition);
|
||||
if (!context) return;
|
||||
|
||||
const before = value.substring(0, context.start);
|
||||
const after = value.substring(cursorPosition);
|
||||
const newValue = `${before}${variable.name}}}${after}`;
|
||||
|
||||
if (oninput) {
|
||||
oninput(newValue);
|
||||
}
|
||||
|
||||
showSuggestions = false;
|
||||
|
||||
setTimeout(() => {
|
||||
if (inputElement) {
|
||||
const newPosition = context.start + variable.name.length + 2;
|
||||
inputElement.setSelectionRange(newPosition, newPosition);
|
||||
inputElement.focus();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
setTimeout(() => {
|
||||
showSuggestions = false;
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function getScopeColor(scope: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
global: "text-purple-500",
|
||||
workspace: "text-blue-500",
|
||||
collection: "text-green-500",
|
||||
request: "text-orange-500",
|
||||
};
|
||||
return colors[scope] || "text-gray-500";
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative {className}">
|
||||
<Input
|
||||
bind:ref={inputElement}
|
||||
{value}
|
||||
{placeholder}
|
||||
oninput={handleInput}
|
||||
onkeydown={handleKeydown}
|
||||
onblur={handleBlur}
|
||||
class="font-mono text-sm"
|
||||
/>
|
||||
|
||||
{#if showSuggestions}
|
||||
<div
|
||||
class="absolute z-50 top-full left-0 right-0 mt-1 bg-popover border rounded-md shadow-lg max-h-48 overflow-auto"
|
||||
>
|
||||
{#each filteredVariables as variable, i (variable.name)}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-3 py-2 text-left text-sm hover:bg-accent flex items-center justify-between gap-2 {i ===
|
||||
selectedIndex
|
||||
? 'bg-accent'
|
||||
: ''}"
|
||||
onmousedown={() => insertVariable(variable)}
|
||||
>
|
||||
<span class="font-mono">
|
||||
{#if variable.isSecret}
|
||||
🔒
|
||||
{/if}
|
||||
{variable.name}
|
||||
</span>
|
||||
<span class="text-xs {getScopeColor(variable.scope)}"
|
||||
>{variable.scope}</span
|
||||
>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -17,7 +17,9 @@ const collections: Map<string, Collection> = new Map<string, Collection>([
|
||||
url: "https://api.example.com/users",
|
||||
headers: [],
|
||||
params: [],
|
||||
bodyType: "none",
|
||||
body: "",
|
||||
formData: [],
|
||||
collectionId: "col-1",
|
||||
workspaceId: "1",
|
||||
},
|
||||
@@ -30,7 +32,9 @@ const collections: Map<string, Collection> = new Map<string, Collection>([
|
||||
{ key: "Content-Type", value: "application/json", enabled: true },
|
||||
],
|
||||
params: [],
|
||||
bodyType: "json",
|
||||
body: '{"name": "John", "email": "john@example.com"}',
|
||||
formData: [],
|
||||
collectionId: "col-1",
|
||||
workspaceId: "1",
|
||||
},
|
||||
@@ -52,7 +56,9 @@ const collections: Map<string, Collection> = new Map<string, Collection>([
|
||||
url: "https://api.example.com/products",
|
||||
headers: [],
|
||||
params: [{ key: "limit", value: "10", enabled: true }],
|
||||
bodyType: "none",
|
||||
body: "",
|
||||
formData: [],
|
||||
collectionId: "col-2",
|
||||
workspaceId: "1",
|
||||
},
|
||||
@@ -71,7 +77,9 @@ const standaloneRequests: Map<string, Request> = new Map<string, Request>([
|
||||
url: "https://api.example.com/health",
|
||||
headers: [],
|
||||
params: [],
|
||||
bodyType: "none",
|
||||
body: "",
|
||||
formData: [],
|
||||
collectionId: null,
|
||||
workspaceId: "1",
|
||||
},
|
||||
|
||||
199
src/lib/services/variables.ts
Normal file
199
src/lib/services/variables.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import type {
|
||||
Variable,
|
||||
VariableScope,
|
||||
ResolvedVariable,
|
||||
} from "$lib/types/variable";
|
||||
|
||||
const variables: Map<string, Variable> = new Map<string, Variable>([
|
||||
[
|
||||
"var-1",
|
||||
{
|
||||
id: "var-1",
|
||||
name: "BASE_URL",
|
||||
value: "https://api.example.com",
|
||||
scope: "global",
|
||||
scopeId: null,
|
||||
isSecret: false,
|
||||
description: "Base URL for all API requests",
|
||||
},
|
||||
],
|
||||
[
|
||||
"var-2",
|
||||
{
|
||||
id: "var-2",
|
||||
name: "API_KEY",
|
||||
value: "sk-1234567890",
|
||||
scope: "global",
|
||||
scopeId: null,
|
||||
isSecret: true,
|
||||
description: "API authentication key",
|
||||
},
|
||||
],
|
||||
[
|
||||
"var-3",
|
||||
{
|
||||
id: "var-3",
|
||||
name: "USER_ID",
|
||||
value: "user-123",
|
||||
scope: "workspace",
|
||||
scopeId: "1",
|
||||
isSecret: false,
|
||||
description: "Default user ID for testing",
|
||||
},
|
||||
],
|
||||
[
|
||||
"var-4",
|
||||
{
|
||||
id: "var-4",
|
||||
name: "AUTH_TOKEN",
|
||||
value: "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9",
|
||||
scope: "workspace",
|
||||
scopeId: "1",
|
||||
isSecret: true,
|
||||
description: "Authentication token",
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
export async function get_all_variables(): Promise<Variable[]> {
|
||||
return [...variables.values()];
|
||||
}
|
||||
|
||||
export async function get_variables_by_scope(
|
||||
scope: VariableScope,
|
||||
scopeId: string | null
|
||||
): Promise<Variable[]> {
|
||||
return [...variables.values()].filter(
|
||||
(v) => v.scope === scope && v.scopeId === scopeId
|
||||
);
|
||||
}
|
||||
|
||||
export async function get_global_variables(): Promise<Variable[]> {
|
||||
return get_variables_by_scope("global", null);
|
||||
}
|
||||
|
||||
export async function get_workspace_variables(
|
||||
workspaceId: string
|
||||
): Promise<Variable[]> {
|
||||
return get_variables_by_scope("workspace", workspaceId);
|
||||
}
|
||||
|
||||
export async function get_collection_variables(
|
||||
collectionId: string
|
||||
): Promise<Variable[]> {
|
||||
return get_variables_by_scope("collection", collectionId);
|
||||
}
|
||||
|
||||
export async function get_request_variables(
|
||||
requestId: string
|
||||
): Promise<Variable[]> {
|
||||
return get_variables_by_scope("request", requestId);
|
||||
}
|
||||
|
||||
export async function get_resolved_variables(
|
||||
workspaceId: string,
|
||||
collectionId: string | null,
|
||||
requestId: string | null
|
||||
): Promise<ResolvedVariable[]> {
|
||||
const resolved: Map<string, ResolvedVariable> = new Map();
|
||||
|
||||
const globalVars = await get_global_variables();
|
||||
for (const v of globalVars) {
|
||||
resolved.set(v.name, {
|
||||
name: v.name,
|
||||
value: v.value,
|
||||
scope: v.scope,
|
||||
isSecret: v.isSecret,
|
||||
});
|
||||
}
|
||||
|
||||
const workspaceVars = await get_workspace_variables(workspaceId);
|
||||
for (const v of workspaceVars) {
|
||||
resolved.set(v.name, {
|
||||
name: v.name,
|
||||
value: v.value,
|
||||
scope: v.scope,
|
||||
isSecret: v.isSecret,
|
||||
});
|
||||
}
|
||||
|
||||
if (collectionId) {
|
||||
const collectionVars = await get_collection_variables(collectionId);
|
||||
for (const v of collectionVars) {
|
||||
resolved.set(v.name, {
|
||||
name: v.name,
|
||||
value: v.value,
|
||||
scope: v.scope,
|
||||
isSecret: v.isSecret,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (requestId) {
|
||||
const requestVars = await get_request_variables(requestId);
|
||||
for (const v of requestVars) {
|
||||
resolved.set(v.name, {
|
||||
name: v.name,
|
||||
value: v.value,
|
||||
scope: v.scope,
|
||||
isSecret: v.isSecret,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [...resolved.values()];
|
||||
}
|
||||
|
||||
export async function get_variable(id: string): Promise<Variable | undefined> {
|
||||
return variables.get(id);
|
||||
}
|
||||
|
||||
export async function create_variable(
|
||||
variable: Omit<Variable, "id">
|
||||
): Promise<Variable> {
|
||||
const newVariable: Variable = {
|
||||
...variable,
|
||||
id: crypto.randomUUID(),
|
||||
};
|
||||
variables.set(newVariable.id, newVariable);
|
||||
return newVariable;
|
||||
}
|
||||
|
||||
export async function update_variable(
|
||||
id: string,
|
||||
updates: Partial<Omit<Variable, "id">>
|
||||
): Promise<boolean> {
|
||||
const variable = variables.get(id);
|
||||
if (!variable) return false;
|
||||
|
||||
Object.assign(variable, updates);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function delete_variable(id: string): Promise<boolean> {
|
||||
return variables.delete(id);
|
||||
}
|
||||
|
||||
export function interpolate_variables(
|
||||
text: string,
|
||||
resolvedVariables: ResolvedVariable[]
|
||||
): string {
|
||||
let result = text;
|
||||
for (const variable of resolvedVariables) {
|
||||
const pattern = new RegExp(`\\{\\{\\s*${variable.name}\\s*\\}\\}`, "g");
|
||||
result = result.replace(pattern, variable.value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function extract_variable_names(text: string): string[] {
|
||||
const pattern = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g;
|
||||
const matches: string[] = [];
|
||||
let match;
|
||||
while ((match = pattern.exec(text)) !== null) {
|
||||
if (!matches.includes(match[1])) {
|
||||
matches.push(match[1]);
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
@@ -7,6 +7,15 @@ export type HttpMethod =
|
||||
| "HEAD"
|
||||
| "OPTIONS";
|
||||
|
||||
export type BodyType =
|
||||
| "none"
|
||||
| "json"
|
||||
| "xml"
|
||||
| "text"
|
||||
| "html"
|
||||
| "form-data"
|
||||
| "x-www-form-urlencoded";
|
||||
|
||||
export type RequestHeader = {
|
||||
key: string;
|
||||
value: string;
|
||||
@@ -19,6 +28,13 @@ export type RequestParam = {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export type FormDataItem = {
|
||||
key: string;
|
||||
value: string;
|
||||
type: "text" | "file";
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export type Request = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -26,7 +42,9 @@ export type Request = {
|
||||
url: string;
|
||||
headers: RequestHeader[];
|
||||
params: RequestParam[];
|
||||
bodyType: BodyType;
|
||||
body: string;
|
||||
formData: FormDataItem[];
|
||||
collectionId: string | null;
|
||||
workspaceId: string;
|
||||
};
|
||||
|
||||
14
src/lib/types/response.ts
Normal file
14
src/lib/types/response.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export type ResponseHeader = {
|
||||
key: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type Response = {
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: ResponseHeader[];
|
||||
body: string;
|
||||
contentType: string;
|
||||
duration: number;
|
||||
size: number;
|
||||
};
|
||||
18
src/lib/types/variable.ts
Normal file
18
src/lib/types/variable.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export type VariableScope = "global" | "workspace" | "collection" | "request";
|
||||
|
||||
export type Variable = {
|
||||
id: string;
|
||||
name: string;
|
||||
value: string;
|
||||
scope: VariableScope;
|
||||
scopeId: string | null;
|
||||
isSecret: boolean;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type ResolvedVariable = {
|
||||
name: string;
|
||||
value: string;
|
||||
scope: VariableScope;
|
||||
isSecret: boolean;
|
||||
};
|
||||
@@ -21,9 +21,13 @@
|
||||
update_request,
|
||||
delete_request,
|
||||
} from "$lib/services/collections";
|
||||
import { get_resolved_variables } from "$lib/services/variables";
|
||||
import type { Workspace } from "$lib/types/workspace";
|
||||
import type { Collection } from "$lib/types/collection";
|
||||
import type { Request, HttpMethod } from "$lib/types/request";
|
||||
import type { Response } from "$lib/types/response";
|
||||
import type { ResolvedVariable } from "$lib/types/variable";
|
||||
import ResponsePanel from "$lib/components/response-panel.svelte";
|
||||
|
||||
const { params } = $props<{ params: { id: string } }>();
|
||||
|
||||
@@ -31,6 +35,9 @@
|
||||
let collections = $state<Collection[]>([]);
|
||||
let standaloneRequests = $state<Request[]>([]);
|
||||
let selectedRequest = $state<Request | null>(null);
|
||||
let response = $state<Response | null>(null);
|
||||
let loading = $state(false);
|
||||
let resolvedVariables = $state<ResolvedVariable[]>([]);
|
||||
|
||||
let collectionDialogOpen = $state(false);
|
||||
let collectionDialogMode = $state<"create" | "edit">("create");
|
||||
@@ -59,6 +66,7 @@
|
||||
workspace = await get_workspace(params.id);
|
||||
collections = await get_collections_by_workspace(params.id);
|
||||
standaloneRequests = await get_standalone_requests_by_workspace(params.id);
|
||||
resolvedVariables = await get_resolved_variables(params.id, null, null);
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
@@ -67,6 +75,7 @@
|
||||
|
||||
function handleRequestSelect(request: Request) {
|
||||
selectedRequest = request;
|
||||
response = null;
|
||||
}
|
||||
|
||||
function handleCreateCollection() {
|
||||
@@ -138,7 +147,9 @@
|
||||
url: "",
|
||||
headers: [],
|
||||
params: [],
|
||||
bodyType: "none",
|
||||
body: "",
|
||||
formData: [],
|
||||
collectionId: requestCollectionId,
|
||||
workspaceId: params.id,
|
||||
});
|
||||
@@ -169,8 +180,59 @@
|
||||
deleteTarget = null;
|
||||
}
|
||||
|
||||
function handleSendRequest(request: Request) {
|
||||
console.log("Sending request:", request);
|
||||
async function handleSendRequest(request: Request) {
|
||||
loading = true;
|
||||
response = null;
|
||||
|
||||
// Simulate API request (will be replaced with actual Tauri call later)
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// Mock response for now
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, 500 + Math.random() * 1000)
|
||||
);
|
||||
|
||||
const mockBody = JSON.stringify(
|
||||
{
|
||||
success: true,
|
||||
message: "This is a mock response",
|
||||
data: {
|
||||
id: 1,
|
||||
name: "Example",
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
|
||||
response = {
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: [
|
||||
{ key: "Content-Type", value: "application/json" },
|
||||
{ key: "X-Request-Id", value: crypto.randomUUID() },
|
||||
{ key: "Cache-Control", value: "no-cache" },
|
||||
],
|
||||
body: mockBody,
|
||||
contentType: "application/json",
|
||||
duration: Date.now() - startTime,
|
||||
size: new Blob([mockBody]).size,
|
||||
};
|
||||
} catch (error) {
|
||||
response = {
|
||||
status: 500,
|
||||
statusText: "Error",
|
||||
headers: [],
|
||||
body: JSON.stringify({ error: "Request failed" }),
|
||||
contentType: "application/json",
|
||||
duration: Date.now() - startTime,
|
||||
size: 0,
|
||||
};
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdateRequest(request: Request) {
|
||||
@@ -220,13 +282,21 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<div class="flex-1 overflow-hidden flex flex-col">
|
||||
{#if selectedRequest}
|
||||
<RequestPanel
|
||||
request={selectedRequest}
|
||||
onSend={handleSendRequest}
|
||||
onUpdate={handleUpdateRequest}
|
||||
/>
|
||||
<div class="flex-1 min-h-0 flex flex-col">
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
<RequestPanel
|
||||
request={selectedRequest}
|
||||
variables={resolvedVariables}
|
||||
onSend={handleSendRequest}
|
||||
onUpdate={handleUpdateRequest}
|
||||
/>
|
||||
</div>
|
||||
<div class="h-[300px] min-h-[200px] border-t">
|
||||
<ResponsePanel {response} {loading} />
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<Empty.Root class="flex h-full items-center justify-center">
|
||||
<Empty.Header>
|
||||
|
||||
@@ -8,6 +8,15 @@ const host = process.env.TAURI_DEV_HOST;
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
optimizeDeps: {
|
||||
exclude: [
|
||||
"svelte-codemirror-editor",
|
||||
"codemirror",
|
||||
"@codemirror/lang-json",
|
||||
"@codemirror/lang-xml",
|
||||
"@codemirror/lang-html",
|
||||
],
|
||||
},
|
||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||
//
|
||||
// 1. prevent vite from obscuring rust errors
|
||||
@@ -18,7 +27,9 @@ export default defineConfig(async () => ({
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host ? { protocol: "ws", host, port: 1421 } : undefined,
|
||||
watch: { // 3. tell vite to ignore watching `src-tauri`
|
||||
ignored: ["**/src-tauri/**"] }
|
||||
}
|
||||
watch: {
|
||||
// 3. tell vite to ignore watching `src-tauri`
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user