Jelajahi Sumber

improve request sending and add more response types

biblius 4 hari lalu
induk
melakukan
8c6e4f2e5c

+ 54 - 0
package-lock.json

@@ -9,6 +9,7 @@
       "version": "0.1.0",
       "license": "MIT",
       "dependencies": {
+        "@codemirror/lang-html": "^6.4.11",
         "@codemirror/lang-javascript": "^6.2.4",
         "@codemirror/lang-json": "^6.0.2",
         "@codemirror/lint": "^6.9.2",
@@ -23,6 +24,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",
@@ -75,6 +77,36 @@
         "@lezer/common": "^1.1.0"
       }
     },
+    "node_modules/@codemirror/lang-css": {
+      "version": "6.3.1",
+      "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
+      "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
+      "license": "MIT",
+      "dependencies": {
+        "@codemirror/autocomplete": "^6.0.0",
+        "@codemirror/language": "^6.0.0",
+        "@codemirror/state": "^6.0.0",
+        "@lezer/common": "^1.0.2",
+        "@lezer/css": "^1.1.7"
+      }
+    },
+    "node_modules/@codemirror/lang-html": {
+      "version": "6.4.11",
+      "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
+      "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
+      "license": "MIT",
+      "dependencies": {
+        "@codemirror/autocomplete": "^6.0.0",
+        "@codemirror/lang-css": "^6.0.0",
+        "@codemirror/lang-javascript": "^6.0.0",
+        "@codemirror/language": "^6.4.0",
+        "@codemirror/state": "^6.0.0",
+        "@codemirror/view": "^6.17.0",
+        "@lezer/common": "^1.0.0",
+        "@lezer/css": "^1.1.0",
+        "@lezer/html": "^1.3.12"
+      }
+    },
     "node_modules/@codemirror/lang-javascript": {
       "version": "6.2.4",
       "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz",
@@ -688,6 +720,17 @@
       "integrity": "sha512-PNGcolp9hr4PJdXR4ix7XtixDrClScvtSCYW3rQG106oVMOOI+jFb+0+J3mbeL/53g1Zd6s0kJzaw6Ri68GmAA==",
       "license": "MIT"
     },
+    "node_modules/@lezer/css": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.0.tgz",
+      "integrity": "sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw==",
+      "license": "MIT",
+      "dependencies": {
+        "@lezer/common": "^1.2.0",
+        "@lezer/highlight": "^1.0.0",
+        "@lezer/lr": "^1.3.0"
+      }
+    },
     "node_modules/@lezer/highlight": {
       "version": "1.2.3",
       "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
@@ -697,6 +740,17 @@
         "@lezer/common": "^1.3.0"
       }
     },
+    "node_modules/@lezer/html": {
+      "version": "1.3.13",
+      "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
+      "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
+      "license": "MIT",
+      "dependencies": {
+        "@lezer/common": "^1.2.0",
+        "@lezer/highlight": "^1.0.0",
+        "@lezer/lr": "^1.0.0"
+      }
+    },
     "node_modules/@lezer/javascript": {
       "version": "1.5.4",
       "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",

+ 2 - 0
package.json

@@ -13,6 +13,7 @@
   },
   "license": "MIT",
   "dependencies": {
+    "@codemirror/lang-html": "^6.4.11",
     "@codemirror/lang-javascript": "^6.2.4",
     "@codemirror/lang-json": "^6.0.2",
     "@codemirror/lint": "^6.9.2",
@@ -27,6 +28,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",

+ 20 - 2
src-tauri/Cargo.lock

@@ -1243,6 +1243,21 @@ dependencies = [
  "new_debug_unreachable",
 ]
 
+[[package]]
+name = "futures"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
 [[package]]
 name = "futures-channel"
 version = "0.3.31"
@@ -1329,6 +1344,7 @@ version = "0.3.31"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
 dependencies = [
+ "futures-channel",
  "futures-core",
  "futures-io",
  "futures-macro",
@@ -3666,6 +3682,7 @@ name = "rquest"
 version = "0.1.0"
 dependencies = [
  "base64 0.22.1",
+ "futures",
  "mime",
  "nom",
  "reqwest",
@@ -3683,6 +3700,7 @@ dependencies = [
  "tauri-plugin-store",
  "thiserror 2.0.17",
  "tokio",
+ "tokio-stream",
 ]
 
 [[package]]
@@ -5150,9 +5168,9 @@ dependencies = [
 
 [[package]]
 name = "tokio-stream"
-version = "0.1.17"
+version = "0.1.18"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047"
+checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
 dependencies = [
  "futures-core",
  "pin-project-lite",

+ 15 - 0
src-tauri/Cargo.toml

@@ -23,6 +23,8 @@ tauri = { version = "2", features = [] }
 tauri-plugin-opener = "2"
 
 tokio = { version = "1.44.1", features = ["macros"] }
+tokio-stream = "0.1.18"
+
 thiserror = "2.0.17"
 
 sqlx = { version = "0.8.6", features = ["sqlite", "runtime-tokio"] }
@@ -45,6 +47,19 @@ tauri-plugin-log = "2"
 tauri-plugin-store = "2"
 base64 = "0.22.1"
 tauri-plugin-fs = "2"
+futures = "0.3.31"
+
+[profile.dev]
+incremental = true
+# rustflags = ["-Z", "threads=8"]
+
+[profile.release]
+codegen-units = 1 # Allows LLVM to perform better optimization.
+lto = true        # Enables link-time-optimizations.
+opt-level = "s"   # Prioritizes small binary size. Use `3` if you prefer speed.
+panic = "abort"   # Higher performance by disabling panic handlers.
+strip = true      # Ensures debug symbols are removed.
+# rustflags = ["-Z", "threads=8"]
 
 [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
 tauri-plugin-global-shortcut = "2"

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

@@ -1,19 +1,19 @@
 use crate::{
-    auth::{expand_auth_vars, Auth, AuthType, Authentication, BasicAuth, OAuth},
+    auth::{expand_auth_vars, Auth, AuthType, Authentication},
     db::{self, Update},
     request::{
-        self,
         url::{RequestUrl, RequestUrlOwned, Segment, UrlError},
         EntryRequestBody, HttpRequestParameters, HttpResponse, RequestBody, RequestHeader,
         RequestHeaderInsert, RequestHeaderUpdate, RequestPathParam, RequestPathUpdate,
     },
-    state::AppState,
+    state::{AppState, ResponseResult},
     var::{expand_vars, parse_vars},
     workspace::{
         Workspace, WorkspaceEntry, WorkspaceEntryBase, WorkspaceEntryCreate, WorkspaceEntryUpdate,
         WorkspaceEntryUpdateBase, WorkspaceEnvVariable, WorkspaceEnvironment,
     },
 };
+use tauri::ipc::Channel;
 use tauri_plugin_log::log;
 
 #[tauri::command]
@@ -35,11 +35,22 @@ pub async fn create_workspace(
     }
 }
 
+#[tauri::command]
+pub async fn get_workspace_entry(
+    state: tauri::State<'_, AppState>,
+    entry_id: i64,
+) -> Result<WorkspaceEntry, String> {
+    match db::get_workspace_entry(state.db.clone(), entry_id).await {
+        Ok(ws) => Ok(ws),
+        Err(e) => Err(e.to_string()),
+    }
+}
+
 #[tauri::command]
 pub async fn list_workspace_entries(
     state: tauri::State<'_, AppState>,
     id: i64,
-) -> Result<Vec<WorkspaceEntry>, String> {
+) -> Result<Vec<WorkspaceEntryBase>, String> {
     match db::list_workspace_entries(state.db.clone(), id).await {
         Ok(ws) => Ok(ws),
         Err(e) => Err(e.to_string()),
@@ -217,11 +228,9 @@ pub async fn update_url(
                 }
             }
 
-            dbg!(&subs);
             for sub in subs {
                 url_parsed.swap_path_segment(sub);
             }
-            dbg!(&update, &url_parsed);
 
             db::update_workspace_entry(
                 state.db.clone(),
@@ -241,8 +250,6 @@ pub async fn update_url(
                 Err(e) => return Err(UrlError::Db(e.to_string())),
             };
 
-            dbg!(&url_parsed, &params);
-
             Ok((url_parsed.into(), params))
         }
         Err(e) => {
@@ -252,12 +259,22 @@ pub async fn update_url(
     }
 }
 
+#[tauri::command]
+pub async fn cancel_request(state: tauri::State<'_, AppState>, req_id: i64) -> Result<(), String> {
+    state
+        .cancel_request(req_id)
+        .await
+        .map_err(|e| e.to_string())?;
+    Ok(())
+}
+
 #[tauri::command]
 pub async fn send_request(
     state: tauri::State<'_, AppState>,
     req_id: i64,
     env_id: Option<i64>,
-) -> Result<HttpResponse, String> {
+    on_complete: Channel<ResponseResult>,
+) -> Result<(), String> {
     let mut req = match db::get_workspace_request(state.db.clone(), req_id).await {
         Ok(req) => req,
         Err(e) => return Err(e.to_string()),
@@ -354,12 +371,18 @@ pub async fn send_request(
         }
     };
 
-    let response = match request::send(state.client.clone(), req).await {
-        Ok(res) => res,
-        Err(e) => return Err(e.to_string()),
-    };
+    state
+        .queue_request(req_id, req, on_complete)
+        .await
+        .map_err(|e| e.to_string())?;
 
-    Ok(response)
+    // let response = match request::send(state.client.clone(), req).await {
+    //     Ok(res) => res,
+    //     Err(e) => return Err(e.to_string()),
+    // };
+
+    // Ok(response)
+    Ok(())
 }
 
 #[tauri::command]

+ 76 - 73
src-tauri/src/db.rs

@@ -107,16 +107,19 @@ pub async fn create_workspace_entry(
             name,
             workspace_id,
             parent_id,
+            auth_inherit,
         } => {
             check_parent(&db, parent_id).await?;
             let entry = sqlx::query_as!(
                 WorkspaceEntryBase,
-                r#"INSERT INTO workspace_entries(name, workspace_id, parent_id, type) VALUES (?, ?, ?, ?) 
+                r#"INSERT INTO workspace_entries(name, workspace_id, parent_id, type, auth_inherit) VALUES (?, ?, ?, ?, ?) 
                    RETURNING id, workspace_id, parent_id, name, type, auth, auth_inherit"#,
                 name,
                 workspace_id,
                 parent_id,
-                1)
+                1,
+                auth_inherit,
+            )
             .fetch_one(&db).await?;
 
             Ok(entry)
@@ -127,30 +130,22 @@ pub async fn create_workspace_entry(
             parent_id,
             method,
             url,
+            auth_inherit,
         } => {
-            if let Some(parent) = parent_id {
-                let ty = sqlx::query!("SELECT type FROM workspace_entries WHERE id = ?", parent)
-                    .fetch_one(&db)
-                    .await?
-                    .r#type;
-
-                if !matches!(WorkspaceEntryType::from(ty), WorkspaceEntryType::Collection) {
-                    return Err(AppError::InvalidUpdate(format!(
-                        "{parent} is not a valid parent ID (type: {ty})"
-                    )));
-                }
-            }
+            check_parent(&db, parent_id).await?;
 
             let mut tx = db.begin().await?;
 
             let entry = match sqlx::query_as!(
                 WorkspaceEntryBase,
-                r#"INSERT INTO workspace_entries(name, workspace_id, parent_id, type) VALUES (?, ?, ?, ?) 
+                r#"INSERT INTO workspace_entries(name, workspace_id, parent_id, type, auth_inherit) VALUES (?, ?, ?, ?, ?) 
                    RETURNING id, workspace_id, name, parent_id, type, auth, auth_inherit"#,
                 name,
                 workspace_id,
                 parent_id,
-                0)
+                0,
+                auth_inherit
+            )
             .fetch_one(&mut *tx).await {
                 Ok(entry) => entry,
                 Err(e) => {
@@ -189,7 +184,7 @@ pub async fn insert_request_body(
     entry_id: i64,
     body: RequestBody,
 ) -> AppResult<EntryRequestBody> {
-    Ok(sqlx::query_as!(EntryRequestBody, r#"INSERT INTO request_bodies(request_id, content_type, body) VALUES (?, ?, ?) RETURNING id, content_type AS "content_type: _", body"#, entry_id, body.ty, body.path).fetch_one(&db).await?)
+    Ok(sqlx::query_as!(EntryRequestBody, r#"INSERT INTO request_bodies(request_id, content_type, body) VALUES (?, ?, ?) RETURNING id, content_type AS "content_type: _", body"#, entry_id, body.ty, body.content).fetch_one(&db).await?)
 }
 
 pub async fn update_request_body(
@@ -202,7 +197,7 @@ pub async fn update_request_body(
             sqlx::query!(
                 "UPDATE request_bodies SET content_type = ?, body = ? WHERE id = ?",
                 body.ty,
-                body.path,
+                body.content,
                 id,
             )
             .execute(&db)
@@ -452,72 +447,80 @@ pub async fn get_workspace_request(db: SqlitePool, id: i64) -> AppResult<Workspa
     ))
 }
 
-pub async fn list_workspace_entries(
-    db: SqlitePool,
-    workspace_id: i64,
-) -> AppResult<Vec<WorkspaceEntry>> {
-    let entries = sqlx::query_as!(
+pub async fn get_workspace_entry(db: SqlitePool, id: i64) -> AppResult<WorkspaceEntry> {
+    let entry = sqlx::query_as!(
         WorkspaceEntryBase,
-        "SELECT id, workspace_id, parent_id, name, type, auth, auth_inherit FROM workspace_entries WHERE workspace_id = ? ORDER BY type DESC",
-        workspace_id,
-    )
-    .fetch_all(&db)
-    .await?;
-
-    let mut request_params: HashMap<i64, RequestParams> = sqlx::query_as!(
-        RequestParams,
         r#"
-           SELECT rp.request_id as id, method as 'method!', url as 'url!', content_type as "content_type: _", body, rb.id as "body_id: _"
-           FROM request_params rp
-           LEFT JOIN request_bodies rb ON rp.request_id = rb.request_id
-           WHERE workspace_id = ? 
+        SELECT id, workspace_id, parent_id, name, type, auth, auth_inherit 
+        FROM workspace_entries 
+        WHERE id = ? 
+        LIMIT 1
         "#,
-        workspace_id
+        id,
     )
-    .fetch_all(&db)
-    .await?
-    .into_iter()
-    .map(|req| (req.id, req))
-    .collect();
-
-    let mut out: Vec<WorkspaceEntry> = vec![];
-
-    for entry in entries {
-        match entry.r#type {
-            WorkspaceEntryType::Request => {
-                let headers = sqlx::query_as!(
-                    RequestHeader,
-                    "SELECT id, name, value FROM request_headers WHERE request_id = ?",
-                    entry.id
-                )
-                .fetch_all(&db)
-                .await?;
+    .fetch_one(&db)
+    .await?;
 
-                let path_params = sqlx::query_as!(
-                    RequestPathParam,
-                    "SELECT position, name, value FROM request_path_params WHERE request_id = ?",
-                    entry.id
-                )
-                .fetch_all(&db)
-                .await?;
+    match entry.r#type {
+        WorkspaceEntryType::Request => {
+            let params = sqlx::query_as!(
+                RequestParams,
+                r#"
+                  SELECT
+                   rp.request_id as id,
+                   method as 'method!',
+                   url as 'url!',
+                   content_type as "content_type: _",
+                   body as "body: _",
+                   rb.id as "body_id: _"
+                  FROM request_params rp
+                  LEFT JOIN request_bodies rb ON rp.request_id = rb.request_id
+                  WHERE rp.request_id = ? 
+                  LIMIT 1
+                "#,
+                id
+            )
+            .fetch_one(&db)
+            .await?;
 
-                let Some(params) = request_params.remove(&entry.id) else {
-                    log::warn!("request {} has no params!", entry.id);
-                    continue;
-                };
+            dbg!(&params);
+
+            let headers = sqlx::query_as!(
+                RequestHeader,
+                "SELECT id, name, value FROM request_headers WHERE request_id = ?",
+                entry.id
+            )
+            .fetch_all(&db)
+            .await?;
 
-                let req =
-                    WorkspaceRequest::from_params_and_headers(entry, params, headers, path_params);
+            let path_params = sqlx::query_as!(
+                RequestPathParam,
+                "SELECT position, name, value FROM request_path_params WHERE request_id = ?",
+                entry.id
+            )
+            .fetch_all(&db)
+            .await?;
 
-                out.push(WorkspaceEntry::new_req(req));
-            }
-            WorkspaceEntryType::Collection => {
-                out.push(WorkspaceEntry::new_col(entry));
-            }
+            let req =
+                WorkspaceRequest::from_params_and_headers(entry, params, headers, path_params);
+
+            Ok(WorkspaceEntry::new_req(req))
         }
+        WorkspaceEntryType::Collection => Ok(WorkspaceEntry::new_col(entry)),
     }
+}
 
-    Ok(out)
+pub async fn list_workspace_entries(
+    db: SqlitePool,
+    workspace_id: i64,
+) -> AppResult<Vec<WorkspaceEntryBase>> {
+    Ok(sqlx::query_as!(
+        WorkspaceEntryBase,
+        "SELECT id, workspace_id, parent_id, name, type, auth, auth_inherit FROM workspace_entries WHERE workspace_id = ? ORDER BY type DESC",
+        workspace_id,
+    )
+    .fetch_all(&db)
+    .await?)
 }
 
 pub async fn list_environments(

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

@@ -36,12 +36,14 @@ pub fn run() {
             cmd::list_workspaces,
             cmd::create_workspace,
             cmd::list_workspace_entries,
+            cmd::get_workspace_entry,
             cmd::create_workspace_entry,
             cmd::update_workspace_entry,
             cmd::parse_url,
             cmd::update_url,
             cmd::expand_url,
             cmd::send_request,
+            cmd::cancel_request,
             cmd::list_environments,
             cmd::create_env,
             cmd::update_env,

+ 34 - 28
src-tauri/src/request.rs

@@ -45,20 +45,20 @@ pub async fn send(client: reqwest::Client, req: HttpRequestParameters) -> AppRes
                 ContentType::Text => insert_ct_if_missing(&mut headers, "text/plain"),
                 ContentType::Json => {
                     insert_ct_if_missing(&mut headers, "application/json");
-                    serde_json::from_str::<serde_json::Value>(&body.path)?;
+                    serde_json::from_str::<serde_json::Value>(&body.content)?;
                 }
                 ContentType::Xml => {
                     insert_ct_if_missing(&mut headers, "application/xml");
-                    roxmltree::Document::parse(&body.path)?;
+                    roxmltree::Document::parse(&body.content)?;
                 }
                 ContentType::FormUrlEncoded => {
-                    serde_urlencoded::from_str::<Vec<(&str, &str)>>(&body.path)
+                    serde_urlencoded::from_str::<Vec<(&str, &str)>>(&body.content)
                         .map_err(|e| AppError::SerdeUrl(e.to_string()))?;
                 }
                 // Handled by reqwest
                 ContentType::FormData => {}
             };
-            Some(Body::from(body.path))
+            Some(Body::from(body.content))
         }
         None => None,
     };
@@ -71,18 +71,15 @@ pub async fn send(client: reqwest::Client, req: HttpRequestParameters) -> AppRes
 
     let req = req.build()?;
 
-    dbg!(&req);
-
     let res = match client.execute(req).await {
         Ok(res) => {
             log::debug!(
-                "{} {} {:?} {:#?}",
+                "{} {} {:?}",
                 res.remote_addr()
                     .map(|addr| addr.to_string())
                     .unwrap_or(String::new()),
                 res.status(),
                 res.content_length(),
-                res.headers()
             );
             let status = res.status();
             let headers = res.headers().clone();
@@ -258,7 +255,7 @@ impl TryFrom<WorkspaceRequest> for HttpRequestParameters {
             method,
             headers,
             body: value.body.map(|body| RequestBody {
-                path: body.body,
+                content: body.body,
                 ty: body.content_type,
             }),
         })
@@ -268,7 +265,7 @@ impl TryFrom<WorkspaceRequest> for HttpRequestParameters {
 #[derive(Debug, Serialize)]
 pub struct HttpResponse {
     pub status: usize,
-    // pub headers: HeaderMap,
+    pub headers: Vec<(String, String)>,
     pub body: Option<ResponseBody>,
 }
 
@@ -276,15 +273,30 @@ impl HttpResponse {
     pub fn new(status: usize, headers: HeaderMap, body: Option<ResponseBody>) -> Self {
         Self {
             status,
-            // headers,
+            headers: headers
+                .into_iter()
+                .filter_map(|(k, v)| {
+                    let Some(k) = k else {
+                        log::warn!("Header key is none: {k:?}");
+                        return None;
+                    };
+                    let Some(v) = v.to_str().ok() else {
+                        log::warn!("Header value cannot be parsed: {v:?}");
+                        return None;
+                    };
+                    Some((k.as_str().to_string(), v.to_string()))
+                })
+                .collect(),
             body,
         }
     }
 }
 
-#[derive(Debug, Clone, Serialize)]
+#[derive(Debug, Serialize)]
+#[serde(tag = "type", content = "content")]
 pub enum ResponseBody {
-    Text(String),
+    TextPlain(String),
+    TextHtml(String),
 
     /// A pretty printed JSON string
     Json(String),
@@ -295,21 +307,14 @@ pub enum ResponseBody {
 }
 
 impl ResponseBody {
-    pub fn len(&self) -> usize {
-        match self {
-            ResponseBody::Text(t) => t.len(),
-            ResponseBody::Json(t) => t.len(),
-        }
-    }
-
     pub async fn try_from_response(res: reqwest::Response) -> AppResult<Option<Self>> {
         if res.content_length().is_none() {
-            log::debug!("Response no content");
+            log::warn!("Missing content-length header");
         }
 
         let Some(ct) = res.headers().get(header::CONTENT_TYPE) else {
             log::warn!("Response does not contain content-type header, attempting to read as text");
-            return Ok(Some(Self::Text(res.text().await?)));
+            return Ok(Some(Self::TextPlain(res.text().await?)));
         };
 
         let ct = match ct.to_str() {
@@ -323,20 +328,21 @@ impl ResponseBody {
         let ct: mime::Mime = ct.parse()?;
 
         if ct.subtype() == mime::JSON || ct.suffix().is_some_and(|s| s == mime::JSON) {
-            log::debug!("reading body");
             let json = serde_json::to_string_pretty(&res.json::<serde_json::Value>().await?)?;
-            log::debug!("body read");
             return Ok(Some(Self::Json(json)));
         }
 
         if ct.type_() == mime::TEXT {
-            log::debug!("reading body");
             let text = res.text().await?;
-            log::debug!("body read");
-            return Ok(Some(Self::Text(text)));
+            match ct.subtype() {
+                mime::HTML => return Ok(Some(Self::TextHtml(text))),
+                mime::XML => return Ok(Some(Self::TextHtml(text))),
+                _ => return Ok(Some(Self::TextPlain(text))),
+            }
         }
 
         log::warn!("Body did not match anything!");
+        log::debug!("{res:?}");
 
         Ok(None)
     }
@@ -389,7 +395,7 @@ pub struct RequestHeaderUpdate {
 
 #[derive(Debug, Serialize, Deserialize)]
 pub struct RequestBody {
-    pub path: String,
+    pub content: String,
     pub ty: ContentType,
 }
 

+ 108 - 2
src-tauri/src/state.rs

@@ -1,21 +1,127 @@
+use crate::{
+    request::{self, HttpRequestParameters, HttpResponse},
+    AppResult,
+};
+use futures::FutureExt;
+use serde::Serialize;
+use std::{collections::HashMap, time::Instant};
+use tauri::ipc::Channel;
 use tauri_plugin_log::log;
+use tokio::select;
+use tokio_stream::{StreamExt, StreamMap};
 
 pub struct AppState {
     /// Sqlite database. Just an Arc so cheap to clone.
     pub db: sqlx::sqlite::SqlitePool,
     pub client: reqwest::Client,
+    pub req_tx: tokio::sync::mpsc::Sender<OutboundRequest>,
+    pub cancel_tx: tokio::sync::mpsc::Sender<i64>,
 }
 
 impl AppState {
     pub async fn new() -> Self {
         log::info!("Connecting to DB");
+
         let db = crate::db::init("sqlite:/home/biblius/codium/rusty/rquest/rquest.db").await;
+        let client = reqwest::Client::new();
+
+        let (req_tx, mut req_rx) = tokio::sync::mpsc::channel::<OutboundRequest>(128);
+        let (cancel_tx, mut cancel_rx) = tokio::sync::mpsc::channel(128);
+
+        let c = client.clone();
+
+        tokio::spawn(async move {
+            log::info!("Spawning request queue runtime");
+
+            let mut timers = HashMap::<i64, Instant>::new();
+            let mut return_channels = HashMap::<i64, Channel<ResponseResult>>::new();
+            let mut outbound = StreamMap::new();
 
-        log::info!("State loaded");
+            loop {
+                select! {
+                    Some(req) = req_rx.recv() => {
+                        let send = Box::pin(request::send(c.clone(), req.req));
+                        outbound.insert(req.req_id, send.into_stream());
+                        timers.insert(req.req_id, Instant::now());
+                        return_channels.insert(req.req_id, req.return_channel);
+                    },
+                    Some(cancel) = cancel_rx.recv() => {
+                        if outbound.remove(&cancel).is_some(){
+                            let Some(start) = timers.remove(&cancel) else {
+                                continue;
+                            };
+                            log::debug!("cancelled request {cancel}; took {}ms", Instant::now().duration_since(start).as_millis());
+                        } else {
+                            log::warn!("cannot cancel request, does not exist: {cancel}");
+                            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());
+                        }
+                        let Some(channel) = return_channels.remove(&req_id) else {
+                            log::warn!("missing return channel for {req_id}");
+                            continue;
+                        };
+
+                        channel.send(res.into()).unwrap();
+                    },
+                }
+            }
+        });
 
         Self {
             db,
-            client: reqwest::Client::new(),
+            client,
+            req_tx,
+            cancel_tx,
+        }
+    }
+
+    pub async fn queue_request(
+        &self,
+        req_id: i64,
+        req: HttpRequestParameters,
+        channel: Channel<ResponseResult>,
+    ) -> AppResult<()> {
+        log::info!("Queueing request {req_id}: {req:?}");
+        let _ = self
+            .req_tx
+            .send(OutboundRequest {
+                req_id,
+                req,
+                return_channel: channel,
+            })
+            .await;
+        Ok(())
+    }
+
+    pub async fn cancel_request(&self, req_id: i64) -> AppResult<()> {
+        log::info!("Cancelling request {req_id}");
+        let _ = self.cancel_tx.send(req_id).await;
+        Ok(())
+    }
+}
+
+pub struct OutboundRequest {
+    req_id: i64,
+    req: HttpRequestParameters,
+    return_channel: Channel<ResponseResult>,
+}
+
+#[derive(Debug, Serialize)]
+#[serde(tag = "type", content = "data")]
+pub enum ResponseResult {
+    Ok(HttpResponse),
+    Err(String),
+}
+
+impl From<AppResult<HttpResponse>> for ResponseResult {
+    fn from(value: AppResult<HttpResponse>) -> Self {
+        match value {
+            Ok(res) => Self::Ok(res),
+            Err(e) => Self::Err(e.to_string()),
         }
     }
 }

+ 2 - 0
src-tauri/src/workspace.rs

@@ -77,6 +77,7 @@ pub enum WorkspaceEntryCreate {
         name: String,
         workspace_id: i64,
         parent_id: Option<i64>,
+        auth_inherit: bool,
     },
     Request {
         name: String,
@@ -84,6 +85,7 @@ pub enum WorkspaceEntryCreate {
         parent_id: Option<i64>,
         method: String,
         url: String,
+        auth_inherit: bool,
     },
 }
 

+ 3 - 3
src/app.html

@@ -1,12 +1,12 @@
 <!doctype html>
-<html lang="en">
+<html class="h-full" lang="en">
   <head>
     <meta charset="utf-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1" />
     <title>rquest</title>
     %sveltekit.head%
   </head>
-  <body data-sveltekit-preload-data="hover">
-    <div style="display: contents">%sveltekit.body%</div>
+  <body class="h-full" data-sveltekit-preload-data="hover">
+    <div class="h-full w-full flex">%sveltekit.body%</div>
   </body>
 </html>

+ 44 - 19
src/lib/codemirror.svelte.ts

@@ -3,34 +3,51 @@ import { Compartment, EditorState, type Extension } from "@codemirror/state";
 import { lineNumbers, ViewUpdate } from "@codemirror/view";
 import { vim } from "@replit/codemirror-vim";
 import { json as cmJson } from "@codemirror/lang-json";
+import { html as cmHtml } from "@codemirror/lang-html";
 import { getSetting, setSetting } from "./settings.svelte";
+import ls from "./localstorage";
 
 const jsonExt = cmJson();
 const vimExtension = vim();
 const relativeLines = lineNumbersRelative();
 
-let vimEnabled: boolean = $state(true);
-
-export async function initVimMode() {
-  vimEnabled = (await getSetting("vimMode")) ?? false;
-}
+let vimEnabled: boolean = $state(ls.VIM_MODE.get());
 
 export const isVimEnabled = () => vimEnabled;
 
 const stateChangeListener = new Compartment();
-const vimMode = new Compartment();
+const vimConfig = new Compartment();
+const lineWrapConfig = new Compartment();
+
+export function init(
+  id: string,
+  lineWrap: boolean,
+  vimMode: boolean,
+): EditorView {
+  const extensions = [
+    basicSetup,
+    cmHtml(),
+    jsonExt,
+    stateChangeListener.of(EditorView.updateListener.of(() => {})),
+  ];
+
+  const vimExtensions = [];
+
+  if (vimEnabled && vimMode) {
+    vimExtensions.push(vimExtension, relativeLines);
+  }
+
+  extensions.push(vimConfig.of(vimExtensions));
+
+  if (lineWrap) {
+    extensions.push(lineWrapConfig.of([EditorView.lineWrapping]));
+  } else {
+    extensions.push(lineWrapConfig.of([]));
+  }
 
-export function init(): EditorView {
   return new EditorView({
-    parent: document.querySelector("#editor") ?? undefined,
-    state: EditorState.create({
-      extensions: [
-        basicSetup,
-        jsonExt,
-        vimMode.of([vimExtension, relativeLines]),
-        stateChangeListener.of(EditorView.updateListener.of(() => {})),
-      ],
-    }),
+    parent: document.getElementById(id) ?? undefined,
+    state: EditorState.create({ extensions }),
   });
 }
 
@@ -67,12 +84,20 @@ export function toggleVim(view: EditorView) {
   vimEnabled = !vimEnabled;
 
   view.dispatch({
-    effects: vimMode.reconfigure(
+    effects: vimConfig.reconfigure(
       vimEnabled ? [vimExtension, relativeLines] : [],
     ),
   });
 
-  setSetting("vimMode", vimEnabled);
+  ls.VIM_MODE.set(vimEnabled);
+}
+
+export function toggleWrap(view: EditorView, value: boolean) {
+  view.dispatch({
+    effects: lineWrapConfig.reconfigure(value ? [EditorView.lineWrapping] : []),
+  });
+
+  ls.WRAP_RESPONSE.set(value);
 }
 
 /** Copy the contents of the editor to the system clipboard. */
@@ -98,7 +123,7 @@ export function formatJson(view: EditorView) {
 /**
  * Sets the gutter to display relative lines for VIM motions.
  */
-export function lineNumbersRelative(): Extension {
+function lineNumbersRelative(): Extension {
   return [lineNumbers({ formatNumber: relativeLineNumbers })];
 }
 

+ 2 - 4
src/lib/components/CodeMirror.svelte → src/lib/components/BodyEditor.svelte

@@ -11,7 +11,6 @@
     setUpdateHandler,
     toggleVim,
     isVimEnabled,
-    initVimMode,
   } from "$lib/codemirror.svelte";
   import { init } from "$lib/codemirror.svelte";
 
@@ -20,10 +19,9 @@
   let view: EditorView;
 
   onMount(async () => {
-    view = init();
-    setUpdateHandler(view, onStateChange);
+    view = init("editor", false, true);
 
-    await initVimMode();
+    setUpdateHandler(view, onStateChange);
   });
 
   $effect(() => {

+ 30 - 12
src/lib/components/Header.svelte

@@ -14,6 +14,12 @@
   } from "$lib/state.svelte";
   import { Input } from "$lib/components/ui/input";
   import type { Workspace } from "$lib/types";
+  import {
+    ArrowLeftCircleIcon,
+    ArrowUpRight,
+    SendToBack,
+    StepBack,
+  } from "@lucide/svelte";
 
   let { workspaces = $bindable() }: { workspaces: Workspace[] } = $props();
 
@@ -81,23 +87,35 @@
 
   {#if _state.entry != null}
     <div class="h-8 flex mx-auto items-center opacity-75">
-      {#each referenceChain as ref}
+      {#if referenceChain.length < 4}
+        {#each referenceChain as ref}
+          <Button
+            class="p-0 h-fit cursor-pointer text-sm"
+            onclick={() => selectEntry(ref.id)}
+            variant="ghost"
+          >
+            {ref.name || ref.type + "(" + ref.id + ")"}
+          </Button>
+          <p class="pl-1 pr-1">/</p>
+        {/each}
+        <Button
+          class="p-0 h-fit cursor-pointer text-sm"
+          onclick={() => selectEntry(_state.entry!!.id)}
+          variant="ghost"
+        >
+          {_state.entry.name || _state.entry.type + "(" + _state.entry.id + ")"}
+        </Button>
+      {:else}
         <Button
           class="p-0 h-fit cursor-pointer text-sm"
-          onclick={() => selectEntry(ref.id)}
+          onclick={() =>
+            selectEntry(referenceChain[referenceChain.length - 1].id)}
           variant="ghost"
         >
-          {ref.name || ref.type + "(" + ref.id + ")"}
+          <StepBack />
+          {_state.entry.name || _state.entry.type + "(" + _state.entry.id + ")"}
         </Button>
-        <p class="pl-1 pr-1">/</p>
-      {/each}
-      <Button
-        class="p-0 h-fit cursor-pointer text-sm"
-        onclick={() => selectEntry(_state.entry!!.id)}
-        variant="ghost"
-      >
-        {_state.entry.name || _state.entry.type + "(" + _state.entry.id + ")"}
-      </Button>
+      {/if}
     </div>
   {/if}
 

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

@@ -0,0 +1,23 @@
+<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}

+ 101 - 0
src/lib/components/Response.svelte

@@ -0,0 +1,101 @@
+<script lang="ts">
+  import { state as _state } from "$lib/state.svelte";
+  import { Button } from "$lib/components/ui/button";
+  import { Badge } from "$lib/components/ui/badge/index.js";
+  import { Clipboard, Dot, TextWrap } from "@lucide/svelte";
+  import { init } from "$lib/codemirror.svelte";
+  import * as Tabs from "$lib/components/ui/tabs";
+  import ls from "$lib/localstorage";
+  import { toggleWrap, setContent, copyContent } from "$lib/codemirror.svelte";
+  import type { EditorView } from "codemirror";
+  import { onMount } from "svelte";
+
+  let response = $derived(_state.responses[_state.entry!!.id]);
+  let wrap = $state(ls.WRAP_RESPONSE.get());
+
+  let view: EditorView;
+
+  function handleToggleWrap() {
+    wrap = !wrap;
+    toggleWrap(view, wrap);
+  }
+
+  onMount(async () => {
+    view = init("response-view", wrap, false);
+  });
+
+  $effect(() => {
+    if (
+      response.body != null &&
+      response.body.content !== view.state.doc.toString()
+    ) {
+      setContent(view, response.body.content);
+    }
+  });
+
+  let borderColor = $derived.by(() => {
+    if (response.status >= 200 && response.status < 300)
+      return "border-green-900";
+    if (response.status >= 400 && response.status < 500)
+      return "border-orange-900";
+    if (response.status >= 500) return "border-red-900";
+  });
+
+  let dotColor = $derived.by(() => {
+    if (response.status >= 200 && response.status < 300) return "green";
+    if (response.status >= 400 && response.status < 500) return "orange";
+    if (response.status >= 500) return "red";
+  });
+</script>
+
+<Tabs.Root value="body" class="min-h-0">
+  <header class="flex items-center w-full px-2 py-2 gap-4">
+    <Badge variant="outline" class={`rounded-sm ${borderColor}`}>
+      <Dot strokeWidth={5} color={dotColor} />
+      {response.status}
+    </Badge>
+
+    <Tabs.List>
+      <Tabs.Trigger class="text-xs" value="body">Body</Tabs.Trigger>
+      <Tabs.Trigger class="text-xs" value="headers">Headers</Tabs.Trigger>
+    </Tabs.List>
+
+    <Tabs.Content value="body">
+      <div class="justify-center relative">
+        <Button
+          variant={wrap ? "default" : "outline"}
+          onclick={handleToggleWrap}
+          size="icon-sm"><TextWrap /></Button
+        >
+        <Button
+          onclick={() => copyContent(view)}
+          variant="outline"
+          size="icon-sm"><Clipboard /></Button
+        >
+      </div>
+    </Tabs.Content>
+  </header>
+
+  <Tabs.Content
+    value="body"
+    class="pb-5 flex flex-col w-full max-h-11/12 overflow-scroll"
+  >
+    {#if response.body != null}
+      <!-- EDITOR -->
+
+      <div id="response-view" class="rounded-md"></div>
+    {/if}
+  </Tabs.Content>
+
+  <Tabs.Content
+    value="headers"
+    class="flex flex-col w-full max-h-10/12 overflow-scroll"
+  >
+    <div class="grid grid-cols-2">
+      {#each response.headers as [name, value]}
+        <p>{name}</p>
+        <p class="wrap-anywhere">{value}</p>
+      {/each}
+    </div>
+  </Tabs.Content>
+</Tabs.Root>

+ 0 - 10
src/lib/components/Sidebar.svelte

@@ -2,22 +2,12 @@
   import * as Sidebar from "$lib/components/ui/sidebar/index.js";
   import * as DropdownMenu from "./ui/dropdown-menu/index";
   import { Button } from "$lib/components/ui/button/index.js";
-  import type { Workspace } from "$lib/types";
-  import { Input } from "./ui/input";
-  import { onMount } from "svelte";
   import {
     state as _state,
-    listWorkspaces,
-    loadWorkspace,
-    createWorkspace,
-    selectWorkspace,
-    selectEntry,
     createCollection,
     createRequest,
-    selectEnvironment,
   } from "$lib/state.svelte";
   import SidebarEntry from "./SidebarEntry.svelte";
-  import { getSetting } from "$lib/settings.svelte";
   import { Plus } from "@lucide/svelte";
 
   let { onSelect } = $props();

+ 12 - 10
src/lib/components/SidebarEntry.svelte

@@ -14,18 +14,19 @@
   import { ChevronDown, ChevronRight } from "@lucide/svelte";
 
   const isSelected = $derived(_state.entry?.id === id);
+  const isParentSelected = $derived(_state.entry?.parent_id === id);
 </script>
 
-<div
-  class="p-1"
-  style={`margin-left: ${level}rem`}
-  class:bg-secondary={isSelected}
->
-  <div class="border-l p-0 m-0 flex items-center transition-colors rounded-l">
+<div class="p-0 m-0" style={`margin-left: ${level}rem`}>
+  <div
+    class="border-l m-0 flex items-center transition-colors"
+    class:bg-primary={isSelected}
+    class:bg-secondary={isParentSelected}
+  >
     {#if _state.indexes[id]!!.type === "Collection"}
       {#if _state.indexes[id]!!.open}
         <Button
-          class="p-1 w-fit"
+          class="rounded-none cursor-pointer w-fit px-1"
           variant="ghost"
           size="icon-sm"
           onclick={() => {
@@ -36,7 +37,7 @@
         </Button>
       {:else}
         <Button
-          class="p-0 w-fit"
+          class="rounded-none cursor-pointer w-fit px-1"
           variant="ghost"
           size="icon-sm"
           onclick={() => {
@@ -49,13 +50,14 @@
     {/if}
 
     <p
-      class="w-full cursor-pointer"
+      class="w-full cursor-pointer py-1"
       onclick={(e) => {
         e.stopPropagation();
         selectEntry(id);
         onSelect();
         setSetting("lastEntry", _state.indexes[id]);
       }}
+      class:ml-2={_state.indexes[id].type === "Request"}
     >
       {_state.indexes[id].name ||
         _state.indexes[id].type + "(" + _state.indexes[id].id + ")"}
@@ -86,7 +88,7 @@
 
   {#if _state.indexes[id].open && _state.children[id]?.length > 0}
     {#each _state.children[id] as child}
-      <Self id={child} level={level + 0.25} {onSelect} />
+      <Self id={child} level={level + 0.1} {onSelect} />
     {/each}
   {/if}
 </div>

+ 58 - 80
src/lib/components/WorkspaceEntry.svelte

@@ -2,10 +2,10 @@
   import * as Select from "$lib/components/ui/select";
   import {
     state as _state,
+    cancelRequest,
     deleteBody,
     deleteHeader,
     insertHeader,
-    selectEntry,
     sendRequest,
     setEntryAuth,
     updateBodyContent,
@@ -16,66 +16,57 @@
   import { Button } from "$lib/components/ui/button";
   import { Input } from "$lib/components/ui/input";
   import * as Tabs from "$lib/components/ui/tabs";
-  import type { UrlError, WorkspaceEntry } from "$lib/types";
+  import type { HttpResponse, UrlError, WorkspaceEntry } from "$lib/types";
   import Editable from "./Editable.svelte";
-  import Highlight, { LineNumbers } from "svelte-highlight";
-  import json from "svelte-highlight/languages/json";
-  import { atelierForest } from "svelte-highlight/styles";
   import { Loader, PlusIcon, Trash } from "@lucide/svelte";
-  import CodeMirror from "./CodeMirror.svelte";
+  import BodyEditor from "./BodyEditor.svelte";
   import * as Resizable from "$lib/components/ui/resizable/index";
   import AuthParams from "./AuthParams.svelte";
-  import Checkbox from "./ui/checkbox/checkbox.svelte";
+  import Response from "./Response.svelte";
 
   let requestPane: Resizable.Pane;
   let responsePane: Resizable.Pane;
-  let isSending = $state(false);
-  let response: any = $state();
+
+  let isSending = $derived(_state.pendingRequests.includes(_state.entry!!.id));
+
+  let updateUrlTimeout: number | undefined = $state();
 
   const parentAuth = $derived.by(() => {
     let parentId = _state.entry!!.parent_id;
 
     while (parentId != null) {
-      const parent = _state.indexes[parentId];
+      const entry = _state.indexes[parentId];
 
-      if (!parent) {
+      if (!entry) {
         console.warn("Parent index is null", parentId);
-        return;
+        return null;
       }
 
-      if (parent.auth_inherit) {
-        parentId = parent.id;
+      if (entry.auth_inherit) {
+        parentId = entry.parent_id;
         continue;
       }
 
-      if (parent.auth == null) {
+      if (entry.auth == null) {
         return null;
       }
 
-      return _state.auth.find((a) => a.id === parent.auth) ?? null;
+      return _state.auth.find((a) => a.id === entry.auth) ?? null;
     }
   });
 
-  const referenceChain = $derived.by(() => {
-    const parents = [];
-
-    let parent = _state.entry!!.parent_id;
-
-    while (parent != null) {
-      parents.push(_state.indexes[parent]);
-      parent = _state.indexes[parent].parent_id;
+  async function handleRequest() {
+    if (isSending) {
+      try {
+        await cancelRequest();
+      } catch (e) {
+        console.error("error cancelling request", e);
+      }
+      return;
     }
 
-    return parents.reverse();
-  });
-
-  async function handleSendRequest() {
-    if (isSending) return;
-    console.time("request");
-
-    isSending = true;
     try {
-      response = await sendRequest();
+      await sendRequest();
     } catch (e) {
       console.error("error sending request", e);
     } finally {
@@ -83,8 +74,6 @@
         requestPane.resize(50);
         responsePane.resize(50);
       }
-      console.timeEnd("request");
-      isSending = false;
     }
   }
 
@@ -150,10 +139,6 @@
   }
 </script>
 
-<svelte:head>
-  {@html atelierForest}
-</svelte:head>
-
 {#snippet authParams(
   entry: WorkspaceEntry & { auth: number | null; auth_inherit: boolean },
 )}
@@ -166,7 +151,7 @@
         {#if entry.auth != null && !entry.auth_inherit}
           {_state.auth.find((a) => a.id === entry.auth)?.name}
         {:else if entry.auth_inherit}
-          Inherit ({parentAuth?.name})
+          Inherit ({parentAuth?.name || "-"})
         {:else}
           -
         {/if}
@@ -183,16 +168,18 @@
           </Select.Item>
         {/if}
 
-        <Select.Separator />
+        {#if _state.auth.length > 0}
+          <Select.Separator />
 
-        {#each _state.auth as auth}
-          <Select.Item
-            onclick={() => setEntryAuth(auth.id, false)}
-            value={auth.id.toString()}
-          >
-            {auth.name}
-          </Select.Item>
-        {/each}
+          {#each _state.auth as auth}
+            <Select.Item
+              onclick={() => setEntryAuth(auth.id, false)}
+              value={auth.id.toString()}
+            >
+              {auth.name}
+            </Select.Item>
+          {/each}
+        {/if}
       </Select.Content>
     </Select.Root>
   </div>
@@ -211,6 +198,8 @@
   {/if}
 {/snippet}
 
+<!-- BEGIN COMPONENT -->
+
 {#if _state.entry?.type === "Collection"}
   <!-- COLLECTION VIEW -->
 
@@ -250,24 +239,25 @@
   <section class="h-[90%] space-y-4">
     <!-- URL BAR -->
 
-    <div class="flex flex-wrap gap-3 mx-auto">
+    <div class="flex flex-wrap w-full gap-3 mx-auto">
       <Input
-        class="w-10/12 flex font-mono"
+        class="flex-1 font-mono"
         bind:value={_state.entry.url}
         placeholder="https://api.example.com/resource"
         oninput={() => {
-          handleUrlUpdate(true);
+          if (updateUrlTimeout !== undefined) {
+            clearTimeout(updateUrlTimeout);
+          }
+          updateUrlTimeout = setTimeout(() => {
+            handleUrlUpdate(true);
+          }, 200);
         }}
       />
 
-      <Button
-        class="w-1/12 flex items-center justify-center gap-2"
-        disabled={isSending}
-        onclick={handleSendRequest}
-      >
+      <Button class="flex items-center justify-center" onclick={handleRequest}>
         {#if isSending}
           <Loader class="h-4 w-4 animate-spin" />
-          Sending
+          Cancel
         {:else}
           Send
         {/if}
@@ -280,7 +270,7 @@
 
     <!-- ================= REQUEST PANEL ================= -->
 
-    <Resizable.PaneGroup direction="vertical" class="flex-1 w-full rounded-lg">
+    <Resizable.PaneGroup direction="vertical" class="flex-1 w-full ">
       <Resizable.Pane defaultSize={100} bind:this={requestPane}>
         <Tabs.Root value="params" class="h-full flex flex-col">
           <Tabs.List class="shrink-0">
@@ -385,7 +375,7 @@
                 </Tabs.List>
 
                 <Tabs.Content value="json">
-                  <CodeMirror
+                  <BodyEditor
                     input={_state.entry.body?.body}
                     onStateChange={(update) => {
                       if (
@@ -424,7 +414,7 @@
 
       <Resizable.Handle
         withHandle
-        class="p-1.5"
+        class="p-0.5"
         ondblclick={() => {
           requestPane.resize(50);
           responsePane.resize(50);
@@ -433,29 +423,17 @@
 
       <!-- RESPONSE -->
 
-      <Resizable.Pane class="p-2" defaultSize={0} bind:this={responsePane}>
+      <Resizable.Pane
+        class="flex flex-col min-h-0"
+        defaultSize={0}
+        bind:this={responsePane}
+      >
         {#if isSending}
           <div class="flex justify-center py-8">
             <Loader class="h-6 w-6 animate-spin text-muted-foreground" />
           </div>
-        {:else if response}
-          <!-- Prevents line number selection -->
-          <p>{response.status}</p>
-          <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>
+        {:else if _state.responses[_state.entry!!.id]}
+          <Response />
         {/if}
       </Resizable.Pane>
     </Resizable.PaneGroup>

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

@@ -0,0 +1,17 @@
+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;

+ 14 - 0
src/lib/localstorage.ts

@@ -0,0 +1,14 @@
+const WRAP_RESPONSE = "wrap-response";
+const VIM_MODE = "vim-mode";
+
+export default {
+  WRAP_RESPONSE: {
+    get: () => localStorage.getItem(WRAP_RESPONSE) === "true",
+    set: (value: boolean) =>
+      localStorage.setItem(WRAP_RESPONSE, value.toString()),
+  },
+  VIM_MODE: {
+    get: () => localStorage.getItem(VIM_MODE) === "true",
+    set: (value: boolean) => localStorage.setItem(VIM_MODE, value.toString()),
+  },
+};

+ 1 - 1
src/lib/settings.svelte.ts

@@ -47,6 +47,6 @@ export async function setSetting<K extends keyof Settings>(
   key: K,
   value: Settings[K],
 ) {
-  console.debug("setting", key, value);
+  console.debug("setting", key, $state.snapshot(value));
   return store.set(key, value);
 }

+ 117 - 37
src/lib/state.svelte.ts

@@ -1,4 +1,4 @@
-import { invoke } from "@tauri-apps/api/core";
+import { Channel, invoke } from "@tauri-apps/api/core";
 import type {
   Workspace,
   WorkspaceEntryBase,
@@ -11,6 +11,8 @@ import type {
   RequestPathParam,
   Authentication,
   AuthType,
+  HttpResponse,
+  ResponseResult,
 } from "./types";
 import { getSetting, setSetting } from "./settings.svelte";
 
@@ -31,14 +33,14 @@ export type WorkspaceState = {
   roots: number[];
 
   /**
-   * Workspace entry parent => children mappings.
+   * Workspace entry root => children mappings.
    */
   children: Record<number, number[]>;
 
   /**
    * All workspace entries.
    */
-  indexes: Record<number, WorkspaceEntry>;
+  indexes: Record<number, WorkspaceEntryBase>;
 
   /**
    * Currently selected workspace environments.
@@ -54,6 +56,16 @@ export type WorkspaceState = {
    * All workspace authentication schemes.
    */
   auth: Authentication[];
+
+  /**
+   * Set of pending sent requests.
+   */
+  pendingRequests: number[];
+
+  /**
+   * Maps request IDs to their latest response.
+   */
+  responses: Record<number, HttpResponse>;
 };
 
 export const state: WorkspaceState = $state({
@@ -65,6 +77,8 @@ export const state: WorkspaceState = $state({
   environments: [],
   environment: null,
   auth: [],
+  pendingRequests: [],
+  responses: {},
 });
 
 const index = (entry: WorkspaceEntry) => {
@@ -90,6 +104,8 @@ function reset() {
   state.environment = null;
   state.environments = [];
   state.auth = [];
+  state.pendingRequests = [];
+  state.responses = {};
 }
 
 export async function selectEnvironment(
@@ -132,7 +148,27 @@ export function selectWorkspace(ws: Workspace) {
 }
 
 export async function selectEntry(id: number) {
-  state.entry = state.indexes[id];
+  const entry = await invoke<WorkspaceEntryResponse>("get_workspace_entry", {
+    entryId: id,
+  });
+
+  switch (entry.type) {
+    case "Collection": {
+      state.entry = entry.data;
+      break;
+    }
+    case "Request": {
+      state.entry = {
+        ...entry.data.entry,
+        method: entry.data.method,
+        url: entry.data.url,
+        headers: entry.data.headers,
+        body: entry.data.body,
+        path: entry.data.path_params,
+      };
+      break;
+    }
+  }
 
   console.log("selected entry:", $state.snapshot(state.entry));
 
@@ -183,26 +219,15 @@ export async function loadWorkspace(ws: Workspace) {
 
   state.workspace = ws;
 
-  const entries = await invoke<WorkspaceEntryResponse[]>(
-    "list_workspace_entries",
-    {
-      id: state.workspace.id,
-    },
-  );
+  const entries = await invoke<WorkspaceEntryBase[]>("list_workspace_entries", {
+    id: state.workspace.id,
+  });
 
   for (const entry of entries) {
-    if (entry.type === "Request") {
-      index({
-        ...entry.data.entry,
-        method: entry.data.method,
-        url: entry.data.url,
-        headers: entry.data.headers,
-        body: entry.data.body,
-        path: entry.data.path_params,
-      });
-    } else {
-      index(entry.data);
-    }
+    // if (entry.type === "Request") {
+    // } else {
+    index(entry);
+    // }
   }
 
   await loadEnvironments(state.workspace.id);
@@ -211,7 +236,7 @@ export async function loadWorkspace(ws: Workspace) {
 
 export function createRequest(parentId?: number) {
   if (state.workspace == null) {
-    console.warn("create workspace request called with no active workspace");
+    console.warn("create request called with no active workspace");
     return;
   }
 
@@ -222,27 +247,22 @@ export function createRequest(parentId?: number) {
       parent_id: parentId,
       method: "GET",
       url: "",
+      auth_inherit: parentId !== undefined,
     },
   };
 
   invoke<WorkspaceEntryBase>("create_workspace_entry", {
     data,
   }).then((entry) => {
-    index({
-      ...entry,
-      method: data.Request.method,
-      url: data.Request.url,
-      body: null,
-      headers: [],
-      path: [],
-    });
+    index(entry);
+    selectEntry(entry.id);
     console.log("request created:", entry);
   });
 }
 
 export function createCollection(parentId?: number) {
   if (state.workspace == null) {
-    console.warn("create workspace request called with no active workspace");
+    console.warn("create collection called with no active workspace");
     return;
   }
 
@@ -251,12 +271,14 @@ export function createCollection(parentId?: number) {
       name: "",
       workspace_id: state.workspace.id,
       parent_id: parentId,
+      auth_inherit: parentId !== undefined,
     },
   };
   invoke<WorkspaceEntryBase>("create_workspace_entry", {
     data,
   }).then((entry) => {
     index(entry);
+    selectEntry(entry.id);
     console.log("collection created:", entry);
   });
 }
@@ -300,15 +322,67 @@ export async function updateEnvironment() {
   });
 }
 
-export async function sendRequest(): Promise<any> {
-  const res = await invoke("send_request", {
-    reqId: state.entry!!.id,
+export async function sendRequest(): Promise<void> {
+  const reqId = state.entry!!.id;
+
+  if (state.pendingRequests.includes(reqId)) {
+    console.warn("request is already pending", reqId);
+  }
+
+  console.log("sending request", reqId);
+
+  const onComplete = new Channel<ResponseResult>();
+
+  console.time("request-" + reqId);
+
+  onComplete.onmessage = (response) => {
+    console.log("received response", response);
+
+    switch (response.type) {
+      case "Ok": {
+        state.responses[state.entry!!.id] = response.data;
+        console.log(state.responses);
+        break;
+      }
+      case "Err": {
+        console.error("received response error", response.data);
+        break;
+      }
+      default: {
+        console.error("unrecognized response type", response.type);
+        break;
+      }
+    }
+
+    console.timeEnd("request-" + reqId);
+
+    state.pendingRequests = state.pendingRequests.filter((id) => id !== reqId);
+  };
+
+  await invoke<HttpResponse>("send_request", {
+    reqId,
     envId: state.environment?.id,
+    onComplete,
   });
 
-  console.debug(res);
+  state.pendingRequests.push(reqId);
+}
+
+export async function cancelRequest(): Promise<void> {
+  if (!state.pendingRequests.includes(state.entry!!.id)) {
+    console.warn("nothing to cancel!");
+    return;
+  }
 
-  return res;
+  console.log("cancelling request");
+
+  await invoke("cancel_request", { reqId: state.entry!!.id });
+
+  console.timeEnd("request-" + state.entry!!.id);
+
+  state.pendingRequests = state.pendingRequests.filter(
+    (id) => id !== state.entry!!.id,
+  );
 }
 
 export async function updateEntryName(name: string) {
@@ -334,6 +408,8 @@ export async function updateEntryName(name: string) {
     entryId: state.entry.id,
     data,
   });
+
+  state.indexes[state.entry.id].name = name;
 }
 
 export async function parseUrl(url: string) {
@@ -494,7 +570,9 @@ export async function createAuth(type: AuthType) {
     workspaceId: state.workspace!!.id,
     type,
   });
+
   console.debug("created auth", auth);
+
   state.auth.unshift(auth);
 }
 
@@ -543,9 +621,11 @@ export async function setEntryAuth(id: number | null, inherit: boolean | null) {
   });
 
   state.entry!!.auth = id;
+  state.indexes[state.entry!!.id].auth = id;
 
   if (inherit != null) {
     state.entry!!.auth_inherit = inherit;
+    state.indexes[state.entry!!.id].auth_inherit = inherit;
   }
 }
 

+ 27 - 0
src/lib/types.ts

@@ -146,3 +146,30 @@ export type AuthParams =
     };
 
 export type AuthType = "Token" | "Basic" | "OAuth";
+
+export type HttpResponse = {
+  status: number;
+  headers: string[][];
+  body: HttpResponseBody | null;
+};
+
+export type HttpResponseBody =
+  | {
+      type: "TextPlain";
+      content: string;
+    }
+  | {
+      type: "TextHtml";
+      content: string;
+    }
+  | {
+      type: "Json";
+      content: string;
+    };
+
+export type ResponseResult =
+  | {
+      type: "Ok";
+      data: HttpResponse;
+    }
+  | { type: "Err"; data: string };

+ 57 - 64
src/routes/+page.svelte

@@ -42,72 +42,65 @@
   });
 </script>
 
-<Sidebar.Provider style="--sidebar-width: 3.5rem">
-  <div class="w-full">
-    <Resizable.PaneGroup direction="horizontal">
-      <Resizable.Pane bind:this={sidePane} defaultSize={15}>
-        <AppSidebar onSelect={() => (displayModal = null)} />
-      </Resizable.Pane>
+<Resizable.PaneGroup direction="horizontal">
+  <Resizable.Pane bind:this={sidePane} defaultSize={15}>
+    <AppSidebar onSelect={() => (displayModal = null)} />
+  </Resizable.Pane>
 
-      <Resizable.Handle
-        class="p-0.5"
-        ondblclick={() => {
-          sidePane.resize(15);
-          mainPane.resize(85);
-        }}
-      />
+  <Resizable.Handle
+    class="p-0.5"
+    ondblclick={() => {
+      sidePane.resize(15);
+      mainPane.resize(85);
+    }}
+  />
 
-      <Resizable.Pane bind:this={mainPane} defaultSize={85}>
-        <main class="w-full h-full p-4 space-y-4">
-          <Header {workspaces} />
-          {#if displayModal === "env"}
-            <Environment />
-          {:else if displayModal === "auth"}
-            <Auth />
-          {:else if _state.entry}
-            <WorkspaceEntry />
-          {:else}{/if}
-        </main>
-      </Resizable.Pane>
-    </Resizable.PaneGroup>
-  </div>
+  <Resizable.Pane bind:this={mainPane} defaultSize={85}>
+    <main class="w-full h-full p-4 space-y-4">
+      <Header {workspaces} />
+      {#if displayModal === "env"}
+        <Environment />
+      {:else if displayModal === "auth"}
+        <Auth />
+      {:else if _state.entry}
+        <WorkspaceEntry />
+      {:else}{/if}
+    </main>
+  </Resizable.Pane>
+</Resizable.PaneGroup>
 
-  <Sidebar.Root fixed={false} variant="floating" side="right">
-    <Sidebar.Menu class="items-center">
-      <Sidebar.MenuItem class="pt-2">
-        <Button onclick={toggleMode} variant="ghost" size="icon-sm">
-          <SunIcon
-            class="h-1 w-1 scale-100 rotate-0 transition-all! dark:scale-0 dark:-rotate-90"
-          />
-          <MoonIcon
-            class="absolute h-1 w-1 scale-0 rotate-90 transition-all! dark:scale-100 dark:rotate-0"
-          />
-          <span class="sr-only">Toggle theme</span>
-        </Button>
-      </Sidebar.MenuItem>
+<Sidebar.Menu class="bg-sidebar rounded-xl p-1 my-2 mr-2 w-fit items-center">
+  <Sidebar.MenuItem class="pt-2">
+    <Button onclick={toggleMode} variant="ghost" size="icon-sm">
+      <SunIcon
+        class="h-1 w-1 scale-100 rotate-0 transition-all! dark:scale-0 dark:-rotate-90"
+      />
+      <MoonIcon
+        class="absolute h-1 w-1 scale-0 rotate-90 transition-all! dark:scale-100 dark:rotate-0"
+      />
+      <span class="sr-only">Toggle theme</span>
+    </Button>
+  </Sidebar.MenuItem>
 
-      <Sidebar.MenuItem class="pt-2">
-        <Button
-          onclick={() => (displayModal = displayModal === "env" ? null : "env")}
-          variant={displayModal === "env" ? "default" : "ghost"}
-          size="icon-sm"
-        >
-          <SlidersHorizontal />
-          <span class="sr-only">Display environment</span>
-        </Button>
-      </Sidebar.MenuItem>
+  <Sidebar.MenuItem class="pt-2">
+    <Button
+      onclick={() => (displayModal = displayModal === "env" ? null : "env")}
+      variant={displayModal === "env" ? "default" : "ghost"}
+      size="icon-sm"
+    >
+      <SlidersHorizontal />
+      <span class="sr-only">Display environment</span>
+    </Button>
+  </Sidebar.MenuItem>
 
-      <Sidebar.MenuItem class="pt-2">
-        <Button
-          onclick={() =>
-            (displayModal = displayModal === "auth" ? null : "auth")}
-          variant={displayModal === "auth" ? "default" : "ghost"}
-          size="icon-sm"
-        >
-          <Lock />
-          <span class="sr-only">Display auth</span>
-        </Button>
-      </Sidebar.MenuItem>
-    </Sidebar.Menu>
-  </Sidebar.Root>
-</Sidebar.Provider>
+  <Sidebar.MenuItem class="pt-2">
+    <Button
+      onclick={() => (displayModal = displayModal === "auth" ? null : "auth")}
+      variant={displayModal === "auth" ? "default" : "ghost"}
+      size="icon-sm"
+    >
+      <Lock />
+      <span class="sr-only">Display auth</span>
+    </Button>
+  </Sidebar.MenuItem>
+</Sidebar.Menu>

+ 1 - 1
src/routes/layout.css

@@ -46,7 +46,7 @@
   --card-foreground: oklch(0.985 0.001 106.423);
   --popover: oklch(0.216 0.006 56.043);
   --popover-foreground: oklch(0.985 0.001 106.423);
-  --primary: oklch(0.923 0.003 48.717);
+  --primary: oklch(0.723 0.003 48.717);
   --primary-foreground: oklch(0.216 0.006 56.043);
   --secondary: oklch(0.268 0.007 34.298);
   --secondary-foreground: oklch(0.985 0.001 106.423);