Переглянути джерело

add resizable response pane

biblius 2 тижнів тому
батько
коміт
57cbcef8b2

+ 69 - 0
package-lock.json

@@ -39,6 +39,7 @@
         "@tailwindcss/vite": "^4.1.17",
         "@tauri-apps/cli": "^2.9.6",
         "bits-ui": "^2.14.4",
+        "paneforge": "^1.0.2",
         "prettier-plugin-svelte": "^3.4.0",
         "svelte": "^5.0.0",
         "svelte-check": "^4.0.0",
@@ -2498,6 +2499,74 @@
         "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
       }
     },
+    "node_modules/paneforge": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/paneforge/-/paneforge-1.0.2.tgz",
+      "integrity": "sha512-KzmIXQH1wCfwZ4RsMohD/IUtEjVhteR+c+ulb/CHYJHX8SuDXoJmChtsc/Xs5Wl8NHS4L5Q7cxL8MG40gSU1bA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "runed": "^0.23.4",
+        "svelte-toolbelt": "^0.9.2"
+      },
+      "peerDependencies": {
+        "svelte": "^5.29.0"
+      }
+    },
+    "node_modules/paneforge/node_modules/runed": {
+      "version": "0.23.4",
+      "resolved": "https://registry.npmjs.org/runed/-/runed-0.23.4.tgz",
+      "integrity": "sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA==",
+      "dev": true,
+      "funding": [
+        "https://github.com/sponsors/huntabyte",
+        "https://github.com/sponsors/tglide"
+      ],
+      "dependencies": {
+        "esm-env": "^1.0.0"
+      },
+      "peerDependencies": {
+        "svelte": "^5.7.0"
+      }
+    },
+    "node_modules/paneforge/node_modules/svelte-toolbelt": {
+      "version": "0.9.3",
+      "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.9.3.tgz",
+      "integrity": "sha512-HCSWxCtVmv+c6g1ACb8LTwHVbDqLKJvHpo6J8TaqwUme2hj9ATJCpjCPNISR1OCq2Q4U1KT41if9ON0isINQZw==",
+      "dev": true,
+      "funding": [
+        "https://github.com/sponsors/huntabyte"
+      ],
+      "dependencies": {
+        "clsx": "^2.1.1",
+        "runed": "^0.29.0",
+        "style-to-object": "^1.0.8"
+      },
+      "engines": {
+        "node": ">=18",
+        "pnpm": ">=8.7.0"
+      },
+      "peerDependencies": {
+        "svelte": "^5.30.2"
+      }
+    },
+    "node_modules/paneforge/node_modules/svelte-toolbelt/node_modules/runed": {
+      "version": "0.29.2",
+      "resolved": "https://registry.npmjs.org/runed/-/runed-0.29.2.tgz",
+      "integrity": "sha512-0cq6cA6sYGZwl/FvVqjx9YN+1xEBu9sDDyuWdDW1yWX7JF2wmvmVKfH+hVCZs+csW+P3ARH92MjI3H9QTagOQA==",
+      "dev": true,
+      "funding": [
+        "https://github.com/sponsors/huntabyte",
+        "https://github.com/sponsors/tglide"
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "esm-env": "^1.0.0"
+      },
+      "peerDependencies": {
+        "svelte": "^5.7.0"
+      }
+    },
     "node_modules/picocolors": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",

+ 1 - 0
package.json

@@ -43,6 +43,7 @@
     "@tailwindcss/vite": "^4.1.17",
     "@tauri-apps/cli": "^2.9.6",
     "bits-ui": "^2.14.4",
+    "paneforge": "^1.0.2",
     "prettier-plugin-svelte": "^3.4.0",
     "svelte": "^5.0.0",
     "svelte-check": "^4.0.0",

+ 209 - 207
src/lib/components/WorkspaceEntry.svelte

@@ -22,6 +22,7 @@
   import { atelierForest } from "svelte-highlight/styles";
   import { Loader, PlusIcon, TrashIcon } from "@lucide/svelte";
   import CodeMirror from "./CodeMirror.svelte";
+  import * as Resizable from "$lib/components/ui/resizable/index";
 
   let isSending = $state(false);
   let response: any = $state();
@@ -144,244 +145,245 @@
   </div>
 {/snippet}
 
-<main class="w-full h-full p-4 space-y-4">
-  {#if _state.entry?.type === "Collection"}
-    <!-- COLLECTION VIEW -->
+{#if _state.entry?.type === "Collection"}
+  <!-- COLLECTION VIEW -->
 
-    {@render entryPath()}
-    <section class="space-y-4">
-      <h1 class="text-xl font-semibold">{_state.entry.name}</h1>
+  {@render entryPath()}
+  <section class="space-y-4">
+    <h1 class="text-xl font-semibold">{_state.entry.name}</h1>
 
-      <div class="rounded-md border p-4 space-y-2">
-        <h2 class="font-medium">Variables</h2>
+    <div class="rounded-md p-4 space-y-2">
+      <h2 class="font-medium">Variables</h2>
 
-        <div class="grid grid-cols-3 gap-2 text-sm">
-          <div class="font-medium text-muted-foreground">Key</div>
-          <div class="font-medium text-muted-foreground col-span-2">Value</div>
+      <div class="grid grid-cols-3 gap-2 text-sm">
+        <div class="font-medium text-muted-foreground">Key</div>
+        <div class="font-medium text-muted-foreground col-span-2">Value</div>
 
-          <div>baseUrl</div>
-          <div class="col-span-2">https://api.example.com</div>
+        <div>baseUrl</div>
+        <div class="col-span-2">https://api.example.com</div>
 
-          <div>token</div>
-          <div class="col-span-2">••••••••</div>
-        </div>
+        <div>token</div>
+        <div class="col-span-2">••••••••</div>
       </div>
-    </section>
-  {:else if _state.entry?.type === "Request"}
-    <!-- REQUEST WORK AREA -->
-
-    {@render entryPath()}
-
-    <section class="space-y-4">
-      <!-- URL BAR -->
-
-      <div class="flex flex-wrap gap-3">
-        <Input
-          class="w-10/12 flex font-mono"
-          bind:value={_state.entry.url}
-          placeholder="https://api.example.com/resource"
-          onblur={() => {
-            handleUrlUpdate(true);
-          }}
-        />
-
-        <Button
-          class="w-1/12 flex items-center justify-center gap-2"
-          disabled={isSending}
-          onclick={handleSendRequest}
-        >
-          {#if isSending}
-            <Loader class="h-4 w-4 animate-spin" />
-            Sending
-          {:else}
-            Send
-          {/if}
-        </Button>
+    </div>
+  </section>
+{:else if _state.entry?.type === "Request"}
+  <!-- REQUEST WORK AREA -->
+
+  {@render entryPath()}
+
+  <section class="h-[90%] space-y-4">
+    <!-- URL BAR -->
+
+    <div class="flex flex-wrap gap-3 mx-auto">
+      <Input
+        class="w-10/12 flex font-mono"
+        bind:value={_state.entry.url}
+        placeholder="https://api.example.com/resource"
+        oninput={() => {
+          handleUrlUpdate(true);
+        }}
+      />
+
+      <Button
+        class="w-1/12 flex items-center justify-center gap-2"
+        disabled={isSending}
+        onclick={handleSendRequest}
+      >
+        {#if isSending}
+          <Loader class="h-4 w-4 animate-spin" />
+          Sending
+        {:else}
+          Send
+        {/if}
+      </Button>
 
-        <p class="w-full pl-1 text-xs text-muted-foreground">
-          {_state.entry.expandedUrl ?? ""}
-        </p>
-      </div>
+      <p class="w-full pl-1 text-xs text-muted-foreground">
+        {_state.entry.expandedUrl ?? ""}
+      </p>
+    </div>
 
-      <!-- COLLAPSIBLE SECTIONS -->
+    <!-- ================= REQUEST PANEL ================= -->
 
-      <Accordion.Root
-        type="multiple"
-        value={["auth", "params", "headers", "body", "response"]}
-        class="w-full"
-      >
-        <!-- URL PARAMS -->
+    <Resizable.PaneGroup direction="vertical" class="flex-1 w-full rounded-lg">
+      <Resizable.Pane defaultSize={100}>
+        <Accordion.Root
+          type="multiple"
+          value={["auth", "params", "headers", "body"]}
+        >
+          <!-- URL PARAMS -->
 
-        {#if _state.entry.path.length > 0 || _state.entry.workingUrl?.query_params?.length > 0}
-          <Accordion.Item value="params">
-            <Accordion.Trigger class="transition-none!"
-              >Parameters</Accordion.Trigger
-            >
+          {#if _state.entry.path.length > 0 || _state.entry.workingUrl?.query_params?.length > 0}
+            <Accordion.Item value="params">
+              <Accordion.Trigger class="transition-none!"
+                >Parameters</Accordion.Trigger
+              >
 
-            <!-- PATH PARAMS -->
+              <!-- PATH PARAMS -->
 
-            <Accordion.Content
-              class="flex-col justify-center items-center space-y-4 "
-            >
-              <div class="flex flex-wrap">
-                <h3 class="w-full mb-2 text-sm font-medium">Path</h3>
-                <div class="w-1/2 grid grid-cols-2 gap-2 text-sm">
-                  {#each _state.entry.path as param}
-                    <Input
-                      bind:value={param.name}
-                      placeholder="key"
-                      oninput={() => handleUrlUpdate()}
-                    />
-                    <Input
-                      bind:value={param.value}
-                      placeholder="value"
-                      oninput={() => handleUrlUpdate()}
-                    />
-                  {/each}
+              <Accordion.Content
+                class="flex-col justify-center items-center space-y-4 "
+              >
+                <div class="flex flex-wrap">
+                  <h3 class="w-full mb-2 text-sm font-medium">Path</h3>
+                  <div class="w-1/2 grid grid-cols-2 gap-2 text-sm">
+                    {#each _state.entry.path as param}
+                      <Input
+                        bind:value={param.name}
+                        placeholder="key"
+                        oninput={() => handleUrlUpdate()}
+                      />
+                      <Input
+                        bind:value={param.value}
+                        placeholder="value"
+                        oninput={() => handleUrlUpdate()}
+                      />
+                    {/each}
+                  </div>
                 </div>
-              </div>
-
-              <!-- QUERY PARAMS -->
 
-              {#if _state.entry.workingUrl?.query_params.length > 0}
-                <h3 class="w-full border-b mb-2 text-sm font-medium">Query</h3>
-                <div class="grid items-center grid-cols-2 gap-2 text-sm">
-                  {#each _state.entry.workingUrl!!.query_params as param}
-                    <Input
-                      bind:value={param[0]}
-                      placeholder="key"
-                      oninput={() => handleUrlUpdate()}
-                    />
-                    <Input
-                      bind:value={param[1]}
-                      placeholder="value"
-                      oninput={() => handleUrlUpdate()}
-                    />
-                  {/each}
-                </div>
-              {/if}
-            </Accordion.Content>
-          </Accordion.Item>
-        {/if}
+                <!-- QUERY PARAMS -->
+
+                {#if _state.entry.workingUrl?.query_params.length > 0}
+                  <h3 class="w-full mb-2 text-sm font-medium">Query</h3>
+                  <div class="grid items-center grid-cols-2 gap-2 text-sm">
+                    {#each _state.entry.workingUrl!!.query_params as param}
+                      <Input
+                        bind:value={param[0]}
+                        placeholder="key"
+                        oninput={() => handleUrlUpdate()}
+                      />
+                      <Input
+                        bind:value={param[1]}
+                        placeholder="value"
+                        oninput={() => handleUrlUpdate()}
+                      />
+                    {/each}
+                  </div>
+                {/if}
+              </Accordion.Content>
+            </Accordion.Item>
+          {/if}
 
-        <!-- HEADERS -->
-
-        <Accordion.Item value="headers">
-          <Accordion.Trigger>Headers</Accordion.Trigger>
-          <Accordion.Content>
-            <div class="grid grid-cols-3 gap-2 text-sm">
-              {#each _state.entry.headers as header}
-                <div class="contents">
-                  <Input
-                    class="w-full"
-                    bind:value={header.name}
-                    placeholder="Name"
-                    oninput={() =>
-                      updateHeader(header.id, header.name, header.value)}
-                  />
+          <!-- HEADERS -->
 
-                  <div class="flex gap-2 col-span-2 items-center">
+          <Accordion.Item value="headers">
+            <Accordion.Trigger>Headers</Accordion.Trigger>
+            <Accordion.Content>
+              <div class="grid grid-cols-3 gap-2 text-sm">
+                {#each _state.entry.headers as header}
+                  <div class="contents">
                     <Input
-                      bind:value={header.value}
-                      placeholder="Value"
-                      class="flex-1"
+                      class="w-full"
+                      bind:value={header.name}
+                      placeholder="Name"
                       oninput={() =>
                         updateHeader(header.id, header.name, header.value)}
                     />
 
-                    <TrashIcon
-                      class="h-4 w-4 cursor-pointer text-muted-foreground hover:text-destructive"
-                      onclick={() => deleteHeader(header.id)}
-                    />
+                    <div class="flex gap-2 col-span-2 items-center">
+                      <Input
+                        bind:value={header.value}
+                        placeholder="Value"
+                        class="flex-1"
+                        oninput={() =>
+                          updateHeader(header.id, header.name, header.value)}
+                      />
+
+                      <TrashIcon
+                        class="h-4 w-4 cursor-pointer text-muted-foreground hover:text-destructive"
+                        onclick={() => deleteHeader(header.id)}
+                      />
+                    </div>
                   </div>
-                </div>
-              {/each}
-              <PlusIcon
-                class="col-span-3 mt-2 cursor-pointer text-muted-foreground hover:text-primary"
-                onclick={() => insertHeader()}
-              />
-            </div>
-          </Accordion.Content>
-        </Accordion.Item>
-
-        <!-- BODY -->
-
-        <Accordion.Item value="body">
-          <Accordion.Trigger>Body</Accordion.Trigger>
-          <Accordion.Content class="space-y-4">
-            <Tabs.Root value={_state.entry.body === null ? "none" : "json"}>
-              <Tabs.List>
-                <Tabs.Trigger value="none" onclick={() => deleteBody()}
-                  >None</Tabs.Trigger
-                >
-                <Tabs.Trigger value="json">JSON</Tabs.Trigger>
-                <Tabs.Trigger value="form">Form</Tabs.Trigger>
-                <Tabs.Trigger value="text">Text</Tabs.Trigger>
-              </Tabs.List>
-
-              <Tabs.Content value="none">No body</Tabs.Content>
-
-              <Tabs.Content value="json">
-                <CodeMirror
-                  input={_state.entry.body?.body}
-                  onStateChange={(update) => {
-                    // console.log(update);
-                    if (
-                      update.docChanged &&
-                      _state.entry!!.body?.body !== update.state.doc.toString()
-                    ) {
-                      updateBodyContent(update.state.doc.toString(), "Json");
-                    }
-                  }}
+                {/each}
+                <PlusIcon
+                  class="col-span-3 mt-2 cursor-pointer text-muted-foreground hover:text-primary"
+                  onclick={() => insertHeader()}
                 />
-              </Tabs.Content>
-
-              <Tabs.Content value="form">
-                <p class="text-sm text-muted-foreground">
-                  Form body editor coming soon.
-                </p>
-              </Tabs.Content>
-
-              <Tabs.Content value="text">
-                <textarea
-                  class="w-full min-h-[200px] rounded-md border bg-background p-2 font-mono text-sm"
-                  placeholder="Raw text body"
-                ></textarea>
-              </Tabs.Content>
-            </Tabs.Root>
-          </Accordion.Content>
-        </Accordion.Item>
-
-        <!-- RESPONSE -->
+              </div>
+            </Accordion.Content>
+          </Accordion.Item>
 
+          <!-- BODY -->
+
+          <Accordion.Item value="body">
+            <Accordion.Trigger>Body</Accordion.Trigger>
+            <Accordion.Content class="space-y-4">
+              <Tabs.Root value={_state.entry.body === null ? "none" : "json"}>
+                <Tabs.List>
+                  <Tabs.Trigger value="none" onclick={() => deleteBody()}
+                    >None</Tabs.Trigger
+                  >
+                  <Tabs.Trigger value="json">JSON</Tabs.Trigger>
+                  <Tabs.Trigger value="form">Form</Tabs.Trigger>
+                  <Tabs.Trigger value="text">Text</Tabs.Trigger>
+                </Tabs.List>
+
+                <Tabs.Content value="none"></Tabs.Content>
+
+                <Tabs.Content value="json">
+                  <CodeMirror
+                    input={_state.entry.body?.body}
+                    onStateChange={(update) => {
+                      // console.log(update);
+                      if (
+                        update.docChanged &&
+                        _state.entry!!.body?.body !==
+                          update.state.doc.toString()
+                      ) {
+                        updateBodyContent(update.state.doc.toString(), "Json");
+                      }
+                    }}
+                  />
+                </Tabs.Content>
+
+                <Tabs.Content value="form">
+                  <p class="text-sm text-muted-foreground">
+                    Form body editor coming soon.
+                  </p>
+                </Tabs.Content>
+
+                <Tabs.Content value="text">
+                  <textarea
+                    class="w-full min-h-[200px] rounded-md border bg-background p-2 font-mono text-sm"
+                    placeholder="Raw text body"
+                  ></textarea>
+                </Tabs.Content>
+              </Tabs.Root>
+            </Accordion.Content>
+          </Accordion.Item>
+        </Accordion.Root>
+      </Resizable.Pane>
+
+      <Resizable.Handle withHandle class="p-1.5" />
+
+      <!-- RESPONSE -->
+
+      <Resizable.Pane defaultSize={0}>
         {#if isSending}
           <div class="flex justify-center py-8">
             <Loader class="h-6 w-6 animate-spin text-muted-foreground" />
           </div>
         {:else if response}
-          <Accordion.Item value="response">
-            <Accordion.Trigger>Response</Accordion.Trigger>
-            <Accordion.Content class="space-y-4 mx-auto w-full max-w-6xl">
-              <!-- Prevents line number selection -->
-              <div
-                class="
+          <!-- Prevents line number selection -->
+          <div
+            class="
                   w-full
                   [&_td:first-child]:select-none
                   [&_td:first-child]:pointer-events-none
                 "
-              >
-                <Highlight
-                  language={json}
-                  code={response.body.Json}
-                  let:highlighted
-                >
-                  <LineNumbers {highlighted} wrapLines hideBorder />
-                </Highlight>
-              </div>
-            </Accordion.Content>
-          </Accordion.Item>
+          >
+            <Highlight
+              language={json}
+              code={response.body.Json}
+              let:highlighted
+            >
+              <LineNumbers {highlighted} wrapLines hideBorder />
+            </Highlight>
+          </div>
         {/if}
-      </Accordion.Root>
-    </section>
-  {/if}
-</main>
+      </Resizable.Pane>
+    </Resizable.PaneGroup>
+  </section>
+{/if}

+ 13 - 0
src/lib/components/ui/resizable/index.ts

@@ -0,0 +1,13 @@
+import { Pane } from "paneforge";
+import Handle from "./resizable-handle.svelte";
+import PaneGroup from "./resizable-pane-group.svelte";
+
+export {
+	PaneGroup,
+	Pane,
+	Handle,
+	//
+	PaneGroup as ResizablePaneGroup,
+	Pane as ResizablePane,
+	Handle as ResizableHandle,
+};

+ 30 - 0
src/lib/components/ui/resizable/resizable-handle.svelte

@@ -0,0 +1,30 @@
+<script lang="ts">
+	import GripVerticalIcon from "@lucide/svelte/icons/grip-vertical";
+	import * as ResizablePrimitive from "paneforge";
+	import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		withHandle = false,
+		...restProps
+	}: WithoutChildrenOrChild<ResizablePrimitive.PaneResizerProps> & {
+		withHandle?: boolean;
+	} = $props();
+</script>
+
+<ResizablePrimitive.PaneResizer
+	bind:ref
+	data-slot="resizable-handle"
+	class={cn(
+		"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:start-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[direction=vertical]:h-px data-[direction=vertical]:w-full data-[direction=vertical]:after:start-0 data-[direction=vertical]:after:h-1 data-[direction=vertical]:after:w-full data-[direction=vertical]:after:translate-x-0 data-[direction=vertical]:after:-translate-y-1/2 [&[data-direction=vertical]>div]:rotate-90",
+		className
+	)}
+	{...restProps}
+>
+	{#if withHandle}
+		<div class="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
+			<GripVerticalIcon class="size-2.5" />
+		</div>
+	{/if}
+</ResizablePrimitive.PaneResizer>

+ 20 - 0
src/lib/components/ui/resizable/resizable-pane-group.svelte

@@ -0,0 +1,20 @@
+<script lang="ts">
+	import * as ResizablePrimitive from "paneforge";
+	import { cn } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		this: paneGroup = $bindable(),
+		class: className,
+		...restProps
+	}: ResizablePrimitive.PaneGroupProps & {
+		this?: ResizablePrimitive.PaneGroup;
+	} = $props();
+</script>
+
+<ResizablePrimitive.PaneGroup
+	bind:this={paneGroup}
+	data-slot="resizable-pane-group"
+	class={cn("flex h-full w-full data-[direction=vertical]:flex-col", className)}
+	{...restProps}
+/>

+ 4 - 6
src/routes/+page.svelte

@@ -9,8 +9,6 @@
   import { SlidersHorizontal } from "@lucide/svelte";
   import Environment from "$lib/components/Environment.svelte";
 
-  let { children } = $props();
-
   let displayEnvs = $state(false);
 </script>
 
@@ -20,13 +18,13 @@
   {#if displayEnvs}
     <Environment />
   {:else if _state.entry}
-    <WorkspaceEntry />
+    <main class="w-full p-4 space-y-4">
+      <WorkspaceEntry />
+    </main>
   {:else}
-    <main class="w-full h-full p-4 space-y-4"></main>
+    <main class="w-full p-4 space-y-4"></main>
   {/if}
 
-  {@render children?.()}
-
   <Sidebar.Provider style="--sidebar-width: 4rem">
     <Sidebar.Root fixed={false} variant="floating" side="right">
       <Sidebar.Menu class="items-center">