3 Commits 05780022bb ... e27fb00066

Autore SHA1 Messaggio Data
  biblius e27fb00066 fix codemirror and remove highlight 1 mese fa
  biblius ee2882bb07 add header enabled functionality and workspace entry sections 1 mese fa
  biblius efcd604829 fix remigrate script and add header enabled column 1 mese fa

+ 0 - 2
package.json

@@ -27,9 +27,7 @@
     "@tauri-apps/plugin-store": "^2.4.1",
     "clsx": "^2.1.1",
     "codemirror": "^6.0.2",
-    "highlight.js": "^11.11.1",
     "mode-watcher": "^1.1.0",
-    "svelte-highlight": "^7.9.0",
     "tailwind-merge": "^3.4.0",
     "tailwindcss": "^4.1.17"
   },

+ 6 - 1
scripts/remigrate.sh

@@ -1,3 +1,8 @@
+if [ -z "$DATABASE_URL" ]; then
+  echo 'DATABASE_URL not set, aborting'
+  exit 1
+fi
+
 sqlx migrate revert --source src-tauri/migrations
 sqlx migrate run --source src-tauri/migrations
-sqlite3 rquest.db < src-tauri/seed/init.sql
+sqlite3 "${DATABASE_URL#sqlite:}" < src-tauri/seed/init.sql

+ 64 - 63
src-tauri/migrations/20250922150745_init.up.sql

@@ -1,91 +1,92 @@
 CREATE TABLE workspaces (
-    id INTEGER PRIMARY KEY NOT NULL,
-    name TEXT NOT NULL UNIQUE
+  id INTEGER PRIMARY KEY NOT NULL,
+  name TEXT NOT NULL UNIQUE
 );
 
-CREATE TABLE auth(
-    id INTEGER PRIMARY KEY NOT NULL,
-    workspace_id INTEGER NOT NULL,
-    name TEXT NOT NULL,
-    params JSONB NOT NULL,
-    FOREIGN KEY (workspace_id) REFERENCES workspaces (id) ON DELETE CASCADE
+CREATE TABLE auth (
+  id INTEGER PRIMARY KEY NOT NULL,
+  workspace_id INTEGER NOT NULL,
+  name TEXT NOT NULL,
+  params JSONB NOT NULL,
+  FOREIGN KEY (workspace_id) REFERENCES workspaces (id) ON DELETE CASCADE
 );
 
 CREATE TABLE workspace_envs (
-    id INTEGER PRIMARY KEY NOT NULL,
-    workspace_id INTEGER NOT NULL,
-    name TEXT NOT NULL,
-    FOREIGN KEY (workspace_id) REFERENCES workspaces (id) ON DELETE CASCADE
+  id INTEGER PRIMARY KEY NOT NULL,
+  workspace_id INTEGER NOT NULL,
+  name TEXT NOT NULL,
+  FOREIGN KEY (workspace_id) REFERENCES workspaces (id) ON DELETE CASCADE
 );
 
 CREATE TABLE workspace_env_variables (
-    id INTEGER PRIMARY KEY NOT NULL,
-    workspace_id INTEGER NOT NULL,
-    env_id INTEGER NOT NULL,
-    name TEXT NOT NULL,
-    value TEXT NOT NULL,
-    secret BOOLEAN NOT NULL,
-    FOREIGN KEY (workspace_id) REFERENCES workspaces (id) ON DELETE CASCADE,
-    FOREIGN KEY (env_id) REFERENCES workspace_envs (id) ON DELETE CASCADE,
-    UNIQUE(env_id, name)
+  id INTEGER PRIMARY KEY NOT NULL,
+  workspace_id INTEGER NOT NULL,
+  env_id INTEGER NOT NULL,
+  name TEXT NOT NULL,
+  value TEXT NOT NULL,
+  secret BOOLEAN NOT NULL,
+  FOREIGN KEY (workspace_id) REFERENCES workspaces (id) ON DELETE CASCADE,
+  FOREIGN KEY (env_id) REFERENCES workspace_envs (id) ON DELETE CASCADE,
+  UNIQUE (env_id, name)
 );
 
 CREATE TABLE workspace_entries (
-    id INTEGER PRIMARY KEY NOT NULL,
-    workspace_id INTEGER NOT NULL,
-    parent_id INTEGER,
-    name TEXT NOT NULL,
-    type INTEGER NOT NULL,
-    auth INTEGER,
-    auth_inherit BOOLEAN NOT NULL DEFAULT TRUE,
-    FOREIGN KEY (parent_id) REFERENCES workspace_entries (id) ON DELETE CASCADE,
-    FOREIGN KEY (workspace_id) REFERENCES workspaces (id) ON DELETE CASCADE,
-    FOREIGN KEY (auth) REFERENCES auth (id) ON DELETE SET NULL
+  id INTEGER PRIMARY KEY NOT NULL,
+  workspace_id INTEGER NOT NULL,
+  parent_id INTEGER,
+  name TEXT NOT NULL,
+  type INTEGER NOT NULL,
+  auth INTEGER,
+  auth_inherit BOOLEAN NOT NULL DEFAULT TRUE,
+  FOREIGN KEY (parent_id) REFERENCES workspace_entries (id) ON DELETE CASCADE,
+  FOREIGN KEY (workspace_id) REFERENCES workspaces (id) ON DELETE CASCADE,
+  FOREIGN KEY (auth) REFERENCES auth (id) ON DELETE SET NULL
 );
 
 CREATE TABLE request_params (
-    id INTEGER PRIMARY KEY NOT NULL,
-    workspace_id INTEGER NOT NULL,
-    request_id INTEGER UNIQUE NOT NULL,
-    method TEXT NOT NULL,
-    url TEXT NOT NULL,
-    FOREIGN KEY (request_id) REFERENCES workspace_entries (id) ON DELETE CASCADE,
-    FOREIGN KEY (workspace_id) REFERENCES workspaces (id) ON DELETE CASCADE
+  id INTEGER PRIMARY KEY NOT NULL,
+  workspace_id INTEGER NOT NULL,
+  request_id INTEGER UNIQUE NOT NULL,
+  method TEXT NOT NULL,
+  url TEXT NOT NULL,
+  FOREIGN KEY (request_id) REFERENCES workspace_entries (id) ON DELETE CASCADE,
+  FOREIGN KEY (workspace_id) REFERENCES workspaces (id) ON DELETE CASCADE
 );
 
 CREATE TABLE request_path_params (
-    id INTEGER PRIMARY KEY NOT NULL,
-    position INTEGER NOT NULL,
-    request_id INTEGER NOT NULL,
-    name TEXT NOT NULL,
-    value TEXT NOT NULL,
-    UNIQUE(position, request_id),
-    FOREIGN KEY (request_id) REFERENCES workspace_entries (id) ON DELETE CASCADE
+  id INTEGER PRIMARY KEY NOT NULL,
+  position INTEGER NOT NULL,
+  request_id INTEGER NOT NULL,
+  name TEXT NOT NULL,
+  value TEXT NOT NULL,
+  UNIQUE (position, request_id),
+  FOREIGN KEY (request_id) REFERENCES workspace_entries (id) ON DELETE CASCADE
 );
 
 CREATE TABLE request_query_params (
-    id INTEGER PRIMARY KEY NOT NULL,
-    -- A non-null position means a QP is enabled
-    position INTEGER,
-    request_id INTEGER NOT NULL,
-    key TEXT NOT NULL,
-    value TEXT NOT NULL,
-    UNIQUE(position, request_id),
-    FOREIGN KEY (request_id) REFERENCES workspace_entries (id) ON DELETE CASCADE
+  id INTEGER PRIMARY KEY NOT NULL,
+  -- A non-null position means a QP is enabled
+  position INTEGER,
+  request_id INTEGER NOT NULL,
+  key TEXT NOT NULL,
+  value TEXT NOT NULL,
+  UNIQUE (position, request_id),
+  FOREIGN KEY (request_id) REFERENCES workspace_entries (id) ON DELETE CASCADE
 );
 
 CREATE TABLE request_bodies (
-    id INTEGER PRIMARY KEY NOT NULL,
-    request_id UNIQUE NOT NULL,
-    ty TEXT NOT NULL,
-    content TEXT NOT NULL,
-    FOREIGN KEY (request_id) REFERENCES workspace_entries (id) ON DELETE CASCADE
+  id INTEGER PRIMARY KEY NOT NULL,
+  request_id UNIQUE NOT NULL,
+  ty TEXT NOT NULL,
+  content TEXT NOT NULL,
+  FOREIGN KEY (request_id) REFERENCES workspace_entries (id) ON DELETE CASCADE
 );
 
 CREATE TABLE request_headers (
-    id INTEGER PRIMARY KEY NOT NULL,
-    request_id INTEGER NOT NULL,
-    name TEXT NOT NULL,
-    value TEXT NOT NULL,
-    FOREIGN KEY (request_id) REFERENCES workspace_entries (id) ON DELETE CASCADE
+  id INTEGER PRIMARY KEY NOT NULL,
+  request_id INTEGER NOT NULL,
+  name TEXT NOT NULL,
+  value TEXT NOT NULL,
+  enabled BOOLEAN NOT NULL DEFAULT TRUE,
+  FOREIGN KEY (request_id) REFERENCES workspace_entries (id) ON DELETE CASCADE
 );

+ 14 - 0
src-tauri/src/cmd.rs

@@ -267,6 +267,8 @@ pub async fn update_query_param_enabled(
                 .await
                 .map_err(|e| e.to_string())?;
 
+            // This acts as a toggle, if the position is present, remove the query param
+            // located at it
             if let Some(position) = qp.position {
                 let Some((removed, offset)) = url.remove_query_param(position as usize) else {
                     return Err(format!("no query param at position {position}"));
@@ -700,6 +702,18 @@ pub async fn update_header(
     Ok(())
 }
 
+#[tauri::command]
+pub async fn update_header_enabled(
+    state: tauri::State<'_, AppState>,
+    header_id: i64,
+    enabled: bool,
+) -> Result<(), String> {
+    if let Err(e) = db::update_header_enabled(state.db.clone(), header_id, enabled).await {
+        return Err(e.to_string());
+    }
+    Ok(())
+}
+
 #[tauri::command]
 pub async fn delete_header(
     state: tauri::State<'_, AppState>,

+ 14 - 11
src-tauri/src/db.rs

@@ -38,15 +38,6 @@ impl<T: Clone> Clone for Update<T> {
 
 impl<T: Copy> Copy for Update<T> {}
 
-impl<T: Copy> Update<T> {
-    pub fn value(self) -> Option<T> {
-        match self {
-            Update::Value(v) => Some(v),
-            Update::Null => None,
-        }
-    }
-}
-
 pub async fn init(url: &str) -> SqlitePool {
     let mut opts = SqliteConnectOptions::from_str(url).unwrap();
 
@@ -517,7 +508,7 @@ pub async fn get_workspace_request(db: SqlitePool, id: i64) -> AppResult<Workspa
 
     let headers = sqlx::query_as!(
         RequestHeader,
-        "SELECT id, name, value FROM request_headers WHERE request_id = ?",
+        "SELECT id, name, value, enabled FROM request_headers WHERE request_id = ?",
         entry.id
     )
     .fetch_all(&db)
@@ -800,12 +791,24 @@ pub async fn insert_headers(
             .push_bind(header.value);
     });
     Ok(insert
-        .push("RETURNING id, name, value")
+        .push("RETURNING id, name, value, enabled")
         .build_query_as()
         .fetch_one(&db)
         .await?)
 }
 
+pub async fn update_header_enabled(db: SqlitePool, id: i64, enabled: bool) -> AppResult<()> {
+    sqlx::query!(
+        "UPDATE request_headers SET enabled = ? WHERE id = ?",
+        enabled,
+        id
+    )
+    .execute(&db)
+    .await?;
+
+    Ok(())
+}
+
 pub async fn update_header(db: SqlitePool, header: RequestHeaderUpdate) -> AppResult<()> {
     sqlx::query!(
         "UPDATE request_headers SET name = COALESCE(?, ''), value = COALESCE(?, '') WHERE id = ?",

+ 1 - 0
src-tauri/src/lib.rs

@@ -68,6 +68,7 @@ pub fn run() {
             cmd::delete_env_var,
             cmd::insert_header,
             cmd::update_header,
+            cmd::update_header_enabled,
             cmd::delete_header,
             cmd::insert_request_body,
             cmd::update_request_body,

+ 14 - 29
src-tauri/src/request.rs

@@ -8,7 +8,7 @@ use crate::{
     error::AppError,
     request::{
         ctype::ContentType,
-        url::{QueryParam, RequestUrl, Segment},
+        url::{QueryParam, RequestUrl},
     },
     workspace::WorkspaceEntryBase,
     AppResult,
@@ -44,6 +44,7 @@ pub async fn send(client: reqwest::Client, req: HttpRequestParameters) -> AppRes
 
     let body = match body {
         Some(body) => {
+            // Parse each body with respective parser to ensure valid syntax
             match body.ty {
                 ContentType::Text => insert_ct_if_missing(&mut headers, "text/plain"),
                 ContentType::Json => {
@@ -95,30 +96,6 @@ pub async fn send(client: reqwest::Client, req: HttpRequestParameters) -> AppRes
     Ok(res)
 }
 
-/// Load the request body into a vec, validating it beforehand to ensure no syntactic errors are
-/// present in bodies that need valid syntax.
-pub async fn get_valid_request_body(path: &str, ty: ContentType) -> AppResult<String> {
-    let body = tokio::fs::read_to_string(path).await?;
-
-    match ty {
-        ContentType::Text => {}
-        ContentType::Json => {
-            serde_json::from_str::<serde_json::Value>(&body)?;
-        }
-        ContentType::Xml => {
-            roxmltree::Document::parse(&body)?;
-        }
-        ContentType::FormUrlEncoded => {
-            serde_urlencoded::from_str::<Vec<(&str, &str)>>(&body)
-                .map_err(|e| AppError::SerdeUrl(e.to_string()))?;
-        }
-        // Handled by reqwest
-        ContentType::FormData => {}
-    };
-
-    Ok(body)
-}
-
 #[derive(Debug, Serialize)]
 pub struct WorkspaceRequest {
     /// Workspace entry representing this request.
@@ -186,6 +163,7 @@ impl WorkspaceRequest {
                         id: -1,
                         name: token.name,
                         value: token.value,
+                        enabled: true,
                     });
                 }
             },
@@ -198,6 +176,7 @@ impl WorkspaceRequest {
                     id: -1,
                     name: "Authorization".to_string(),
                     value,
+                    enabled: true,
                 });
             }
             crate::auth::Auth::OAuth(OAuth {
@@ -241,10 +220,13 @@ impl TryFrom<WorkspaceRequest> for HttpRequestParameters {
         let mut headers = HeaderMap::new();
 
         for header in value.headers {
-            headers.insert(
-                reqwest::header::HeaderName::from_str(&header.name).map_err(|e| e.to_string())?,
-                HeaderValue::from_str(&header.value).map_err(|e| e.to_string())?,
-            );
+            if header.enabled {
+                headers.insert(
+                    reqwest::header::HeaderName::from_str(&header.name)
+                        .map_err(|e| e.to_string())?,
+                    HeaderValue::from_str(&header.value).map_err(|e| e.to_string())?,
+                );
+            }
         }
 
         Ok(Self {
@@ -264,6 +246,7 @@ pub struct HttpResponse {
     pub status: usize,
     pub headers: Vec<(String, String)>,
     pub body: Option<ResponseBody>,
+    pub duration_ms: Option<usize>,
 }
 
 impl HttpResponse {
@@ -285,6 +268,7 @@ impl HttpResponse {
                 })
                 .collect(),
             body,
+            duration_ms: None,
         }
     }
 }
@@ -450,6 +434,7 @@ pub struct RequestHeader {
     pub id: i64,
     pub name: String,
     pub value: String,
+    pub enabled: bool,
 }
 
 #[derive(Debug, Deserialize)]

+ 31 - 12
src-tauri/src/state.rs

@@ -5,7 +5,7 @@ use crate::{
 use futures::FutureExt;
 use serde::Serialize;
 use sqlx::SqlitePool;
-use std::{collections::HashMap, time::Instant};
+use std::{collections::HashMap, error::Error, time::Instant};
 use tauri::ipc::Channel;
 use tauri_plugin_log::log;
 use tokio::select;
@@ -56,16 +56,22 @@ impl AppState {
                             continue;
                         };
                     },
-                    Some((req_id, res)) = outbound.next() => {
-                        if let Some(start) = timers.remove(&req_id) {
-                            log::debug!("request complete {req_id}; took {}ms", Instant::now().duration_since(start).as_millis());
-                        }
+                    Some((req_id, mut res)) = outbound.next() => {
+                        let Some(start) = timers.remove(&req_id) else {
+                            log::warn!("missing timer for {req_id}");
+                            continue;
+                        };
+
+                        let duration = Instant::now().duration_since(start).as_millis();
+
+                        log::debug!("request complete {req_id}; took {}ms", duration);
+
                         let Some(channel) = return_channels.remove(&req_id) else {
                             log::warn!("missing return channel for {req_id}");
                             continue;
                         };
 
-                        channel.send(res.into()).unwrap();
+                        channel.send(ResponseResult::from_response(res, duration)).unwrap();
                     },
                 }
             }
@@ -113,15 +119,28 @@ pub struct OutboundRequest {
 #[derive(Debug, Serialize)]
 #[serde(tag = "type", content = "data")]
 pub enum ResponseResult {
-    Ok(HttpResponse),
-    Err(String),
+    Ok {
+        response: HttpResponse,
+        duration: u128,
+    },
+    Err {
+        message: String,
+        duration: u128,
+    },
 }
 
-impl From<AppResult<HttpResponse>> for ResponseResult {
-    fn from(value: AppResult<HttpResponse>) -> Self {
+impl ResponseResult {
+    fn from_response(value: AppResult<HttpResponse>, duration: u128) -> Self {
         match value {
-            Ok(res) => Self::Ok(res),
-            Err(e) => Self::Err(e.to_string()),
+            Ok(response) => Self::Ok { response, duration },
+            Err(e) => {
+                log::error!("request error: {e}");
+                log::error!(" - source: {:?}", e.source());
+                Self::Err {
+                    message: e.to_string(),
+                    duration,
+                }
+            }
         }
     }
 }

+ 5 - 5
src/lib/codemirror.svelte.ts

@@ -24,24 +24,24 @@ const lineWrapConfig = new Compartment();
 
 const editorPadding = EditorView.theme({
   ".cm-content": {
-    marginBottom: "4rem",
+    marginBottom: "2.5rem",
   },
   ".cm-scroller": {
-    marginBottom: "2rem",
+    marginBottom: "1.5rem",
   },
 
   "@media (min-height: 900px)": {
     ".cm-content": {
-      marginBottom: "3rem",
+      marginBottom: "1.5rem",
     },
     ".cm-scroller": {
-      marginBottom: "1rem",
+      marginBottom: "0.5rem",
     },
   },
 
   "@media (min-height: 1200px)": {
     ".cm-content": {
-      marginBottom: "2rem",
+      marginBottom: "1rem",
     },
     ".cm-scroller": {
       marginBottom: "0.5rem",

+ 0 - 23
src/lib/components/Highlight.svelte

@@ -1,23 +0,0 @@
-<script lang="ts">
-  import hljs from "$lib/highlight.svelte";
-  import "highlight.js/styles/sunburst.css";
-
-  let { code, lang, wrap }: { code: string; lang: string; wrap: boolean } =
-    $props();
-
-  let wrapped = $derived(wrap ? "whitespace-pre-wrap" : "whitespace-pre");
-
-  let highlighted = $derived(hljs.highlight(code, { language: lang }).code);
-
-  $effect(() => {
-    hljs.highlightAll();
-    console.log(highlighted);
-  });
-</script>
-
-{#if highlighted}
-  <pre
-    class={"wrap-anywhere text-sm overflow-auto max-w-fit min-w-0 " + wrapped}>
-      <code class={"lang-" + lang}>{highlighted}</code>
-  </pre>
-{/if}

+ 41 - 0
src/lib/components/KeyValInput.svelte

@@ -0,0 +1,41 @@
+<script lang="ts">
+  import { Input } from "$lib/components/ui/input";
+
+  type Props = {
+    class?: string;
+    onInput: (key: string, value: string) => Promise<void>;
+  };
+
+  let key: string = $state("");
+  let value: string = $state("");
+  let inputTimeout: number | undefined = $state();
+
+  let { class: className = "", onInput }: Props = $props();
+
+  function handleInput() {
+    if (inputTimeout != undefined) {
+      clearTimeout(inputTimeout);
+    }
+
+    inputTimeout = setTimeout(async () => {
+      await onInput(key, value);
+      key = "";
+      value = "";
+    }, 200);
+  }
+</script>
+
+<div class={"flex gap-2 " + className}>
+  <Input
+    class="col-start-2"
+    bind:value={key}
+    placeholder="Key"
+    oninput={handleInput}
+  />
+  <Input
+    class="col-start-3"
+    bind:value
+    placeholder="Value"
+    oninput={handleInput}
+  />
+</div>

+ 65 - 74
src/lib/components/Response.svelte

@@ -10,7 +10,7 @@
   import type { EditorView } from "codemirror";
   import { onMount } from "svelte";
 
-  let response = $derived(_state.responses[_state.entry!!.id]);
+  let result = $derived(_state.responses[_state.entry!!.id]);
   let wrap = $state(ls.WRAP_RESPONSE.get());
 
   let view: EditorView;
@@ -25,15 +25,26 @@
   });
 
   $effect(() => {
-    if (
-      response.body != null &&
-      response.body.content !== view.state.doc.toString()
-    ) {
-      setContent(view, response.body.content, response.body.type);
+    if (result.type === "Err") {
+      return;
+    }
+
+    if (result.type == "Ok" && result.data.response.body != null) {
+      setContent(
+        view,
+        result.data.response.body.content,
+        result.data.response.body.type,
+      );
     }
   });
 
   let borderColor = $derived.by(() => {
+    if (result.type === "Err") {
+      return "border-red-900";
+    }
+
+    const response = result.data.response;
+
     if (response.status >= 200 && response.status < 300)
       return "border-green-900";
     if (response.status >= 400 && response.status < 500)
@@ -42,6 +53,12 @@
   });
 
   let dotColor = $derived.by(() => {
+    if (result.type === "Err") {
+      return "red";
+    }
+
+    const response = result.data.response;
+
     if (response.status >= 200 && response.status < 300) return "green";
     if (response.status >= 400 && response.status < 500) return "orange";
     if (response.status >= 500) return "red";
@@ -49,21 +66,36 @@
 </script>
 
 <Tabs.Root value="body" class="min-h-0">
-  <header class="flex items-center w-full py-2 gap-4 border-b">
-    <Badge variant="outline" class={`h-4 rounded-sm ${borderColor}`}>
+  <header class="flex items-center w-full py-2 gap-2 border-b">
+    <!-- STATUS BADGE -->
+
+    <Badge variant="outline" class={`rounded-sm ${borderColor}`}>
       <Dot strokeWidth={5} color={dotColor} />
-      {response.status}
+      {#if result.type === "Ok"}
+        {result.data.response.status}
+      {:else}
+        ERROR
+      {/if}
+    </Badge>
+
+    <Badge variant="outline" class={`rounded-sm`}>
+      {result.data.duration}ms
     </Badge>
 
-    <Tabs.List class="h-6">
-      <Tabs.Trigger class="text-xs" value="body">Body</Tabs.Trigger>
-      <Tabs.Trigger class="text-xs" value="headers">Headers</Tabs.Trigger>
-    </Tabs.List>
+    {#if result.type === "Ok"}
+      <Tabs.List>
+        <Tabs.Trigger class="text-xs" value="body">Body</Tabs.Trigger>
+        <Tabs.Trigger class="text-xs" value="headers">Headers</Tabs.Trigger>
+      </Tabs.List>
+
+      <!-- RESPONSE UTILS -->
 
-    <Tabs.Content value="body" class="flex items-center">
-      <div class="flex items-center gap-1">
+      <Tabs.Content
+        value="body"
+        class="flex justify-end w-full items-center gap-1"
+      >
         <Button
-          class="h-6 p-1"
+          class="p-1"
           variant={wrap ? "default" : "outline"}
           onclick={handleToggleWrap}
           size="icon-sm"
@@ -71,73 +103,32 @@
           <TextWrap />
         </Button>
         <Button
-          class="h-6 p-1"
+          class="p-1"
           onclick={() => copyContent(view)}
           variant="outline"
           size="icon-sm"
         >
           <Clipboard />
         </Button>
-      </div>
-    </Tabs.Content>
+      </Tabs.Content>
+    {/if}
   </header>
 
-  <Tabs.Content value="body" class="flex-1 min-h-0 overflow-auto">
-    {#if response.body != null}
-      <!-- EDITOR -->
-
-      <div id="response-view"></div>
+  <Tabs.Content value="body" class="overflow-scroll h-[90%]">
+    {#if result.type === "Err"}
+      {result.data.message}
     {/if}
+    <div id="response-view" class:hidden={result.type === "Err"}></div>
   </Tabs.Content>
 
-  <Tabs.Content value="headers" class="flex-1 overflow-auto">
-    <div class="grid grid-cols-2" id="header-tab">
-      {#each response.headers as [name, value]}
-        <p>{name}</p>
-        <p class="wrap-anywhere">{value}</p>
-      {/each}
-    </div>
-  </Tabs.Content>
+  {#if result.type === "Ok"}
+    <Tabs.Content value="headers" class="flex-1">
+      <div class="grid grid-cols-2">
+        {#each result.data.response.headers as [name, value]}
+          <p>{name}</p>
+          <p class="wrap-anywhere">{value}</p>
+        {/each}
+      </div>
+    </Tabs.Content>
+  {/if}
 </Tabs.Root>
-
-<style>
-  #header-tab {
-    padding-bottom: 8rem;
-  }
-
-  @media (min-height: 400px) {
-    #header-tab {
-      padding-bottom: 7rem;
-    }
-  }
-
-  @media (min-height: 600px) {
-    #header-tab {
-      padding-bottom: 6rem;
-    }
-  }
-
-  @media (min-height: 800px) {
-    #header-tab {
-      padding-bottom: 5rem;
-    }
-  }
-
-  @media (min-height: 900px) {
-    #header-tab {
-      padding-bottom: 4rem;
-    }
-  }
-
-  @media (min-height: 1000px) {
-    #header-tab {
-      padding-bottom: 3rem;
-    }
-  }
-
-  @media (min-height: 1200px) {
-    #header-tab {
-      padding-bottom: 2rem;
-    }
-  }
-</style>

+ 1 - 1
src/lib/components/Sidebar.svelte

@@ -57,7 +57,7 @@
     <Sidebar.Group>
       <Sidebar.GroupContent>
         <Sidebar.Menu>
-          {#each _state.roots as root}
+          {#each _state.roots as root (root)}
             <SidebarEntry id={root} level={0} {onSelect} />
           {/each}
         </Sidebar.Menu>

+ 6 - 7
src/lib/components/SidebarEntry.svelte

@@ -13,7 +13,6 @@
   import * as DropdownMenu from "./ui/dropdown-menu/index";
   import { Button } from "./ui/button";
   import DropdownMenuItem from "./ui/dropdown-menu/dropdown-menu-item.svelte";
-  import { Badge } from "$lib/components/ui/badge/index.js";
   import { ChevronDown, ChevronRight } from "@lucide/svelte";
 
   const isSelected = $derived(_state.entry?.id === id);
@@ -92,24 +91,24 @@
         </Button>
       {/if}
     {:else if _state.indexes[id]!!.type === "Request"}
-      <p
+      <button
         onclick={(e) => onEntrySelect(e)}
         class={`cursor-pointer scale-75 text-xs text-center
           ${REQUEST_METHODS.find((m) => m.method === _state.indexes[id].method)?.textColor}`}
       >
         {_state.indexes[id]!!.method}
-      </p>
+      </button>
     {/if}
 
-    <p
-      class="w-full cursor-pointer py-1 ml-1"
+    <button
+      class="text-start w-full cursor-pointer py-1 ml-1"
       onclick={(e) => {
         onEntrySelect(e);
       }}
     >
       {_state.indexes[id].name ||
         _state.indexes[id].type + "(" + _state.indexes[id].id + ")"}
-    </p>
+    </button>
 
     <!-- ACTION MENU -->
 
@@ -135,7 +134,7 @@
   </div>
 
   {#if _state.indexes[id].open && _state.children[id]?.length > 0}
-    {#each _state.children[id] as child}
+    {#each _state.children[id] as child (child)}
       <Self id={child} level={level + 2} {onSelect} />
     {/each}
   {/if}

+ 134 - 145
src/lib/components/WorkspaceEntry.svelte

@@ -1,5 +1,4 @@
 <script lang="ts">
-  let { requestPane = $bindable(), responsePane = $bindable() } = $props();
   import { Clipboard } from "@lucide/svelte";
   import * as Select from "$lib/components/ui/select";
   import {
@@ -15,6 +14,7 @@
     updateBodyContent,
     updateEntryName,
     updateHeader,
+    updateHeaderEnabled,
     updateQueryParamEnabled,
     updateRequestMethod,
     updateUrl,
@@ -30,13 +30,17 @@
     RESPONSE_PANE_ID,
   } from "$lib/types";
   import Editable from "./Editable.svelte";
-  import { Loader, PlusIcon, Trash } from "@lucide/svelte";
+  import { Loader, Trash } from "@lucide/svelte";
   import BodyEditor from "./BodyEditor.svelte";
   import * as Resizable from "$lib/components/ui/resizable/index";
   import AuthParams from "./AuthParams.svelte";
   import Response from "./Response.svelte";
   import Checkbox from "./ui/checkbox/checkbox.svelte";
   import { tick } from "svelte";
+  import KeyValInput from "./KeyValInput.svelte";
+  import WorkspaceEntrySection from "./WorkspaceEntrySection.svelte";
+
+  let { requestPane = $bindable(), responsePane = $bindable() } = $props();
 
   let isSending = $derived.by(isRequestSending);
 
@@ -67,12 +71,6 @@
   // Used for inputs in the URL bar
   let updateUrlTimeout: number | undefined = $state();
 
-  // Used for inputs in the query params
-  let addQueryTimeout: number | undefined = $state();
-
-  let addQueryParamKeyInput: string | undefined = $state();
-  let addQueryParamValInput: string | undefined = $state();
-
   async function handleRequest() {
     if (isRequestSending()) {
       try {
@@ -123,39 +121,40 @@
     }, 200);
   }
 
-  function handleAddQueryParam() {
-    if (addQueryTimeout != undefined) {
-      clearTimeout(addQueryTimeout);
+  async function handleAddHeader(key: string, val: string) {
+    const headerId = await insertHeader(key, val);
+
+    await tick(); // wait for DOM update
+
+    if (key) {
+      document.getElementById(`${headerId}_header_key`)?.focus();
+    } else {
+      document.getElementById(`${headerId}_header_val`)?.focus();
     }
-    addQueryTimeout = setTimeout(async () => {
-      const key = addQueryParamKeyInput ? addQueryParamKeyInput : "";
-      const val = addQueryParamValInput ? addQueryParamValInput : "";
-
-      if (_state.entry.query.length === 0) {
-        _state.entry.url += `?${key}=${val}`;
-      } else {
-        _state.entry.url += `&${key}=${val}`;
-      }
+  }
 
-      await updateUrl({
-        type: "URL",
-        url: _state.entry.url,
-      });
+  async function handleAddQueryParam(key: string, val: string) {
+    if (_state.entry.query.length === 0) {
+      _state.entry.url += `?${key}=${val}`;
+    } else {
+      _state.entry.url += `&${key}=${val}`;
+    }
 
-      await tick(); // wait for DOM update
+    await updateUrl({
+      type: "URL",
+      url: _state.entry.url,
+    });
 
-      addQueryParamValInput = "";
-      addQueryParamKeyInput = "";
+    await tick(); // wait for DOM update
 
-      // Added param will always be last
-      const param = _state.entry.query[_state.entry.query.length - 1];
+    // Added param will always be last
+    const param = _state.entry.query[_state.entry.query.length - 1];
 
-      if (key) {
-        document.getElementById(`${param.id}_query_key`)?.focus();
-      } else {
-        document.getElementById(`${param.id}_query_val`)?.focus();
-      }
-    }, 200);
+    if (key) {
+      document.getElementById(`${param.id}_query_key`)?.focus();
+    } else {
+      document.getElementById(`${param.id}_query_val`)?.focus();
+    }
   }
 </script>
 
@@ -337,67 +336,67 @@
         <Tabs.Root value="params" class="h-full flex flex-col">
           <Tabs.List class="shrink-0">
             <Tabs.Trigger value="params">Parameters</Tabs.Trigger>
-            <Tabs.Trigger value="headers">Headers</Tabs.Trigger>
             <Tabs.Trigger value="auth">Auth</Tabs.Trigger>
           </Tabs.List>
 
           <div class="flex-1 overflow-auto p-2">
             <!-- ================= PARAMETERS ================= -->
 
-            <!-- ================= HEADERS ================= -->
-
-            <div>
-              <h3
-                class="mb-2 pb-1 pointer-events-none text-xs font-medium border-b border-secondary text-muted-foreground"
-              >
-                Headers
-              </h3>
-              <div
-                class="grid grid-cols-[2%_1fr_1fr_2%] items-center justify-center gap-2 text-sm"
-              >
-                {#each _state.entry.headers as header (header.id)}
-                  <Input
-                    class="col-start-2"
-                    bind:value={header.name}
-                    placeholder="Name"
-                    oninput={() =>
-                      updateHeader(header.id, header.name, header.value)}
-                  />
+            <Tabs.Content value="params" class="space-y-4">
+              <!-- ================= HEADERS ================= -->
 
-                  <Input
-                    class="col-start-3"
-                    bind:value={header.value}
-                    placeholder="Value"
-                    oninput={() =>
-                      updateHeader(header.id, header.name, header.value)}
-                  />
+              <WorkspaceEntrySection title="Headers" initialOpen={false}>
+                <div
+                  class="grid grid-cols-[2%_1fr_1fr_2%] items-center justify-center gap-2 text-sm"
+                >
+                  {#each _state.entry.headers as header (header.id)}
+                    <Checkbox
+                      checked={header.enabled}
+                      onCheckedChange={() =>
+                        updateHeaderEnabled(header.id, !header.enabled)}
+                    />
 
-                  <Trash
-                    class="col-start-4 h-4 w-4 cursor-pointer text-muted-foreground hover:text-destructive"
-                    onclick={() => deleteHeader(header.id)}
-                  />
-                {/each}
+                    <Input
+                      id={`${header.id}_header_key`}
+                      class="col-start-2"
+                      bind:value={header.name}
+                      placeholder="Name"
+                      oninput={() =>
+                        updateHeader(header.id, header.name, header.value)}
+                    />
 
-                <PlusIcon
-                  class="border p-1 rounded-2xl mx-auto col-span-3 cursor-pointer"
-                  onclick={() => insertHeader()}
-                />
-              </div>
-            </div>
+                    <Input
+                      id={`${header.id}_header_val`}
+                      class="col-start-3"
+                      bind:value={header.value}
+                      placeholder="Value"
+                      oninput={() =>
+                        updateHeader(header.id, header.name, header.value)}
+                    />
+
+                    <Trash
+                      class="col-start-4 h-4 w-4 cursor-pointer text-muted-foreground hover:text-destructive"
+                      onclick={() => deleteHeader(header.id)}
+                    />
+                  {/each}
+
+                  <KeyValInput
+                    class="col-start-2 col-span-2"
+                    onInput={handleAddHeader}
+                  />
+                </div>
+              </WorkspaceEntrySection>
 
-            <Tabs.Content value="params" class="space-y-4">
               <!-- ================= PATH ================= -->
 
               {#if _state.entry?.path?.length > 0}
-                <div>
-                  <h3
-                    class="mb-2 pb-1 pointer-events-none text-xs font-medium border-b border-secondary text-muted-foreground"
+                <WorkspaceEntrySection title="Path">
+                  <div
+                    class="grid grid-cols-[2%_1fr_1fr_2%] items-center justify-center gap-2 text-sm"
                   >
-                    Path
-                  </h3>
-                  <div class="grid grid-cols-2 gap-2 text-sm">
                     {#each _state.entry.path as param}
                       <Input
+                        class="col-start-2"
                         bind:value={param.name}
                         placeholder="key"
                         oninput={() =>
@@ -408,6 +407,7 @@
                           })}
                       />
                       <Input
+                        class="col-start-3"
                         bind:value={param.value}
                         placeholder="value"
                         oninput={() =>
@@ -419,78 +419,67 @@
                       />
                     {/each}
                   </div>
-                </div>
+                </WorkspaceEntrySection>
               {/if}
 
               <!-- ================= QUERY ================= -->
 
-              <div>
-                <h3
-                  class="mb-2 pb-1 pointer-events-none text-xs font-medium border-b border-secondary text-muted-foreground"
-                >
-                  Query
-                </h3>
-                <div
-                  class="grid grid-cols-[2%_1fr_1fr_2%] items-center justify-center gap-2 text-sm"
-                >
-                  {#each _state.entry.query as param (param.id)}
-                    <div class="flex justify-center">
-                      <Checkbox
-                        checked={param.position != null}
-                        onCheckedChange={() => updateQueryParamEnabled(param)}
+              {#if _state.entry?.query?.length > 0}
+                <WorkspaceEntrySection title="Query">
+                  <div
+                    class="grid grid-cols-[2%_1fr_1fr_2%] items-center justify-center gap-2 text-sm"
+                  >
+                    {#each _state.entry.query as param (param.id)}
+                      <div class="flex justify-center">
+                        <Checkbox
+                          checked={param.position != null}
+                          onCheckedChange={() => updateQueryParamEnabled(param)}
+                        />
+                      </div>
+                      <Input
+                        id={`${param.id}_query_key`}
+                        class={param.position == null
+                          ? `text-muted-foreground opacity-75`
+                          : ""}
+                        bind:value={param.key}
+                        placeholder="key"
+                        oninput={() =>
+                          handleUrlUpdate({
+                            type: "Query",
+                            url: _state.entry.url,
+                            param,
+                          })}
                       />
-                    </div>
-                    <Input
-                      id={`${param.id}_query_key`}
-                      class={param.position == null
-                        ? `text-muted-foreground opacity-75`
-                        : ""}
-                      bind:value={param.key}
-                      placeholder="key"
-                      oninput={() =>
-                        handleUrlUpdate({
-                          type: "Query",
-                          url: _state.entry.url,
-                          param,
-                        })}
-                    />
-                    <Input
-                      id={`${param.id}_query_val`}
-                      class={param.position == null
-                        ? `text-muted-foreground opacity-75`
-                        : ""}
-                      bind:value={param.value}
-                      placeholder="value"
-                      oninput={() =>
-                        handleUrlUpdate({
-                          type: "Query",
-                          url: _state.entry.url,
-                          param,
-                        })}
-                    />
-                    <div class="flex justify-center">
-                      <Trash
-                        class="h-4 w-4 cursor-pointer text-muted-foreground hover:text-destructive"
-                        onclick={() => deleteQueryParam(param)}
+                      <Input
+                        id={`${param.id}_query_val`}
+                        class={param.position == null
+                          ? `text-muted-foreground opacity-75`
+                          : ""}
+                        bind:value={param.value}
+                        placeholder="value"
+                        oninput={() =>
+                          handleUrlUpdate({
+                            type: "Query",
+                            url: _state.entry.url,
+                            param,
+                          })}
                       />
-                    </div>
-                  {/each}
-                  <!-- ================= ADD QUERY PARAM ================= -->
+                      <div class="flex justify-center">
+                        <Trash
+                          class="h-4 w-4 cursor-pointer text-muted-foreground hover:text-destructive"
+                          onclick={() => deleteQueryParam(param)}
+                        />
+                      </div>
+                    {/each}
+                    <!-- ================= ADD QUERY PARAM ================= -->
 
-                  <Input
-                    class="col-start-2"
-                    bind:value={addQueryParamKeyInput}
-                    placeholder="Key"
-                    oninput={() => handleAddQueryParam()}
-                  />
-                  <Input
-                    class="col-start-3"
-                    bind:value={addQueryParamValInput}
-                    placeholder="Value"
-                    oninput={() => handleAddQueryParam()}
-                  />
-                </div>
-              </div>
+                    <KeyValInput
+                      class="col-start-2 col-span-2"
+                      onInput={handleAddQueryParam}
+                    />
+                  </div>
+                </WorkspaceEntrySection>
+              {/if}
 
               <!-- ================= BODY ================= -->
 

+ 36 - 0
src/lib/components/WorkspaceEntrySection.svelte

@@ -0,0 +1,36 @@
+<script lang="ts">
+  import { ChevronDown, ChevronRight } from "@lucide/svelte";
+
+  type Props = {
+    title: string;
+    children: any;
+    initialOpen?: boolean;
+  };
+
+  let { title, children, initialOpen = true }: Props = $props();
+
+  let open = $state(initialOpen);
+</script>
+
+<div>
+  <h3 class="mb-2 pb-1 border-b border-secondary">
+    <button
+      type="button"
+      onclick={() => (open = !open)}
+      aria-expanded={open}
+      class="flex w-full cursor-pointer gap-1 items-center text-xs font-medium text-muted-foreground"
+    >
+      {#if open}
+        <ChevronDown size={14} />
+      {:else}
+        <ChevronRight size={14} />
+      {/if}
+
+      <span>{title}</span>
+    </button>
+  </h3>
+
+  {#if open}
+    {@render children?.()}
+  {/if}
+</div>

+ 0 - 17
src/lib/highlight.svelte.ts

@@ -1,17 +0,0 @@
-import hljs from "highlight.js/lib/core";
-import javascript from "highlight.js/lib/languages/javascript";
-import json from "highlight.js/lib/languages/json";
-import xml from "highlight.js/lib/languages/xml";
-import plaintext from "highlight.js/lib/languages/plaintext";
-
-export const JS = "javascript";
-export const JS_ON = "json";
-export const HTML = "html";
-export const PLAIN = "plaintext";
-
-hljs.registerLanguage(JS, javascript);
-hljs.registerLanguage(JS_ON, json);
-hljs.registerLanguage(HTML, xml);
-hljs.registerLanguage(PLAIN, plaintext);
-
-export default hljs;

+ 4 - 0
src/lib/settings.svelte.ts

@@ -23,6 +23,10 @@ export async function init() {
 
 export type Settings = {
   theme: "dark" | "light";
+
+  /**
+   * Last selected workspace entry, loaded on init.
+   */
   lastEntry: WorkspaceEntry | null;
 
   /**

+ 17 - 12
src/lib/state.svelte.ts

@@ -15,7 +15,6 @@ import type {
   ResponseResult,
   QueryParam,
 } from "./types";
-import * as Resizable from "$lib/components/ui/resizable/index";
 import { getSetting, setSetting } from "./settings.svelte";
 
 export type WorkspaceState = {
@@ -67,7 +66,7 @@ export type WorkspaceState = {
   /**
    * Maps request IDs to their latest response.
    */
-  responses: Record<number, HttpResponse>;
+  responses: Record<number, ResponseResult>;
 
   /**
    * Holds entry selection history.
@@ -96,6 +95,7 @@ export const state: WorkspaceState = $state({
 });
 
 export function isRequestSending() {
+  console.log(state.pendingRequests);
   return state.pendingRequests.includes(state.entry!!.id);
 }
 
@@ -435,18 +435,15 @@ export async function sendRequest(): Promise<void> {
 
     switch (response.type) {
       case "Ok": {
-        state.responses[state.entry!!.id] = response.data;
+        state.responses[state.entry!!.id] = response;
         console.log(state.responses);
         break;
       }
       case "Err": {
+        state.responses[state.entry!!.id] = response;
         console.error("received response error", response.data);
         break;
       }
-      default: {
-        console.error("unrecognized response type", response.type);
-        break;
-      }
     }
 
     console.timeEnd("request-" + reqId);
@@ -454,13 +451,13 @@ export async function sendRequest(): Promise<void> {
     state.pendingRequests = state.pendingRequests.filter((id) => id !== reqId);
   };
 
+  state.pendingRequests.push(reqId);
+
   await invoke<HttpResponse>("send_request", {
     reqId,
     envId: state.environment?.id,
     onComplete,
   });
-
-  state.pendingRequests.push(reqId);
 }
 
 export async function cancelRequest(): Promise<void> {
@@ -720,13 +717,15 @@ export async function deleteEnvVariable(id: number) {
   );
 }
 
-export async function insertHeader() {
-  const header = await invoke("insert_header", {
+export async function insertHeader(name: string = "", value: string = "") {
+  const header: RequestHeader = await invoke("insert_header", {
     entryId: state.entry!!.id,
-    insert: { name: "", value: "" },
+    insert: { name, value },
   });
 
   state.entry!!.headers.push(header);
+
+  return header.id;
 }
 
 export async function updateHeader(id: number, name: string, value: string) {
@@ -738,6 +737,12 @@ export async function updateHeader(id: number, name: string, value: string) {
   header.value = value;
 }
 
+export async function updateHeaderEnabled(id: number, enabled: boolean) {
+  await invoke("update_header_enabled", { headerId: id, enabled });
+  const header = state.entry!!.headers.find((header) => header.id === id);
+  header.enabled = enabled;
+}
+
 export async function deleteHeader(id: number) {
   await invoke("delete_header", {
     headerId: id,

+ 12 - 2
src/lib/types.ts

@@ -62,6 +62,7 @@ export type RequestHeader = {
   id: number;
   name: string;
   value: string;
+  enabled: boolean;
 };
 
 export type PathParam = {
@@ -182,9 +183,18 @@ export type HttpResponseBody =
 export type ResponseResult =
   | {
       type: "Ok";
-      data: HttpResponse;
+      data: {
+        response: HttpResponse;
+        duration: number;
+      };
     }
-  | { type: "Err"; data: string };
+  | {
+      type: "Err";
+      data: {
+        message: string;
+        duration: number;
+      };
+    };
 
 /**
  * As defined in