Added multiple components, added CodeMirror for body editor and added variable substitution for params and headers

This commit is contained in:
xyroscar
2025-11-24 16:46:02 -08:00
parent 653a23f805
commit e2a7761388
37 changed files with 1596 additions and 173 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -13,8 +13,12 @@
}, },
"license": "MIT", "license": "MIT",
"dependencies": { "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/api": "^2",
"@tauri-apps/plugin-opener": "^2" "@tauri-apps/plugin-opener": "^2",
"svelte-codemirror-editor": "^2.1.0"
}, },
"devDependencies": { "devDependencies": {
"@internationalized/date": "^3.8.1", "@internationalized/date": "^3.8.1",

View 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>

View File

@@ -2,20 +2,26 @@
import { Button } from "$lib/components/ui/button/index.js"; import { Button } from "$lib/components/ui/button/index.js";
import { Input } from "$lib/components/ui/input/index.js"; import { Input } from "$lib/components/ui/input/index.js";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/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 SendIcon from "@lucide/svelte/icons/send";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down"; 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 = { type Props = {
request: Request; request: Request;
variables?: ResolvedVariable[];
onSend: (request: Request) => void; onSend: (request: Request) => void;
onUpdate: (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 localRequest = $state<Request>({ ...request });
let activeTab = $state<"params" | "headers" | "body">("params");
$effect(() => { $effect(() => {
localRequest = { ...request }; localRequest = { ...request };
@@ -30,6 +36,15 @@
"HEAD", "HEAD",
"OPTIONS", "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 { function getMethodColor(method: string): string {
const colors: Record<string, string> = { const colors: Record<string, string> = {
@@ -49,15 +64,39 @@
onUpdate(localRequest); onUpdate(localRequest);
} }
function handleUrlChange(e: Event) { function handleUrlChange(value: string) {
const target = e.target as HTMLInputElement; localRequest.url = value;
localRequest.url = target.value; onUpdate(localRequest);
}
function handleBodyTypeChange(value: BodyType) {
localRequest.bodyType = value;
onUpdate(localRequest);
}
function handleBodyChange(value: string) {
localRequest.body = value;
onUpdate(localRequest); onUpdate(localRequest);
} }
function handleSend() { function handleSend() {
onSend(localRequest); 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> </script>
<div class="flex flex-col h-full"> <div class="flex flex-col h-full">
@@ -89,10 +128,11 @@
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Root> </DropdownMenu.Root>
<Input <VariableInput
class="flex-1 font-mono text-sm" class="flex-1"
placeholder="Enter request URL" placeholder={"Enter request URL (use {{VAR_NAME}} for variables)"}
value={localRequest.url} value={localRequest.url}
{variables}
oninput={handleUrlChange} oninput={handleUrlChange}
/> />
@@ -103,70 +143,57 @@
</div> </div>
</div> </div>
<div class="flex-1 flex flex-col overflow-hidden"> <Tabs.Root value="params" class="flex-1 flex flex-col overflow-hidden">
<div class="border-b"> <Tabs.List
<div class="flex"> class="w-full justify-start rounded-none border-b bg-transparent p-0 h-auto"
<button >
class={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${ <Tabs.Trigger
activeTab === "params" value="params"
? "border-primary text-primary" class="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
onclick={() => (activeTab = "params")}
> >
Params Params
</button> </Tabs.Trigger>
<button <Tabs.Trigger
class={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${ value="headers"
activeTab === "headers" class="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
onclick={() => (activeTab = "headers")}
> >
Headers Headers
</button> </Tabs.Trigger>
<button <Tabs.Trigger
class={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${ value="body"
activeTab === "body" class="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
onclick={() => (activeTab = "body")}
> >
Body Body
</button> </Tabs.Trigger>
</div> </Tabs.List>
</div>
<div class="flex-1 p-4 overflow-auto"> <Tabs.Content value="params" class="flex-1 m-0 p-4 overflow-auto">
{#if activeTab === "params"}
<div class="space-y-2"> <div class="space-y-2">
{#each localRequest.params as param, i (i)} {#each localRequest.params as param, i (i)}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Input <VariableInput
class="flex-1" class="flex-1"
placeholder="Key" placeholder="Key"
value={param.key} value={param.key}
oninput={(e) => { {variables}
const target = e.target as HTMLInputElement; oninput={(value) => {
localRequest.params[i].key = target.value; localRequest.params[i].key = value;
onUpdate(localRequest); onUpdate(localRequest);
}} }}
/> />
<Input <VariableInput
class="flex-1" class="flex-1"
placeholder="Value" placeholder="Value"
value={param.value} value={param.value}
oninput={(e) => { {variables}
const target = e.target as HTMLInputElement; oninput={(value) => {
localRequest.params[i].value = target.value; localRequest.params[i].value = value;
onUpdate(localRequest); onUpdate(localRequest);
}} }}
/> />
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="icon"
onclick={() => { onclick={() => {
localRequest.params = localRequest.params.filter( localRequest.params = localRequest.params.filter(
(_, idx) => idx !== i (_, idx) => idx !== i
@@ -174,7 +201,7 @@
onUpdate(localRequest); onUpdate(localRequest);
}} }}
> >
× <XIcon class="size-4" />
</Button> </Button>
</div> </div>
{/each} {/each}
@@ -192,33 +219,35 @@
Add Parameter Add Parameter
</Button> </Button>
</div> </div>
{:else if activeTab === "headers"} </Tabs.Content>
<Tabs.Content value="headers" class="flex-1 m-0 p-4 overflow-auto">
<div class="space-y-2"> <div class="space-y-2">
{#each localRequest.headers as header, i (i)} {#each localRequest.headers as header, i (i)}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Input <VariableInput
class="flex-1" class="flex-1"
placeholder="Key" placeholder="Key"
value={header.key} value={header.key}
oninput={(e) => { {variables}
const target = e.target as HTMLInputElement; oninput={(value) => {
localRequest.headers[i].key = target.value; localRequest.headers[i].key = value;
onUpdate(localRequest); onUpdate(localRequest);
}} }}
/> />
<Input <VariableInput
class="flex-1" class="flex-1"
placeholder="Value" placeholder="Value"
value={header.value} value={header.value}
oninput={(e) => { {variables}
const target = e.target as HTMLInputElement; oninput={(value) => {
localRequest.headers[i].value = target.value; localRequest.headers[i].value = value;
onUpdate(localRequest); onUpdate(localRequest);
}} }}
/> />
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="icon"
onclick={() => { onclick={() => {
localRequest.headers = localRequest.headers.filter( localRequest.headers = localRequest.headers.filter(
(_, idx) => idx !== i (_, idx) => idx !== i
@@ -226,7 +255,7 @@
onUpdate(localRequest); onUpdate(localRequest);
}} }}
> >
× <XIcon class="size-4" />
</Button> </Button>
</div> </div>
{/each} {/each}
@@ -244,18 +273,99 @@
Add Header Add Header
</Button> </Button>
</div> </div>
{:else if activeTab === "body"} </Tabs.Content>
<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" <Tabs.Content value="body" class="flex-1 m-0 flex flex-col overflow-hidden">
placeholder="Request body (JSON, XML, etc.)" <div class="flex items-center gap-2 p-2 border-b bg-muted/30">
value={localRequest.body} <span class="text-sm text-muted-foreground">Content Type:</span>
oninput={(e) => { <Select.Root
const target = e.target as HTMLTextAreaElement; type="single"
localRequest.body = target.value; value={localRequest.bodyType}
onUpdate(localRequest); onValueChange={(value) => handleBodyTypeChange(value as BodyType)}
}} >
></textarea> <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} {/if}
</div> </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> </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> </div>

View 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>

View 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>

View File

@@ -0,0 +1,2 @@
export { default as Badge } from "./badge.svelte";
export { badgeVariants, type BadgeVariant } from "./badge.svelte";

View 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,
};

View File

@@ -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>

View 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>

View 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,
};

View 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>

View 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>

View 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} />

View 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>

View 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>

View File

@@ -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>

View 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>

View 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}
/>

View 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>

View File

@@ -0,0 +1 @@
export { default as Spinner } from "./spinner.svelte";

View 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}
/>

View 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,
};

View 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}
/>

View 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}
/>

View 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}
/>

View 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}
/>

View File

@@ -0,0 +1,7 @@
import Root from "./textarea.svelte";
export {
Root,
//
Root as Textarea,
};

View 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>

View 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>

View File

@@ -17,7 +17,9 @@ const collections: Map<string, Collection> = new Map<string, Collection>([
url: "https://api.example.com/users", url: "https://api.example.com/users",
headers: [], headers: [],
params: [], params: [],
bodyType: "none",
body: "", body: "",
formData: [],
collectionId: "col-1", collectionId: "col-1",
workspaceId: "1", workspaceId: "1",
}, },
@@ -30,7 +32,9 @@ const collections: Map<string, Collection> = new Map<string, Collection>([
{ key: "Content-Type", value: "application/json", enabled: true }, { key: "Content-Type", value: "application/json", enabled: true },
], ],
params: [], params: [],
bodyType: "json",
body: '{"name": "John", "email": "john@example.com"}', body: '{"name": "John", "email": "john@example.com"}',
formData: [],
collectionId: "col-1", collectionId: "col-1",
workspaceId: "1", workspaceId: "1",
}, },
@@ -52,7 +56,9 @@ const collections: Map<string, Collection> = new Map<string, Collection>([
url: "https://api.example.com/products", url: "https://api.example.com/products",
headers: [], headers: [],
params: [{ key: "limit", value: "10", enabled: true }], params: [{ key: "limit", value: "10", enabled: true }],
bodyType: "none",
body: "", body: "",
formData: [],
collectionId: "col-2", collectionId: "col-2",
workspaceId: "1", workspaceId: "1",
}, },
@@ -71,7 +77,9 @@ const standaloneRequests: Map<string, Request> = new Map<string, Request>([
url: "https://api.example.com/health", url: "https://api.example.com/health",
headers: [], headers: [],
params: [], params: [],
bodyType: "none",
body: "", body: "",
formData: [],
collectionId: null, collectionId: null,
workspaceId: "1", workspaceId: "1",
}, },

View 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;
}

View File

@@ -7,6 +7,15 @@ export type HttpMethod =
| "HEAD" | "HEAD"
| "OPTIONS"; | "OPTIONS";
export type BodyType =
| "none"
| "json"
| "xml"
| "text"
| "html"
| "form-data"
| "x-www-form-urlencoded";
export type RequestHeader = { export type RequestHeader = {
key: string; key: string;
value: string; value: string;
@@ -19,6 +28,13 @@ export type RequestParam = {
enabled: boolean; enabled: boolean;
}; };
export type FormDataItem = {
key: string;
value: string;
type: "text" | "file";
enabled: boolean;
};
export type Request = { export type Request = {
id: string; id: string;
name: string; name: string;
@@ -26,7 +42,9 @@ export type Request = {
url: string; url: string;
headers: RequestHeader[]; headers: RequestHeader[];
params: RequestParam[]; params: RequestParam[];
bodyType: BodyType;
body: string; body: string;
formData: FormDataItem[];
collectionId: string | null; collectionId: string | null;
workspaceId: string; workspaceId: string;
}; };

14
src/lib/types/response.ts Normal file
View 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
View 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;
};

View File

@@ -21,9 +21,13 @@
update_request, update_request,
delete_request, delete_request,
} from "$lib/services/collections"; } from "$lib/services/collections";
import { get_resolved_variables } from "$lib/services/variables";
import type { Workspace } from "$lib/types/workspace"; import type { Workspace } from "$lib/types/workspace";
import type { Collection } from "$lib/types/collection"; import type { Collection } from "$lib/types/collection";
import type { Request, HttpMethod } from "$lib/types/request"; 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 } }>(); const { params } = $props<{ params: { id: string } }>();
@@ -31,6 +35,9 @@
let collections = $state<Collection[]>([]); let collections = $state<Collection[]>([]);
let standaloneRequests = $state<Request[]>([]); let standaloneRequests = $state<Request[]>([]);
let selectedRequest = $state<Request | null>(null); 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 collectionDialogOpen = $state(false);
let collectionDialogMode = $state<"create" | "edit">("create"); let collectionDialogMode = $state<"create" | "edit">("create");
@@ -59,6 +66,7 @@
workspace = await get_workspace(params.id); workspace = await get_workspace(params.id);
collections = await get_collections_by_workspace(params.id); collections = await get_collections_by_workspace(params.id);
standaloneRequests = await get_standalone_requests_by_workspace(params.id); standaloneRequests = await get_standalone_requests_by_workspace(params.id);
resolvedVariables = await get_resolved_variables(params.id, null, null);
} }
function goBack() { function goBack() {
@@ -67,6 +75,7 @@
function handleRequestSelect(request: Request) { function handleRequestSelect(request: Request) {
selectedRequest = request; selectedRequest = request;
response = null;
} }
function handleCreateCollection() { function handleCreateCollection() {
@@ -138,7 +147,9 @@
url: "", url: "",
headers: [], headers: [],
params: [], params: [],
bodyType: "none",
body: "", body: "",
formData: [],
collectionId: requestCollectionId, collectionId: requestCollectionId,
workspaceId: params.id, workspaceId: params.id,
}); });
@@ -169,8 +180,59 @@
deleteTarget = null; deleteTarget = null;
} }
function handleSendRequest(request: Request) { async function handleSendRequest(request: Request) {
console.log("Sending 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) { async function handleUpdateRequest(request: Request) {
@@ -220,13 +282,21 @@
</div> </div>
</header> </header>
<div class="flex-1 overflow-hidden"> <div class="flex-1 overflow-hidden flex flex-col">
{#if selectedRequest} {#if selectedRequest}
<div class="flex-1 min-h-0 flex flex-col">
<div class="flex-1 min-h-0 overflow-hidden">
<RequestPanel <RequestPanel
request={selectedRequest} request={selectedRequest}
variables={resolvedVariables}
onSend={handleSendRequest} onSend={handleSendRequest}
onUpdate={handleUpdateRequest} onUpdate={handleUpdateRequest}
/> />
</div>
<div class="h-[300px] min-h-[200px] border-t">
<ResponsePanel {response} {loading} />
</div>
</div>
{:else} {:else}
<Empty.Root class="flex h-full items-center justify-center"> <Empty.Root class="flex h-full items-center justify-center">
<Empty.Header> <Empty.Header>

View File

@@ -8,6 +8,15 @@ const host = process.env.TAURI_DEV_HOST;
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig(async () => ({ export default defineConfig(async () => ({
plugins: [tailwindcss(), sveltekit()], 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` // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
// //
// 1. prevent vite from obscuring rust errors // 1. prevent vite from obscuring rust errors
@@ -18,7 +27,9 @@ export default defineConfig(async () => ({
strictPort: true, strictPort: true,
host: host || false, host: host || false,
hmr: host ? { protocol: "ws", host, port: 1421 } : undefined, hmr: host ? { protocol: "ws", host, port: 1421 } : undefined,
watch: { // 3. tell vite to ignore watching `src-tauri` watch: {
ignored: ["**/src-tauri/**"] } // 3. tell vite to ignore watching `src-tauri`
} ignored: ["**/src-tauri/**"],
},
},
})); }));