Bladeren bron

add code editor and response highlighting

biblius 2 weken geleden
bovenliggende
commit
ef4c90d04e

+ 241 - 1
package-lock.json

@@ -9,6 +9,10 @@
       "version": "0.1.0",
       "license": "MIT",
       "dependencies": {
+        "@codemirror/lang-javascript": "^6.2.4",
+        "@codemirror/lang-json": "^6.0.2",
+        "@codemirror/state": "^6.5.3",
+        "@replit/codemirror-vim": "^6.3.0",
         "@tailwindcss/vite": "^4.1.17",
         "@tauri-apps/api": "^2",
         "@tauri-apps/plugin-global-shortcut": "^2.3.1",
@@ -16,9 +20,12 @@
         "@tauri-apps/plugin-opener": "^2",
         "@tauri-apps/plugin-store": "^2.4.1",
         "clsx": "^2.1.1",
+        "codemirror": "^6.0.2",
         "mode-watcher": "^1.1.0",
+        "svelte-highlight": "^7.9.0",
         "tailwind-merge": "^3.4.0",
-        "tailwindcss": "^4.1.17"
+        "tailwindcss": "^4.1.17",
+        "thememirror": "^2.0.1"
       },
       "devDependencies": {
         "@internationalized/date": "^3.10.0",
@@ -41,6 +48,112 @@
         "vite": "^6.0.3"
       }
     },
+    "node_modules/@codemirror/autocomplete": {
+      "version": "6.20.0",
+      "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz",
+      "integrity": "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==",
+      "license": "MIT",
+      "dependencies": {
+        "@codemirror/language": "^6.0.0",
+        "@codemirror/state": "^6.0.0",
+        "@codemirror/view": "^6.17.0",
+        "@lezer/common": "^1.0.0"
+      }
+    },
+    "node_modules/@codemirror/commands": {
+      "version": "6.10.1",
+      "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.1.tgz",
+      "integrity": "sha512-uWDWFypNdQmz2y1LaNJzK7fL7TYKLeUAU0npEC685OKTF3KcQ2Vu3klIM78D7I6wGhktme0lh3CuQLv0ZCrD9Q==",
+      "license": "MIT",
+      "dependencies": {
+        "@codemirror/language": "^6.0.0",
+        "@codemirror/state": "^6.4.0",
+        "@codemirror/view": "^6.27.0",
+        "@lezer/common": "^1.1.0"
+      }
+    },
+    "node_modules/@codemirror/lang-javascript": {
+      "version": "6.2.4",
+      "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz",
+      "integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==",
+      "license": "MIT",
+      "dependencies": {
+        "@codemirror/autocomplete": "^6.0.0",
+        "@codemirror/language": "^6.6.0",
+        "@codemirror/lint": "^6.0.0",
+        "@codemirror/state": "^6.0.0",
+        "@codemirror/view": "^6.17.0",
+        "@lezer/common": "^1.0.0",
+        "@lezer/javascript": "^1.0.0"
+      }
+    },
+    "node_modules/@codemirror/lang-json": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
+      "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@codemirror/language": "^6.0.0",
+        "@lezer/json": "^1.0.0"
+      }
+    },
+    "node_modules/@codemirror/language": {
+      "version": "6.12.1",
+      "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.1.tgz",
+      "integrity": "sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@codemirror/state": "^6.0.0",
+        "@codemirror/view": "^6.23.0",
+        "@lezer/common": "^1.5.0",
+        "@lezer/highlight": "^1.0.0",
+        "@lezer/lr": "^1.0.0",
+        "style-mod": "^4.0.0"
+      }
+    },
+    "node_modules/@codemirror/lint": {
+      "version": "6.9.2",
+      "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.2.tgz",
+      "integrity": "sha512-sv3DylBiIyi+xKwRCJAAsBZZZWo82shJ/RTMymLabAdtbkV5cSKwWDeCgtUq3v8flTaXS2y1kKkICuRYtUswyQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@codemirror/state": "^6.0.0",
+        "@codemirror/view": "^6.35.0",
+        "crelt": "^1.0.5"
+      }
+    },
+    "node_modules/@codemirror/search": {
+      "version": "6.5.11",
+      "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz",
+      "integrity": "sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA==",
+      "license": "MIT",
+      "dependencies": {
+        "@codemirror/state": "^6.0.0",
+        "@codemirror/view": "^6.0.0",
+        "crelt": "^1.0.5"
+      }
+    },
+    "node_modules/@codemirror/state": {
+      "version": "6.5.3",
+      "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.3.tgz",
+      "integrity": "sha512-MerMzJzlXogk2fxWFU1nKp36bY5orBG59HnPiz0G9nLRebWa0zXuv2siH6PLIHBvv5TH8CkQRqjBs0MlxCZu+A==",
+      "license": "MIT",
+      "dependencies": {
+        "@marijn/find-cluster-break": "^1.0.0"
+      }
+    },
+    "node_modules/@codemirror/view": {
+      "version": "6.39.8",
+      "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.8.tgz",
+      "integrity": "sha512-1rASYd9Z/mE3tkbC9wInRlCNyCkSn+nLsiQKZhEDUUJiUfs/5FHDpCUDaQpoTIaNGeDc6/bhaEAyLmeEucEFPw==",
+      "license": "MIT",
+      "dependencies": {
+        "@codemirror/state": "^6.5.0",
+        "crelt": "^1.0.6",
+        "style-mod": "^4.1.0",
+        "w3c-keyname": "^2.2.4"
+      }
+    },
     "node_modules/@esbuild/aix-ppc64": {
       "version": "0.25.12",
       "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -566,6 +679,52 @@
         "@jridgewell/sourcemap-codec": "^1.4.14"
       }
     },
+    "node_modules/@lezer/common": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.0.tgz",
+      "integrity": "sha512-PNGcolp9hr4PJdXR4ix7XtixDrClScvtSCYW3rQG106oVMOOI+jFb+0+J3mbeL/53g1Zd6s0kJzaw6Ri68GmAA==",
+      "license": "MIT"
+    },
+    "node_modules/@lezer/highlight": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
+      "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
+      "license": "MIT",
+      "dependencies": {
+        "@lezer/common": "^1.3.0"
+      }
+    },
+    "node_modules/@lezer/javascript": {
+      "version": "1.5.4",
+      "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
+      "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
+      "license": "MIT",
+      "dependencies": {
+        "@lezer/common": "^1.2.0",
+        "@lezer/highlight": "^1.1.3",
+        "@lezer/lr": "^1.3.0"
+      }
+    },
+    "node_modules/@lezer/json": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
+      "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@lezer/common": "^1.2.0",
+        "@lezer/highlight": "^1.0.0",
+        "@lezer/lr": "^1.0.0"
+      }
+    },
+    "node_modules/@lezer/lr": {
+      "version": "1.4.5",
+      "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.5.tgz",
+      "integrity": "sha512-/YTRKP5yPPSo1xImYQk7AZZMAgap0kegzqCSYHjAL9x1AZ0ZQW+IpcEzMKagCsbTsLnVeWkxYrCNeXG8xEPrjg==",
+      "license": "MIT",
+      "dependencies": {
+        "@lezer/common": "^1.0.0"
+      }
+    },
     "node_modules/@lucide/svelte": {
       "version": "0.561.0",
       "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-0.561.0.tgz",
@@ -576,6 +735,12 @@
         "svelte": "^5"
       }
     },
+    "node_modules/@marijn/find-cluster-break": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
+      "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
+      "license": "MIT"
+    },
     "node_modules/@polka/url": {
       "version": "1.0.0-next.29",
       "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@@ -583,6 +748,19 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/@replit/codemirror-vim": {
+      "version": "6.3.0",
+      "resolved": "https://registry.npmjs.org/@replit/codemirror-vim/-/codemirror-vim-6.3.0.tgz",
+      "integrity": "sha512-aTx931ULAMuJx6xLf7KQDOL7CxD+Sa05FktTDrtLaSy53uj01ll3Zf17JdKsriER248oS55GBzg0CfCTjEneAQ==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@codemirror/commands": "6.x.x",
+        "@codemirror/language": "6.x.x",
+        "@codemirror/search": "6.x.x",
+        "@codemirror/state": "6.x.x",
+        "@codemirror/view": "6.x.x"
+      }
+    },
     "node_modules/@rollup/rollup-android-arm-eabi": {
       "version": "4.53.3",
       "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz",
@@ -1660,6 +1838,21 @@
         "node": ">=6"
       }
     },
+    "node_modules/codemirror": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
+      "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
+      "license": "MIT",
+      "dependencies": {
+        "@codemirror/autocomplete": "^6.0.0",
+        "@codemirror/commands": "^6.0.0",
+        "@codemirror/language": "^6.0.0",
+        "@codemirror/lint": "^6.0.0",
+        "@codemirror/search": "^6.0.0",
+        "@codemirror/state": "^6.0.0",
+        "@codemirror/view": "^6.0.0"
+      }
+    },
     "node_modules/cookie": {
       "version": "0.6.0",
       "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
@@ -1670,6 +1863,12 @@
         "node": ">= 0.6"
       }
     },
+    "node_modules/crelt": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
+      "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
+      "license": "MIT"
+    },
     "node_modules/cssesc": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -1849,6 +2048,15 @@
       "dev": true,
       "license": "ISC"
     },
+    "node_modules/highlight.js": {
+      "version": "11.11.1",
+      "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
+      "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=12.0.0"
+      }
+    },
     "node_modules/inline-style-parser": {
       "version": "0.2.7",
       "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
@@ -2506,6 +2714,12 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/style-mod": {
+      "version": "4.1.3",
+      "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
+      "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
+      "license": "MIT"
+    },
     "node_modules/style-to-object": {
       "version": "1.0.14",
       "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz",
@@ -2564,6 +2778,15 @@
         "typescript": ">=5.0.0"
       }
     },
+    "node_modules/svelte-highlight": {
+      "version": "7.9.0",
+      "resolved": "https://registry.npmjs.org/svelte-highlight/-/svelte-highlight-7.9.0.tgz",
+      "integrity": "sha512-226LBTtvTnM2L2JkQq8mZeKEeMfPLYyta7VxZatFT4UPX5zdHEerKeMTvrfbxm7MVTWc7TPThsNoVdhWC177KQ==",
+      "license": "MIT",
+      "dependencies": {
+        "highlight.js": "11.11.1"
+      }
+    },
     "node_modules/svelte-toolbelt": {
       "version": "0.10.6",
       "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz",
@@ -2643,6 +2866,17 @@
         "url": "https://opencollective.com/webpack"
       }
     },
+    "node_modules/thememirror": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/thememirror/-/thememirror-2.0.1.tgz",
+      "integrity": "sha512-d5i6FVvWWPkwrm4cHLI3t9AT1OrkAt7Ig8dtdYSofgF7C/eiyNuq6zQzSTusWTde3jpW9WLvA9J/fzNKMUsd0w==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@codemirror/language": "^6.0.0",
+        "@codemirror/state": "^6.0.0",
+        "@codemirror/view": "^6.0.0"
+      }
+    },
     "node_modules/tinyglobby": {
       "version": "0.2.15",
       "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -2803,6 +3037,12 @@
         }
       }
     },
+    "node_modules/w3c-keyname": {
+      "version": "2.2.8",
+      "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
+      "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
+      "license": "MIT"
+    },
     "node_modules/zimmerframe": {
       "version": "1.1.4",
       "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",

+ 8 - 1
package.json

@@ -13,6 +13,10 @@
   },
   "license": "MIT",
   "dependencies": {
+    "@codemirror/lang-javascript": "^6.2.4",
+    "@codemirror/lang-json": "^6.0.2",
+    "@codemirror/state": "^6.5.3",
+    "@replit/codemirror-vim": "^6.3.0",
     "@tailwindcss/vite": "^4.1.17",
     "@tauri-apps/api": "^2",
     "@tauri-apps/plugin-global-shortcut": "^2.3.1",
@@ -20,9 +24,12 @@
     "@tauri-apps/plugin-opener": "^2",
     "@tauri-apps/plugin-store": "^2.4.1",
     "clsx": "^2.1.1",
+    "codemirror": "^6.0.2",
     "mode-watcher": "^1.1.0",
+    "svelte-highlight": "^7.9.0",
     "tailwind-merge": "^3.4.0",
-    "tailwindcss": "^4.1.17"
+    "tailwindcss": "^4.1.17",
+    "thememirror": "^2.0.1"
   },
   "devDependencies": {
     "@internationalized/date": "^3.10.0",

+ 63 - 10
src-tauri/src/cmd.rs

@@ -1,9 +1,10 @@
 use crate::{
-    db,
+    db::{self, Update},
     request::{
         self,
         url::{RequestUrl, RequestUrlOwned, Segment, UrlError},
-        HttpRequestParameters, HttpResponse, RequestPathParam, RequestPathUpdate,
+        EntryRequestBody, HttpRequestParameters, HttpResponse, RequestBody, RequestHeader,
+        RequestHeaderInsert, RequestHeaderUpdate, RequestPathParam, RequestPathUpdate,
     },
     state::AppState,
     var::{expand_vars, parse_vars},
@@ -34,11 +35,11 @@ pub async fn create_workspace(
 }
 
 #[tauri::command]
-pub async fn get_workspace_entries(
+pub async fn list_workspace_entries(
     state: tauri::State<'_, AppState>,
     id: i64,
 ) -> Result<Vec<WorkspaceEntry>, String> {
-    match db::get_workspace_entries(state.db.clone(), id).await {
+    match db::list_workspace_entries(state.db.clone(), id).await {
         Ok(ws) => Ok(ws),
         Err(e) => Err(e.to_string()),
     }
@@ -68,11 +69,31 @@ pub async fn update_workspace_entry(
 }
 
 #[tauri::command]
-pub async fn parse_url(
+pub async fn insert_request_body(
     state: tauri::State<'_, AppState>,
-    env_id: Option<i64>,
-    url: String,
-) -> Result<RequestUrlOwned, UrlError> {
+    entry_id: i64,
+    body: RequestBody,
+) -> Result<EntryRequestBody, String> {
+    match db::insert_request_body(state.db.clone(), entry_id, body).await {
+        Ok(b) => Ok(b),
+        Err(e) => return Err(e.to_string()),
+    }
+}
+
+#[tauri::command]
+pub async fn update_request_body(
+    state: tauri::State<'_, AppState>,
+    id: i64,
+    body: Update<RequestBody>,
+) -> Result<(), String> {
+    if let Err(e) = db::update_request_body(state.db.clone(), id, body).await {
+        return Err(e.to_string());
+    }
+    Ok(())
+}
+
+#[tauri::command]
+pub async fn parse_url(url: String) -> Result<RequestUrlOwned, UrlError> {
     match RequestUrl::parse(&url) {
         Ok(url_parsed) => Ok(url_parsed.into()),
         Err(e) => {
@@ -212,8 +233,6 @@ pub async fn update_url(
                     path_params: Some(update),
                     url: Some(url_parsed.to_string()),
                     base: WorkspaceEntryUpdateBase::default(),
-                    body: None,
-                    headers: None,
                     method: None,
                 },
             )
@@ -382,3 +401,37 @@ pub async fn delete_env_var(state: tauri::State<'_, AppState>, id: i64) -> Resul
     }
     Ok(())
 }
+
+#[tauri::command]
+pub async fn insert_header(
+    state: tauri::State<'_, AppState>,
+    entry_id: i64,
+    insert: RequestHeaderInsert,
+) -> Result<RequestHeader, String> {
+    match db::insert_headers(state.db.clone(), entry_id, vec![insert]).await {
+        Ok(header) => Ok(header),
+        Err(e) => return Err(e.to_string()),
+    }
+}
+
+#[tauri::command]
+pub async fn update_header(
+    state: tauri::State<'_, AppState>,
+    update: RequestHeaderUpdate,
+) -> Result<(), String> {
+    if let Err(e) = db::update_header(state.db.clone(), update).await {
+        return Err(e.to_string());
+    }
+    Ok(())
+}
+
+#[tauri::command]
+pub async fn delete_header(
+    state: tauri::State<'_, AppState>,
+    header_id: i64,
+) -> Result<(), String> {
+    if let Err(e) = db::delete_header(state.db.clone(), header_id).await {
+        return Err(e.to_string());
+    }
+    Ok(())
+}

+ 93 - 53
src-tauri/src/db.rs

@@ -1,6 +1,9 @@
 use crate::{
     error::AppError,
-    request::{RequestHeader, RequestParams, RequestPathParam, WorkspaceRequest},
+    request::{
+        EntryRequestBody, RequestBody, RequestHeader, RequestHeaderInsert, RequestHeaderUpdate,
+        RequestParams, RequestPathParam, WorkspaceRequest,
+    },
     workspace::{
         Workspace, WorkspaceEntry, WorkspaceEntryBase, WorkspaceEntryCreate, WorkspaceEntryType,
         WorkspaceEntryUpdate, WorkspaceEnvVariable, WorkspaceEnvironment,
@@ -181,6 +184,39 @@ pub async fn create_workspace_entry(
     }
 }
 
+pub async fn insert_request_body(
+    db: SqlitePool,
+    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.content).fetch_one(&db).await?)
+}
+
+pub async fn update_request_body(
+    db: SqlitePool,
+    id: i64,
+    body: Update<RequestBody>,
+) -> AppResult<()> {
+    match body {
+        Update::Value(body) => {
+            sqlx::query!(
+                "UPDATE request_bodies SET content_type = ?, body = ? WHERE id = ?",
+                body.ty,
+                body.content,
+                id,
+            )
+            .execute(&db)
+            .await?;
+        }
+        Update::Null => {
+            sqlx::query!("DELETE FROM request_bodies WHERE id = ?", id)
+                .execute(&db)
+                .await?;
+        }
+    }
+    Ok(())
+}
+
 pub async fn update_workspace_entry(
     db: SqlitePool,
     entry_id: i64,
@@ -236,8 +272,6 @@ pub async fn update_workspace_entry(
             base,
             method,
             url,
-            body,
-            headers,
             path_params,
         } => {
             let mut tx = db.begin().await?;
@@ -309,43 +343,6 @@ pub async fn update_workspace_entry(
                     .await?;
             };
 
-            if let Some(body) = body {
-                match body {
-                    Update::Value(body) => {
-                        sqlx::query!(
-                            "UPDATE request_bodies SET content_type = ?, body = ? WHERE request_id = ?",
-                            body.ty,
-                            body.content,
-                            entry_id,
-                        )
-                        .execute(&mut *tx)
-                        .await?;
-                    }
-                    Update::Null => {
-                        sqlx::query!("DELETE FROM request_bodies WHERE request_id = ?", entry_id)
-                            .execute(&mut *tx)
-                            .await?;
-                    }
-                }
-            }
-
-            if let Some(headers) = headers {
-                if !headers.is_empty() {
-                    let mut sql = QueryBuilder::new(
-                        "INSERT INTO request_headers(id, request_id, name, value) ",
-                    );
-
-                    sql.push_values(headers, |mut b, header| {
-                        b.push_bind(header.id)
-                            .push_bind(entry_id)
-                            .push_bind(header.name)
-                            .push_bind(header.value);
-                    });
-
-                    sql.build().execute(&mut *tx).await?;
-                }
-            }
-
             if let Some(path_params) = path_params {
                 if !path_params.is_empty() {
                     let mut sql = QueryBuilder::new(
@@ -406,17 +403,23 @@ pub async fn get_workspace_request(db: SqlitePool, id: i64) -> AppResult<Workspa
     .await?;
 
     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: _"
-                   FROM request_params rp
-                   LEFT JOIN request_bodies rb ON rp.request_id = rb.request_id
-                   WHERE rp.request_id = ? 
-                "#,
-                id
-            )
-            .fetch_one(&db)
-            .await?;
+        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 = ? 
+        "#,
+        id
+    )
+    .fetch_one(&db)
+    .await?;
 
     let headers = sqlx::query_as!(
         RequestHeader,
@@ -442,7 +445,7 @@ pub async fn get_workspace_request(db: SqlitePool, id: i64) -> AppResult<Workspa
     ))
 }
 
-pub async fn get_workspace_entries(
+pub async fn list_workspace_entries(
     db: SqlitePool,
     workspace_id: i64,
 ) -> AppResult<Vec<WorkspaceEntry>> {
@@ -457,7 +460,7 @@ pub async fn get_workspace_entries(
     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
+           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 = ? 
@@ -708,3 +711,40 @@ pub async fn list_request_path_params(db: SqlitePool, id: i64) -> AppResult<Vec<
     .fetch_all(&db)
     .await?)
 }
+
+pub async fn insert_headers(
+    db: SqlitePool,
+    entry_id: i64,
+    headers: Vec<RequestHeaderInsert>,
+) -> AppResult<RequestHeader> {
+    let mut insert = QueryBuilder::new("INSERT INTO request_headers(request_id, name, value) ");
+    insert.push_values(headers, |mut b, header| {
+        b.push_bind(entry_id)
+            .push_bind(header.name)
+            .push_bind(header.value);
+    });
+    Ok(insert
+        .push("RETURNING id, name, value")
+        .build_query_as()
+        .fetch_one(&db)
+        .await?)
+}
+
+pub async fn update_header(db: SqlitePool, header: RequestHeaderUpdate) -> AppResult<()> {
+    sqlx::query!(
+        "UPDATE request_headers SET name = COALESCE(?, ''), value = COALESCE(?, '') WHERE id = ?",
+        header.name,
+        header.value,
+        header.id
+    )
+    .execute(&db)
+    .await?;
+    Ok(())
+}
+
+pub async fn delete_header(db: SqlitePool, header_id: i64) -> AppResult<()> {
+    sqlx::query!("DELETE FROM request_headers WHERE id = ?", header_id)
+        .execute(&db)
+        .await?;
+    Ok(())
+}

+ 8 - 3
src-tauri/src/lib.rs

@@ -16,7 +16,7 @@ mod workspace;
 #[cfg_attr(mobile, tauri::mobile_entry_point)]
 pub fn run() {
     tauri::Builder::default()
-        .plugin(tauri_plugin_global_shortcut::Builder::new().build())
+        // .plugin(tauri_plugin_global_shortcut::Builder::new().build())
         .plugin(tauri_plugin_store::Builder::new().build())
         .plugin(
             tauri_plugin_log::Builder::new()
@@ -33,7 +33,7 @@ pub fn run() {
         .invoke_handler(tauri::generate_handler![
             cmd::list_workspaces,
             cmd::create_workspace,
-            cmd::get_workspace_entries,
+            cmd::list_workspace_entries,
             cmd::create_workspace_entry,
             cmd::update_workspace_entry,
             cmd::parse_url,
@@ -45,7 +45,12 @@ pub fn run() {
             cmd::update_env,
             cmd::insert_env_var,
             cmd::update_env_var,
-            cmd::delete_env_var
+            cmd::delete_env_var,
+            cmd::insert_header,
+            cmd::update_header,
+            cmd::delete_header,
+            cmd::insert_request_body,
+            cmd::update_request_body,
         ])
         .run(tauri::generate_context!())
         .expect("error while running tauri application");

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

@@ -86,7 +86,7 @@ pub struct WorkspaceRequest {
     pub url: String,
 
     /// Request HTTP body.
-    pub body: Option<RequestBody>,
+    pub body: Option<EntryRequestBody>,
 
     /// HTTP header names => values.
     pub headers: Vec<RequestHeader>,
@@ -113,10 +113,14 @@ impl WorkspaceRequest {
         headers: Vec<RequestHeader>,
         path_params: Vec<RequestPathParam>,
     ) -> Self {
-        let body = match (params.body, params.content_type) {
-            (Some(content), Some(ty)) => Some(RequestBody { content, ty }),
-            (None, None) => None,
-            _ => panic!("body and content_type must both be present"),
+        let body = match (params.body_id, params.body, params.content_type) {
+            (Some(id), Some(body), Some(content_type)) => Some(EntryRequestBody {
+                id,
+                body,
+                content_type,
+            }),
+            (None, None, None) => None,
+            _ => panic!("id, body and content_type must all be present"),
         };
         WorkspaceRequest {
             entry,
@@ -160,7 +164,10 @@ impl TryFrom<WorkspaceRequest> for HttpRequestParameters {
             url: value.url,
             method,
             headers,
-            body: value.body,
+            body: value.body.map(|body| RequestBody {
+                content: body.body,
+                ty: body.content_type,
+            }),
         })
     }
 }
@@ -250,6 +257,7 @@ pub struct RequestParams {
     pub url: String,
     pub content_type: Option<ContentType>,
     pub body: Option<String>,
+    pub body_id: Option<i64>,
 }
 
 #[derive(Debug, Deserialize, Serialize)]
@@ -266,13 +274,19 @@ pub struct RequestPathUpdate {
     pub value: Option<String>,
 }
 
-#[derive(Debug, Serialize)]
+#[derive(Debug, Serialize, FromRow)]
 pub struct RequestHeader {
     pub id: i64,
     pub name: String,
     pub value: String,
 }
 
+#[derive(Debug, Deserialize)]
+pub struct RequestHeaderInsert {
+    pub name: String,
+    pub value: String,
+}
+
 #[derive(Debug, Deserialize)]
 pub struct RequestHeaderUpdate {
     pub id: i64,
@@ -285,3 +299,10 @@ pub struct RequestBody {
     pub content: String,
     pub ty: ContentType,
 }
+
+#[derive(Debug, Serialize)]
+pub struct EntryRequestBody {
+    pub id: i64,
+    pub body: String,
+    pub content_type: ContentType,
+}

+ 3 - 8
src-tauri/src/workspace.rs

@@ -1,12 +1,9 @@
-use serde::{Deserialize, Serialize};
-use sqlx::prelude::Type;
-
 use crate::{
     db::Update,
-    request::{
-        RequestBody, RequestHeaderUpdate, RequestPathParam, RequestPathUpdate, WorkspaceRequest,
-    },
+    request::{RequestPathUpdate, WorkspaceRequest},
 };
+use serde::{Deserialize, Serialize};
+use sqlx::prelude::Type;
 
 #[derive(Debug, Serialize)]
 pub struct Workspace {
@@ -82,8 +79,6 @@ pub enum WorkspaceEntryUpdate {
         base: WorkspaceEntryUpdateBase,
         method: Option<String>,
         url: Option<String>,
-        body: Option<Update<RequestBody>>,
-        headers: Option<Vec<RequestHeaderUpdate>>,
         path_params: Option<Vec<RequestPathUpdate>>,
     },
 }

+ 25 - 0
src/lib/codemirror/lines.ts

@@ -0,0 +1,25 @@
+import { EditorView } from "codemirror";
+import { EditorState, type Extension } from "@codemirror/state";
+import { lineNumbers, ViewUpdate } from "@codemirror/view";
+
+function relativeLineNumbers(lineNo: number, state: EditorState) {
+  const cursorLine = state.doc.lineAt(
+    state.selection.asSingle().ranges[0].to,
+  ).number;
+
+  // Absolute number for the current line
+  if (lineNo === cursorLine) return lineNo.toString();
+
+  // Relative number for all other lines
+  return Math.abs(cursorLine - lineNo).toString();
+}
+
+export function lineNumbersRelative(): Extension {
+  return [lineNumbers({ formatNumber: relativeLineNumbers })];
+}
+
+export function stateChangeListener(
+  of: (update: ViewUpdate) => void,
+): Extension {
+  return EditorView.updateListener.of(of);
+}

+ 235 - 53
src/lib/components/WorkspaceEntry.svelte

@@ -1,27 +1,120 @@
 <script lang="ts">
   import {
     state as _state,
+    deleteBody,
+    deleteHeader,
+    insertHeader,
     selectEntry,
     sendRequest,
+    updateBodyContent,
     updateEntryName,
+    updateHeader,
     updateUrl,
   } from "$lib/state.svelte";
   import { Button } from "$lib/components/ui/button";
   import { Input } from "$lib/components/ui/input";
   import * as Accordion from "$lib/components/ui/accordion";
   import * as Tabs from "$lib/components/ui/tabs";
-  import type { RequestPathParam, UrlError } from "$lib/types";
+  import type { UrlError } 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 { json as cmJson } from "@codemirror/lang-json";
+  import { EditorView, basicSetup } from "codemirror";
+  // import { dracula, coolGlow } from "thememirror";
+  import { onMount } from "svelte";
+  import {
+    BrushCleaning,
+    Clipboard,
+    FastForward,
+    Loader,
+    PlusIcon,
+    TrashIcon,
+  } from "@lucide/svelte";
+  import { vim } from "@replit/codemirror-vim";
+  import { EditorState, StateEffect } from "@codemirror/state";
+  import {
+    lineNumbersRelative,
+    stateChangeListener,
+  } from "$lib/codemirror/lines";
+
+  let view: EditorView;
+
+  const vimExtension = vim();
+  const relativeLines = lineNumbersRelative();
+  let vimEnabled = $state(true);
+  const jsonExt = cmJson();
+
+  let editorState = EditorState.create({
+    doc: _state.entry.body?.body ?? "",
+    extensions: [
+      basicSetup,
+      jsonExt,
+      vimExtension,
+      relativeLines,
+      stateChangeListener((update) => {
+        console.log(update);
+        if (update.docChanged) {
+          updateBodyContent(update.state.doc.toString(), "Json");
+        }
+      }),
+    ],
+  });
+
+  onMount(() => {
+    view = new EditorView({
+      parent: document.querySelector("#editor"),
+      state: editorState,
+    });
+  });
+
+  function toggleVim() {
+    vimEnabled = !vimEnabled;
 
-  let headers = [
-    { key: "Authorization", value: "Bearer token" },
-    { key: "Content-Type", value: "application/json" },
-  ];
+    const exts = [basicSetup, jsonExt];
 
-  let body = $state(`{
-    "name": "John Doe"
-  }`);
+    if (vimEnabled) {
+      exts.push(vimExtension);
+      exts.push(relativeLines);
+    }
+
+    view.dispatch({
+      effects: StateEffect.reconfigure.of([exts]),
+    });
+  }
 
+  async function copyContent() {
+    const content = view.state.doc.toString();
+    await navigator.clipboard.writeText(content);
+  }
+
+  async function formatContent() {
+    const content = view.state.doc.toString();
+
+    view.dispatch({
+      changes: {
+        from: 0,
+        to: view.state.doc.length,
+        insert: JSON.stringify(JSON.parse(content), null, 2),
+      },
+    });
+  }
+
+  // $effect(() => {
+  //   if (_state.entry.body !== null) {
+  //     console.log("triggering effect editor dispatch");
+  //     view.dispatch({
+  //       changes: {
+  //         from: 0,
+  //         to: view.state.doc.length,
+  //         insert: _state.entry.body.body,
+  //       },
+  //     });
+  //   }
+  // });
+
+  let isSending = $state(false);
   let response: any = $state();
 
   const referenceChain = $derived.by(() => {
@@ -38,14 +131,22 @@
   });
 
   async function handleSendRequest() {
-    sendRequest()
-      .then((res) => (response = res))
-      .catch((e) => console.error("error sending request", e));
+    if (isSending) return;
+    console.time("request");
+
+    isSending = true;
+    try {
+      response = await sendRequest();
+      console.timeEnd("request");
+    } catch (e) {
+      console.error("error sending request", e);
+    } finally {
+      isSending = false;
+    }
   }
 
   async function handleUrlUpdate(direct: boolean = false) {
     const u = direct ? _state.entry!!.url : reconstructUrl();
-    console.log(u);
 
     try {
       await updateUrl(u, !direct);
@@ -74,7 +175,6 @@
     let url = _state.entry.workingUrl.pre;
 
     for (const param of _state.entry.workingUrl.path) {
-      console.log(param);
       const [name, position] = param.value;
 
       if (param.type === "Static") {
@@ -103,22 +203,14 @@
       url += "?";
     }
 
-    console.debug("url constructed", url);
-
     return url;
   }
-
-  function responseContent() {
-    if (!response) {
-      return "";
-    }
-
-    if (response.body["Json"]) {
-      return response.body["Json"];
-    }
-  }
 </script>
 
+<svelte:head>
+  {@html atelierForest}
+</svelte:head>
+
 {#snippet entryPath()}
   <!-- ENTRY PATH -->
   <div class="h-8 flex items-center">
@@ -179,12 +271,23 @@
           class="w-10/12 flex font-mono"
           bind:value={_state.entry.url}
           placeholder="https://api.example.com/resource"
-          oninput={() => {
+          onblur={() => {
             handleUrlUpdate(true);
           }}
         />
 
-        <Button class="w-1/12" onclick={() => handleSendRequest()}>Send</Button>
+        <Button
+          class="w-1/12 flex items-center justify-center gap-2"
+          disabled={isSending}
+          onclick={handleSendRequest}
+        >
+          {#if isSending}
+            <Loader class="h-4 w-4 animate-spin" />
+            Sending
+          {:else}
+            Send
+          {/if}
+        </Button>
 
         <p class="w-full pl-1 text-xs text-muted-foreground">
           {_state.entry.expandedUrl ?? ""}
@@ -209,9 +312,9 @@
             <!-- PATH PARAMS -->
 
             <Accordion.Content
-              class="border flex-col justify-center items-center space-y-4 "
+              class="flex-col justify-center items-center space-y-4 "
             >
-              <div class="flex flex-wrap border">
+              <div class="flex flex-wrap">
                 <h3 class="w-full mb-2 text-sm font-medium">Path</h3>
                 <div class="w-1/2 grid grid-cols-2 gap-2 text-sm">
                   {#each _state.entry.path as param}
@@ -253,23 +356,41 @@
         {/if}
 
         <!-- HEADERS -->
+
         <Accordion.Item value="headers">
           <Accordion.Trigger>Headers</Accordion.Trigger>
           <Accordion.Content>
             <div class="grid grid-cols-3 gap-2 text-sm">
-              <div class="font-medium text-muted-foreground">Key</div>
-              <div class="font-medium text-muted-foreground col-span-2">
-                Value
-              </div>
+              {#each _state.entry.headers as header}
+                <div class="contents">
+                  <Input
+                    class="w-full"
+                    bind:value={header.name}
+                    placeholder="Name"
+                    oninput={() =>
+                      updateHeader(header.id, header.name, header.value)}
+                  />
+
+                  <div class="flex gap-2 col-span-2 items-center">
+                    <Input
+                      bind:value={header.value}
+                      placeholder="Value"
+                      class="flex-1"
+                      oninput={() =>
+                        updateHeader(header.id, header.name, header.value)}
+                    />
 
-              {#each headers as header}
-                <Input bind:value={header.key} placeholder="Header" />
-                <Input
-                  bind:value={header.value}
-                  placeholder="Value"
-                  class="col-span-2"
-                />
+                    <TrashIcon
+                      class="h-4 w-4 cursor-pointer text-muted-foreground hover:text-destructive"
+                      onclick={() => deleteHeader(header.id)}
+                    />
+                  </div>
+                </div>
               {/each}
+              <PlusIcon
+                class="col-span-3 mt-2 cursor-pointer text-muted-foreground hover:text-primary"
+                onclick={() => insertHeader()}
+              />
             </div>
           </Accordion.Content>
         </Accordion.Item>
@@ -279,18 +400,64 @@
         <Accordion.Item value="body">
           <Accordion.Trigger>Body</Accordion.Trigger>
           <Accordion.Content class="space-y-4">
-            <Tabs.Root value="json">
+            <Tabs.Root value={_state.entry.body === null ? "none" : "json"}>
               <Tabs.List>
-                <Tabs.Trigger value="json">JSON</Tabs.Trigger>
+                <Tabs.Trigger value="none" onclick={() => deleteBody()}
+                  >None</Tabs.Trigger
+                >
+                <Tabs.Trigger
+                  value="json"
+                  onclick={() =>
+                    updateBodyContent("", "Json").then(() => {
+                      console.log("triggering effect editor dispatch");
+                      view.dispatch({
+                        changes: {
+                          from: 0,
+                          to: view.state.doc.length,
+                          insert: _state.entry.body.body,
+                        },
+                      });
+                    })}>JSON</Tabs.Trigger
+                >
                 <Tabs.Trigger value="form">Form</Tabs.Trigger>
                 <Tabs.Trigger value="text">Text</Tabs.Trigger>
               </Tabs.List>
 
+              <Tabs.Content value="none">No body</Tabs.Content>
+
               <Tabs.Content value="json">
-                <textarea
-                  class="w-full min-h-[200px] rounded-md border bg-background p-2 font-mono text-sm"
-                  bind:value={body}
-                ></textarea>
+                <div class="w-full flex rounded-md border max-h-60">
+                  <!-- EDITOR SIDEBAR -->
+
+                  <div class="sticky flex flex-col gap-2 p-2 border-r">
+                    <Button
+                      onclick={toggleVim}
+                      variant={vimEnabled ? "default" : "outline"}
+                      size="icon-sm"
+                    >
+                      <FastForward />
+                    </Button>
+
+                    <Button
+                      onclick={copyContent}
+                      variant="outline"
+                      size="icon-sm"><Clipboard /></Button
+                    >
+
+                    <Button
+                      onclick={formatContent}
+                      variant="outline"
+                      size="icon-sm"><BrushCleaning /></Button
+                    >
+                  </div>
+
+                  <!-- EDITOR -->
+
+                  <div
+                    id="editor"
+                    class="mx-auto w-full rounded-md overflow-scroll max-h-fit"
+                  ></div>
+                </div>
               </Tabs.Content>
 
               <Tabs.Content value="form">
@@ -311,15 +478,30 @@
 
         <!-- RESPONSE -->
 
-        {#if response}
+        {#if isSending}
+          <div class="flex justify-center py-8">
+            <Loader class="h-6 w-6 animate-spin text-muted-foreground" />
+          </div>
+        {:else if response}
           <Accordion.Item value="response">
             <Accordion.Trigger>Response</Accordion.Trigger>
-            <Accordion.Content class="space-y-4">
-              <textarea
-                class="w-full min-h-[200px] rounded-md border bg-background p-2 font-mono text-sm"
-                bind:value={response.body["Json"]}
-                readonly
-              ></textarea>
+            <Accordion.Content class="space-y-4 mx-auto w-full max-w-6xl">
+              <!-- Prevents line number selection -->
+              <div
+                class="
+                  w-full
+                  [&_td:first-child]:select-none
+                  [&_td:first-child]:pointer-events-none
+                "
+              >
+                <Highlight
+                  language={json}
+                  code={response.body.Json}
+                  let:highlighted
+                >
+                  <LineNumbers {highlighted} wrapLines hideBorder />
+                </Highlight>
+              </div>
             </Accordion.Content>
           </Accordion.Item>
         {/if}

+ 83 - 7
src/lib/state.svelte.ts

@@ -108,7 +108,7 @@ export function selectWorkspace(ws: Workspace) {
 export async function selectEntry(id: number) {
   state.entry = state.indexes[id];
 
-  console.log("selected entry:", state.entry);
+  console.log("selected entry:", $state.snapshot(state.entry));
 
   if (state.entry.parent_id !== null) {
     let parent = state.indexes[state.entry.parent_id];
@@ -124,14 +124,17 @@ export async function selectEntry(id: number) {
   if (state.entry.type === "Request") {
     parseUrl(state.entry!!.url)
       .then(() =>
-        console.log("working URL:", $state.snapshot(state.entry.workingUrl)),
+        console.debug("working URL:", $state.snapshot(state.entry.workingUrl)),
       )
       .catch((e) => {
         console.error("error parsing URL", e);
       });
     expandUrl()
       .then(() =>
-        console.log("expanded URL:", $state.snapshot(state.entry.expandedUrl)),
+        console.debug(
+          "expanded URL:",
+          $state.snapshot(state.entry.expandedUrl),
+        ),
       )
       .catch((e) => {
         console.error("error expanding URL", e);
@@ -155,7 +158,7 @@ export async function loadWorkspace(ws: Workspace) {
   state.workspace = ws;
 
   const entries = await invoke<WorkspaceEntryResponse[]>(
-    "get_workspace_entries",
+    "list_workspace_entries",
     {
       id: state.workspace.id,
     },
@@ -310,8 +313,12 @@ export async function parseUrl(url: string) {
   });
 }
 
-export async function updateUrl(u: string, usePathParams) {
-  console.log(u);
+/**
+ * Update a request's URL string. If `usePathparams` is true, path entries
+ * from state.entry.path will be used to replace those at the same position and should
+ * be set to true whenever this is called from an input field of a destructured URL.
+ */
+export async function updateUrl(u: string, usePathParams: boolean) {
   const [url, params] = await invoke<any[]>("update_url", {
     entryId: state.entry!!.id,
     usePathParams,
@@ -329,7 +336,6 @@ export async function updateUrl(u: string, usePathParams) {
 }
 
 export async function expandUrl() {
-  console.debug("expanding URL", $state.snapshot(state.entry.url));
   state.entry!!.expandedUrl = await invoke<string>("expand_url", {
     entryId: state.entry!!.id,
     envId: state.environment?.id,
@@ -379,6 +385,76 @@ export async function deleteEnvVariable(id: number) {
   );
 }
 
+export async function insertHeader() {
+  const header = await invoke("insert_header", {
+    entryId: state.entry!!.id,
+    insert: { name: "", value: "" },
+  });
+
+  state.entry!!.headers.push(header);
+}
+
+export async function updateHeader(id: number, name: string, value: string) {
+  await invoke("update_header", {
+    update: { id, name, value },
+  });
+  const header = state.entry!!.headers.find((header) => header.id === id);
+  header.name = name;
+  header.value = value;
+}
+
+export async function deleteHeader(id: number) {
+  await invoke("delete_header", {
+    headerId: id,
+  });
+  state.entry!!.headers = state.entry!!.headers.filter(
+    (header) => header.id !== id,
+  );
+}
+
+export async function deleteBody() {
+  if (state.entry!!.body === null) {
+    console.warn("attempted to delete null body", $state.snapshot(state.entry));
+    return;
+  }
+
+  await invoke("update_request_body", {
+    id: state.entry!!.body.id,
+    body: { Null: null },
+  });
+
+  state.entry.body = null;
+
+  console.debug("Deleted request body");
+}
+
+export async function updateBodyContent(body: string, ct: string) {
+  if (state.entry!!.body !== null) {
+    await invoke("update_request_body", {
+      id: state.entry!!.body.id,
+      body: {
+        Value: {
+          ty: ct,
+          content: body,
+        },
+      },
+    });
+    state.entry.body.body = body;
+    state.entry.body.content_type = ct;
+  } else {
+    const b = await invoke("insert_request_body", {
+      entryId: state.entry!!.id,
+      body: {
+        ty: ct,
+        content: body,
+      },
+    });
+    state.entry!!.body = b;
+  }
+
+  console.debug("Updated body content to", $state.snapshot(state.entry!!.body));
+}
+
 type WorkspaceEntryResponse =
   | {
       type: "Collection";

+ 1 - 0
src/lib/types.ts

@@ -79,6 +79,7 @@ export type WorkspaceCreateRequest = {
 };
 
 export type RequestBody = {
+  id: number;
   content: string;
   ty: string;
 };