Răsfoiți Sursa

add request and url parsing

biblius 4 săptămâni în urmă
părinte
comite
4367571b4c
46 a modificat fișierele cu 1291 adăugiri și 199 ștergeri
  1. 68 48
      package-lock.json
  2. 3 1
      package.json
  3. 84 0
      src-tauri/Cargo.lock
  4. 4 0
      src-tauri/Cargo.toml
  5. 5 5
      src-tauri/capabilities/default.json
  6. 14 0
      src-tauri/capabilities/desktop.json
  7. 0 2
      src-tauri/migrations/20250922150745_init.up.sql
  8. 60 0
      src-tauri/src/cmd.rs
  9. 1 0
      src-tauri/src/collection.rs
  10. 6 6
      src-tauri/src/db.rs
  11. 6 51
      src-tauri/src/lib.rs
  12. 5 5
      src-tauri/src/request.rs
  13. 66 15
      src-tauri/src/request/url.rs
  14. 5 3
      src-tauri/src/workspace.rs
  15. 25 18
      src/lib/components/Sidebar.svelte
  16. 3 1
      src/lib/components/SidebarEntry.svelte
  17. 252 1
      src/lib/components/WorkspaceEntry.svelte
  18. 22 0
      src/lib/components/ui/accordion/accordion-content.svelte
  19. 17 0
      src/lib/components/ui/accordion/accordion-item.svelte
  20. 32 0
      src/lib/components/ui/accordion/accordion-trigger.svelte
  21. 16 0
      src/lib/components/ui/accordion/accordion.svelte
  22. 16 0
      src/lib/components/ui/accordion/index.ts
  23. 50 0
      src/lib/components/ui/badge/badge.svelte
  24. 2 0
      src/lib/components/ui/badge/index.ts
  25. 37 0
      src/lib/components/ui/select/index.ts
  26. 45 0
      src/lib/components/ui/select/select-content.svelte
  27. 21 0
      src/lib/components/ui/select/select-group-heading.svelte
  28. 7 0
      src/lib/components/ui/select/select-group.svelte
  29. 38 0
      src/lib/components/ui/select/select-item.svelte
  30. 20 0
      src/lib/components/ui/select/select-label.svelte
  31. 7 0
      src/lib/components/ui/select/select-portal.svelte
  32. 20 0
      src/lib/components/ui/select/select-scroll-down-button.svelte
  33. 20 0
      src/lib/components/ui/select/select-scroll-up-button.svelte
  34. 18 0
      src/lib/components/ui/select/select-separator.svelte
  35. 29 0
      src/lib/components/ui/select/select-trigger.svelte
  36. 11 0
      src/lib/components/ui/select/select.svelte
  37. 16 0
      src/lib/components/ui/tabs/index.ts
  38. 17 0
      src/lib/components/ui/tabs/tabs-content.svelte
  39. 20 0
      src/lib/components/ui/tabs/tabs-list.svelte
  40. 20 0
      src/lib/components/ui/tabs/tabs-trigger.svelte
  41. 19 0
      src/lib/components/ui/tabs/tabs.svelte
  42. 30 0
      src/lib/settings.svelte.ts
  43. 104 39
      src/lib/state.svelte.ts
  44. 21 3
      src/lib/types.ts
  45. 4 1
      src/routes/+layout.svelte
  46. 5 0
      svelte.config.js

+ 68 - 48
package-lock.json

@@ -11,8 +11,10 @@
       "dependencies": {
         "@tailwindcss/vite": "^4.1.17",
         "@tauri-apps/api": "^2",
+        "@tauri-apps/plugin-global-shortcut": "^2.3.1",
         "@tauri-apps/plugin-log": "^2.7.1",
         "@tauri-apps/plugin-opener": "^2",
+        "@tauri-apps/plugin-store": "^2.4.1",
         "clsx": "^2.1.1",
         "mode-watcher": "^1.1.0",
         "tailwind-merge": "^3.4.0",
@@ -27,7 +29,7 @@
         "@tailwindcss/forms": "^0.5.10",
         "@tailwindcss/typography": "^0.5.19",
         "@tailwindcss/vite": "^4.1.17",
-        "@tauri-apps/cli": "^2",
+        "@tauri-apps/cli": "^2.9.6",
         "bits-ui": "^2.14.4",
         "prettier-plugin-svelte": "^3.4.0",
         "svelte": "^5.0.0",
@@ -1313,9 +1315,9 @@
       }
     },
     "node_modules/@tauri-apps/cli": {
-      "version": "2.9.4",
-      "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.9.4.tgz",
-      "integrity": "sha512-pvylWC9QckrOS9ATWXIXcgu7g2hKK5xTL5ZQyZU/U0n9l88SEFGcWgLQNa8WZmd+wWIOWhkxOFcOl3i6ubDNNw==",
+      "version": "2.9.6",
+      "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.9.6.tgz",
+      "integrity": "sha512-3xDdXL5omQ3sPfBfdC8fCtDKcnyV7OqyzQgfyT5P3+zY6lcPqIYKQBvUasNvppi21RSdfhy44ttvJmftb0PCDw==",
       "dev": true,
       "license": "Apache-2.0 OR MIT",
       "bin": {
@@ -1329,23 +1331,23 @@
         "url": "https://opencollective.com/tauri"
       },
       "optionalDependencies": {
-        "@tauri-apps/cli-darwin-arm64": "2.9.4",
-        "@tauri-apps/cli-darwin-x64": "2.9.4",
-        "@tauri-apps/cli-linux-arm-gnueabihf": "2.9.4",
-        "@tauri-apps/cli-linux-arm64-gnu": "2.9.4",
-        "@tauri-apps/cli-linux-arm64-musl": "2.9.4",
-        "@tauri-apps/cli-linux-riscv64-gnu": "2.9.4",
-        "@tauri-apps/cli-linux-x64-gnu": "2.9.4",
-        "@tauri-apps/cli-linux-x64-musl": "2.9.4",
-        "@tauri-apps/cli-win32-arm64-msvc": "2.9.4",
-        "@tauri-apps/cli-win32-ia32-msvc": "2.9.4",
-        "@tauri-apps/cli-win32-x64-msvc": "2.9.4"
+        "@tauri-apps/cli-darwin-arm64": "2.9.6",
+        "@tauri-apps/cli-darwin-x64": "2.9.6",
+        "@tauri-apps/cli-linux-arm-gnueabihf": "2.9.6",
+        "@tauri-apps/cli-linux-arm64-gnu": "2.9.6",
+        "@tauri-apps/cli-linux-arm64-musl": "2.9.6",
+        "@tauri-apps/cli-linux-riscv64-gnu": "2.9.6",
+        "@tauri-apps/cli-linux-x64-gnu": "2.9.6",
+        "@tauri-apps/cli-linux-x64-musl": "2.9.6",
+        "@tauri-apps/cli-win32-arm64-msvc": "2.9.6",
+        "@tauri-apps/cli-win32-ia32-msvc": "2.9.6",
+        "@tauri-apps/cli-win32-x64-msvc": "2.9.6"
       }
     },
     "node_modules/@tauri-apps/cli-darwin-arm64": {
-      "version": "2.9.4",
-      "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.9.4.tgz",
-      "integrity": "sha512-9rHkMVtbMhe0AliVbrGpzMahOBg3rwV46JYRELxR9SN6iu1dvPOaMaiC4cP6M/aD1424ziXnnMdYU06RAH8oIw==",
+      "version": "2.9.6",
+      "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.9.6.tgz",
+      "integrity": "sha512-gf5no6N9FCk1qMrti4lfwP77JHP5haASZgVbBgpZG7BUepB3fhiLCXGUK8LvuOjP36HivXewjg72LTnPDScnQQ==",
       "cpu": [
         "arm64"
       ],
@@ -1360,9 +1362,9 @@
       }
     },
     "node_modules/@tauri-apps/cli-darwin-x64": {
-      "version": "2.9.4",
-      "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.9.4.tgz",
-      "integrity": "sha512-VT9ymNuT06f5TLjCZW2hfSxbVtZDhORk7CDUDYiq5TiSYQdxkl8MVBy0CCFFcOk4QAkUmqmVUA9r3YZ/N/vPRQ==",
+      "version": "2.9.6",
+      "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.9.6.tgz",
+      "integrity": "sha512-oWh74WmqbERwwrwcueJyY6HYhgCksUc6NT7WKeXyrlY/FPmNgdyQAgcLuTSkhRFuQ6zh4Np1HZpOqCTpeZBDcw==",
       "cpu": [
         "x64"
       ],
@@ -1377,9 +1379,9 @@
       }
     },
     "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
-      "version": "2.9.4",
-      "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.9.4.tgz",
-      "integrity": "sha512-tTWkEPig+2z3Rk0zqZYfjUYcgD+aSm72wdrIhdYobxbQZOBw0zfn50YtWv+av7bm0SHvv75f0l7JuwgZM1HFow==",
+      "version": "2.9.6",
+      "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.9.6.tgz",
+      "integrity": "sha512-/zde3bFroFsNXOHN204DC2qUxAcAanUjVXXSdEGmhwMUZeAQalNj5cz2Qli2elsRjKN/hVbZOJj0gQ5zaYUjSg==",
       "cpu": [
         "arm"
       ],
@@ -1394,9 +1396,9 @@
       }
     },
     "node_modules/@tauri-apps/cli-linux-arm64-gnu": {
-      "version": "2.9.4",
-      "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.9.4.tgz",
-      "integrity": "sha512-ql6vJ611qoqRYHxkKPnb2vHa27U+YRKRmIpLMMBeZnfFtZ938eao7402AQCH1mO2+/8ioUhbpy9R/ZcLTXVmkg==",
+      "version": "2.9.6",
+      "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.9.6.tgz",
+      "integrity": "sha512-pvbljdhp9VOo4RnID5ywSxgBs7qiylTPlK56cTk7InR3kYSTJKYMqv/4Q/4rGo/mG8cVppesKIeBMH42fw6wjg==",
       "cpu": [
         "arm64"
       ],
@@ -1411,9 +1413,9 @@
       }
     },
     "node_modules/@tauri-apps/cli-linux-arm64-musl": {
-      "version": "2.9.4",
-      "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.9.4.tgz",
-      "integrity": "sha512-vg7yNn7ICTi6hRrcA/6ff2UpZQP7un3xe3SEld5QM0prgridbKAiXGaCKr3BnUBx/rGXegQlD/wiLcWdiiraSw==",
+      "version": "2.9.6",
+      "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.9.6.tgz",
+      "integrity": "sha512-02TKUndpodXBCR0oP//6dZWGYcc22Upf2eP27NvC6z0DIqvkBBFziQUcvi2n6SrwTRL0yGgQjkm9K5NIn8s6jw==",
       "cpu": [
         "arm64"
       ],
@@ -1428,9 +1430,9 @@
       }
     },
     "node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
-      "version": "2.9.4",
-      "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.9.4.tgz",
-      "integrity": "sha512-l8L+3VxNk6yv5T/Z/gv5ysngmIpsai40B9p6NQQyqYqxImqYX37pqREoEBl1YwG7szGnDibpWhidPrWKR59OJA==",
+      "version": "2.9.6",
+      "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.9.6.tgz",
+      "integrity": "sha512-fmp1hnulbqzl1GkXl4aTX9fV+ubHw2LqlLH1PE3BxZ11EQk+l/TmiEongjnxF0ie4kV8DQfDNJ1KGiIdWe1GvQ==",
       "cpu": [
         "riscv64"
       ],
@@ -1445,9 +1447,9 @@
       }
     },
     "node_modules/@tauri-apps/cli-linux-x64-gnu": {
-      "version": "2.9.4",
-      "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.9.4.tgz",
-      "integrity": "sha512-PepPhCXc/xVvE3foykNho46OmCyx47E/aG676vKTVp+mqin5d+IBqDL6wDKiGNT5OTTxKEyNlCQ81Xs2BQhhqA==",
+      "version": "2.9.6",
+      "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.9.6.tgz",
+      "integrity": "sha512-vY0le8ad2KaV1PJr+jCd8fUF9VOjwwQP/uBuTJvhvKTloEwxYA/kAjKK9OpIslGA9m/zcnSo74czI6bBrm2sYA==",
       "cpu": [
         "x64"
       ],
@@ -1462,9 +1464,9 @@
       }
     },
     "node_modules/@tauri-apps/cli-linux-x64-musl": {
-      "version": "2.9.4",
-      "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.9.4.tgz",
-      "integrity": "sha512-zcd1QVffh5tZs1u1SCKUV/V7RRynebgYUNWHuV0FsIF1MjnULUChEXhAhug7usCDq4GZReMJOoXa6rukEozWIw==",
+      "version": "2.9.6",
+      "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.9.6.tgz",
+      "integrity": "sha512-TOEuB8YCFZTWVDzsO2yW0+zGcoMiPPwcUgdnW1ODnmgfwccpnihDRoks+ABT1e3fHb1ol8QQWsHSCovb3o2ENQ==",
       "cpu": [
         "x64"
       ],
@@ -1479,9 +1481,9 @@
       }
     },
     "node_modules/@tauri-apps/cli-win32-arm64-msvc": {
-      "version": "2.9.4",
-      "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.9.4.tgz",
-      "integrity": "sha512-/7ZhnP6PY04bEob23q8MH/EoDISdmR1wuNm0k9d5HV7TDMd2GGCDa8dPXA4vJuglJKXIfXqxFmZ4L+J+MO42+w==",
+      "version": "2.9.6",
+      "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.9.6.tgz",
+      "integrity": "sha512-ujmDGMRc4qRLAnj8nNG26Rlz9klJ0I0jmZs2BPpmNNf0gM/rcVHhqbEkAaHPTBVIrtUdf7bGvQAD2pyIiUrBHQ==",
       "cpu": [
         "arm64"
       ],
@@ -1496,9 +1498,9 @@
       }
     },
     "node_modules/@tauri-apps/cli-win32-ia32-msvc": {
-      "version": "2.9.4",
-      "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.9.4.tgz",
-      "integrity": "sha512-1LmAfaC4Cq+3O1Ir1ksdhczhdtFSTIV51tbAGtbV/mr348O+M52A/xwCCXQank0OcdBxy5BctqkMtuZnQvA8uQ==",
+      "version": "2.9.6",
+      "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.9.6.tgz",
+      "integrity": "sha512-S4pT0yAJgFX8QRCyKA1iKjZ9Q/oPjCZf66A/VlG5Yw54Nnr88J1uBpmenINbXxzyhduWrIXBaUbEY1K80ZbpMg==",
       "cpu": [
         "ia32"
       ],
@@ -1513,9 +1515,9 @@
       }
     },
     "node_modules/@tauri-apps/cli-win32-x64-msvc": {
-      "version": "2.9.4",
-      "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.9.4.tgz",
-      "integrity": "sha512-EdYd4c9wGvtPB95kqtEyY+bUR+k4kRw3IA30mAQ1jPH6z57AftT8q84qwv0RDp6kkEqOBKxeInKfqi4BESYuqg==",
+      "version": "2.9.6",
+      "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.9.6.tgz",
+      "integrity": "sha512-ldWuWSSkWbKOPjQMJoYVj9wLHcOniv7diyI5UAJ4XsBdtaFB0pKHQsqw/ItUma0VXGC7vB4E9fZjivmxur60aw==",
       "cpu": [
         "x64"
       ],
@@ -1529,6 +1531,15 @@
         "node": ">= 10"
       }
     },
+    "node_modules/@tauri-apps/plugin-global-shortcut": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-global-shortcut/-/plugin-global-shortcut-2.3.1.tgz",
+      "integrity": "sha512-vr40W2N6G63dmBPaha1TsBQLLURXG538RQbH5vAm0G/ovVZyXJrmZR1HF1W+WneNloQvwn4dm8xzwpEXRW560g==",
+      "license": "MIT OR Apache-2.0",
+      "dependencies": {
+        "@tauri-apps/api": "^2.8.0"
+      }
+    },
     "node_modules/@tauri-apps/plugin-log": {
       "version": "2.7.1",
       "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-log/-/plugin-log-2.7.1.tgz",
@@ -1547,6 +1558,15 @@
         "@tauri-apps/api": "^2.8.0"
       }
     },
+    "node_modules/@tauri-apps/plugin-store": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-store/-/plugin-store-2.4.1.tgz",
+      "integrity": "sha512-ckGSEzZ5Ii4Hf2D5x25Oqnm2Zf9MfDWAzR+volY0z/OOBz6aucPKEY0F649JvQ0Vupku6UJo7ugpGRDOFOunkA==",
+      "license": "MIT OR Apache-2.0",
+      "dependencies": {
+        "@tauri-apps/api": "^2.8.0"
+      }
+    },
     "node_modules/@types/cookie": {
       "version": "0.6.0",
       "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",

+ 3 - 1
package.json

@@ -15,8 +15,10 @@
   "dependencies": {
     "@tailwindcss/vite": "^4.1.17",
     "@tauri-apps/api": "^2",
+    "@tauri-apps/plugin-global-shortcut": "^2.3.1",
     "@tauri-apps/plugin-log": "^2.7.1",
     "@tauri-apps/plugin-opener": "^2",
+    "@tauri-apps/plugin-store": "^2.4.1",
     "clsx": "^2.1.1",
     "mode-watcher": "^1.1.0",
     "tailwind-merge": "^3.4.0",
@@ -31,7 +33,7 @@
     "@tailwindcss/forms": "^0.5.10",
     "@tailwindcss/typography": "^0.5.19",
     "@tailwindcss/vite": "^4.1.17",
-    "@tauri-apps/cli": "^2",
+    "@tauri-apps/cli": "^2.9.6",
     "bits-ui": "^2.14.4",
     "prettier-plugin-svelte": "^3.4.0",
     "svelte": "^5.0.0",

+ 84 - 0
src-tauri/Cargo.lock

@@ -1458,6 +1458,16 @@ dependencies = [
  "version_check",
 ]
 
+[[package]]
+name = "gethostname"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
+dependencies = [
+ "rustix",
+ "windows-link 0.2.1",
+]
+
 [[package]]
 name = "getrandom"
 version = "0.1.16"
@@ -1577,6 +1587,24 @@ version = "0.3.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
 
+[[package]]
+name = "global-hotkey"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9247516746aa8e53411a0db9b62b0e24efbcf6a76e0ba73e5a91b512ddabed7"
+dependencies = [
+ "crossbeam-channel",
+ "keyboard-types",
+ "objc2 0.6.3",
+ "objc2-app-kit",
+ "once_cell",
+ "serde",
+ "thiserror 2.0.17",
+ "windows-sys 0.59.0",
+ "x11rb",
+ "xkeysym",
+]
+
 [[package]]
 name = "gobject-sys"
 version = "0.18.0"
@@ -3636,8 +3664,10 @@ dependencies = [
  "sqlx",
  "tauri",
  "tauri-build",
+ "tauri-plugin-global-shortcut",
  "tauri-plugin-log",
  "tauri-plugin-opener",
+ "tauri-plugin-store",
  "thiserror 2.0.17",
  "tokio",
 ]
@@ -4739,6 +4769,21 @@ dependencies = [
  "walkdir",
 ]
 
+[[package]]
+name = "tauri-plugin-global-shortcut"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "424af23c7e88d05e4a1a6fc2c7be077912f8c76bd7900fd50aa2b7cbf5a2c405"
+dependencies = [
+ "global-hotkey",
+ "log",
+ "serde",
+ "serde_json",
+ "tauri",
+ "tauri-plugin",
+ "thiserror 2.0.17",
+]
+
 [[package]]
 name = "tauri-plugin-log"
 version = "2.7.1"
@@ -4783,6 +4828,22 @@ dependencies = [
  "zbus",
 ]
 
+[[package]]
+name = "tauri-plugin-store"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59a77036340a97eb5bbe1b3209c31e5f27f75e6f92a52fd9dd4b211ef08bf310"
+dependencies = [
+ "dunce",
+ "serde",
+ "serde_json",
+ "tauri",
+ "tauri-plugin",
+ "thiserror 2.0.17",
+ "tokio",
+ "tracing",
+]
+
 [[package]]
 name = "tauri-runtime"
 version = "2.9.1"
@@ -6327,6 +6388,29 @@ dependencies = [
  "pkg-config",
 ]
 
+[[package]]
+name = "x11rb"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
+dependencies = [
+ "gethostname",
+ "rustix",
+ "x11rb-protocol",
+]
+
+[[package]]
+name = "x11rb-protocol"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
+
+[[package]]
+name = "xkeysym"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
+
 [[package]]
 name = "yoke"
 version = "0.8.1"

+ 4 - 0
src-tauri/Cargo.toml

@@ -38,4 +38,8 @@ reqwest = { version = "0.12.15", features = [
 	"stream",
 ] }
 tauri-plugin-log = "2"
+tauri-plugin-store = "2"
+
+[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
+tauri-plugin-global-shortcut = "2"
 

+ 5 - 5
src-tauri/capabilities/default.json

@@ -2,12 +2,12 @@
   "$schema": "../gen/schemas/desktop-schema.json",
   "identifier": "default",
   "description": "Capability for the main window",
-  "windows": [
-    "main"
-  ],
+  "windows": ["main"],
   "permissions": [
     "core:default",
     "opener:default",
-    "log:default"
+    "log:default",
+    "store:default"
   ]
-}
+}
+

+ 14 - 0
src-tauri/capabilities/desktop.json

@@ -0,0 +1,14 @@
+{
+  "identifier": "desktop-capability",
+  "platforms": [
+    "macOS",
+    "windows",
+    "linux"
+  ],
+  "windows": [
+    "main"
+  ],
+  "permissions": [
+    "global-shortcut:default"
+  ]
+}

+ 0 - 2
src-tauri/migrations/20250922150745_init.up.sql

@@ -17,8 +17,6 @@ CREATE TABLE workspace_env_variables (
     name TEXT NOT NULL,
     value TEXT,
     secret BOOLEAN NOT NULL,
-    -- Optional collection scope
-    collection_id INTEGER,
     FOREIGN KEY (workspace_id) REFERENCES workspaces (id) ON DELETE CASCADE,
     FOREIGN KEY (env_id) REFERENCES workspace_envs (id) ON DELETE CASCADE
 );

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

@@ -0,0 +1,60 @@
+use tauri_plugin_log::log;
+
+use crate::{
+    db,
+    request::url::{RequestUrl, RequestUrlOwned},
+    state::AppState,
+    workspace::{Workspace, WorkspaceEntry, WorkspaceEntryBase, WorkspaceEntryCreate},
+};
+
+#[tauri::command]
+pub async fn list_workspaces(state: tauri::State<'_, AppState>) -> Result<Vec<Workspace>, String> {
+    match db::list_workspaces(state.db.clone()).await {
+        Ok(ws) => Ok(ws),
+        Err(e) => Err(e.to_string()),
+    }
+}
+
+#[tauri::command]
+pub async fn create_workspace(
+    state: tauri::State<'_, AppState>,
+    name: String,
+) -> Result<Workspace, String> {
+    match db::create_workspace(state.db.clone(), name).await {
+        Ok(ws) => Ok(ws),
+        Err(e) => Err(e.to_string()),
+    }
+}
+
+#[tauri::command]
+pub async fn get_workspace_entries(
+    state: tauri::State<'_, AppState>,
+    id: i64,
+) -> Result<Vec<WorkspaceEntry>, String> {
+    match db::get_workspace_entries(state.db.clone(), id).await {
+        Ok(ws) => Ok(ws),
+        Err(e) => Err(e.to_string()),
+    }
+}
+
+#[tauri::command]
+pub async fn create_workspace_entry(
+    state: tauri::State<'_, AppState>,
+    data: WorkspaceEntryCreate,
+) -> Result<WorkspaceEntryBase, String> {
+    match db::create_workspace_entry(state.db.clone(), data).await {
+        Ok(ws) => Ok(ws),
+        Err(e) => Err(e.to_string()),
+    }
+}
+
+#[tauri::command]
+pub fn parse_url(url: String) -> Result<RequestUrlOwned, String> {
+    match RequestUrl::parse(&url) {
+        Ok(url) => Ok(url.into()),
+        Err(e) => {
+            log::debug!("{e}");
+            Err(e.to_string())
+        }
+    }
+}

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

@@ -0,0 +1 @@
+

+ 6 - 6
src-tauri/src/db.rs

@@ -2,7 +2,7 @@ use crate::{
     error::AppError,
     request::{ctype::ContentType, WorkspaceRequest},
     workspace::{
-        Workspace, WorkspaceEntry, WorkspaceEntryCreate, WorkspaceEntryItem, WorkspaceEntryType,
+        Workspace, WorkspaceEntry, WorkspaceEntryBase, WorkspaceEntryCreate, WorkspaceEntryType,
         WorkspaceEnvVariable, WorkspaceEnvironment,
     },
     AppResult,
@@ -65,7 +65,7 @@ pub async fn list_workspaces(db: SqlitePool) -> AppResult<Vec<Workspace>> {
 pub async fn create_workspace_entry(
     db: SqlitePool,
     entry: WorkspaceEntryCreate,
-) -> AppResult<WorkspaceEntryItem> {
+) -> AppResult<WorkspaceEntryBase> {
     match entry {
         WorkspaceEntryCreate::Collection {
             name,
@@ -85,7 +85,7 @@ pub async fn create_workspace_entry(
                 }
             }
             let entry = sqlx::query_as!(
-                WorkspaceEntryItem,
+                WorkspaceEntryBase,
                 r#"INSERT INTO workspace_entries(name, workspace_id, parent_id, type) VALUES (?, ?, ?, ?) 
                    RETURNING id, workspace_id, parent_id, name, type"#,
                 name,
@@ -119,7 +119,7 @@ pub async fn create_workspace_entry(
             let mut tx = db.begin().await?;
 
             let entry = match sqlx::query_as!(
-                WorkspaceEntryItem,
+                WorkspaceEntryBase,
                 r#"INSERT INTO workspace_entries(name, workspace_id, parent_id, type) VALUES (?, ?, ?, ?) 
                    RETURNING id, workspace_id, name, parent_id, type"#,
                 name,
@@ -164,7 +164,7 @@ pub async fn get_workspace_entries(
     workspace_id: i64,
 ) -> Result<Vec<WorkspaceEntry>, String> {
     let entries = sqlx::query_as!(
-        WorkspaceEntryItem,
+        WorkspaceEntryBase,
         "SELECT id, workspace_id, parent_id, name, type FROM workspace_entries WHERE workspace_id = ? ORDER BY type DESC",
         workspace_id,
     )
@@ -244,7 +244,7 @@ pub async fn get_workspace_env_variables(
 ) -> Result<Vec<WorkspaceEnvVariable>, String> {
     match sqlx::query_as!(
         WorkspaceEnvVariable,
-        "SELECT id, env_id, name, value, secret FROM workspace_env_variables WHERE env_id = $1",
+        "SELECT id, env_id, workspace_id, name, value, secret FROM workspace_env_variables WHERE env_id = $1",
         env_id
     )
     .fetch_all(&db)

+ 6 - 51
src-tauri/src/lib.rs

@@ -1,8 +1,10 @@
 use crate::state::AppState;
 use tauri::Manager;
+use tauri_plugin_store::StoreExt;
 
 pub type AppResult<T> = Result<T, error::AppError>;
 
+mod cmd;
 mod collection;
 mod db;
 mod error;
@@ -13,6 +15,8 @@ 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_store::Builder::new().build())
         .plugin(
             tauri_plugin_log::Builder::new()
                 .level(tauri_plugin_log::log::LevelFilter::Info)
@@ -20,6 +24,7 @@ pub fn run() {
         )
         .plugin(tauri_plugin_opener::init())
         .setup(|app| {
+            app.store("settings.json")?;
             let state = tauri::async_runtime::block_on(async move { AppState::new().await });
             app.manage(state);
             Ok(())
@@ -29,58 +34,8 @@ pub fn run() {
             cmd::create_workspace,
             cmd::get_workspace_entries,
             cmd::create_workspace_entry,
+            cmd::parse_url
         ])
         .run(tauri::generate_context!())
         .expect("error while running tauri application");
 }
-
-pub mod cmd {
-    use crate::{
-        db,
-        state::AppState,
-        workspace::{Workspace, WorkspaceEntry, WorkspaceEntryCreate, WorkspaceEntryItem},
-    };
-
-    #[tauri::command]
-    pub async fn list_workspaces(
-        state: tauri::State<'_, AppState>,
-    ) -> Result<Vec<Workspace>, String> {
-        match db::list_workspaces(state.db.clone()).await {
-            Ok(ws) => Ok(ws),
-            Err(e) => Err(e.to_string()),
-        }
-    }
-
-    #[tauri::command]
-    pub async fn create_workspace(
-        state: tauri::State<'_, AppState>,
-        name: String,
-    ) -> Result<Workspace, String> {
-        match db::create_workspace(state.db.clone(), name).await {
-            Ok(ws) => Ok(ws),
-            Err(e) => Err(e.to_string()),
-        }
-    }
-
-    #[tauri::command]
-    pub async fn get_workspace_entries(
-        state: tauri::State<'_, AppState>,
-        id: i64,
-    ) -> Result<Vec<WorkspaceEntry>, String> {
-        match db::get_workspace_entries(state.db.clone(), id).await {
-            Ok(ws) => Ok(ws),
-            Err(e) => Err(e.to_string()),
-        }
-    }
-
-    #[tauri::command]
-    pub async fn create_workspace_entry(
-        state: tauri::State<'_, AppState>,
-        data: WorkspaceEntryCreate,
-    ) -> Result<WorkspaceEntryItem, String> {
-        match db::create_workspace_entry(state.db.clone(), data).await {
-            Ok(ws) => Ok(ws),
-            Err(e) => Err(e.to_string()),
-        }
-    }
-}

+ 5 - 5
src-tauri/src/request.rs

@@ -13,12 +13,12 @@ use tauri_plugin_log::log;
 use crate::{
     db::{RequestHeader, RequestParams},
     request::ctype::ContentType,
-    workspace::WorkspaceEntryItem,
+    workspace::WorkspaceEntryBase,
     AppResult,
 };
 
 pub const DEFAULT_HEADERS: &'static [(&'static str, &'static str)] = &[
-    ("user-agent", "RestEZ/0.0.1"),
+    ("user-agent", "rquest/0.0.1"),
     ("accept", "*/*"),
     ("accept-encoding", "gzip, defalte, br"),
 ];
@@ -82,7 +82,7 @@ pub async fn send(client: reqwest::Client, req: HttpRequestParameters) -> AppRes
 #[derive(Debug, Clone, Serialize)]
 pub struct WorkspaceRequest {
     /// Workspace entry representing this request.
-    pub entry: WorkspaceEntryItem,
+    pub entry: WorkspaceEntryBase,
 
     /// Request method.
     pub method: String,
@@ -98,7 +98,7 @@ pub struct WorkspaceRequest {
 }
 
 impl WorkspaceRequest {
-    pub fn new(entry: WorkspaceEntryItem, method: String, url: String) -> Self {
+    pub fn new(entry: WorkspaceEntryBase, method: String, url: String) -> Self {
         Self {
             entry,
             method,
@@ -109,7 +109,7 @@ impl WorkspaceRequest {
     }
 
     pub fn from_params_and_headers(
-        entry: WorkspaceEntryItem,
+        entry: WorkspaceEntryBase,
         params: RequestParams,
         headers: Vec<RequestHeader>,
     ) -> Self {

+ 66 - 15
src-tauri/src/request/url.rs

@@ -2,9 +2,25 @@ use nom::{
     bytes::complete::{tag, take_until, take_until1, take_while, take_while1},
     character::complete::char,
     multi::many0,
-    sequence::{preceded, separated_pair},
+    sequence::{delimited, preceded, separated_pair},
     Parser,
 };
+use serde::Serialize;
+
+#[derive(Debug, Serialize)]
+pub struct RequestUrlOwned {
+    pub scheme: String,
+    pub host: String,
+    pub path: Vec<SegmentOwned>,
+    pub query_params: Vec<(String, String)>,
+}
+
+#[derive(Debug, Serialize)]
+#[serde(tag = "type", content = "value")]
+pub enum SegmentOwned {
+    Static(String),
+    Dynamic(String),
+}
 
 /// A fully deconstructed URL from a workspace request.
 /// Used as an intermediate step for populating the final URL with variables.
@@ -32,13 +48,12 @@ impl<'a> RequestUrl<'a> {
 
         let (input, _) = tag("://")(input)?;
 
-        let mut path_parser = many0(preceded(
-            char('/'),
-            take_while(|c: char| c.is_ascii_alphanumeric() || c == ':'),
-        ));
+        let mut path_parser = many0(preceded(char('/'), take_while(|c: char| c != '/')));
 
-        let result = take_until1::<_, _, nom::error::Error<_>>("?")(input);
-        match result {
+        let mut segment_parser =
+            preceded(tag::<_, _, nom::error::Error<_>>(":"), take_while(|_| true));
+
+        match take_until1::<_, _, nom::error::Error<_>>("?")(input) {
             // URL has query parameters
             Ok((query, path)) => {
                 // Parse query
@@ -81,9 +96,13 @@ impl<'a> RequestUrl<'a> {
                             path: segments
                                 .into_iter()
                                 .map(|segment| {
-                                    segment
-                                        .strip_prefix(':')
-                                        .map_or(Segment::Static(segment), Segment::Dynamic)
+                                    segment_parser.parse(segment).ok().map_or(
+                                        Segment::Static(segment),
+                                        |(r, s)| {
+                                            debug_assert_eq!("", r);
+                                            Segment::Dynamic(s)
+                                        },
+                                    )
                                 })
                                 .collect(),
                             query_params,
@@ -112,9 +131,13 @@ impl<'a> RequestUrl<'a> {
                             path: segments
                                 .into_iter()
                                 .map(|segment| {
-                                    segment
-                                        .strip_prefix(':')
-                                        .map_or(Segment::Static(segment), Segment::Dynamic)
+                                    segment_parser.parse(segment).ok().map_or(
+                                        Segment::Static(segment),
+                                        |(r, s)| {
+                                            debug_assert!(r.is_empty());
+                                            Segment::Dynamic(s)
+                                        },
+                                    )
                                 })
                                 .collect(),
                             query_params: vec![],
@@ -133,6 +156,21 @@ impl<'a> RequestUrl<'a> {
     }
 }
 
+impl<'a> From<RequestUrl<'a>> for RequestUrlOwned {
+    fn from(value: RequestUrl<'a>) -> Self {
+        Self {
+            scheme: value.scheme.to_owned(),
+            host: value.host.to_owned(),
+            path: value.path.into_iter().map(Into::into).collect(),
+            query_params: value
+                .query_params
+                .into_iter()
+                .map(|(k, v)| (k.to_owned(), v.to_owned()))
+                .collect(),
+        }
+    }
+}
+
 #[derive(Debug, PartialEq, Eq)]
 pub enum Segment<'a> {
     /// Path segments that do not change.
@@ -145,6 +183,15 @@ pub enum Segment<'a> {
     Dynamic(&'a str),
 }
 
+impl<'a> From<Segment<'a>> for SegmentOwned {
+    fn from(value: Segment<'a>) -> Self {
+        match value {
+            Segment::Static(s) => Self::Static(s.to_owned()),
+            Segment::Dynamic(s) => Self::Dynamic(s.to_owned()),
+        }
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::{RequestUrl, Segment};
@@ -224,14 +271,18 @@ mod tests {
 
     #[test]
     fn parse_query_params_with_path() {
-        let input = "http://localhost:4000/foo/:bar?foo=bar&baz=bax";
+        let input = "http://localhost:4000/foo/:bar/:qux?foo=bar&baz=bax";
 
         let url = RequestUrl::parse(input).unwrap();
 
         assert_eq!("http", url.scheme);
         assert_eq!("localhost:4000", url.host);
         assert_eq!(
-            vec![Segment::Static("foo"), Segment::Dynamic("bar")],
+            vec![
+                Segment::Static("foo"),
+                Segment::Dynamic("bar"),
+                Segment::Dynamic("qux")
+            ],
             url.path
         );
         assert_eq!(vec![("foo", "bar"), ("baz", "bax")], url.query_params);

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

@@ -17,8 +17,9 @@ pub struct WorkspaceEnvironment {
 }
 
 #[derive(Debug, Clone, Serialize)]
+#[serde(tag = "type", content = "data")]
 pub enum WorkspaceEntry {
-    Collection(WorkspaceEntryItem),
+    Collection(WorkspaceEntryBase),
     Request(WorkspaceRequest),
 }
 
@@ -27,7 +28,7 @@ impl WorkspaceEntry {
         Self::Request(req)
     }
 
-    pub fn new_col(col: WorkspaceEntryItem) -> Self {
+    pub fn new_col(col: WorkspaceEntryBase) -> Self {
         Self::Collection(col)
     }
 
@@ -48,7 +49,7 @@ impl WorkspaceEntry {
 
 /// Database model representation
 #[derive(Debug, Clone, Serialize)]
-pub struct WorkspaceEntryItem {
+pub struct WorkspaceEntryBase {
     pub id: i64,
     pub workspace_id: i64,
     pub parent_id: Option<i64>,
@@ -60,6 +61,7 @@ pub struct WorkspaceEntryItem {
 pub struct WorkspaceEnvVariable {
     pub id: i64,
     pub env_id: i64,
+    pub workspace_id: i64,
     pub name: String,
     pub value: Option<String>,
     pub secret: bool,

+ 25 - 18
src/lib/components/Sidebar.svelte

@@ -6,33 +6,39 @@
   import DropdownMenuLabel from "./ui/dropdown-menu/dropdown-menu-label.svelte";
   import DropdownMenuSeparator from "./ui/dropdown-menu/dropdown-menu-separator.svelte";
   import { Input } from "./ui/input";
-  import { invoke } from "@tauri-apps/api/core";
   import { onMount } from "svelte";
-  import { state as _state, loadWorkspace } from "$lib/state.svelte";
+  import {
+    state as _state,
+    listWorkspaces,
+    loadWorkspace,
+    createWorkspace,
+    selectWorkspace,
+    selectEntry,
+  } from "$lib/state.svelte";
   import SidebarEntry from "./SidebarEntry.svelte";
   import SidebarActionButton from "./SidebarActionButton.svelte";
+  import { getSetting } from "$lib/settings.svelte";
 
   let workspaces: Workspace[] = $state([]);
 
   let addWorkspaceInput = $state("");
 
-  function createWorkspace(name: string) {
-    invoke<Workspace>("create_workspace", { name }).then((ws) => {
-      _state.workspace = ws;
-      loadWorkspaces();
-    });
-  }
-
-  function loadWorkspaces() {
-    invoke<Workspace[]>("list_workspaces").then((ws) => {
-      workspaces = ws;
-    });
-  }
-
-  onMount(() => loadWorkspaces());
+  onMount(async () => {
+    workspaces = await listWorkspaces();
+    const lastEntry = await getSetting("lastEntry");
+    if (lastEntry) {
+      const ws = workspaces.find((w) => w.id === lastEntry.workspace_id);
+      if (!ws) {
+        console.error("workspace for last entry not found", lastEntry);
+        return;
+      }
+      await loadWorkspace(ws);
+      selectEntry(lastEntry.id);
+    }
+  });
 </script>
 
-<Sidebar.Root variant="sidebar">
+<Sidebar.Root fixed={true} variant="sidebar">
   <Sidebar.Header>
     <Sidebar.Menu>
       <Sidebar.MenuItem class="flex flex-nowrap items-center">
@@ -60,7 +66,7 @@
               onsubmit={(e) => {
                 e.preventDefault();
                 if (addWorkspaceInput.length > 0) {
-                  createWorkspace(addWorkspaceInput);
+                  createWorkspace(addWorkspaceInput).then(selectWorkspace);
                   addWorkspaceInput = "";
                 }
               }}
@@ -93,6 +99,7 @@
   </Sidebar.Header>
   <Sidebar.Content>
     <!-- Workspace entries -->
+
     {#if _state.workspace}
       <Sidebar.Group>
         <Sidebar.GroupContent>

+ 3 - 1
src/lib/components/SidebarEntry.svelte

@@ -4,8 +4,9 @@
   import * as Sidebar from "./ui/sidebar";
   import Self from "./SidebarEntry.svelte";
   import SidebarActionButton from "./SidebarActionButton.svelte";
+  import { setSetting } from "$lib/settings.svelte";
 
-  let open = $state(false);
+  let open = $derived(!!_state.indexes[id].open);
 
   const isSelected = $derived(_state.entry?.id === id);
 </script>
@@ -16,6 +17,7 @@
       e.stopPropagation();
       selectEntry(id);
       open = !open;
+      setSetting("lastEntry", _state.indexes[id]);
     }}
   >
     <Sidebar.MenuButton>

+ 252 - 1
src/lib/components/WorkspaceEntry.svelte

@@ -1,4 +1,255 @@
 <script lang="ts">
+  import { state as _state, selectEntry } 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 { invoke } from "@tauri-apps/api/core";
+  import type { RequestUrl } from "$lib/types";
+  import { onMount } from "svelte";
+
+  let headers = [
+    { key: "Authorization", value: "Bearer token" },
+    { key: "Content-Type", value: "application/json" },
+  ];
+
+  let body = $state(`{
+    "name": "John Doe"
+  }`);
+
+  const referenceChain = $derived(() => {
+    const parents = [];
+
+    let parent = _state.entry!!.parent_id;
+
+    while (parent !== null) {
+      parents.push(_state.indexes[parent]);
+      parent = _state.indexes[parent].parent_id;
+    }
+
+    return parents.reverse();
+  });
+
+  let urlTemplate: RequestUrl | null = $derived(await parseUrl());
+  let urlDynPaths = {};
+
+  async function parseUrl() {
+    try {
+      const url = await invoke<RequestUrl>("parse_url", {
+        url: _state.entry.url,
+      });
+      for (const seg of url.path) {
+        if (seg.type === "Dynamic") {
+          urlDynPaths[seg.value] = "";
+        }
+      }
+      console.log(url);
+      return url;
+    } catch (e) {
+      console.error(e);
+      return null;
+    }
+  }
+
+  function constructUrl(): string {
+    if (!urlTemplate) {
+      return "";
+    }
+
+    const path = urlTemplate.path
+      .map((s) => (s.type === "Dynamic" ? `:${s.value}` : s.value))
+      .join("/");
+    const query = urlTemplate.query_params.length
+      ? "?" + urlTemplate.query_params.map((p) => `${p[0]}=${p[1]}`).join("&")
+      : "";
+
+    _state.entry.url = `${urlTemplate.scheme}://${urlTemplate.host}/${path}${query}`;
+    parseUrl(_state.entry.url);
+    console.log(_state.entry.url);
+  }
+
+  onMount(() => {
+    if (_state.entry?.type === "Request") {
+      parseUrl(_state.entry.url);
+    }
+  });
 </script>
 
-<main class="w-full">ALO</main>
+{#snippet entryPath()}
+  <!-- ENTRY PATH -->
+  <div class="h-8 flex items-center">
+    {#each referenceChain() as ref, i}
+      <Button onclick={() => selectEntry(ref.id)} variant="ghost">
+        {ref.name || ref.type + "(" + ref.id + ")"}
+      </Button>
+      <p>/</p>
+    {/each}
+    <p>
+      {_state.entry!!.name ||
+        _state.entry!!.type + "(" + _state.entry!!.id + ")"}
+    </p>
+  </div>
+{/snippet}
+
+<main class="w-full h-full p-4 space-y-4">
+  {#if _state.entry?.type === "Collection"}
+    <!-- COLLECTION VIEW -->
+    {@render entryPath()}
+    <section class="space-y-4">
+      <h1 class="text-xl font-semibold">{_state.entry.name}</h1>
+
+      <div class="rounded-md border p-4 space-y-2">
+        <h2 class="font-medium">Variables</h2>
+
+        <div class="grid grid-cols-3 gap-2 text-sm">
+          <div class="font-medium text-muted-foreground">Key</div>
+          <div class="font-medium text-muted-foreground col-span-2">Value</div>
+
+          <div>baseUrl</div>
+          <div class="col-span-2">https://api.example.com</div>
+
+          <div>token</div>
+          <div class="col-span-2">••••••••</div>
+        </div>
+      </div>
+    </section>
+  {:else if _state.entry?.type === "Request"}
+    <!-- REQUEST WORK AREA -->
+    <section class="space-y-4">
+      {@render entryPath()}
+      <!-- URL BAR -->
+
+      <div class="flex gap-2">
+        <Input
+          class="flex-1 font-mono"
+          bind:value={_state.entry.url}
+          placeholder="https://api.example.com/resource"
+          oninput={() => {
+            parseUrl(_state.entry.url);
+          }}
+        />
+        <Button>Send</Button>
+      </div>
+
+      <!-- COLLAPSIBLE SECTIONS -->
+
+      <Accordion.Root
+        type="multiple"
+        value={["auth", "params", "headers", "body"]}
+        class="w-full"
+      >
+        <!-- URL PARAMS -->
+
+        {#if urlTemplate?.path.some((p) => p.type === "Dynamic") || urlTemplate?.query_params.length > 0}
+          <Accordion.Item value="params">
+            <Accordion.Trigger>Parameters</Accordion.Trigger>
+
+            <!-- PATH PARAMS -->
+
+            <Accordion.Content
+              class="border flex-col justify-center items-center space-y-4"
+            >
+              {#if urlTemplate?.path.some((p) => p.type === "Dynamic")}
+                <div class="flex flex-wrap border">
+                  <h3 class="w-full mb-2 text-sm font-medium">Path</h3>
+                  <div
+                    class="border border-pink-900 w-1/2 grid grid-cols-2 gap-2 text-sm"
+                  >
+                    {#each urlTemplate.path.filter((p) => p.type === "Dynamic") as param}
+                      <Input
+                        bind:value={param.value}
+                        placeholder="key"
+                        oninput={constructUrl}
+                      />
+                      <Input
+                        bind:value={urlDynPaths[param.value]}
+                        placeholder="value"
+                        oninput={constructUrl}
+                      />
+                    {/each}
+                  </div>
+                </div>
+              {/if}
+
+              <!-- QUERY PARAMS -->
+
+              {#if urlTemplate?.query_params.length > 0}
+                <h3 class="w-full border-b mb-2 text-sm font-medium">Query</h3>
+                <div class="grid items-center grid-cols-2 gap-2 text-sm">
+                  {#each urlTemplate!!.query_params as param}
+                    <Input
+                      bind:value={param[0]}
+                      placeholder="key"
+                      oninput={constructUrl}
+                    />
+                    <Input
+                      bind:value={param[1]}
+                      placeholder="value"
+                      oninput={constructUrl}
+                    />
+                  {/each}
+                </div>
+              {/if}
+            </Accordion.Content>
+          </Accordion.Item>
+        {/if}
+
+        <!-- HEADERS -->
+        <Accordion.Item value="headers">
+          <Accordion.Trigger>Headers</Accordion.Trigger>
+          <Accordion.Content>
+            <div class="grid grid-cols-3 gap-2 text-sm">
+              <div class="font-medium text-muted-foreground">Key</div>
+              <div class="font-medium text-muted-foreground col-span-2">
+                Value
+              </div>
+
+              {#each headers as header}
+                <Input bind:value={header.key} placeholder="Header" />
+                <Input
+                  bind:value={header.value}
+                  placeholder="Value"
+                  class="col-span-2"
+                />
+              {/each}
+            </div>
+          </Accordion.Content>
+        </Accordion.Item>
+
+        <!-- BODY -->
+        <Accordion.Item value="body">
+          <Accordion.Trigger>Body</Accordion.Trigger>
+          <Accordion.Content class="space-y-4">
+            <Tabs.Root value="json">
+              <Tabs.List>
+                <Tabs.Trigger value="json">JSON</Tabs.Trigger>
+                <Tabs.Trigger value="form">Form</Tabs.Trigger>
+                <Tabs.Trigger value="text">Text</Tabs.Trigger>
+              </Tabs.List>
+
+              <Tabs.Content value="json">
+                <textarea
+                  class="w-full min-h-[200px] rounded-md border bg-background p-2 font-mono text-sm"
+                  bind:value={body}
+                ></textarea>
+              </Tabs.Content>
+
+              <Tabs.Content value="form">
+                <p class="text-sm text-muted-foreground">
+                  Form body editor coming soon.
+                </p>
+              </Tabs.Content>
+
+              <Tabs.Content value="text">
+                <textarea
+                  class="w-full min-h-[200px] rounded-md border bg-background p-2 font-mono text-sm"
+                  placeholder="Raw text body"
+                ></textarea>
+              </Tabs.Content>
+            </Tabs.Root>
+          </Accordion.Content>
+        </Accordion.Item>
+      </Accordion.Root>
+    </section>
+  {/if}
+</main>

+ 22 - 0
src/lib/components/ui/accordion/accordion-content.svelte

@@ -0,0 +1,22 @@
+<script lang="ts">
+	import { Accordion as AccordionPrimitive } from "bits-ui";
+	import { cn, type WithoutChild } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		children,
+		...restProps
+	}: WithoutChild<AccordionPrimitive.ContentProps> = $props();
+</script>
+
+<AccordionPrimitive.Content
+	bind:ref
+	data-slot="accordion-content"
+	class="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
+	{...restProps}
+>
+	<div class={cn("pt-0 pb-4", className)}>
+		{@render children?.()}
+	</div>
+</AccordionPrimitive.Content>

+ 17 - 0
src/lib/components/ui/accordion/accordion-item.svelte

@@ -0,0 +1,17 @@
+<script lang="ts">
+	import { Accordion as AccordionPrimitive } from "bits-ui";
+	import { cn } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		...restProps
+	}: AccordionPrimitive.ItemProps = $props();
+</script>
+
+<AccordionPrimitive.Item
+	bind:ref
+	data-slot="accordion-item"
+	class={cn("border-b last:border-b-0", className)}
+	{...restProps}
+/>

+ 32 - 0
src/lib/components/ui/accordion/accordion-trigger.svelte

@@ -0,0 +1,32 @@
+<script lang="ts">
+	import { Accordion as AccordionPrimitive } from "bits-ui";
+	import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
+	import { cn, type WithoutChild } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		level = 3,
+		children,
+		...restProps
+	}: WithoutChild<AccordionPrimitive.TriggerProps> & {
+		level?: AccordionPrimitive.HeaderProps["level"];
+	} = $props();
+</script>
+
+<AccordionPrimitive.Header {level} class="flex">
+	<AccordionPrimitive.Trigger
+		data-slot="accordion-trigger"
+		bind:ref
+		class={cn(
+			"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-start text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
+			className
+		)}
+		{...restProps}
+	>
+		{@render children?.()}
+		<ChevronDownIcon
+			class="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200"
+		/>
+	</AccordionPrimitive.Trigger>
+</AccordionPrimitive.Header>

+ 16 - 0
src/lib/components/ui/accordion/accordion.svelte

@@ -0,0 +1,16 @@
+<script lang="ts">
+	import { Accordion as AccordionPrimitive } from "bits-ui";
+
+	let {
+		ref = $bindable(null),
+		value = $bindable(),
+		...restProps
+	}: AccordionPrimitive.RootProps = $props();
+</script>
+
+<AccordionPrimitive.Root
+	bind:ref
+	bind:value={value as never}
+	data-slot="accordion"
+	{...restProps}
+/>

+ 16 - 0
src/lib/components/ui/accordion/index.ts

@@ -0,0 +1,16 @@
+import Root from "./accordion.svelte";
+import Content from "./accordion-content.svelte";
+import Item from "./accordion-item.svelte";
+import Trigger from "./accordion-trigger.svelte";
+
+export {
+	Root,
+	Content,
+	Item,
+	Trigger,
+	//
+	Root as Accordion,
+	Content as AccordionContent,
+	Item as AccordionItem,
+	Trigger as AccordionTrigger,
+};

+ 50 - 0
src/lib/components/ui/badge/badge.svelte

@@ -0,0 +1,50 @@
+<script lang="ts" module>
+	import { type VariantProps, tv } from "tailwind-variants";
+
+	export const badgeVariants = tv({
+		base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3",
+		variants: {
+			variant: {
+				default:
+					"bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent",
+				secondary:
+					"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent",
+				destructive:
+					"bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white",
+				outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+			},
+		},
+		defaultVariants: {
+			variant: "default",
+		},
+	});
+
+	export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
+</script>
+
+<script lang="ts">
+	import type { HTMLAnchorAttributes } from "svelte/elements";
+	import { cn, type WithElementRef } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		href,
+		class: className,
+		variant = "default",
+		children,
+		...restProps
+	}: WithElementRef<HTMLAnchorAttributes> & {
+		variant?: BadgeVariant;
+	} = $props();
+</script>
+
+<svelte:element
+	this={href ? "a" : "span"}
+	bind:this={ref}
+	data-slot="badge"
+	{href}
+	class={cn(badgeVariants({ variant }), className)}
+	{...restProps}
+>
+	{@render children?.()}
+</svelte:element>

+ 2 - 0
src/lib/components/ui/badge/index.ts

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

+ 37 - 0
src/lib/components/ui/select/index.ts

@@ -0,0 +1,37 @@
+import Root from "./select.svelte";
+import Group from "./select-group.svelte";
+import Label from "./select-label.svelte";
+import Item from "./select-item.svelte";
+import Content from "./select-content.svelte";
+import Trigger from "./select-trigger.svelte";
+import Separator from "./select-separator.svelte";
+import ScrollDownButton from "./select-scroll-down-button.svelte";
+import ScrollUpButton from "./select-scroll-up-button.svelte";
+import GroupHeading from "./select-group-heading.svelte";
+import Portal from "./select-portal.svelte";
+
+export {
+	Root,
+	Group,
+	Label,
+	Item,
+	Content,
+	Trigger,
+	Separator,
+	ScrollDownButton,
+	ScrollUpButton,
+	GroupHeading,
+	Portal,
+	//
+	Root as Select,
+	Group as SelectGroup,
+	Label as SelectLabel,
+	Item as SelectItem,
+	Content as SelectContent,
+	Trigger as SelectTrigger,
+	Separator as SelectSeparator,
+	ScrollDownButton as SelectScrollDownButton,
+	ScrollUpButton as SelectScrollUpButton,
+	GroupHeading as SelectGroupHeading,
+	Portal as SelectPortal,
+};

+ 45 - 0
src/lib/components/ui/select/select-content.svelte

@@ -0,0 +1,45 @@
+<script lang="ts">
+	import { Select as SelectPrimitive } from "bits-ui";
+	import SelectPortal from "./select-portal.svelte";
+	import SelectScrollUpButton from "./select-scroll-up-button.svelte";
+	import SelectScrollDownButton from "./select-scroll-down-button.svelte";
+	import { cn, type WithoutChild } from "$lib/utils.js";
+	import type { ComponentProps } from "svelte";
+	import type { WithoutChildrenOrChild } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		sideOffset = 4,
+		portalProps,
+		children,
+		preventScroll = true,
+		...restProps
+	}: WithoutChild<SelectPrimitive.ContentProps> & {
+		portalProps?: WithoutChildrenOrChild<ComponentProps<typeof SelectPortal>>;
+	} = $props();
+</script>
+
+<SelectPortal {...portalProps}>
+	<SelectPrimitive.Content
+		bind:ref
+		{sideOffset}
+		{preventScroll}
+		data-slot="select-content"
+		class={cn(
+			"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--bits-select-content-available-height) min-w-[8rem] origin-(--bits-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
+			className
+		)}
+		{...restProps}
+	>
+		<SelectScrollUpButton />
+		<SelectPrimitive.Viewport
+			class={cn(
+				"h-(--bits-select-anchor-height) w-full min-w-(--bits-select-anchor-width) scroll-my-1 p-1"
+			)}
+		>
+			{@render children?.()}
+		</SelectPrimitive.Viewport>
+		<SelectScrollDownButton />
+	</SelectPrimitive.Content>
+</SelectPortal>

+ 21 - 0
src/lib/components/ui/select/select-group-heading.svelte

@@ -0,0 +1,21 @@
+<script lang="ts">
+	import { Select as SelectPrimitive } from "bits-ui";
+	import { cn } from "$lib/utils.js";
+	import type { ComponentProps } from "svelte";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		children,
+		...restProps
+	}: ComponentProps<typeof SelectPrimitive.GroupHeading> = $props();
+</script>
+
+<SelectPrimitive.GroupHeading
+	bind:ref
+	data-slot="select-group-heading"
+	class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
+	{...restProps}
+>
+	{@render children?.()}
+</SelectPrimitive.GroupHeading>

+ 7 - 0
src/lib/components/ui/select/select-group.svelte

@@ -0,0 +1,7 @@
+<script lang="ts">
+	import { Select as SelectPrimitive } from "bits-ui";
+
+	let { ref = $bindable(null), ...restProps }: SelectPrimitive.GroupProps = $props();
+</script>
+
+<SelectPrimitive.Group data-slot="select-group" {...restProps} />

+ 38 - 0
src/lib/components/ui/select/select-item.svelte

@@ -0,0 +1,38 @@
+<script lang="ts">
+	import CheckIcon from "@lucide/svelte/icons/check";
+	import { Select as SelectPrimitive } from "bits-ui";
+	import { cn, type WithoutChild } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		value,
+		label,
+		children: childrenProp,
+		...restProps
+	}: WithoutChild<SelectPrimitive.ItemProps> = $props();
+</script>
+
+<SelectPrimitive.Item
+	bind:ref
+	{value}
+	data-slot="select-item"
+	class={cn(
+		"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 ps-2 pe-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
+		className
+	)}
+	{...restProps}
+>
+	{#snippet children({ selected, highlighted })}
+		<span class="absolute end-2 flex size-3.5 items-center justify-center">
+			{#if selected}
+				<CheckIcon class="size-4" />
+			{/if}
+		</span>
+		{#if childrenProp}
+			{@render childrenProp({ selected, highlighted })}
+		{:else}
+			{label || value}
+		{/if}
+	{/snippet}
+</SelectPrimitive.Item>

+ 20 - 0
src/lib/components/ui/select/select-label.svelte

@@ -0,0 +1,20 @@
+<script lang="ts">
+	import { cn, type WithElementRef } from "$lib/utils.js";
+	import type { HTMLAttributes } from "svelte/elements";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		children,
+		...restProps
+	}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {} = $props();
+</script>
+
+<div
+	bind:this={ref}
+	data-slot="select-label"
+	class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
+	{...restProps}
+>
+	{@render children?.()}
+</div>

+ 7 - 0
src/lib/components/ui/select/select-portal.svelte

@@ -0,0 +1,7 @@
+<script lang="ts">
+	import { Select as SelectPrimitive } from "bits-ui";
+
+	let { ...restProps }: SelectPrimitive.PortalProps = $props();
+</script>
+
+<SelectPrimitive.Portal {...restProps} />

+ 20 - 0
src/lib/components/ui/select/select-scroll-down-button.svelte

@@ -0,0 +1,20 @@
+<script lang="ts">
+	import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
+	import { Select as SelectPrimitive } from "bits-ui";
+	import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		...restProps
+	}: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
+</script>
+
+<SelectPrimitive.ScrollDownButton
+	bind:ref
+	data-slot="select-scroll-down-button"
+	class={cn("flex cursor-default items-center justify-center py-1", className)}
+	{...restProps}
+>
+	<ChevronDownIcon class="size-4" />
+</SelectPrimitive.ScrollDownButton>

+ 20 - 0
src/lib/components/ui/select/select-scroll-up-button.svelte

@@ -0,0 +1,20 @@
+<script lang="ts">
+	import ChevronUpIcon from "@lucide/svelte/icons/chevron-up";
+	import { Select as SelectPrimitive } from "bits-ui";
+	import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		...restProps
+	}: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props();
+</script>
+
+<SelectPrimitive.ScrollUpButton
+	bind:ref
+	data-slot="select-scroll-up-button"
+	class={cn("flex cursor-default items-center justify-center py-1", className)}
+	{...restProps}
+>
+	<ChevronUpIcon class="size-4" />
+</SelectPrimitive.ScrollUpButton>

+ 18 - 0
src/lib/components/ui/select/select-separator.svelte

@@ -0,0 +1,18 @@
+<script lang="ts">
+	import type { Separator as SeparatorPrimitive } from "bits-ui";
+	import { Separator } from "$lib/components/ui/separator/index.js";
+	import { cn } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		...restProps
+	}: SeparatorPrimitive.RootProps = $props();
+</script>
+
+<Separator
+	bind:ref
+	data-slot="select-separator"
+	class={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
+	{...restProps}
+/>

+ 29 - 0
src/lib/components/ui/select/select-trigger.svelte

@@ -0,0 +1,29 @@
+<script lang="ts">
+	import { Select as SelectPrimitive } from "bits-ui";
+	import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
+	import { cn, type WithoutChild } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		children,
+		size = "default",
+		...restProps
+	}: WithoutChild<SelectPrimitive.TriggerProps> & {
+		size?: "sm" | "default";
+	} = $props();
+</script>
+
+<SelectPrimitive.Trigger
+	bind:ref
+	data-slot="select-trigger"
+	data-size={size}
+	class={cn(
+		"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none select-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+		className
+	)}
+	{...restProps}
+>
+	{@render children?.()}
+	<ChevronDownIcon class="size-4 opacity-50" />
+</SelectPrimitive.Trigger>

+ 11 - 0
src/lib/components/ui/select/select.svelte

@@ -0,0 +1,11 @@
+<script lang="ts">
+	import { Select as SelectPrimitive } from "bits-ui";
+
+	let {
+		open = $bindable(false),
+		value = $bindable(),
+		...restProps
+	}: SelectPrimitive.RootProps = $props();
+</script>
+
+<SelectPrimitive.Root bind:open bind:value={value as never} {...restProps} />

+ 16 - 0
src/lib/components/ui/tabs/index.ts

@@ -0,0 +1,16 @@
+import Root from "./tabs.svelte";
+import Content from "./tabs-content.svelte";
+import List from "./tabs-list.svelte";
+import Trigger from "./tabs-trigger.svelte";
+
+export {
+	Root,
+	Content,
+	List,
+	Trigger,
+	//
+	Root as Tabs,
+	Content as TabsContent,
+	List as TabsList,
+	Trigger as TabsTrigger,
+};

+ 17 - 0
src/lib/components/ui/tabs/tabs-content.svelte

@@ -0,0 +1,17 @@
+<script lang="ts">
+	import { Tabs as TabsPrimitive } from "bits-ui";
+	import { cn } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		...restProps
+	}: TabsPrimitive.ContentProps = $props();
+</script>
+
+<TabsPrimitive.Content
+	bind:ref
+	data-slot="tabs-content"
+	class={cn("flex-1 outline-none", className)}
+	{...restProps}
+/>

+ 20 - 0
src/lib/components/ui/tabs/tabs-list.svelte

@@ -0,0 +1,20 @@
+<script lang="ts">
+	import { Tabs as TabsPrimitive } from "bits-ui";
+	import { cn } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		...restProps
+	}: TabsPrimitive.ListProps = $props();
+</script>
+
+<TabsPrimitive.List
+	bind:ref
+	data-slot="tabs-list"
+	class={cn(
+		"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
+		className
+	)}
+	{...restProps}
+/>

+ 20 - 0
src/lib/components/ui/tabs/tabs-trigger.svelte

@@ -0,0 +1,20 @@
+<script lang="ts">
+	import { Tabs as TabsPrimitive } from "bits-ui";
+	import { cn } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		class: className,
+		...restProps
+	}: TabsPrimitive.TriggerProps = $props();
+</script>
+
+<TabsPrimitive.Trigger
+	bind:ref
+	data-slot="tabs-trigger"
+	class={cn(
+		"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+		className
+	)}
+	{...restProps}
+/>

+ 19 - 0
src/lib/components/ui/tabs/tabs.svelte

@@ -0,0 +1,19 @@
+<script lang="ts">
+	import { Tabs as TabsPrimitive } from "bits-ui";
+	import { cn } from "$lib/utils.js";
+
+	let {
+		ref = $bindable(null),
+		value = $bindable(""),
+		class: className,
+		...restProps
+	}: TabsPrimitive.RootProps = $props();
+</script>
+
+<TabsPrimitive.Root
+	bind:ref
+	bind:value
+	data-slot="tabs"
+	class={cn("flex flex-col gap-2", className)}
+	{...restProps}
+/>

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

@@ -0,0 +1,30 @@
+import { load, Store } from "@tauri-apps/plugin-store";
+import type { WorkspaceEntry } from "./types";
+
+let store: Store;
+
+export async function init() {
+  store = await load("settings.json", {
+    defaults: {
+      theme: "dark",
+    },
+    autoSave: false,
+  });
+}
+export type Settings = {
+  theme?: "dark" | "light";
+  lastEntry?: WorkspaceEntry;
+};
+
+export async function getSetting<K extends keyof Settings>(
+  key: K,
+): Promise<Settings[K] | undefined> {
+  return store.get<Settings[K]>(key);
+}
+
+export async function setSetting<K extends keyof Settings>(
+  key: K,
+  value: Settings[K],
+) {
+  return store.set(key, value);
+}

+ 104 - 39
src/lib/state.svelte.ts

@@ -1,5 +1,10 @@
 import { invoke } from "@tauri-apps/api/core";
-import type { Workspace, WorkspaceEntry } from "./types";
+import type {
+  Workspace,
+  WorkspaceEntryBase,
+  RequestBody,
+  WorkspaceEntry,
+} from "./types";
 
 export type WorkspaceState = {
   /**
@@ -36,37 +41,60 @@ export const state: WorkspaceState = $state({
   indexes: {},
 });
 
-export function loadWorkspace(ws: Workspace) {
+const index = (entry: WorkspaceEntry) => {
+  console.log("indexing", entry);
+  state.indexes[entry.id] = entry;
+
+  if (entry.parent_id !== null) {
+    if (state.children[entry.parent_id]) {
+      state.children[entry.parent_id].push(entry.id);
+    } else {
+      state.children[entry.parent_id] = [entry.id];
+    }
+  } else {
+    state.roots.push(entry.id);
+  }
+};
+
+export function selectWorkspace(ws: Workspace) {
   state.workspace = ws;
+}
 
+export async function createWorkspace(name: string): Promise<Workspace> {
+  return invoke<Workspace>("create_workspace", { name });
+}
+
+export async function listWorkspaces(): Promise<Workspace[]> {
+  return invoke<Workspace[]>("list_workspaces");
+}
+
+export async function loadWorkspace(ws: Workspace) {
   state.children = {};
   state.indexes = {};
   state.roots = [];
+  state.entry = null;
 
-  invoke("get_workspace_entries", { id: state.workspace.id }).then(
-    (entries) => {
-      console.log(entries);
-      for (const entry of entries) {
-        // Index all entries
-        indexEntry(entry["Collection"] || entry["Request"].entry);
-      }
+  state.workspace = ws;
+
+  const entries = await invoke<WorkspaceEntryResponse[]>(
+    "get_workspace_entries",
+    {
+      id: state.workspace.id,
     },
   );
-}
-
-function indexEntry(entry: WorkspaceEntry) {
-  state.indexes[entry.id] = entry;
 
-  if (entry.parent_id !== null) {
-    if (state.children[entry.parent_id]) {
-      console.log("PUSHING CHILD", entry.id);
-      state.children[entry.parent_id].push(entry.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,
+      });
     } else {
-      console.log("SETTING CHILD", entry.id);
-      state.children[entry.parent_id] = [entry.id];
+      index(entry.data);
     }
-  } else {
-    state.roots.push(entry.id);
   }
 }
 
@@ -76,18 +104,26 @@ export function createRequest(parent_id?: number) {
     return;
   }
 
-  invoke<WorkspaceEntry>("create_workspace_entry", {
-    data: {
-      Request: {
-        name: "",
-        workspace_id: state.workspace.id,
-        parent_id,
-        method: "",
-        url: "",
-      },
+  const data = {
+    Request: {
+      name: "",
+      workspace_id: state.workspace.id,
+      parent_id,
+      method: "GET",
+      url: "",
     },
+  };
+
+  invoke<WorkspaceEntryBase>("create_workspace_entry", {
+    data,
   }).then((entry) => {
-    indexEntry(entry);
+    index({
+      ...entry,
+      method: data.Request.method,
+      url: data.Request.url,
+      body: null,
+      headers: {},
+    });
     console.log("request created:", entry);
   });
 }
@@ -98,16 +134,17 @@ export function createCollection(parent_id?: number) {
     return;
   }
 
-  invoke<WorkspaceEntry>("create_workspace_entry", {
-    data: {
-      Collection: {
-        name: "",
-        workspace_id: state.workspace.id,
-        parent_id,
-      },
+  const data = {
+    Collection: {
+      name: "",
+      workspace_id: state.workspace.id,
+      parent_id,
     },
+  };
+  invoke<WorkspaceEntryBase>("create_workspace_entry", {
+    data,
   }).then((entry) => {
-    indexEntry(entry);
+    index(entry);
     console.log("collection created:", entry);
   });
 }
@@ -115,4 +152,32 @@ export function createCollection(parent_id?: number) {
 export function selectEntry(id: number) {
   state.entry = state.indexes[id];
   console.log("entry selected:", id);
+  if (state.entry.parent_id !== null) {
+    let parent = state.indexes[state.entry.parent_id];
+    while (parent) {
+      parent.open = true;
+      if (parent.parent_id === null) {
+        break;
+      }
+      parent = state.indexes[parent.parent_id];
+    }
+  }
 }
+
+type WorkspaceEntryResponse =
+  | {
+      type: "Collection";
+      data: WorkspaceEntryBase;
+    }
+  | {
+      type: "Request";
+      data: WorkspaceRequestResponse;
+    };
+
+type WorkspaceRequestResponse = {
+  entry: WorkspaceEntryBase;
+  method: string;
+  url: string;
+  body: RequestBody | null;
+  headers: { [key: string]: string };
+};

+ 21 - 3
src/lib/types.ts

@@ -6,20 +6,38 @@ export type Workspace = {
 export type WorkspaceEntryType = "Request" | "Collection";
 
 export type WorkspaceEntryBase = {
+  // Values from models
   id: number;
   workspace_id: number;
   parent_id: number | null;
   name: string;
   type: WorkspaceEntryType;
+
+  // UI values,
+  open?: boolean;
 };
 
-export type WorkspaceEntry = WorkspaceEntryBase & {
+export type WorkspaceRequest = WorkspaceEntryBase & {
   method: string;
   url: string;
-  body: RequestBody;
-  headers: Map<string, string>;
+  body: RequestBody | null;
+  headers: { [key: string]: string };
+};
+
+export type RequestUrl = {
+  scheme: string;
+  host: string;
+  path: PathSegment[];
+  query_params: string[][];
 };
 
+export type PathSegment = {
+  type: "Static" | "Dynamic";
+  value: string;
+};
+
+export type WorkspaceEntry = WorkspaceEntryBase | WorkspaceRequest;
+
 export type WorkspaceCreateCollection = {
   name: string;
   workspace_id: number;

+ 4 - 1
src/routes/+layout.svelte

@@ -1,7 +1,10 @@
 <script lang="ts">
+  import { init } from "$lib/settings.svelte";
+  import { ModeWatcher } from "mode-watcher";
   import "./layout.css";
+
   let { children } = $props();
-  import { ModeWatcher } from "mode-watcher";
+  await init();
 </script>
 
 <ModeWatcher />

+ 5 - 0
svelte.config.js

@@ -8,6 +8,11 @@ import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
 /** @type {import('@sveltejs/kit').Config} */
 const config = {
   preprocess: vitePreprocess(),
+  compilerOptions: {
+    experimental: {
+      async: true,
+    },
+  },
   kit: {
     adapter: adapter({
       fallback: "index.html",