Compare commits

...

12 Commits

169 changed files with 10246 additions and 143 deletions

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Xyroscar
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,7 +1,67 @@
# Tauri + SvelteKit + TypeScript # Resona
This template should help get you started developing with Tauri, SvelteKit and TypeScript in Vite. Resona is a desktop API client for testing and debugging HTTP APIs. Built with Tauri, SvelteKit, and Rust.
## Recommended IDE Setup ## Features
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer). - **Workspaces**: Organize your API requests into workspaces with tags for easy filtering
- **Collections**: Group related requests into collections within workspaces
- **Variables**: Define variables at global, workspace, collection, or request scope with automatic interpolation
- **HTTP Client**: Send HTTP requests with support for various body types (JSON, form-data, URL-encoded, etc.)
- **Sync Groups**: Sync variables across multiple workspaces
- **Themes**: Multiple built-in themes including light, dark, and Catppuccin variants (Latte, Frappe, Macchiato, Mocha)
- **Persistent Storage**: All data is stored locally using an embedded database (redb)
## Tech Stack
- **Frontend**: SvelteKit, TypeScript, TailwindCSS, shadcn-svelte
- **Backend**: Rust, Tauri
- **Database**: redb (embedded key-value store)
- **HTTP**: reqwest
## Development
### Prerequisites
- Node.js (v18+)
- Rust (latest stable)
- Bun (or npm/yarn/pnpm)
### Setup
```bash
# Install dependencies
bun install
# Run in development mode
bun run tauri dev
# Build for production
bun run tauri build
```
### Project Structure
```
resona/
├── src/ # Frontend (SvelteKit)
│ ├── lib/
│ │ ├── components/ # UI components
│ │ ├── services/ # API service layer
│ │ └── types/ # TypeScript types
│ └── routes/ # SvelteKit routes
├── src-tauri/ # Backend (Rust/Tauri)
│ └── src/
│ ├── collections/ # Collections module
│ ├── db/ # Database layer
│ ├── http/ # HTTP client
│ ├── requests/ # Requests module
│ ├── settings/ # App settings
│ ├── variables/ # Variables module
│ └── workspaces/ # Workspaces module
└── static/ # Static assets
```
## License
MIT License - see [LICENSE](LICENSE) for details.

BIN
bun.lockb

Binary file not shown.

View File

@@ -13,11 +13,16 @@
}, },
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-xml": "^6.1.0",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2" "@tauri-apps/plugin-opener": "^2",
"svelte-codemirror-editor": "^2.1.0"
}, },
"devDependencies": { "devDependencies": {
"@lucide/svelte": "^0.554.0", "@internationalized/date": "^3.8.1",
"@lucide/svelte": "^0.544.0",
"@sveltejs/adapter-static": "^3.0.6", "@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.9.0", "@sveltejs/kit": "^2.9.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
@@ -25,6 +30,7 @@
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.1.17",
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2",
"bits-ui": "^2.11.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"svelte": "^5.0.0", "svelte": "^5.0.0",
"svelte-check": "^4.0.0", "svelte-check": "^4.0.0",

396
src-tauri/Cargo.lock generated
View File

@@ -451,8 +451,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
dependencies = [ dependencies = [
"iana-time-zone", "iana-time-zone",
"js-sys",
"num-traits", "num-traits",
"serde", "serde",
"wasm-bindgen",
"windows-link 0.2.1", "windows-link 0.2.1",
] ]
@@ -491,6 +493,16 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.10.1" version = "0.10.1"
@@ -514,9 +526,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
dependencies = [ dependencies = [
"bitflags 2.10.0", "bitflags 2.10.0",
"core-foundation", "core-foundation 0.10.1",
"core-graphics-types", "core-graphics-types",
"foreign-types", "foreign-types 0.5.0",
"libc", "libc",
] ]
@@ -527,7 +539,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [ dependencies = [
"bitflags 2.10.0", "bitflags 2.10.0",
"core-foundation", "core-foundation 0.10.1",
"libc", "libc",
] ]
@@ -679,6 +691,15 @@ dependencies = [
"crypto-common", "crypto-common",
] ]
[[package]]
name = "directories"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d"
dependencies = [
"dirs-sys",
]
[[package]] [[package]]
name = "dirs" name = "dirs"
version = "6.0.0" version = "6.0.0"
@@ -806,6 +827,15 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "endi" name = "endi"
version = "1.1.0" version = "1.1.0"
@@ -928,6 +958,15 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared 0.1.1",
]
[[package]] [[package]]
name = "foreign-types" name = "foreign-types"
version = "0.5.0" version = "0.5.0"
@@ -935,7 +974,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [ dependencies = [
"foreign-types-macros", "foreign-types-macros",
"foreign-types-shared", "foreign-types-shared 0.3.1",
] ]
[[package]] [[package]]
@@ -949,6 +988,12 @@ dependencies = [
"syn 2.0.111", "syn 2.0.111",
] ]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]] [[package]]
name = "foreign-types-shared" name = "foreign-types-shared"
version = "0.3.1" version = "0.3.1"
@@ -1359,6 +1404,25 @@ dependencies = [
"syn 2.0.111", "syn 2.0.111",
] ]
[[package]]
name = "h2"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.12.1",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.12.3" version = "0.12.3"
@@ -1457,6 +1521,7 @@ dependencies = [
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"h2",
"http", "http",
"http-body", "http-body",
"httparse", "httparse",
@@ -1468,6 +1533,38 @@ dependencies = [
"want", "want",
] ]
[[package]]
name = "hyper-rustls"
version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]] [[package]]
name = "hyper-util" name = "hyper-util"
version = "0.1.18" version = "0.1.18"
@@ -1487,9 +1584,11 @@ dependencies = [
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"socket2", "socket2",
"system-configuration",
"tokio", "tokio",
"tower-service", "tower-service",
"tracing", "tracing",
"windows-registry",
] ]
[[package]] [[package]]
@@ -1948,6 +2047,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"
@@ -1990,6 +2099,23 @@ dependencies = [
"windows-sys 0.60.2", "windows-sys 0.60.2",
] ]
[[package]]
name = "native-tls"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]] [[package]]
name = "ndk" name = "ndk"
version = "0.9.0" version = "0.9.0"
@@ -2364,6 +2490,50 @@ dependencies = [
"pathdiff", "pathdiff",
] ]
[[package]]
name = "openssl"
version = "0.10.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
dependencies = [
"bitflags 2.10.0",
"cfg-if",
"foreign-types 0.3.2",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
]
[[package]]
name = "openssl-probe"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-sys"
version = "0.9.111"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]] [[package]]
name = "option-ext" name = "option-ext"
version = "0.2.0" version = "0.2.0"
@@ -2858,6 +3028,15 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
name = "redb"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae323eb086579a3769daa2c753bb96deb95993c534711e0dbe881b5192906a06"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.18" version = "0.5.18"
@@ -2935,22 +3114,31 @@ checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bytes", "bytes",
"encoding_rs",
"futures-core", "futures-core",
"futures-util", "futures-util",
"h2",
"http", "http",
"http-body", "http-body",
"http-body-util", "http-body-util",
"hyper", "hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util", "hyper-util",
"js-sys", "js-sys",
"log", "log",
"mime",
"mime_guess",
"native-tls",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"rustls-pki-types",
"serde", "serde",
"serde_json", "serde_json",
"serde_urlencoded", "serde_urlencoded",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-native-tls",
"tokio-util", "tokio-util",
"tower", "tower",
"tower-http", "tower-http",
@@ -2966,11 +3154,33 @@ dependencies = [
name = "resona" name = "resona"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"chrono",
"directories",
"redb",
"reqwest",
"serde", "serde",
"serde_json", "serde_json",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-opener", "tauri-plugin-opener",
"thiserror 2.0.17",
"tokio",
"urlencoding",
"uuid",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.16",
"libc",
"untrusted",
"windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -2995,6 +3205,39 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "rustls"
version = "0.23.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f"
dependencies = [
"once_cell",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.22" version = "1.0.22"
@@ -3016,6 +3259,15 @@ dependencies = [
"winapi-util", "winapi-util",
] ]
[[package]]
name = "schannel"
version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
dependencies = [
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "schemars" name = "schemars"
version = "0.8.22" version = "0.8.22"
@@ -3073,6 +3325,29 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags 2.10.0",
"core-foundation 0.9.4",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "selectors" name = "selectors"
version = "0.24.0" version = "0.24.0"
@@ -3346,7 +3621,7 @@ dependencies = [
"bytemuck", "bytemuck",
"cfg_aliases", "cfg_aliases",
"core-graphics", "core-graphics",
"foreign-types", "foreign-types 0.5.0",
"js-sys", "js-sys",
"log", "log",
"objc2 0.5.2", "objc2 0.5.2",
@@ -3428,6 +3703,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "swift-rs" name = "swift-rs"
version = "1.0.7" version = "1.0.7"
@@ -3481,6 +3762,27 @@ dependencies = [
"syn 2.0.111", "syn 2.0.111",
] ]
[[package]]
name = "system-configuration"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
dependencies = [
"bitflags 2.10.0",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "system-deps" name = "system-deps"
version = "6.2.2" version = "6.2.2"
@@ -3502,7 +3804,7 @@ checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7"
dependencies = [ dependencies = [
"bitflags 2.10.0", "bitflags 2.10.0",
"block2 0.6.2", "block2 0.6.2",
"core-foundation", "core-foundation 0.10.1",
"core-graphics", "core-graphics",
"crossbeam-channel", "crossbeam-channel",
"dispatch", "dispatch",
@@ -3921,9 +4223,41 @@ dependencies = [
"mio", "mio",
"pin-project-lite", "pin-project-lite",
"socket2", "socket2",
"tokio-macros",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "tokio-macros"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]] [[package]]
name = "tokio-util" name = "tokio-util"
version = "0.7.17" version = "0.7.17"
@@ -4201,6 +4535,12 @@ dependencies = [
"unic-common", "unic-common",
] ]
[[package]]
name = "unicase"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.22" version = "1.0.22"
@@ -4213,6 +4553,12 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.7" version = "2.5.7"
@@ -4225,6 +4571,12 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]] [[package]]
name = "urlpattern" name = "urlpattern"
version = "0.3.0" version = "0.3.0"
@@ -4261,6 +4613,12 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]] [[package]]
name = "version-compare" name = "version-compare"
version = "0.2.1" version = "0.2.1"
@@ -4643,6 +5001,17 @@ dependencies = [
"windows-link 0.1.3", "windows-link 0.1.3",
] ]
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
]
[[package]] [[package]]
name = "windows-result" name = "windows-result"
version = "0.3.4" version = "0.3.4"
@@ -4688,6 +5057,15 @@ dependencies = [
"windows-targets 0.42.2", "windows-targets 0.42.2",
] ]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.59.0" version = "0.59.0"
@@ -5150,6 +5528,12 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"
version = "0.2.3" version = "0.2.3"

View File

@@ -23,3 +23,17 @@ tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
# Database
redb = "3.1.0"
# Utilities
uuid = { version = "1", features = ["v4", "serde"] }
thiserror = "2"
chrono = { version = "0.4", features = ["serde"] }
directories = "6.0.0"
# HTTP client
reqwest = { version = "0.12", features = ["json", "multipart"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
urlencoding = "2"

View File

@@ -0,0 +1,51 @@
use tauri::State;
use crate::db::Database;
use super::service::CollectionService;
use super::types::{Collection, CreateCollectionInput, UpdateCollectionInput};
#[tauri::command]
pub fn get_collections(db: State<Database>) -> Result<Vec<Collection>, String> {
let service = CollectionService::new(db.inner().clone());
service.get_all().map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_collection(db: State<Database>, id: String) -> Result<Collection, String> {
let service = CollectionService::new(db.inner().clone());
service.get(&id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_collections_by_workspace(
db: State<Database>,
workspace_id: String,
) -> Result<Vec<Collection>, String> {
let service = CollectionService::new(db.inner().clone());
service.get_by_workspace(&workspace_id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn create_collection(
db: State<Database>,
input: CreateCollectionInput,
) -> Result<Collection, String> {
let service = CollectionService::new(db.inner().clone());
service.create(input).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn update_collection(
db: State<Database>,
input: UpdateCollectionInput,
) -> Result<Collection, String> {
let service = CollectionService::new(db.inner().clone());
service.update(input).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn delete_collection(db: State<Database>, id: String) -> Result<(), String> {
let service = CollectionService::new(db.inner().clone());
service.delete(&id).map_err(|e| e.to_string())
}

View File

@@ -0,0 +1,8 @@
mod commands;
mod service;
mod types;
pub use commands::*;
#[allow(unused_imports)]
pub use types::{Collection, CreateCollectionInput, UpdateCollectionInput};
pub(crate) use service::CollectionService;

View File

@@ -0,0 +1,164 @@
use chrono::Utc;
use redb::ReadableTable;
use crate::db::{
Database, DbError, DbResult, COLLECTIONS, COLLECTIONS_BY_WORKSPACE,
};
use super::types::{Collection, CreateCollectionInput, UpdateCollectionInput};
pub struct CollectionService {
db: Database,
}
impl CollectionService {
pub fn new(db: Database) -> Self {
Self { db }
}
pub fn get_all(&self) -> DbResult<Vec<Collection>> {
let read_txn = self.db.begin_read()?;
let table = read_txn.open_table(COLLECTIONS)?;
let mut collections = Vec::new();
for entry in table.iter()? {
let (_, value) = entry?;
let collection: Collection = serde_json::from_str(value.value())
.map_err(|e| DbError::Serialization(e.to_string()))?;
collections.push(collection);
}
collections.sort_by(|a, b| a.name.cmp(&b.name));
Ok(collections)
}
pub fn get(&self, id: &str) -> DbResult<Collection> {
let read_txn = self.db.begin_read()?;
let table = read_txn.open_table(COLLECTIONS)?;
let value = table
.get(id)?
.ok_or_else(|| DbError::NotFound(format!("Collection not found: {}", id)))?;
let collection: Collection = serde_json::from_str(value.value())
.map_err(|e| DbError::Serialization(e.to_string()))?;
Ok(collection)
}
pub fn get_by_workspace(&self, workspace_id: &str) -> DbResult<Vec<Collection>> {
let read_txn = self.db.begin_read()?;
let idx_table = read_txn.open_table(COLLECTIONS_BY_WORKSPACE)?;
let collection_ids: Vec<String> = match idx_table.get(workspace_id)? {
Some(value) => serde_json::from_str(value.value())
.map_err(|e| DbError::Serialization(e.to_string()))?,
None => return Ok(Vec::new()),
};
drop(idx_table);
drop(read_txn);
let mut collections = Vec::new();
for id in collection_ids {
if let Ok(collection) = self.get(&id) {
collections.push(collection);
}
}
collections.sort_by(|a, b| a.name.cmp(&b.name));
Ok(collections)
}
pub fn create(&self, input: CreateCollectionInput) -> DbResult<Collection> {
let collection = Collection::new(input.name, input.description, input.workspace_id.clone());
let json = serde_json::to_string(&collection)
.map_err(|e| DbError::Serialization(e.to_string()))?;
let write_txn = self.db.begin_write()?;
{
let mut table = write_txn.open_table(COLLECTIONS)?;
table.insert(collection.id.as_str(), json.as_str())?;
}
// Update index
{
let mut idx_table = write_txn.open_table(COLLECTIONS_BY_WORKSPACE)?;
let ids_json = match idx_table.get(input.workspace_id.as_str())? {
Some(value) => value.value().to_string(),
None => "[]".to_string(),
};
let mut ids: Vec<String> = serde_json::from_str(&ids_json)
.map_err(|e| DbError::Serialization(e.to_string()))?;
ids.push(collection.id.clone());
let new_json = serde_json::to_string(&ids)
.map_err(|e| DbError::Serialization(e.to_string()))?;
idx_table.insert(input.workspace_id.as_str(), new_json.as_str())?;
}
write_txn.commit()?;
Ok(collection)
}
pub fn update(&self, input: UpdateCollectionInput) -> DbResult<Collection> {
let mut collection = self.get(&input.id)?;
if let Some(name) = input.name {
collection.name = name;
}
if let Some(description) = input.description {
collection.description = description;
}
collection.updated_at = Utc::now();
let json = serde_json::to_string(&collection)
.map_err(|e| DbError::Serialization(e.to_string()))?;
let write_txn = self.db.begin_write()?;
{
let mut table = write_txn.open_table(COLLECTIONS)?;
table.insert(collection.id.as_str(), json.as_str())?;
}
write_txn.commit()?;
Ok(collection)
}
pub fn delete(&self, id: &str) -> DbResult<()> {
let collection = self.get(id)?;
let write_txn = self.db.begin_write()?;
// Remove from collections table
{
let mut table = write_txn.open_table(COLLECTIONS)?;
table.remove(id)?;
}
// Update index
{
let mut idx_table = write_txn.open_table(COLLECTIONS_BY_WORKSPACE)?;
let ids_json = idx_table
.get(collection.workspace_id.as_str())?
.map(|v| v.value().to_string())
.unwrap_or_else(|| "[]".to_string());
let mut ids: Vec<String> = serde_json::from_str(&ids_json)
.map_err(|e| DbError::Serialization(e.to_string()))?;
ids.retain(|i| i != id);
let new_json = serde_json::to_string(&ids)
.map_err(|e| DbError::Serialization(e.to_string()))?;
idx_table.insert(collection.workspace_id.as_str(), new_json.as_str())?;
}
write_txn.commit()?;
Ok(())
}
}

View File

@@ -0,0 +1,43 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Collection {
pub id: String,
pub name: String,
pub description: String,
pub workspace_id: String,
#[serde(default = "Utc::now")]
pub created_at: DateTime<Utc>,
#[serde(default = "Utc::now")]
pub updated_at: DateTime<Utc>,
}
impl Collection {
pub fn new(name: String, description: String, workspace_id: String) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4().to_string(),
name,
description,
workspace_id,
created_at: now,
updated_at: now,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateCollectionInput {
pub name: String,
pub description: String,
pub workspace_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateCollectionInput {
pub id: String,
pub name: Option<String>,
pub description: Option<String>,
}

View File

@@ -0,0 +1,130 @@
//! Database initialization and management
use std::path::PathBuf;
use std::sync::Arc;
use directories::ProjectDirs;
use redb::{Database as RedbDatabase, ReadableDatabase};
use super::error::{DbError, DbResult};
use super::tables::*;
/// Main database wrapper
pub struct Database {
db: Arc<RedbDatabase>,
}
impl Database {
/// Create or open the database at the default application data directory
pub fn open() -> DbResult<Self> {
let path = Self::get_db_path()?;
// Ensure parent directory exists
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let db = RedbDatabase::create(&path)?;
let database = Self { db: Arc::new(db) };
// Initialize tables
database.init_tables()?;
Ok(database)
}
/// Open database at a specific path (useful for testing)
#[allow(dead_code)]
pub fn open_at(path: PathBuf) -> DbResult<Self> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let db = RedbDatabase::create(&path)?;
let database = Self { db: Arc::new(db) };
database.init_tables()?;
Ok(database)
}
/// Get the default database path
fn get_db_path() -> DbResult<PathBuf> {
let proj_dirs = ProjectDirs::from("com", "xyroscar", "resona")
.ok_or_else(|| DbError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
"Could not determine application data directory",
)))?;
Ok(proj_dirs.data_dir().join("resona.redb"))
}
/// Initialize all tables
fn init_tables(&self) -> DbResult<()> {
let write_txn = self.db.begin_write()?;
// Create main tables
write_txn.open_table(WORKSPACES)?;
write_txn.open_table(WORKSPACE_SYNC_GROUPS)?;
write_txn.open_table(COLLECTIONS)?;
write_txn.open_table(REQUESTS)?;
write_txn.open_table(VARIABLES)?;
write_txn.open_table(APP_SETTINGS)?;
// Create index tables
write_txn.open_table(COLLECTIONS_BY_WORKSPACE)?;
write_txn.open_table(REQUESTS_BY_COLLECTION)?;
write_txn.open_table(REQUESTS_BY_WORKSPACE)?;
write_txn.open_table(VARIABLES_BY_SCOPE)?;
write_txn.open_table(WORKSPACES_BY_SYNC_GROUP)?;
write_txn.commit()?;
Ok(())
}
/// Get a reference to the underlying redb database
#[allow(dead_code)]
pub fn inner(&self) -> &RedbDatabase {
&self.db
}
/// Begin a read transaction
pub fn begin_read(&self) -> DbResult<redb::ReadTransaction> {
Ok(self.db.begin_read()?)
}
/// Begin a write transaction
pub fn begin_write(&self) -> DbResult<redb::WriteTransaction> {
Ok(self.db.begin_write()?)
}
}
impl Clone for Database {
fn clone(&self) -> Self {
Self {
db: Arc::clone(&self.db),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::env::temp_dir;
#[test]
fn test_database_creation() {
let path = temp_dir().join("resona_test.redb");
let _ = std::fs::remove_file(&path); // Clean up any previous test
let db = Database::open_at(path.clone()).expect("Failed to create database");
// Verify tables exist by attempting to read from them
let read_txn = db.begin_read().expect("Failed to begin read transaction");
let _ = read_txn.open_table(WORKSPACES).expect("Workspaces table should exist");
// Clean up
drop(db);
let _ = std::fs::remove_file(&path);
}
}

42
src-tauri/src/db/error.rs Normal file
View File

@@ -0,0 +1,42 @@
//! Database error types
use thiserror::Error;
#[derive(Error, Debug)]
pub enum DbError {
#[error("Database error: {0}")]
Database(#[from] redb::DatabaseError),
#[error("Storage error: {0}")]
Storage(#[from] redb::StorageError),
#[error("Table error: {0}")]
Table(#[from] redb::TableError),
#[error("Transaction error: {0}")]
Transaction(#[from] redb::TransactionError),
#[error("Commit error: {0}")]
Commit(#[from] redb::CommitError),
#[error("Not found: {0}")]
NotFound(String),
#[error("Serialization error: {0}")]
Serialization(String),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
pub type DbResult<T> = Result<T, DbError>;
// Implement conversion to tauri::Error for command returns
impl From<DbError> for String {
fn from(err: DbError) -> Self {
err.to_string()
}
}

11
src-tauri/src/db/mod.rs Normal file
View File

@@ -0,0 +1,11 @@
//! Database module for Resona
//!
//! This module handles all database operations using redb as the storage backend.
mod database;
mod error;
mod tables;
pub use database::Database;
pub use error::{DbError, DbResult};
pub use tables::*;

View File

@@ -0,0 +1,47 @@
//! Table definitions for redb
//!
//! All tables are defined here as constants for consistent access across the application.
use redb::TableDefinition;
/// Workspaces table: workspace_id -> workspace JSON
pub const WORKSPACES: TableDefinition<&str, &str> = TableDefinition::new("workspaces");
/// Workspace sync groups table: sync_group_id -> sync_group JSON
pub const WORKSPACE_SYNC_GROUPS: TableDefinition<&str, &str> =
TableDefinition::new("workspace_sync_groups");
/// Collections table: collection_id -> collection JSON
pub const COLLECTIONS: TableDefinition<&str, &str> = TableDefinition::new("collections");
/// Requests table: request_id -> request JSON
pub const REQUESTS: TableDefinition<&str, &str> = TableDefinition::new("requests");
/// Variables table: variable_id -> variable JSON
pub const VARIABLES: TableDefinition<&str, &str> = TableDefinition::new("variables");
/// App settings table: "settings" -> settings JSON (single row)
pub const APP_SETTINGS: TableDefinition<&str, &str> = TableDefinition::new("app_settings");
// Index tables for efficient lookups
/// Collections by workspace index: workspace_id -> collection_ids JSON array
pub const COLLECTIONS_BY_WORKSPACE: TableDefinition<&str, &str> =
TableDefinition::new("idx_collections_by_workspace");
/// Requests by collection index: collection_id -> request_ids JSON array
pub const REQUESTS_BY_COLLECTION: TableDefinition<&str, &str> =
TableDefinition::new("idx_requests_by_collection");
/// Requests by workspace (standalone) index: workspace_id -> request_ids JSON array
pub const REQUESTS_BY_WORKSPACE: TableDefinition<&str, &str> =
TableDefinition::new("idx_requests_by_workspace");
/// Variables by scope index: scope_key -> variable_ids JSON array
/// scope_key format: "global", "workspace:{id}", "collection:{id}", "request:{id}"
pub const VARIABLES_BY_SCOPE: TableDefinition<&str, &str> =
TableDefinition::new("idx_variables_by_scope");
/// Workspaces by sync group index: sync_group_id -> workspace_ids JSON array
pub const WORKSPACES_BY_SYNC_GROUP: TableDefinition<&str, &str> =
TableDefinition::new("idx_workspaces_by_sync_group");

View File

@@ -0,0 +1,174 @@
use std::time::Instant;
use reqwest::{header::HeaderMap, Client, Method};
use super::types::{HttpRequest, HttpResponse, HttpResponseHeader};
pub async fn execute_request(request: HttpRequest) -> Result<HttpResponse, String> {
let client = Client::new();
let start = Instant::now();
// Parse method
let method = match request.method.to_uppercase().as_str() {
"GET" => Method::GET,
"POST" => Method::POST,
"PUT" => Method::PUT,
"PATCH" => Method::PATCH,
"DELETE" => Method::DELETE,
"HEAD" => Method::HEAD,
"OPTIONS" => Method::OPTIONS,
_ => return Err(format!("Unsupported HTTP method: {}", request.method)),
};
// Build URL with query params
let mut url = request.url.clone();
let enabled_params: Vec<_> = request
.params
.iter()
.filter(|p| p.enabled && !p.key.is_empty())
.collect();
if !enabled_params.is_empty() {
let query_string: String = enabled_params
.iter()
.map(|p| format!("{}={}", urlencoding::encode(&p.key), urlencoding::encode(&p.value)))
.collect::<Vec<_>>()
.join("&");
if url.contains('?') {
url = format!("{}&{}", url, query_string);
} else {
url = format!("{}?{}", url, query_string);
}
}
// Build headers
let mut headers = HeaderMap::new();
for header in &request.headers {
if header.enabled && !header.key.is_empty() {
if let (Ok(name), Ok(value)) = (
header.key.parse::<reqwest::header::HeaderName>(),
header.value.parse::<reqwest::header::HeaderValue>(),
) {
headers.insert(name, value);
}
}
}
// Build request
let mut req_builder = client.request(method, &url).headers(headers);
// Add body based on body type
match request.body_type.as_str() {
"json" => {
req_builder = req_builder
.header("Content-Type", "application/json")
.body(request.body.clone());
}
"xml" => {
req_builder = req_builder
.header("Content-Type", "application/xml")
.body(request.body.clone());
}
"text" => {
req_builder = req_builder
.header("Content-Type", "text/plain")
.body(request.body.clone());
}
"html" => {
req_builder = req_builder
.header("Content-Type", "text/html")
.body(request.body.clone());
}
"x-www-form-urlencoded" => {
let enabled_form: Vec<_> = request
.form_data
.iter()
.filter(|f| f.enabled && !f.key.is_empty())
.collect();
let form_string: String = enabled_form
.iter()
.map(|f| format!("{}={}", urlencoding::encode(&f.key), urlencoding::encode(&f.value)))
.collect::<Vec<_>>()
.join("&");
req_builder = req_builder
.header("Content-Type", "application/x-www-form-urlencoded")
.body(form_string);
}
"form-data" => {
let mut form = reqwest::multipart::Form::new();
for item in &request.form_data {
if item.enabled && !item.key.is_empty() {
if item.item_type == "file" {
// For file uploads, read the file
match std::fs::read(&item.value) {
Ok(contents) => {
let file_name = std::path::Path::new(&item.value)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("file")
.to_string();
let part = reqwest::multipart::Part::bytes(contents)
.file_name(file_name);
form = form.part(item.key.clone(), part);
}
Err(e) => {
return Err(format!("Failed to read file {}: {}", item.value, e));
}
}
} else {
form = form.text(item.key.clone(), item.value.clone());
}
}
}
req_builder = req_builder.multipart(form);
}
_ => {
// "none" or unknown - no body
}
}
// Execute request
let response = req_builder
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
let elapsed = start.elapsed();
let status = response.status().as_u16();
let status_text = response
.status()
.canonical_reason()
.unwrap_or("Unknown")
.to_string();
// Collect headers
let response_headers: Vec<HttpResponseHeader> = response
.headers()
.iter()
.map(|(k, v)| HttpResponseHeader {
key: k.to_string(),
value: v.to_str().unwrap_or("").to_string(),
})
.collect();
// Get body
let body_bytes = response
.bytes()
.await
.map_err(|e| format!("Failed to read response body: {}", e))?;
let size_bytes = body_bytes.len();
let body = String::from_utf8_lossy(&body_bytes).to_string();
Ok(HttpResponse {
status,
status_text,
headers: response_headers,
body,
time_ms: elapsed.as_millis() as u64,
size_bytes,
})
}

View File

@@ -0,0 +1,5 @@
mod client;
mod types;
pub use client::execute_request;
pub use types::{HttpRequest, HttpResponse, HttpResponseHeader};

View File

@@ -0,0 +1,55 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HttpRequest {
pub method: String,
pub url: String,
#[serde(default)]
pub headers: Vec<HttpRequestHeader>,
#[serde(default)]
pub params: Vec<HttpRequestParam>,
pub body_type: String,
#[serde(default)]
pub body: String,
#[serde(default)]
pub form_data: Vec<HttpFormDataItem>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HttpRequestHeader {
pub key: String,
pub value: String,
pub enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HttpRequestParam {
pub key: String,
pub value: String,
pub enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HttpFormDataItem {
pub key: String,
pub value: String,
#[serde(rename = "type")]
pub item_type: String,
pub enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HttpResponse {
pub status: u16,
pub status_text: String,
pub headers: Vec<HttpResponseHeader>,
pub body: String,
pub time_ms: u64,
pub size_bytes: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HttpResponseHeader {
pub key: String,
pub value: String,
}

View File

@@ -1,14 +1,98 @@
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ // Resona - API Client Application
mod collections;
mod db;
mod http;
mod requests;
mod settings;
mod variables;
mod workspaces;
use db::Database;
use http::{HttpRequest, HttpResponse};
use collections::{
create_collection, delete_collection, get_collection, get_collections,
get_collections_by_workspace, update_collection,
};
use requests::{
create_request, delete_request, get_all_requests_by_workspace, get_request,
get_requests_by_collection, get_standalone_requests_by_workspace, update_request,
};
use settings::{get_settings, reset_settings, update_settings};
use variables::{
create_variable, delete_variable, get_collection_variables, get_global_variables,
get_request_variables, get_resolved_variables, get_variable, get_workspace_variables,
update_variable,
};
use workspaces::{
add_workspace_to_sync_group, create_sync_group, create_workspace, delete_sync_group,
delete_workspace, get_sync_group, get_sync_group_for_workspace, get_sync_groups,
get_workspace, get_workspaces, get_workspaces_by_sync_group, remove_workspace_from_sync_group,
update_sync_group, update_workspace,
};
#[tauri::command] #[tauri::command]
fn greet(name: &str) -> String { async fn send_http_request(request: HttpRequest) -> Result<HttpResponse, String> {
format!("Hello, {}! You've been greeted from Rust!", name) http::execute_request(request).await
} }
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
let db = Database::open().expect("Failed to initialize database");
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![greet]) .manage(db)
.invoke_handler(tauri::generate_handler![
// Workspace commands
get_workspaces,
get_workspace,
create_workspace,
update_workspace,
delete_workspace,
// Sync group commands
get_sync_groups,
get_sync_group,
get_sync_group_for_workspace,
create_sync_group,
update_sync_group,
delete_sync_group,
get_workspaces_by_sync_group,
add_workspace_to_sync_group,
remove_workspace_from_sync_group,
// Collection commands
get_collections,
get_collection,
get_collections_by_workspace,
create_collection,
update_collection,
delete_collection,
// Request commands
get_request,
get_requests_by_collection,
get_standalone_requests_by_workspace,
get_all_requests_by_workspace,
create_request,
update_request,
delete_request,
// Variable commands
get_variable,
get_global_variables,
get_workspace_variables,
get_collection_variables,
get_request_variables,
get_resolved_variables,
create_variable,
update_variable,
delete_variable,
// HTTP client
send_http_request,
// Settings commands
get_settings,
update_settings,
reset_settings,
])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }

View File

@@ -0,0 +1,63 @@
use tauri::State;
use crate::db::Database;
use super::service::RequestService;
use super::types::{CreateRequestInput, Request, UpdateRequestInput};
#[tauri::command]
pub fn get_request(db: State<Database>, id: String) -> Result<Request, String> {
let service = RequestService::new(db.inner().clone());
service.get(&id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_requests_by_collection(
db: State<Database>,
collection_id: String,
) -> Result<Vec<Request>, String> {
let service = RequestService::new(db.inner().clone());
service.get_by_collection(&collection_id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_standalone_requests_by_workspace(
db: State<Database>,
workspace_id: String,
) -> Result<Vec<Request>, String> {
let service = RequestService::new(db.inner().clone());
service.get_standalone_by_workspace(&workspace_id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_all_requests_by_workspace(
db: State<Database>,
workspace_id: String,
) -> Result<Vec<Request>, String> {
let service = RequestService::new(db.inner().clone());
service.get_all_by_workspace(&workspace_id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn create_request(
db: State<Database>,
input: CreateRequestInput,
) -> Result<Request, String> {
let service = RequestService::new(db.inner().clone());
service.create(input).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn update_request(
db: State<Database>,
input: UpdateRequestInput,
) -> Result<Request, String> {
let service = RequestService::new(db.inner().clone());
service.update(input).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn delete_request(db: State<Database>, id: String) -> Result<(), String> {
let service = RequestService::new(db.inner().clone());
service.delete(&id).map_err(|e| e.to_string())
}

View File

@@ -0,0 +1,11 @@
mod commands;
mod service;
mod types;
pub use commands::*;
#[allow(unused_imports)]
pub use types::{
BodyType, CreateRequestInput, FormDataItem, HttpMethod, Request, RequestHeader,
RequestParam, UpdateRequestInput,
};
pub(crate) use service::RequestService;

View File

@@ -0,0 +1,298 @@
use chrono::Utc;
use redb::ReadableTable;
use crate::db::{
Database, DbError, DbResult, REQUESTS, REQUESTS_BY_COLLECTION, REQUESTS_BY_WORKSPACE,
};
use super::types::{CreateRequestInput, Request, UpdateRequestInput};
pub struct RequestService {
db: Database,
}
impl RequestService {
pub fn new(db: Database) -> Self {
Self { db }
}
pub fn get(&self, id: &str) -> DbResult<Request> {
let read_txn = self.db.begin_read()?;
let table = read_txn.open_table(REQUESTS)?;
let value = table
.get(id)?
.ok_or_else(|| DbError::NotFound(format!("Request not found: {}", id)))?;
let request: Request = serde_json::from_str(value.value())
.map_err(|e| DbError::Serialization(e.to_string()))?;
Ok(request)
}
pub fn get_by_collection(&self, collection_id: &str) -> DbResult<Vec<Request>> {
let read_txn = self.db.begin_read()?;
let idx_table = read_txn.open_table(REQUESTS_BY_COLLECTION)?;
let request_ids: Vec<String> = match idx_table.get(collection_id)? {
Some(value) => serde_json::from_str(value.value())
.map_err(|e| DbError::Serialization(e.to_string()))?,
None => return Ok(Vec::new()),
};
drop(idx_table);
drop(read_txn);
let mut requests = Vec::new();
for id in request_ids {
if let Ok(request) = self.get(&id) {
requests.push(request);
}
}
requests.sort_by(|a, b| a.name.cmp(&b.name));
Ok(requests)
}
pub fn get_standalone_by_workspace(&self, workspace_id: &str) -> DbResult<Vec<Request>> {
let read_txn = self.db.begin_read()?;
let idx_table = read_txn.open_table(REQUESTS_BY_WORKSPACE)?;
let request_ids: Vec<String> = match idx_table.get(workspace_id)? {
Some(value) => serde_json::from_str(value.value())
.map_err(|e| DbError::Serialization(e.to_string()))?,
None => return Ok(Vec::new()),
};
drop(idx_table);
drop(read_txn);
let mut requests = Vec::new();
for id in request_ids {
if let Ok(request) = self.get(&id) {
if request.collection_id.is_none() {
requests.push(request);
}
}
}
requests.sort_by(|a, b| a.name.cmp(&b.name));
Ok(requests)
}
pub fn get_all_by_workspace(&self, workspace_id: &str) -> DbResult<Vec<Request>> {
let read_txn = self.db.begin_read()?;
let idx_table = read_txn.open_table(REQUESTS_BY_WORKSPACE)?;
let request_ids: Vec<String> = match idx_table.get(workspace_id)? {
Some(value) => serde_json::from_str(value.value())
.map_err(|e| DbError::Serialization(e.to_string()))?,
None => return Ok(Vec::new()),
};
drop(idx_table);
drop(read_txn);
let mut requests = Vec::new();
for id in request_ids {
if let Ok(request) = self.get(&id) {
requests.push(request);
}
}
requests.sort_by(|a, b| a.name.cmp(&b.name));
Ok(requests)
}
pub fn create(&self, input: CreateRequestInput) -> DbResult<Request> {
let mut request = Request::new(input.name, input.method, input.workspace_id.clone());
request.url = input.url;
request.headers = input.headers;
request.params = input.params;
request.body_type = input.body_type;
request.body = input.body;
request.form_data = input.form_data;
request.collection_id = input.collection_id.clone();
let json = serde_json::to_string(&request)
.map_err(|e| DbError::Serialization(e.to_string()))?;
let write_txn = self.db.begin_write()?;
{
let mut table = write_txn.open_table(REQUESTS)?;
table.insert(request.id.as_str(), json.as_str())?;
}
// Update workspace index
{
let mut idx_table = write_txn.open_table(REQUESTS_BY_WORKSPACE)?;
let ids_json = match idx_table.get(input.workspace_id.as_str())? {
Some(value) => value.value().to_string(),
None => "[]".to_string(),
};
let mut ids: Vec<String> = serde_json::from_str(&ids_json)
.map_err(|e| DbError::Serialization(e.to_string()))?;
ids.push(request.id.clone());
let new_json = serde_json::to_string(&ids)
.map_err(|e| DbError::Serialization(e.to_string()))?;
idx_table.insert(input.workspace_id.as_str(), new_json.as_str())?;
}
// Update collection index if applicable
if let Some(ref collection_id) = input.collection_id {
let mut idx_table = write_txn.open_table(REQUESTS_BY_COLLECTION)?;
let ids_json = match idx_table.get(collection_id.as_str())? {
Some(value) => value.value().to_string(),
None => "[]".to_string(),
};
let mut ids: Vec<String> = serde_json::from_str(&ids_json)
.map_err(|e| DbError::Serialization(e.to_string()))?;
ids.push(request.id.clone());
let new_json = serde_json::to_string(&ids)
.map_err(|e| DbError::Serialization(e.to_string()))?;
idx_table.insert(collection_id.as_str(), new_json.as_str())?;
}
write_txn.commit()?;
Ok(request)
}
pub fn update(&self, input: UpdateRequestInput) -> DbResult<Request> {
let mut request = self.get(&input.id)?;
let old_collection_id = request.collection_id.clone();
if let Some(name) = input.name {
request.name = name;
}
if let Some(method) = input.method {
request.method = method;
}
if let Some(url) = input.url {
request.url = url;
}
if let Some(headers) = input.headers {
request.headers = headers;
}
if let Some(params) = input.params {
request.params = params;
}
if let Some(body_type) = input.body_type {
request.body_type = body_type;
}
if let Some(body) = input.body {
request.body = body;
}
if let Some(form_data) = input.form_data {
request.form_data = form_data;
}
if let Some(collection_id) = input.collection_id {
request.collection_id = collection_id;
}
request.updated_at = Utc::now();
let json = serde_json::to_string(&request)
.map_err(|e| DbError::Serialization(e.to_string()))?;
let write_txn = self.db.begin_write()?;
{
let mut table = write_txn.open_table(REQUESTS)?;
table.insert(request.id.as_str(), json.as_str())?;
}
// Update collection index if collection changed
if old_collection_id != request.collection_id {
// Remove from old collection index
if let Some(ref old_id) = old_collection_id {
let mut idx_table = write_txn.open_table(REQUESTS_BY_COLLECTION)?;
let ids_json = idx_table
.get(old_id.as_str())?
.map(|v| v.value().to_string())
.unwrap_or_else(|| "[]".to_string());
let mut ids: Vec<String> = serde_json::from_str(&ids_json)
.map_err(|e| DbError::Serialization(e.to_string()))?;
ids.retain(|i| i != &request.id);
let new_json = serde_json::to_string(&ids)
.map_err(|e| DbError::Serialization(e.to_string()))?;
idx_table.insert(old_id.as_str(), new_json.as_str())?;
}
// Add to new collection index
if let Some(ref new_id) = request.collection_id {
let mut idx_table = write_txn.open_table(REQUESTS_BY_COLLECTION)?;
let ids_json = idx_table
.get(new_id.as_str())?
.map(|v| v.value().to_string())
.unwrap_or_else(|| "[]".to_string());
let mut ids: Vec<String> = serde_json::from_str(&ids_json)
.map_err(|e| DbError::Serialization(e.to_string()))?;
if !ids.contains(&request.id) {
ids.push(request.id.clone());
}
let new_json = serde_json::to_string(&ids)
.map_err(|e| DbError::Serialization(e.to_string()))?;
idx_table.insert(new_id.as_str(), new_json.as_str())?;
}
}
write_txn.commit()?;
Ok(request)
}
pub fn delete(&self, id: &str) -> DbResult<()> {
let request = self.get(id)?;
let write_txn = self.db.begin_write()?;
// Remove from requests table
{
let mut table = write_txn.open_table(REQUESTS)?;
table.remove(id)?;
}
// Update workspace index
{
let mut idx_table = write_txn.open_table(REQUESTS_BY_WORKSPACE)?;
let ids_json = idx_table
.get(request.workspace_id.as_str())?
.map(|v| v.value().to_string())
.unwrap_or_else(|| "[]".to_string());
let mut ids: Vec<String> = serde_json::from_str(&ids_json)
.map_err(|e| DbError::Serialization(e.to_string()))?;
ids.retain(|i| i != id);
let new_json = serde_json::to_string(&ids)
.map_err(|e| DbError::Serialization(e.to_string()))?;
idx_table.insert(request.workspace_id.as_str(), new_json.as_str())?;
}
// Update collection index if applicable
if let Some(ref collection_id) = request.collection_id {
let mut idx_table = write_txn.open_table(REQUESTS_BY_COLLECTION)?;
let ids_json = idx_table
.get(collection_id.as_str())?
.map(|v| v.value().to_string())
.unwrap_or_else(|| "[]".to_string());
let mut ids: Vec<String> = serde_json::from_str(&ids_json)
.map_err(|e| DbError::Serialization(e.to_string()))?;
ids.retain(|i| i != id);
let new_json = serde_json::to_string(&ids)
.map_err(|e| DbError::Serialization(e.to_string()))?;
idx_table.insert(collection_id.as_str(), new_json.as_str())?;
}
write_txn.commit()?;
Ok(())
}
}

View File

@@ -0,0 +1,142 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "UPPERCASE")]
pub enum HttpMethod {
Get,
Post,
Put,
Patch,
Delete,
Head,
Options,
}
impl Default for HttpMethod {
fn default() -> Self {
Self::Get
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub enum BodyType {
None,
Json,
Xml,
Text,
Html,
FormData,
#[serde(rename = "x-www-form-urlencoded")]
XWwwFormUrlencoded,
}
impl Default for BodyType {
fn default() -> Self {
Self::None
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RequestHeader {
pub key: String,
pub value: String,
pub enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RequestParam {
pub key: String,
pub value: String,
pub enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FormDataItem {
pub key: String,
pub value: String,
#[serde(rename = "type")]
pub item_type: String, // "text" or "file"
pub enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Request {
pub id: String,
pub name: String,
pub method: HttpMethod,
pub url: String,
#[serde(default)]
pub headers: Vec<RequestHeader>,
#[serde(default)]
pub params: Vec<RequestParam>,
#[serde(default)]
pub body_type: BodyType,
#[serde(default)]
pub body: String,
#[serde(default)]
pub form_data: Vec<FormDataItem>,
pub collection_id: Option<String>,
pub workspace_id: String,
#[serde(default = "Utc::now")]
pub created_at: DateTime<Utc>,
#[serde(default = "Utc::now")]
pub updated_at: DateTime<Utc>,
}
impl Request {
pub fn new(name: String, method: HttpMethod, workspace_id: String) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4().to_string(),
name,
method,
url: String::new(),
headers: Vec::new(),
params: Vec::new(),
body_type: BodyType::None,
body: String::new(),
form_data: Vec::new(),
collection_id: None,
workspace_id,
created_at: now,
updated_at: now,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateRequestInput {
pub name: String,
pub method: HttpMethod,
#[serde(default)]
pub url: String,
#[serde(default)]
pub headers: Vec<RequestHeader>,
#[serde(default)]
pub params: Vec<RequestParam>,
#[serde(default)]
pub body_type: BodyType,
#[serde(default)]
pub body: String,
#[serde(default)]
pub form_data: Vec<FormDataItem>,
pub collection_id: Option<String>,
pub workspace_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateRequestInput {
pub id: String,
pub name: Option<String>,
pub method: Option<HttpMethod>,
pub url: Option<String>,
pub headers: Option<Vec<RequestHeader>>,
pub params: Option<Vec<RequestParam>>,
pub body_type: Option<BodyType>,
pub body: Option<String>,
pub form_data: Option<Vec<FormDataItem>>,
pub collection_id: Option<Option<String>>,
}

View File

@@ -0,0 +1,27 @@
use tauri::State;
use crate::db::Database;
use super::service::SettingsService;
use super::types::{AppSettings, UpdateSettingsInput};
#[tauri::command]
pub fn get_settings(db: State<Database>) -> Result<AppSettings, String> {
let service = SettingsService::new(db.inner().clone());
service.get().map_err(|e| e.to_string())
}
#[tauri::command]
pub fn update_settings(
db: State<Database>,
input: UpdateSettingsInput,
) -> Result<AppSettings, String> {
let service = SettingsService::new(db.inner().clone());
service.update(input).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn reset_settings(db: State<Database>) -> Result<AppSettings, String> {
let service = SettingsService::new(db.inner().clone());
service.reset().map_err(|e| e.to_string())
}

View File

@@ -0,0 +1,7 @@
mod commands;
mod service;
mod types;
pub use commands::*;
#[allow(unused_imports)]
pub use types::{AppSettings, CustomTheme, Theme, ThemeColors, UpdateSettingsInput};

View File

@@ -0,0 +1,78 @@
use crate::db::{Database, DbResult, APP_SETTINGS};
use super::types::{AppSettings, UpdateSettingsInput};
const SETTINGS_KEY: &str = "settings";
pub struct SettingsService {
db: Database,
}
impl SettingsService {
pub fn new(db: Database) -> Self {
Self { db }
}
pub fn get(&self) -> DbResult<AppSettings> {
let read_txn = self.db.begin_read()?;
let table = read_txn.open_table(APP_SETTINGS)?;
match table.get(SETTINGS_KEY)? {
Some(value) => {
let settings: AppSettings = serde_json::from_str(value.value())?;
Ok(settings)
}
None => Ok(AppSettings::default()),
}
}
pub fn update(&self, input: UpdateSettingsInput) -> DbResult<AppSettings> {
let mut settings = self.get()?;
if let Some(theme) = input.theme {
settings.theme = theme;
}
if let Some(custom_themes) = input.custom_themes {
settings.custom_themes = custom_themes;
}
if let Some(default_timeout) = input.default_timeout {
settings.default_timeout = default_timeout;
}
if let Some(follow_redirects) = input.follow_redirects {
settings.follow_redirects = follow_redirects;
}
if let Some(validate_ssl) = input.validate_ssl {
settings.validate_ssl = validate_ssl;
}
if let Some(max_history_items) = input.max_history_items {
settings.max_history_items = max_history_items;
}
if let Some(auto_save_requests) = input.auto_save_requests {
settings.auto_save_requests = auto_save_requests;
}
let write_txn = self.db.begin_write()?;
{
let mut table = write_txn.open_table(APP_SETTINGS)?;
let json = serde_json::to_string(&settings)?;
table.insert(SETTINGS_KEY, json.as_str())?;
}
write_txn.commit()?;
Ok(settings)
}
pub fn reset(&self) -> DbResult<AppSettings> {
let settings = AppSettings::default();
let write_txn = self.db.begin_write()?;
{
let mut table = write_txn.open_table(APP_SETTINGS)?;
let json = serde_json::to_string(&settings)?;
table.insert(SETTINGS_KEY, json.as_str())?;
}
write_txn.commit()?;
Ok(settings)
}
}

View File

@@ -0,0 +1,95 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum Theme {
System,
Light,
Dark,
Latte,
Frappe,
Macchiato,
Mocha,
Custom,
}
impl Default for Theme {
fn default() -> Self {
Self::System
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CustomTheme {
pub name: String,
pub colors: ThemeColors,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct ThemeColors {
pub background: String,
pub foreground: String,
pub primary: String,
pub secondary: String,
pub accent: String,
pub muted: String,
pub border: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppSettings {
pub theme: Theme,
#[serde(default)]
pub custom_themes: Vec<CustomTheme>,
#[serde(default = "default_timeout")]
pub default_timeout: u32,
#[serde(default = "default_true")]
pub follow_redirects: bool,
#[serde(default = "default_true")]
pub validate_ssl: bool,
#[serde(default = "default_max_history")]
pub max_history_items: u32,
#[serde(default = "default_true")]
pub auto_save_requests: bool,
}
fn default_timeout() -> u32 {
30000
}
fn default_true() -> bool {
true
}
fn default_max_history() -> u32 {
100
}
impl Default for AppSettings {
fn default() -> Self {
Self {
theme: Theme::System,
custom_themes: Vec::new(),
default_timeout: 30000,
follow_redirects: true,
validate_ssl: true,
max_history_items: 100,
auto_save_requests: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateSettingsInput {
pub theme: Option<Theme>,
pub custom_themes: Option<Vec<CustomTheme>>,
pub default_timeout: Option<u32>,
pub follow_redirects: Option<bool>,
pub validate_ssl: Option<bool>,
pub max_history_items: Option<u32>,
pub auto_save_requests: Option<bool>,
}

View File

@@ -0,0 +1,86 @@
use tauri::State;
use crate::db::Database;
use super::service::VariableService;
use super::types::{CreateVariableInput, ResolvedVariable, UpdateVariableInput, Variable};
#[tauri::command]
pub fn get_variable(db: State<Database>, id: String) -> Result<Variable, String> {
let service = VariableService::new(db.inner().clone());
service.get(&id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_global_variables(db: State<Database>) -> Result<Vec<Variable>, String> {
let service = VariableService::new(db.inner().clone());
service.get_global().map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_workspace_variables(
db: State<Database>,
workspace_id: String,
) -> Result<Vec<Variable>, String> {
let service = VariableService::new(db.inner().clone());
service.get_by_workspace(&workspace_id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_collection_variables(
db: State<Database>,
collection_id: String,
) -> Result<Vec<Variable>, String> {
let service = VariableService::new(db.inner().clone());
service.get_by_collection(&collection_id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_request_variables(
db: State<Database>,
request_id: String,
) -> Result<Vec<Variable>, String> {
let service = VariableService::new(db.inner().clone());
service.get_by_request(&request_id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_resolved_variables(
db: State<Database>,
workspace_id: Option<String>,
collection_id: Option<String>,
request_id: Option<String>,
) -> Result<Vec<ResolvedVariable>, String> {
let service = VariableService::new(db.inner().clone());
service
.get_resolved(
workspace_id.as_deref(),
collection_id.as_deref(),
request_id.as_deref(),
)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn create_variable(
db: State<Database>,
input: CreateVariableInput,
) -> Result<Variable, String> {
let service = VariableService::new(db.inner().clone());
service.create(input).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn update_variable(
db: State<Database>,
input: UpdateVariableInput,
) -> Result<Variable, String> {
let service = VariableService::new(db.inner().clone());
service.update(input).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn delete_variable(db: State<Database>, id: String) -> Result<(), String> {
let service = VariableService::new(db.inner().clone());
service.delete(&id).map_err(|e| e.to_string())
}

View File

@@ -0,0 +1,8 @@
mod commands;
mod service;
mod types;
pub use commands::*;
#[allow(unused_imports)]
pub use types::{CreateVariableInput, UpdateVariableInput, Variable, VariableScope};
pub(crate) use service::VariableService;

View File

@@ -0,0 +1,236 @@
use chrono::Utc;
use redb::ReadableTable;
use crate::db::{Database, DbError, DbResult, VARIABLES, VARIABLES_BY_SCOPE};
use super::types::{CreateVariableInput, ResolvedVariable, UpdateVariableInput, Variable, VariableScope};
pub struct VariableService {
db: Database,
}
impl VariableService {
pub fn new(db: Database) -> Self {
Self { db }
}
pub fn get(&self, id: &str) -> DbResult<Variable> {
let read_txn = self.db.begin_read()?;
let table = read_txn.open_table(VARIABLES)?;
let value = table
.get(id)?
.ok_or_else(|| DbError::NotFound(format!("Variable not found: {}", id)))?;
let variable: Variable = serde_json::from_str(value.value())
.map_err(|e| DbError::Serialization(e.to_string()))?;
Ok(variable)
}
fn get_by_scope_key(&self, scope_key: &str) -> DbResult<Vec<Variable>> {
let read_txn = self.db.begin_read()?;
let idx_table = read_txn.open_table(VARIABLES_BY_SCOPE)?;
let variable_ids: Vec<String> = match idx_table.get(scope_key)? {
Some(value) => serde_json::from_str(value.value())
.map_err(|e| DbError::Serialization(e.to_string()))?,
None => return Ok(Vec::new()),
};
drop(idx_table);
drop(read_txn);
let mut variables = Vec::new();
for id in variable_ids {
if let Ok(variable) = self.get(&id) {
variables.push(variable);
}
}
variables.sort_by(|a, b| a.name.cmp(&b.name));
Ok(variables)
}
pub fn get_global(&self) -> DbResult<Vec<Variable>> {
self.get_by_scope_key("global")
}
pub fn get_by_workspace(&self, workspace_id: &str) -> DbResult<Vec<Variable>> {
self.get_by_scope_key(&format!("workspace:{}", workspace_id))
}
pub fn get_by_collection(&self, collection_id: &str) -> DbResult<Vec<Variable>> {
self.get_by_scope_key(&format!("collection:{}", collection_id))
}
pub fn get_by_request(&self, request_id: &str) -> DbResult<Vec<Variable>> {
self.get_by_scope_key(&format!("request:{}", request_id))
}
pub fn get_resolved(
&self,
workspace_id: Option<&str>,
collection_id: Option<&str>,
request_id: Option<&str>,
) -> DbResult<Vec<ResolvedVariable>> {
let mut resolved_map = std::collections::HashMap::new();
// Global variables (lowest priority)
for var in self.get_global()? {
resolved_map.insert(var.name.clone(), ResolvedVariable {
name: var.name,
value: var.value,
scope: var.scope,
is_secret: var.is_secret,
});
}
// Workspace variables
if let Some(ws_id) = workspace_id {
for var in self.get_by_workspace(ws_id)? {
resolved_map.insert(var.name.clone(), ResolvedVariable {
name: var.name,
value: var.value,
scope: var.scope,
is_secret: var.is_secret,
});
}
}
// Collection variables
if let Some(coll_id) = collection_id {
for var in self.get_by_collection(coll_id)? {
resolved_map.insert(var.name.clone(), ResolvedVariable {
name: var.name,
value: var.value,
scope: var.scope,
is_secret: var.is_secret,
});
}
}
// Request variables (highest priority)
if let Some(req_id) = request_id {
for var in self.get_by_request(req_id)? {
resolved_map.insert(var.name.clone(), ResolvedVariable {
name: var.name,
value: var.value,
scope: var.scope,
is_secret: var.is_secret,
});
}
}
let mut resolved: Vec<_> = resolved_map.into_values().collect();
resolved.sort_by(|a, b| a.name.cmp(&b.name));
Ok(resolved)
}
pub fn create(&self, input: CreateVariableInput) -> DbResult<Variable> {
let mut variable = Variable::new(
input.name,
input.value,
input.scope,
input.scope_id,
);
variable.is_secret = input.is_secret;
variable.description = input.description;
let scope_key = variable.scope_key();
let json = serde_json::to_string(&variable)
.map_err(|e| DbError::Serialization(e.to_string()))?;
let write_txn = self.db.begin_write()?;
{
let mut table = write_txn.open_table(VARIABLES)?;
table.insert(variable.id.as_str(), json.as_str())?;
}
// Update scope index
{
let mut idx_table = write_txn.open_table(VARIABLES_BY_SCOPE)?;
let ids_json = match idx_table.get(scope_key.as_str())? {
Some(value) => value.value().to_string(),
None => "[]".to_string(),
};
let mut ids: Vec<String> = serde_json::from_str(&ids_json)
.map_err(|e| DbError::Serialization(e.to_string()))?;
ids.push(variable.id.clone());
let new_json = serde_json::to_string(&ids)
.map_err(|e| DbError::Serialization(e.to_string()))?;
idx_table.insert(scope_key.as_str(), new_json.as_str())?;
}
write_txn.commit()?;
Ok(variable)
}
pub fn update(&self, input: UpdateVariableInput) -> DbResult<Variable> {
let mut variable = self.get(&input.id)?;
if let Some(name) = input.name {
variable.name = name;
}
if let Some(value) = input.value {
variable.value = value;
}
if let Some(is_secret) = input.is_secret {
variable.is_secret = is_secret;
}
if let Some(description) = input.description {
variable.description = Some(description);
}
variable.updated_at = Utc::now();
let json = serde_json::to_string(&variable)
.map_err(|e| DbError::Serialization(e.to_string()))?;
let write_txn = self.db.begin_write()?;
{
let mut table = write_txn.open_table(VARIABLES)?;
table.insert(variable.id.as_str(), json.as_str())?;
}
write_txn.commit()?;
Ok(variable)
}
pub fn delete(&self, id: &str) -> DbResult<()> {
let variable = self.get(id)?;
let scope_key = variable.scope_key();
let write_txn = self.db.begin_write()?;
// Remove from variables table
{
let mut table = write_txn.open_table(VARIABLES)?;
table.remove(id)?;
}
// Update scope index
{
let mut idx_table = write_txn.open_table(VARIABLES_BY_SCOPE)?;
let ids_json = idx_table
.get(scope_key.as_str())?
.map(|v| v.value().to_string())
.unwrap_or_else(|| "[]".to_string());
let mut ids: Vec<String> = serde_json::from_str(&ids_json)
.map_err(|e| DbError::Serialization(e.to_string()))?;
ids.retain(|i| i != id);
let new_json = serde_json::to_string(&ids)
.map_err(|e| DbError::Serialization(e.to_string()))?;
idx_table.insert(scope_key.as_str(), new_json.as_str())?;
}
write_txn.commit()?;
Ok(())
}
}

View File

@@ -0,0 +1,87 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum VariableScope {
Global,
Workspace,
Collection,
Request,
}
impl Default for VariableScope {
fn default() -> Self {
Self::Global
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Variable {
pub id: String,
pub name: String,
pub value: String,
pub scope: VariableScope,
pub scope_id: Option<String>,
pub is_secret: bool,
pub description: Option<String>,
#[serde(default = "Utc::now")]
pub created_at: DateTime<Utc>,
#[serde(default = "Utc::now")]
pub updated_at: DateTime<Utc>,
}
impl Variable {
pub fn new(name: String, value: String, scope: VariableScope, scope_id: Option<String>) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4().to_string(),
name,
value,
scope,
scope_id,
is_secret: false,
description: None,
created_at: now,
updated_at: now,
}
}
pub fn scope_key(&self) -> String {
match self.scope {
VariableScope::Global => "global".to_string(),
VariableScope::Workspace => format!("workspace:{}", self.scope_id.as_deref().unwrap_or("")),
VariableScope::Collection => format!("collection:{}", self.scope_id.as_deref().unwrap_or("")),
VariableScope::Request => format!("request:{}", self.scope_id.as_deref().unwrap_or("")),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateVariableInput {
pub name: String,
pub value: String,
pub scope: VariableScope,
pub scope_id: Option<String>,
#[serde(default)]
pub is_secret: bool,
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateVariableInput {
pub id: String,
pub name: Option<String>,
pub value: Option<String>,
pub is_secret: Option<bool>,
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResolvedVariable {
pub name: String,
pub value: String,
pub scope: VariableScope,
pub is_secret: bool,
}

View File

@@ -0,0 +1,143 @@
//! Tauri commands for operations on workspace
use tauri::State;
use crate::db::Database;
use super::types::{
CreateSyncGroupInput, CreateWorkspaceInput, UpdateSyncGroupInput, UpdateWorkspaceInput,
Workspace, WorkspaceSyncGroup,
};
use super::workspace::WorkspaceService;
/// Get all workspaces
#[tauri::command]
pub fn get_workspaces(db: State<Database>) -> Result<Vec<Workspace>, String> {
let service = WorkspaceService::new(db.inner().clone());
service.get_all().map_err(|e| e.to_string())
}
/// Get a workspace by ID
#[tauri::command]
pub fn get_workspace(db: State<Database>, id: String) -> Result<Workspace, String> {
let service = WorkspaceService::new(db.inner().clone());
service.get(&id).map_err(|e| e.to_string())
}
/// Create a new workspace
#[tauri::command]
pub fn create_workspace(
db: State<Database>,
input: CreateWorkspaceInput,
) -> Result<Workspace, String> {
let service = WorkspaceService::new(db.inner().clone());
service.create(input).map_err(|e| e.to_string())
}
/// Update an existing workspace
#[tauri::command]
pub fn update_workspace(
db: State<Database>,
input: UpdateWorkspaceInput,
) -> Result<Workspace, String> {
let service = WorkspaceService::new(db.inner().clone());
service.update(input).map_err(|e| e.to_string())
}
/// Delete a workspace
#[tauri::command]
pub fn delete_workspace(db: State<Database>, id: String) -> Result<(), String> {
let service = WorkspaceService::new(db.inner().clone());
service.delete(&id).map_err(|e| e.to_string())
}
/// Get all sync groups
#[tauri::command]
pub fn get_sync_groups(db: State<Database>) -> Result<Vec<WorkspaceSyncGroup>, String> {
let service = WorkspaceService::new(db.inner().clone());
service.get_all_sync_groups().map_err(|e| e.to_string())
}
/// Get a sync group by ID
#[tauri::command]
pub fn get_sync_group(db: State<Database>, id: String) -> Result<WorkspaceSyncGroup, String> {
let service = WorkspaceService::new(db.inner().clone());
service.get_sync_group(&id).map_err(|e| e.to_string())
}
/// Get sync group for a workspace
#[tauri::command]
pub fn get_sync_group_for_workspace(
db: State<Database>,
workspace_id: String,
) -> Result<Option<WorkspaceSyncGroup>, String> {
let service = WorkspaceService::new(db.inner().clone());
service
.get_sync_group_for_workspace(&workspace_id)
.map_err(|e| e.to_string())
}
/// Create a new sync group
#[tauri::command]
pub fn create_sync_group(
db: State<Database>,
input: CreateSyncGroupInput,
) -> Result<WorkspaceSyncGroup, String> {
let service = WorkspaceService::new(db.inner().clone());
service.create_sync_group(input).map_err(|e| e.to_string())
}
/// Update an existing sync group
#[tauri::command]
pub fn update_sync_group(
db: State<Database>,
input: UpdateSyncGroupInput,
) -> Result<WorkspaceSyncGroup, String> {
let service = WorkspaceService::new(db.inner().clone());
service.update_sync_group(input).map_err(|e| e.to_string())
}
/// Delete a sync group
#[tauri::command]
pub fn delete_sync_group(db: State<Database>, id: String) -> Result<(), String> {
let service = WorkspaceService::new(db.inner().clone());
service.delete_sync_group(&id).map_err(|e| e.to_string())
}
/// Get workspaces by sync group
#[tauri::command]
pub fn get_workspaces_by_sync_group(
db: State<Database>,
sync_group_id: String,
) -> Result<Vec<Workspace>, String> {
let service = WorkspaceService::new(db.inner().clone());
service
.get_workspaces_by_sync_group(&sync_group_id)
.map_err(|e| e.to_string())
}
/// Add a workspace to a sync group
#[tauri::command]
pub fn add_workspace_to_sync_group(
db: State<Database>,
sync_group_id: String,
workspace_id: String,
) -> Result<(), String> {
let service = WorkspaceService::new(db.inner().clone());
service
.add_workspace_to_sync_group(&sync_group_id, &workspace_id)
.map_err(|e| e.to_string())
}
/// Remove a workspace from a sync group
#[tauri::command]
pub fn remove_workspace_from_sync_group(
db: State<Database>,
sync_group_id: String,
workspace_id: String,
) -> Result<(), String> {
let service = WorkspaceService::new(db.inner().clone());
service
.remove_workspace_from_sync_group(&sync_group_id, &workspace_id)
.map_err(|e| e.to_string())
}

View File

@@ -0,0 +1,21 @@
//! Workspaces module
//!
//! Handles workspace management including CRUD operations and sync groups.
mod commands;
mod types;
mod workspace;
// Re-export commands for use in lib.rs
pub use commands::*;
// Re-export types for external use (frontend bindings)
#[allow(unused_imports)]
pub use types::{
CreateSyncGroupInput, CreateWorkspaceInput, UpdateSyncGroupInput, UpdateWorkspaceInput,
Workspace, WorkspaceSyncGroup,
};
// WorkspaceService is used internally by commands
#[allow(unused_imports)]
pub(crate) use workspace::WorkspaceService;

View File

@@ -0,0 +1,97 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct Workspace {
pub id: String,
pub name: String,
pub description: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sync_group_id: Option<String>,
#[serde(default = "Utc::now")]
pub created_at: DateTime<Utc>,
#[serde(default = "Utc::now")]
pub updated_at: DateTime<Utc>,
}
impl Workspace {
pub fn new(name: String, description: String) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4().to_string(),
name,
description,
tags: Vec::new(),
sync_group_id: None,
created_at: now,
updated_at: now,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateWorkspaceInput {
pub name: String,
pub description: String,
#[serde(default)]
pub tags: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateWorkspaceInput {
pub id: String,
pub name: Option<String>,
pub description: Option<String>,
pub tags: Option<Vec<String>>,
pub sync_group_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceSyncGroup {
pub id: String,
pub name: String,
pub workspace_ids: Vec<String>,
pub synced_variable_names: Vec<String>,
pub sync_secrets: bool,
#[serde(default = "Utc::now")]
pub created_at: DateTime<Utc>,
#[serde(default = "Utc::now")]
pub updated_at: DateTime<Utc>,
}
impl WorkspaceSyncGroup {
pub fn new(name: String, workspace_ids: Vec<String>) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4().to_string(),
name,
workspace_ids,
synced_variable_names: Vec::new(),
sync_secrets: false,
created_at: now,
updated_at: now,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateSyncGroupInput {
pub name: String,
pub workspace_ids: Vec<String>,
#[serde(default)]
pub synced_variable_names: Vec<String>,
#[serde(default)]
pub sync_secrets: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateSyncGroupInput {
pub id: String,
pub name: Option<String>,
pub synced_variable_names: Option<Vec<String>>,
pub sync_secrets: Option<bool>,
}

View File

@@ -0,0 +1,572 @@
use chrono::Utc;
use redb::ReadableTable;
use crate::db::{
Database, DbError, DbResult, WORKSPACES, WORKSPACE_SYNC_GROUPS, WORKSPACES_BY_SYNC_GROUP,
};
use super::types::{
CreateSyncGroupInput, CreateWorkspaceInput, UpdateSyncGroupInput, UpdateWorkspaceInput,
Workspace, WorkspaceSyncGroup,
};
pub struct WorkspaceService {
db: Database,
}
impl WorkspaceService {
pub fn new(db: Database) -> Self {
Self { db }
}
pub fn get_all(&self) -> DbResult<Vec<Workspace>> {
let read_txn = self.db.begin_read()?;
let table = read_txn.open_table(WORKSPACES)?;
let mut workspaces = Vec::new();
for entry in table.iter()? {
let (_, value) = entry?;
let workspace: Workspace = serde_json::from_str(value.value())
.map_err(|e| DbError::Serialization(e.to_string()))?;
workspaces.push(workspace);
}
workspaces.sort_by(|a, b| a.name.cmp(&b.name));
Ok(workspaces)
}
pub fn get(&self, id: &str) -> DbResult<Workspace> {
let read_txn = self.db.begin_read()?;
let table = read_txn.open_table(WORKSPACES)?;
let value = table
.get(id)?
.ok_or_else(|| DbError::NotFound(format!("Workspace not found: {}", id)))?;
let workspace: Workspace = serde_json::from_str(value.value())
.map_err(|e| DbError::Serialization(e.to_string()))?;
Ok(workspace)
}
pub fn create(&self, input: CreateWorkspaceInput) -> DbResult<Workspace> {
let mut workspace = Workspace::new(input.name, input.description);
workspace.tags = input.tags;
let json = serde_json::to_string(&workspace)
.map_err(|e| DbError::Serialization(e.to_string()))?;
let write_txn = self.db.begin_write()?;
{
let mut table = write_txn.open_table(WORKSPACES)?;
table.insert(workspace.id.as_str(), json.as_str())?;
}
write_txn.commit()?;
Ok(workspace)
}
pub fn update(&self, input: UpdateWorkspaceInput) -> DbResult<Workspace> {
let mut workspace = self.get(&input.id)?;
if let Some(name) = input.name {
workspace.name = name;
}
if let Some(description) = input.description {
workspace.description = description;
}
if let Some(tags) = input.tags {
workspace.tags = tags;
}
if let Some(sync_group_id) = input.sync_group_id {
workspace.sync_group_id = Some(sync_group_id);
}
workspace.updated_at = Utc::now();
// Write back
let json = serde_json::to_string(&workspace)
.map_err(|e| DbError::Serialization(e.to_string()))?;
let write_txn = self.db.begin_write()?;
{
let mut table = write_txn.open_table(WORKSPACES)?;
table.insert(workspace.id.as_str(), json.as_str())?;
}
write_txn.commit()?;
Ok(workspace)
}
/// Delete a workspace
pub fn delete(&self, id: &str) -> DbResult<()> {
// First get the workspace to check sync_group_id
let workspace = self.get(id)?;
let sync_group_id = workspace.sync_group_id.clone();
let write_txn = self.db.begin_write()?;
// Remove from workspaces table
{
let mut table = write_txn.open_table(WORKSPACES)?;
table.remove(id)?;
}
// Remove from sync group index if applicable
if let Some(group_id) = sync_group_id {
self.remove_from_sync_index(&write_txn, &group_id, id)?;
}
write_txn.commit()?;
Ok(())
}
/// Helper to remove a workspace ID from the sync group index
fn remove_from_sync_index(
&self,
write_txn: &redb::WriteTransaction,
group_id: &str,
workspace_id: &str,
) -> DbResult<()> {
let mut idx_table = write_txn.open_table(WORKSPACES_BY_SYNC_GROUP)?;
// Read current IDs
let ids_json = match idx_table.get(group_id)? {
Some(value) => value.value().to_string(),
None => return Ok(()),
};
let mut ids: Vec<String> = serde_json::from_str(&ids_json)
.map_err(|e| DbError::Serialization(e.to_string()))?;
ids.retain(|i| i != workspace_id);
let new_json = serde_json::to_string(&ids)
.map_err(|e| DbError::Serialization(e.to_string()))?;
idx_table.insert(group_id, new_json.as_str())?;
Ok(())
}
/// Helper to add a workspace ID to the sync group index
fn add_to_sync_index(
&self,
write_txn: &redb::WriteTransaction,
group_id: &str,
workspace_id: &str,
) -> DbResult<()> {
let mut idx_table = write_txn.open_table(WORKSPACES_BY_SYNC_GROUP)?;
// Read current IDs or start with empty
let ids_json = match idx_table.get(group_id)? {
Some(value) => value.value().to_string(),
None => "[]".to_string(),
};
let mut ids: Vec<String> = serde_json::from_str(&ids_json)
.map_err(|e| DbError::Serialization(e.to_string()))?;
if !ids.contains(&workspace_id.to_string()) {
ids.push(workspace_id.to_string());
}
let new_json = serde_json::to_string(&ids)
.map_err(|e| DbError::Serialization(e.to_string()))?;
idx_table.insert(group_id, new_json.as_str())?;
Ok(())
}
// ==================== Sync Group Operations ====================
/// Get all sync groups
pub fn get_all_sync_groups(&self) -> DbResult<Vec<WorkspaceSyncGroup>> {
let read_txn = self.db.begin_read()?;
let table = read_txn.open_table(WORKSPACE_SYNC_GROUPS)?;
let mut groups = Vec::new();
for entry in table.iter()? {
let (_, value) = entry?;
let group: WorkspaceSyncGroup = serde_json::from_str(value.value())
.map_err(|e| DbError::Serialization(e.to_string()))?;
groups.push(group);
}
Ok(groups)
}
/// Get a sync group by ID
pub fn get_sync_group(&self, id: &str) -> DbResult<WorkspaceSyncGroup> {
let read_txn = self.db.begin_read()?;
let table = read_txn.open_table(WORKSPACE_SYNC_GROUPS)?;
let value = table
.get(id)?
.ok_or_else(|| DbError::NotFound(format!("Sync group not found: {}", id)))?;
let group: WorkspaceSyncGroup = serde_json::from_str(value.value())
.map_err(|e| DbError::Serialization(e.to_string()))?;
Ok(group)
}
/// Get sync group for a workspace
pub fn get_sync_group_for_workspace(
&self,
workspace_id: &str,
) -> DbResult<Option<WorkspaceSyncGroup>> {
let workspace = self.get(workspace_id)?;
match workspace.sync_group_id {
Some(group_id) => Ok(Some(self.get_sync_group(&group_id)?)),
None => Ok(None),
}
}
/// Create a new sync group
pub fn create_sync_group(&self, input: CreateSyncGroupInput) -> DbResult<WorkspaceSyncGroup> {
let mut group = WorkspaceSyncGroup::new(input.name, input.workspace_ids.clone());
group.synced_variable_names = input.synced_variable_names;
group.sync_secrets = input.sync_secrets;
let json = serde_json::to_string(&group)
.map_err(|e| DbError::Serialization(e.to_string()))?;
let write_txn = self.db.begin_write()?;
// Insert sync group
{
let mut table = write_txn.open_table(WORKSPACE_SYNC_GROUPS)?;
table.insert(group.id.as_str(), json.as_str())?;
}
// Update index
{
let mut idx_table = write_txn.open_table(WORKSPACES_BY_SYNC_GROUP)?;
let ids_json = serde_json::to_string(&input.workspace_ids)
.map_err(|e| DbError::Serialization(e.to_string()))?;
idx_table.insert(group.id.as_str(), ids_json.as_str())?;
}
// Update each workspace's sync_group_id
{
let mut ws_table = write_txn.open_table(WORKSPACES)?;
for ws_id in &input.workspace_ids {
// Read workspace
let ws_json = match ws_table.get(ws_id.as_str())? {
Some(value) => value.value().to_string(),
None => continue,
};
let mut workspace: Workspace = serde_json::from_str(&ws_json)
.map_err(|e| DbError::Serialization(e.to_string()))?;
workspace.sync_group_id = Some(group.id.clone());
workspace.updated_at = Utc::now();
let new_ws_json = serde_json::to_string(&workspace)
.map_err(|e| DbError::Serialization(e.to_string()))?;
ws_table.insert(ws_id.as_str(), new_ws_json.as_str())?;
}
}
write_txn.commit()?;
Ok(group)
}
/// Update a sync group
pub fn update_sync_group(&self, input: UpdateSyncGroupInput) -> DbResult<WorkspaceSyncGroup> {
// Read existing
let mut group = self.get_sync_group(&input.id)?;
// Apply updates
if let Some(name) = input.name {
group.name = name;
}
if let Some(synced_variable_names) = input.synced_variable_names {
group.synced_variable_names = synced_variable_names;
}
if let Some(sync_secrets) = input.sync_secrets {
group.sync_secrets = sync_secrets;
}
group.updated_at = Utc::now();
// Write back
let json = serde_json::to_string(&group)
.map_err(|e| DbError::Serialization(e.to_string()))?;
let write_txn = self.db.begin_write()?;
{
let mut table = write_txn.open_table(WORKSPACE_SYNC_GROUPS)?;
table.insert(group.id.as_str(), json.as_str())?;
}
write_txn.commit()?;
Ok(group)
}
/// Delete a sync group
pub fn delete_sync_group(&self, id: &str) -> DbResult<()> {
// Get the sync group to find associated workspaces
let group = self.get_sync_group(id)?;
let workspace_ids = group.workspace_ids.clone();
let write_txn = self.db.begin_write()?;
// Remove sync_group_id from all associated workspaces
{
let mut ws_table = write_txn.open_table(WORKSPACES)?;
for ws_id in &workspace_ids {
let ws_json = match ws_table.get(ws_id.as_str())? {
Some(value) => value.value().to_string(),
None => continue,
};
let mut workspace: Workspace = serde_json::from_str(&ws_json)
.map_err(|e| DbError::Serialization(e.to_string()))?;
workspace.sync_group_id = None;
workspace.updated_at = Utc::now();
let new_ws_json = serde_json::to_string(&workspace)
.map_err(|e| DbError::Serialization(e.to_string()))?;
ws_table.insert(ws_id.as_str(), new_ws_json.as_str())?;
}
}
// Remove from sync groups table
{
let mut table = write_txn.open_table(WORKSPACE_SYNC_GROUPS)?;
table.remove(id)?;
}
// Remove from index
{
let mut idx_table = write_txn.open_table(WORKSPACES_BY_SYNC_GROUP)?;
idx_table.remove(id)?;
}
write_txn.commit()?;
Ok(())
}
/// Get workspaces by sync group
pub fn get_workspaces_by_sync_group(&self, sync_group_id: &str) -> DbResult<Vec<Workspace>> {
let read_txn = self.db.begin_read()?;
let idx_table = read_txn.open_table(WORKSPACES_BY_SYNC_GROUP)?;
let workspace_ids: Vec<String> = match idx_table.get(sync_group_id)? {
Some(value) => serde_json::from_str(value.value())
.map_err(|e| DbError::Serialization(e.to_string()))?,
None => return Ok(Vec::new()),
};
drop(idx_table);
drop(read_txn);
let mut workspaces = Vec::new();
for ws_id in workspace_ids {
if let Ok(workspace) = self.get(&ws_id) {
workspaces.push(workspace);
}
}
Ok(workspaces)
}
/// Add a workspace to a sync group
pub fn add_workspace_to_sync_group(
&self,
sync_group_id: &str,
workspace_id: &str,
) -> DbResult<()> {
// Read existing data
let mut group = self.get_sync_group(sync_group_id)?;
let mut workspace = self.get(workspace_id)?;
// Update in memory
if !group.workspace_ids.contains(&workspace_id.to_string()) {
group.workspace_ids.push(workspace_id.to_string());
group.updated_at = Utc::now();
}
workspace.sync_group_id = Some(sync_group_id.to_string());
workspace.updated_at = Utc::now();
// Serialize
let group_json = serde_json::to_string(&group)
.map_err(|e| DbError::Serialization(e.to_string()))?;
let ws_json = serde_json::to_string(&workspace)
.map_err(|e| DbError::Serialization(e.to_string()))?;
// Write all changes
let write_txn = self.db.begin_write()?;
{
let mut table = write_txn.open_table(WORKSPACE_SYNC_GROUPS)?;
table.insert(sync_group_id, group_json.as_str())?;
}
self.add_to_sync_index(&write_txn, sync_group_id, workspace_id)?;
{
let mut ws_table = write_txn.open_table(WORKSPACES)?;
ws_table.insert(workspace_id, ws_json.as_str())?;
}
write_txn.commit()?;
Ok(())
}
/// Remove a workspace from a sync group
pub fn remove_workspace_from_sync_group(
&self,
sync_group_id: &str,
workspace_id: &str,
) -> DbResult<()> {
// Read existing data
let mut group = self.get_sync_group(sync_group_id)?;
let mut workspace = self.get(workspace_id)?;
// Update in memory
group.workspace_ids.retain(|id| id != workspace_id);
group.updated_at = Utc::now();
workspace.sync_group_id = None;
workspace.updated_at = Utc::now();
// Serialize
let group_json = serde_json::to_string(&group)
.map_err(|e| DbError::Serialization(e.to_string()))?;
let ws_json = serde_json::to_string(&workspace)
.map_err(|e| DbError::Serialization(e.to_string()))?;
// Write all changes
let write_txn = self.db.begin_write()?;
{
let mut table = write_txn.open_table(WORKSPACE_SYNC_GROUPS)?;
table.insert(sync_group_id, group_json.as_str())?;
}
self.remove_from_sync_index(&write_txn, sync_group_id, workspace_id)?;
{
let mut ws_table = write_txn.open_table(WORKSPACES)?;
ws_table.insert(workspace_id, ws_json.as_str())?;
}
write_txn.commit()?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::env::temp_dir;
fn create_test_db() -> Database {
let path = temp_dir().join(format!("resona_test_{}.redb", uuid::Uuid::new_v4()));
Database::open_at(path).expect("Failed to create test database")
}
#[test]
fn test_workspace_crud() {
let db = create_test_db();
let service = WorkspaceService::new(db);
let workspace = service
.create(CreateWorkspaceInput {
name: "Test Workspace".to_string(),
description: "A test workspace".to_string(),
tags: vec!["Development".to_string()],
})
.expect("Failed to create workspace");
assert_eq!(workspace.name, "Test Workspace");
assert_eq!(workspace.tags, vec!["Development".to_string()]);
let fetched = service.get(&workspace.id).expect("Failed to get workspace");
assert_eq!(fetched.id, workspace.id);
let updated = service
.update(UpdateWorkspaceInput {
id: workspace.id.clone(),
name: Some("Updated Workspace".to_string()),
description: None,
tags: None,
sync_group_id: None,
})
.expect("Failed to update workspace");
assert_eq!(updated.name, "Updated Workspace");
assert_eq!(updated.description, "A test workspace");
let all = service.get_all().expect("Failed to get all workspaces");
assert_eq!(all.len(), 1);
service
.delete(&workspace.id)
.expect("Failed to delete workspace");
let all = service.get_all().expect("Failed to get all workspaces");
assert_eq!(all.len(), 0);
}
#[test]
fn test_sync_groups() {
let db = create_test_db();
let service = WorkspaceService::new(db);
let ws1 = service
.create(CreateWorkspaceInput {
name: "Workspace 1".to_string(),
description: "First workspace".to_string(),
tags: vec!["Development".to_string()],
})
.expect("Failed to create workspace 1");
let ws2 = service
.create(CreateWorkspaceInput {
name: "Workspace 2".to_string(),
description: "Second workspace".to_string(),
tags: vec!["Production".to_string()],
})
.expect("Failed to create workspace 2");
// Create sync group
let group = service
.create_sync_group(CreateSyncGroupInput {
name: "Test Sync Group".to_string(),
workspace_ids: vec![ws1.id.clone(), ws2.id.clone()],
synced_variable_names: vec!["API_KEY".to_string()],
sync_secrets: false,
})
.expect("Failed to create sync group");
// Verify workspaces are linked
let ws1_updated = service.get(&ws1.id).expect("Failed to get workspace 1");
assert_eq!(ws1_updated.sync_group_id, Some(group.id.clone()));
// Get workspaces by sync group
let grouped = service
.get_workspaces_by_sync_group(&group.id)
.expect("Failed to get workspaces by sync group");
assert_eq!(grouped.len(), 2);
// Delete sync group
service
.delete_sync_group(&group.id)
.expect("Failed to delete sync group");
// Verify workspaces are unlinked
let ws1_final = service.get(&ws1.id).expect("Failed to get workspace 1");
assert_eq!(ws1_final.sync_group_id, None);
}
}

View File

@@ -0,0 +1,103 @@
<script lang="ts">
import CodeMirror from "svelte-codemirror-editor";
import { json } from "@codemirror/lang-json";
import { xml } from "@codemirror/lang-xml";
import { html } from "@codemirror/lang-html";
import type { BodyType } from "$lib/types/request";
type Props = {
value: string;
language: BodyType;
readonly?: boolean;
placeholder?: string;
class?: string;
onchange?: (value: string) => void;
};
let {
value = "",
language = "json",
readonly = false,
placeholder = "",
class: className = "",
onchange,
}: Props = $props();
function getLanguageSupport(lang: BodyType) {
switch (lang) {
case "json":
return json();
case "xml":
return xml();
case "html":
return html();
default:
return undefined;
}
}
function handleChange(newValue: string) {
if (onchange) {
onchange(newValue);
}
}
let lang = $derived(getLanguageSupport(language));
</script>
<div class="code-editor-wrapper {className}">
<CodeMirror
bind:value
{lang}
{readonly}
{placeholder}
lineNumbers={true}
tabSize={2}
lineWrapping={true}
onchange={handleChange}
styles={{
"&": {
height: "100%",
fontSize: "13px",
},
".cm-scroller": {
fontFamily:
"ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace",
},
".cm-gutters": {
backgroundColor: "hsl(var(--muted))",
borderRight: "1px solid hsl(var(--border))",
},
".cm-activeLineGutter": {
backgroundColor: "hsl(var(--accent))",
},
".cm-activeLine": {
backgroundColor: "hsl(var(--accent) / 0.3)",
},
"&.cm-focused": {
outline: "none",
},
".cm-content": {
caretColor: "hsl(var(--foreground))",
},
}}
/>
</div>
<style>
.code-editor-wrapper {
border: 1px solid hsl(var(--border));
border-radius: calc(var(--radius) - 2px);
overflow: hidden;
}
.code-editor-wrapper :global(.cm-editor) {
height: 100%;
background-color: hsl(var(--background));
color: hsl(var(--foreground));
}
.code-editor-wrapper :global(.cm-placeholder) {
color: hsl(var(--muted-foreground));
}
</style>

View File

@@ -0,0 +1,381 @@
<script lang="ts">
import * as Dialog from "$lib/components/ui/dialog/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import { Textarea } from "$lib/components/ui/textarea/index.js";
import { Badge } from "$lib/components/ui/badge/index.js";
import { Separator } from "$lib/components/ui/separator/index.js";
import CopyIcon from "@lucide/svelte/icons/copy";
import LinkIcon from "@lucide/svelte/icons/link";
import type { Workspace } from "$lib/types/workspace";
import type { Variable } from "$lib/types/variable";
import { get_workspace_variables } from "$lib/services/variables";
import {
duplicate_workspace,
type DuplicateWorkspaceOptions,
} from "$lib/services/sync";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
sourceWorkspace: Workspace | null;
onDuplicate: (options: DuplicateWorkspaceOptions) => Promise<void>;
};
let {
open = $bindable(),
onOpenChange,
sourceWorkspace,
onDuplicate,
}: Props = $props();
let newName = $state("");
let newDescription = $state("");
let tags = $state<string[]>([]);
let tagInput = $state("");
let copyVariables = $state(true);
let copySecrets = $state(false);
let createSyncGroup = $state(false);
let syncGroupName = $state("");
let variablesToSync = $state<string[]>([]);
let workspaceVariables = $state<Variable[]>([]);
let loading = $state(false);
$effect(() => {
if (open && sourceWorkspace) {
newName = `${sourceWorkspace.Name} (Copy)`;
newDescription = sourceWorkspace.Description;
tags = [...(sourceWorkspace.Tags ?? [])];
syncGroupName = `${sourceWorkspace.Name} Environments`;
loadVariables();
}
});
async function loadVariables() {
if (sourceWorkspace) {
workspaceVariables = await get_workspace_variables(sourceWorkspace.Id);
}
}
function toggleVariableSync(varName: string) {
if (variablesToSync.includes(varName)) {
variablesToSync = variablesToSync.filter((v) => v !== varName);
} else {
variablesToSync = [...variablesToSync, varName];
}
}
function selectAllVariables() {
variablesToSync = workspaceVariables
.filter((v) => !v.isSecret || copySecrets)
.map((v) => v.name);
}
function deselectAllVariables() {
variablesToSync = [];
}
function addTag() {
const trimmed = tagInput.trim();
if (trimmed && !tags.includes(trimmed)) {
tags = [...tags, trimmed];
tagInput = "";
}
}
function removeTag(tag: string) {
tags = tags.filter((t) => t !== tag);
}
async function handleSubmit() {
if (!sourceWorkspace) return;
loading = true;
try {
await onDuplicate({
sourceWorkspaceId: sourceWorkspace.Id,
newName,
newDescription,
tags,
copyVariables,
copySecrets,
createSyncGroup,
syncGroupName: createSyncGroup ? syncGroupName : undefined,
variablesToSync: createSyncGroup ? variablesToSync : undefined,
});
onOpenChange(false);
} finally {
loading = false;
}
}
</script>
<Dialog.Root bind:open {onOpenChange}>
<Dialog.Content
class="sm:max-w-[550px] max-h-[85vh] overflow-hidden flex flex-col"
>
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<CopyIcon class="size-5" />
Duplicate Workspace
</Dialog.Title>
<Dialog.Description>
Create a copy of "{sourceWorkspace?.Name}" with all its collections and
requests.
</Dialog.Description>
</Dialog.Header>
<form
class="flex-1 overflow-auto space-y-4 py-4"
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
>
<div class="space-y-4">
<div class="grid grid-cols-4 items-center gap-4">
<Label for="new-name" class="text-end">Name</Label>
<Input
id="new-name"
class="col-span-3"
placeholder="New workspace name"
bind:value={newName}
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="new-desc" class="text-end">Description</Label>
<Textarea
id="new-desc"
class="col-span-3"
placeholder="Workspace description"
bind:value={newDescription}
rows={2}
/>
</div>
<div class="grid grid-cols-4 items-start gap-4">
<Label for="tags" class="text-end pt-2">Tags</Label>
<div class="col-span-3 space-y-2">
<div class="flex gap-2">
<Input
id="tags"
placeholder="Add a tag..."
bind:value={tagInput}
onkeydown={(e) =>
e.key === "Enter" && (e.preventDefault(), addTag())}
/>
<Button
type="button"
variant="outline"
size="sm"
onclick={addTag}
>
Add
</Button>
</div>
{#if tags.length > 0}
<div class="flex flex-wrap gap-1">
{#each tags as tag}
<Badge variant="secondary" class="gap-1">
{tag}
<button
type="button"
class="ml-1 hover:text-destructive"
onclick={() => removeTag(tag)}
aria-label="Remove tag"
>
×
</button>
</Badge>
{/each}
</div>
{/if}
</div>
</div>
</div>
<Separator />
<div class="space-y-4">
<h4 class="font-medium">Variables</h4>
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<Label>Copy Variables</Label>
<p class="text-xs text-muted-foreground">
Copy workspace variables to the new workspace.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={copyVariables}
aria-label="Toggle copy variables"
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 {copyVariables
? 'bg-primary'
: 'bg-input'}"
onclick={() => (copyVariables = !copyVariables)}
>
<span
class="pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform {copyVariables
? 'translate-x-5'
: 'translate-x-0'}"
></span>
</button>
</div>
{#if copyVariables}
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<Label>Copy Secret Values</Label>
<p class="text-xs text-muted-foreground">
Copy secret values (otherwise they'll be empty).
</p>
</div>
<button
type="button"
role="switch"
aria-checked={copySecrets}
aria-label="Toggle copy secrets"
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 {copySecrets
? 'bg-primary'
: 'bg-input'}"
onclick={() => (copySecrets = !copySecrets)}
>
<span
class="pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform {copySecrets
? 'translate-x-5'
: 'translate-x-0'}"
></span>
</button>
</div>
{/if}
</div>
<Separator />
<div class="space-y-4">
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<div class="flex items-center gap-2">
<LinkIcon class="size-4" />
<Label>Create Sync Group</Label>
</div>
<p class="text-xs text-muted-foreground">
Link workspaces to sync selected variables between environments.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={createSyncGroup}
aria-label="Toggle create sync group"
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 {createSyncGroup
? 'bg-primary'
: 'bg-input'}"
onclick={() => (createSyncGroup = !createSyncGroup)}
>
<span
class="pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform {createSyncGroup
? 'translate-x-5'
: 'translate-x-0'}"
></span>
</button>
</div>
{#if createSyncGroup}
<div class="grid grid-cols-4 items-center gap-4">
<Label for="sync-name" class="text-end">Group Name</Label>
<Input
id="sync-name"
class="col-span-3"
placeholder="Sync group name"
bind:value={syncGroupName}
/>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<Label>Variables to Sync</Label>
<div class="flex gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onclick={selectAllVariables}
>
Select All
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onclick={deselectAllVariables}
>
Deselect All
</Button>
</div>
</div>
<p class="text-xs text-muted-foreground mb-2">
Selected variables will be kept in sync across linked workspaces.
</p>
{#if workspaceVariables.length === 0}
<div class="text-center py-4 text-muted-foreground text-sm">
No workspace variables to sync.
</div>
{:else}
<div
class="grid grid-cols-2 gap-2 max-h-[150px] overflow-auto p-2 border rounded-md"
>
{#each workspaceVariables as variable (variable.id)}
{#if !variable.isSecret || copySecrets}
<button
type="button"
class="flex items-center gap-2 p-2 rounded text-left text-sm hover:bg-accent transition-colors {variablesToSync.includes(
variable.name
)
? 'bg-accent'
: ''}"
onclick={() => toggleVariableSync(variable.name)}
>
<input
type="checkbox"
checked={variablesToSync.includes(variable.name)}
class="rounded"
onchange={() => {}}
/>
<span class="font-mono text-xs truncate"
>{variable.name}</span
>
{#if variable.isSecret}
<Badge variant="secondary" class="text-xs">secret</Badge
>
{/if}
</button>
{/if}
{/each}
</div>
{/if}
</div>
{/if}
</div>
<Dialog.Footer class="pt-4">
<Button
type="button"
variant="outline"
onclick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button type="submit" disabled={loading || !newName.trim()}>
{loading ? "Creating..." : "Duplicate Workspace"}
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,371 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
import * as Select from "$lib/components/ui/select/index.js";
import * as Tabs from "$lib/components/ui/tabs/index.js";
import SendIcon from "@lucide/svelte/icons/send";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import XIcon from "@lucide/svelte/icons/x";
import CodeEditor from "./code-editor.svelte";
import VariableInput from "./variable-input.svelte";
import type { Request, HttpMethod, BodyType } from "$lib/types/request";
import type { ResolvedVariable } from "$lib/types/variable";
type Props = {
request: Request;
variables?: ResolvedVariable[];
onSend: (request: Request) => void;
onUpdate: (request: Request) => void;
};
let { request, variables = [], onSend, onUpdate }: Props = $props();
let localRequest = $state<Request>({ ...request });
$effect(() => {
localRequest = { ...request };
});
const methods: HttpMethod[] = [
"GET",
"POST",
"PUT",
"PATCH",
"DELETE",
"HEAD",
"OPTIONS",
];
const bodyTypes: { value: BodyType; label: string }[] = [
{ value: "none", label: "None" },
{ value: "json", label: "JSON" },
{ value: "xml", label: "XML" },
{ value: "text", label: "Text" },
{ value: "html", label: "HTML" },
{ value: "form-data", label: "Form Data" },
{ value: "x-www-form-urlencoded", label: "URL Encoded" },
];
function getMethodColor(method: string): string {
const colors: Record<string, string> = {
GET: "bg-green-500/10 text-green-500 hover:bg-green-500/20",
POST: "bg-blue-500/10 text-blue-500 hover:bg-blue-500/20",
PUT: "bg-orange-500/10 text-orange-500 hover:bg-orange-500/20",
PATCH: "bg-yellow-500/10 text-yellow-500 hover:bg-yellow-500/20",
DELETE: "bg-red-500/10 text-red-500 hover:bg-red-500/20",
HEAD: "bg-purple-500/10 text-purple-500 hover:bg-purple-500/20",
OPTIONS: "bg-gray-500/10 text-gray-500 hover:bg-gray-500/20",
};
return colors[method] || "bg-gray-500/10 text-gray-500";
}
function handleMethodChange(method: HttpMethod) {
localRequest.method = method;
onUpdate(localRequest);
}
function handleUrlChange(value: string) {
localRequest.url = value;
onUpdate(localRequest);
}
function handleBodyTypeChange(value: BodyType) {
localRequest.bodyType = value;
onUpdate(localRequest);
}
function handleBodyChange(value: string) {
localRequest.body = value;
onUpdate(localRequest);
}
function handleSend() {
onSend(localRequest);
}
function formatBody() {
if (localRequest.bodyType === "json") {
try {
localRequest.body = JSON.stringify(
JSON.parse(localRequest.body),
null,
2
);
onUpdate(localRequest);
} catch {
// Invalid JSON, don't format
}
}
}
</script>
<div class="flex flex-col h-full">
<div class="border-b p-4">
<div class="flex items-center gap-2">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button
variant="outline"
class={`font-mono font-semibold min-w-[100px] ${getMethodColor(localRequest.method)}`}
{...props}
>
{localRequest.method}
<ChevronDownIcon class="ml-1 size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content>
{#each methods as method (method)}
<DropdownMenu.Item onclick={() => handleMethodChange(method)}>
<span
class={`font-mono font-semibold ${getMethodColor(method).split(" ")[1]}`}
>
{method}
</span>
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
<VariableInput
class="flex-1"
placeholder={"Enter request URL (use {{VAR_NAME}} for variables)"}
value={localRequest.url}
{variables}
oninput={handleUrlChange}
/>
<Button onclick={handleSend}>
<SendIcon class="size-4 mr-2" />
Send
</Button>
</div>
</div>
<Tabs.Root value="params" class="flex-1 flex flex-col overflow-hidden">
<Tabs.List
class="w-full justify-start rounded-none border-b bg-transparent p-0 h-auto"
>
<Tabs.Trigger
value="params"
class="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
>
Params
</Tabs.Trigger>
<Tabs.Trigger
value="headers"
class="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
>
Headers
</Tabs.Trigger>
<Tabs.Trigger
value="body"
class="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
>
Body
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="params" class="flex-1 m-0 p-4 overflow-auto">
<div class="space-y-2">
{#each localRequest.params as param, i (i)}
<div class="flex items-center gap-2">
<VariableInput
class="flex-1"
placeholder="Key"
value={param.key}
{variables}
oninput={(value) => {
localRequest.params[i].key = value;
onUpdate(localRequest);
}}
/>
<VariableInput
class="flex-1"
placeholder="Value"
value={param.value}
{variables}
oninput={(value) => {
localRequest.params[i].value = value;
onUpdate(localRequest);
}}
/>
<Button
variant="ghost"
size="icon"
onclick={() => {
localRequest.params = localRequest.params.filter(
(_, idx) => idx !== i
);
onUpdate(localRequest);
}}
>
<XIcon class="size-4" />
</Button>
</div>
{/each}
<Button
variant="outline"
size="sm"
onclick={() => {
localRequest.params = [
...localRequest.params,
{ key: "", value: "", enabled: true },
];
onUpdate(localRequest);
}}
>
Add Parameter
</Button>
</div>
</Tabs.Content>
<Tabs.Content value="headers" class="flex-1 m-0 p-4 overflow-auto">
<div class="space-y-2">
{#each localRequest.headers as header, i (i)}
<div class="flex items-center gap-2">
<VariableInput
class="flex-1"
placeholder="Key"
value={header.key}
{variables}
oninput={(value) => {
localRequest.headers[i].key = value;
onUpdate(localRequest);
}}
/>
<VariableInput
class="flex-1"
placeholder="Value"
value={header.value}
{variables}
oninput={(value) => {
localRequest.headers[i].value = value;
onUpdate(localRequest);
}}
/>
<Button
variant="ghost"
size="icon"
onclick={() => {
localRequest.headers = localRequest.headers.filter(
(_, idx) => idx !== i
);
onUpdate(localRequest);
}}
>
<XIcon class="size-4" />
</Button>
</div>
{/each}
<Button
variant="outline"
size="sm"
onclick={() => {
localRequest.headers = [
...localRequest.headers,
{ key: "", value: "", enabled: true },
];
onUpdate(localRequest);
}}
>
Add Header
</Button>
</div>
</Tabs.Content>
<Tabs.Content value="body" class="flex-1 m-0 flex flex-col overflow-hidden">
<div class="flex items-center gap-2 p-2 border-b bg-muted/30">
<span class="text-sm text-muted-foreground">Content Type:</span>
<Select.Root
type="single"
value={localRequest.bodyType}
onValueChange={(value) => handleBodyTypeChange(value as BodyType)}
>
<Select.Trigger class="w-40 h-8">
{bodyTypes.find((t) => t.value === localRequest.bodyType)?.label ||
"None"}
</Select.Trigger>
<Select.Content>
{#each bodyTypes as bodyType (bodyType.value)}
<Select.Item value={bodyType.value}>{bodyType.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{#if localRequest.bodyType === "json"}
<Button variant="ghost" size="sm" onclick={formatBody}>Format</Button>
{/if}
</div>
<div class="flex-1 p-2 overflow-hidden">
{#if localRequest.bodyType === "none"}
<div
class="flex items-center justify-center h-full text-muted-foreground text-sm"
>
This request does not have a body
</div>
{:else if localRequest.bodyType === "form-data" || localRequest.bodyType === "x-www-form-urlencoded"}
<div class="space-y-2 p-2">
{#each localRequest.formData as item, i (i)}
<div class="flex items-center gap-2">
<VariableInput
class="flex-1"
placeholder="Key"
value={item.key}
{variables}
oninput={(value) => {
localRequest.formData[i].key = value;
onUpdate(localRequest);
}}
/>
<VariableInput
class="flex-1"
placeholder="Value"
value={item.value}
{variables}
oninput={(value) => {
localRequest.formData[i].value = value;
onUpdate(localRequest);
}}
/>
<Button
variant="ghost"
size="icon"
onclick={() => {
localRequest.formData = localRequest.formData.filter(
(_, idx) => idx !== i
);
onUpdate(localRequest);
}}
>
<XIcon class="size-4" />
</Button>
</div>
{/each}
<Button
variant="outline"
size="sm"
onclick={() => {
localRequest.formData = [
...localRequest.formData,
{ key: "", value: "", type: "text", enabled: true },
];
onUpdate(localRequest);
}}
>
Add Field
</Button>
</div>
{:else}
<CodeEditor
value={localRequest.body}
language={localRequest.bodyType}
placeholder="Enter request body..."
class="h-full"
onchange={handleBodyChange}
/>
{/if}
</div>
</Tabs.Content>
</Tabs.Root>
</div>

View File

@@ -0,0 +1,176 @@
<script lang="ts">
import * as Tabs from "$lib/components/ui/tabs/index.js";
import { Badge } from "$lib/components/ui/badge/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import { Spinner } from "$lib/components/ui/spinner/index.js";
import CodeEditor from "./code-editor.svelte";
import CopyIcon from "@lucide/svelte/icons/copy";
import CheckIcon from "@lucide/svelte/icons/check";
import type { Response } from "$lib/types/response";
import type { BodyType } from "$lib/types/request";
type Props = {
response: Response | null;
loading?: boolean;
};
let { response, loading = false }: Props = $props();
let copied = $state(false);
function getStatusColor(status: number): string {
if (status >= 200 && status < 300) return "bg-green-500/10 text-green-500";
if (status >= 300 && status < 400) return "bg-blue-500/10 text-blue-500";
if (status >= 400 && status < 500)
return "bg-yellow-500/10 text-yellow-500";
if (status >= 500) return "bg-red-500/10 text-red-500";
return "bg-gray-500/10 text-gray-500";
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms} ms`;
return `${(ms / 1000).toFixed(2)} s`;
}
function getBodyType(): BodyType {
if (!response) return "text";
const ct = response.contentType.toLowerCase();
if (ct.includes("json")) return "json";
if (ct.includes("xml")) return "xml";
if (ct.includes("html")) return "html";
return "text";
}
function formatBody(body: string, type: BodyType): string {
if (type === "json") {
try {
return JSON.stringify(JSON.parse(body), null, 2);
} catch {
return body;
}
}
return body;
}
async function copyToClipboard() {
if (!response) return;
await navigator.clipboard.writeText(response.body);
copied = true;
setTimeout(() => (copied = false), 2000);
}
let bodyType = $derived(getBodyType());
let formattedBody = $derived(
response ? formatBody(response.body, bodyType) : ""
);
</script>
<div class="flex flex-col h-full border-t">
{#if loading}
<div class="flex-1 flex items-center justify-center">
<div class="flex flex-col items-center gap-3">
<Spinner class="size-8" />
<span class="text-sm text-muted-foreground">Sending request...</span>
</div>
</div>
{:else if response}
<div class="flex items-center gap-4 px-4 py-2 border-b bg-muted/30">
<Badge class={getStatusColor(response.status)}>
{response.status}
{response.statusText}
</Badge>
<span class="text-sm text-muted-foreground">
{formatDuration(response.duration)}
</span>
<span class="text-sm text-muted-foreground">
{formatSize(response.size)}
</span>
<div class="flex-1"></div>
<Button variant="ghost" size="sm" onclick={copyToClipboard}>
{#if copied}
<CheckIcon class="size-4 mr-1" />
Copied
{:else}
<CopyIcon class="size-4 mr-1" />
Copy
{/if}
</Button>
</div>
<Tabs.Root value="body" class="flex-1 flex flex-col">
<Tabs.List
class="w-full justify-start rounded-none border-b bg-transparent p-0"
>
<Tabs.Trigger
value="body"
class="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent"
>
Body
</Tabs.Trigger>
<Tabs.Trigger
value="headers"
class="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent"
>
Headers ({response.headers.length})
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content
value="body"
class="flex-1 m-0 p-0 data-[state=active]:flex data-[state=active]:flex-col"
>
<div class="flex-1 p-2">
<CodeEditor
value={formattedBody}
language={bodyType}
readonly={true}
class="h-full"
/>
</div>
</Tabs.Content>
<Tabs.Content
value="headers"
class="flex-1 m-0 data-[state=active]:flex data-[state=active]:flex-col overflow-auto"
>
<div class="p-4">
<table class="w-full text-sm">
<thead>
<tr class="border-b">
<th
class="text-left py-2 pr-4 font-medium text-muted-foreground"
>Name</th
>
<th class="text-left py-2 font-medium text-muted-foreground"
>Value</th
>
</tr>
</thead>
<tbody>
{#each response.headers as header (header.key)}
<tr class="border-b last:border-0">
<td class="py-2 pr-4 font-mono text-xs">{header.key}</td>
<td class="py-2 font-mono text-xs break-all"
>{header.value}</td
>
</tr>
{/each}
</tbody>
</table>
</div>
</Tabs.Content>
</Tabs.Root>
{:else}
<div class="flex-1 flex items-center justify-center">
<span class="text-sm text-muted-foreground"
>Send a request to see the response</span
>
</div>
{/if}
</div>

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import { Label } from "$lib/components/ui/label/index.js";
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
import type { WithElementRef } from "$lib/utils.js";
import SearchIcon from "@lucide/svelte/icons/search";
import type { HTMLFormAttributes } from "svelte/elements";
let {
ref = $bindable(null),
...restProps
}: WithElementRef<HTMLFormAttributes> = $props();
</script>
<form bind:this={ref} {...restProps}>
<Sidebar.Group class="py-0">
<Sidebar.GroupContent class="relative">
<Label for="search" class="sr-only">Search</Label>
<Sidebar.Input
id="search"
placeholder="Search the docs..."
class="ps-8"
/>
<SearchIcon
class="pointer-events-none absolute start-2 top-1/2 size-4 -translate-y-1/2 select-none opacity-50"
/>
</Sidebar.GroupContent>
</Sidebar.Group>
</form>

View File

@@ -0,0 +1,300 @@
<script lang="ts">
import * as Dialog from "$lib/components/ui/dialog/index.js";
import * as Tabs from "$lib/components/ui/tabs/index.js";
import * as Select from "$lib/components/ui/select/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import { Separator } from "$lib/components/ui/separator/index.js";
import SettingsIcon from "@lucide/svelte/icons/settings";
import CheckIcon from "@lucide/svelte/icons/check";
import type { AppSettings, Theme } from "$lib/types/workspace";
import {
get_settings,
update_settings,
reset_settings,
} from "$lib/services/settings";
import {
theme as themeStore,
themes,
themeLabels,
} from "$lib/theme-switcher";
import { onMount } from "svelte";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
};
let { open = $bindable(), onOpenChange }: Props = $props();
let settings = $state<AppSettings>({
theme: "system",
customThemes: [],
defaultTimeout: 30000,
followRedirects: true,
validateSsl: true,
maxHistoryItems: 100,
autoSaveRequests: true,
});
let loading = $state(false);
onMount(async () => {
settings = await get_settings();
});
$effect(() => {
if (open) {
loadSettings();
}
});
async function loadSettings() {
settings = await get_settings();
}
async function handleSave() {
loading = true;
await update_settings(settings);
loading = false;
onOpenChange(false);
}
async function handleReset() {
settings = await reset_settings();
themeStore.applyTheme(settings.theme);
}
async function handleThemeChange(newTheme: Theme) {
settings.theme = newTheme;
await themeStore.applyTheme(newTheme);
}
</script>
<Dialog.Root bind:open {onOpenChange}>
<Dialog.Content
class="sm:max-w-[600px] max-h-[80vh] overflow-hidden flex flex-col"
>
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<SettingsIcon class="size-5" />
Settings
</Dialog.Title>
<Dialog.Description>
Configure application settings and preferences.
</Dialog.Description>
</Dialog.Header>
<Tabs.Root value="appearance" class="flex-1 overflow-hidden">
<Tabs.List class="grid w-full grid-cols-4">
<Tabs.Trigger value="appearance">Appearance</Tabs.Trigger>
<Tabs.Trigger value="general">General</Tabs.Trigger>
<Tabs.Trigger value="requests">Requests</Tabs.Trigger>
<Tabs.Trigger value="advanced">Advanced</Tabs.Trigger>
</Tabs.List>
<div class="mt-4 overflow-auto max-h-[400px]">
<Tabs.Content value="appearance" class="space-y-4">
<div class="space-y-3">
<Label>Theme</Label>
<p class="text-xs text-muted-foreground">
Select a color theme for the application.
</p>
<div class="grid grid-cols-2 gap-2">
{#each themes as t}
<button
type="button"
class="flex items-center justify-between px-3 py-2 rounded-md border text-sm transition-colors {settings.theme ===
t
? 'border-primary bg-primary/10'
: 'border-border hover:bg-muted'}"
onclick={() => handleThemeChange(t)}
>
<span>{themeLabels[t]}</span>
{#if settings.theme === t}
<CheckIcon class="size-4 text-primary" />
{/if}
</button>
{/each}
</div>
</div>
<Separator />
<div class="rounded-lg border p-4 bg-muted/30">
<h4 class="font-medium mb-2">Theme Preview</h4>
<p class="text-sm text-muted-foreground mb-3">
Preview of the current theme colors.
</p>
<div class="flex gap-2">
<div
class="size-8 rounded bg-background border"
title="Background"
></div>
<div
class="size-8 rounded bg-foreground"
title="Foreground"
></div>
<div class="size-8 rounded bg-primary" title="Primary"></div>
<div class="size-8 rounded bg-secondary" title="Secondary"></div>
<div class="size-8 rounded bg-muted" title="Muted"></div>
<div class="size-8 rounded bg-accent" title="Accent"></div>
<div
class="size-8 rounded bg-destructive"
title="Destructive"
></div>
</div>
</div>
</Tabs.Content>
<Tabs.Content value="general" class="space-y-4">
<div class="space-y-2">
<Label for="maxHistory">History Items</Label>
<Input
id="maxHistory"
type="number"
min="10"
max="1000"
bind:value={settings.maxHistoryItems}
/>
<p class="text-xs text-muted-foreground">
Maximum number of request history items to keep.
</p>
</div>
<Separator />
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<Label>Auto-save Requests</Label>
<p class="text-xs text-muted-foreground">
Automatically save request changes.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={settings.autoSaveRequests}
aria-label="Toggle auto-save requests"
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 {settings.autoSaveRequests
? 'bg-primary'
: 'bg-input'}"
onclick={() =>
(settings.autoSaveRequests = !settings.autoSaveRequests)}
>
<span
class="pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform {settings.autoSaveRequests
? 'translate-x-5'
: 'translate-x-0'}"
></span>
</button>
</div>
</Tabs.Content>
<Tabs.Content value="requests" class="space-y-4">
<div class="space-y-2">
<Label for="timeout">Default Timeout (ms)</Label>
<Input
id="timeout"
type="number"
min="1000"
max="300000"
step="1000"
bind:value={settings.defaultTimeout}
/>
<p class="text-xs text-muted-foreground">
Default timeout for HTTP requests in milliseconds.
</p>
</div>
<Separator />
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<Label>Follow Redirects</Label>
<p class="text-xs text-muted-foreground">
Automatically follow HTTP redirects.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={settings.followRedirects}
aria-label="Toggle follow redirects"
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 {settings.followRedirects
? 'bg-primary'
: 'bg-input'}"
onclick={() =>
(settings.followRedirects = !settings.followRedirects)}
>
<span
class="pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform {settings.followRedirects
? 'translate-x-5'
: 'translate-x-0'}"
></span>
</button>
</div>
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<Label>Validate SSL Certificates</Label>
<p class="text-xs text-muted-foreground">
Verify SSL/TLS certificates for HTTPS requests.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={settings.validateSsl}
aria-label="Toggle SSL validation"
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 {settings.validateSsl
? 'bg-primary'
: 'bg-input'}"
onclick={() => (settings.validateSsl = !settings.validateSsl)}
>
<span
class="pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform {settings.validateSsl
? 'translate-x-5'
: 'translate-x-0'}"
></span>
</button>
</div>
</Tabs.Content>
<Tabs.Content value="advanced" class="space-y-4">
<div class="rounded-lg border p-4 bg-muted/30">
<h4 class="font-medium mb-2">Reset Settings</h4>
<p class="text-sm text-muted-foreground mb-4">
Reset all settings to their default values. This action cannot be
undone.
</p>
<Button variant="destructive" size="sm" onclick={handleReset}>
Reset to Defaults
</Button>
</div>
<div class="rounded-lg border p-4 bg-muted/30">
<h4 class="font-medium mb-2">Data Management</h4>
<p class="text-sm text-muted-foreground mb-4">
Export or import your workspaces, collections, and settings.
</p>
<div class="flex gap-2">
<Button variant="outline" size="sm" disabled>Export Data</Button>
<Button variant="outline" size="sm" disabled>Import Data</Button>
</div>
</div>
</Tabs.Content>
</div>
</Tabs.Root>
<Dialog.Footer class="mt-4">
<Button variant="outline" onclick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onclick={handleSave} disabled={loading}>
{loading ? "Saving..." : "Save Changes"}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -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 whitespace-nowrap rounded-full border px-2 py-0.5 text-xs font-medium 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>

View File

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

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import EllipsisIcon from "@lucide/svelte/icons/ellipsis";
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLSpanElement>>> = $props();
</script>
<span
bind:this={ref}
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
class={cn("flex size-9 items-center justify-center", className)}
{...restProps}
>
<EllipsisIcon class="size-4" />
<span class="sr-only">More</span>
</span>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLLiAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLLiAttributes> = $props();
</script>
<li
bind:this={ref}
data-slot="breadcrumb-item"
class={cn("inline-flex items-center gap-1.5", className)}
{...restProps}
>
{@render children?.()}
</li>

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import type { HTMLAnchorAttributes } from "svelte/elements";
import type { Snippet } from "svelte";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
href = undefined,
child,
children,
...restProps
}: WithElementRef<HTMLAnchorAttributes> & {
child?: Snippet<[{ props: HTMLAnchorAttributes }]>;
} = $props();
const attrs = $derived({
"data-slot": "breadcrumb-link",
class: cn("hover:text-foreground transition-colors", className),
href,
...restProps,
});
</script>
{#if child}
{@render child({ props: attrs })}
{:else}
<a bind:this={ref} {...attrs}>
{@render children?.()}
</a>
{/if}

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import type { HTMLOlAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLOlAttributes> = $props();
</script>
<ol
bind:this={ref}
data-slot="breadcrumb-list"
class={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 break-words text-sm sm:gap-2.5",
className
)}
{...restProps}
>
{@render children?.()}
</ol>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
class={cn("text-foreground font-normal", className)}
{...restProps}
>
{@render children?.()}
</span>

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLLiAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLLiAttributes> = $props();
</script>
<li
bind:this={ref}
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
class={cn("[&>svg]:size-3.5", className)}
{...restProps}
>
{#if children}
{@render children?.()}
{:else}
<ChevronRightIcon />
{/if}
</li>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import type { WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<nav
bind:this={ref}
data-slot="breadcrumb"
class={className}
aria-label="breadcrumb"
{...restProps}
>
{@render children?.()}
</nav>

View File

@@ -0,0 +1,25 @@
import Root from "./breadcrumb.svelte";
import Ellipsis from "./breadcrumb-ellipsis.svelte";
import Item from "./breadcrumb-item.svelte";
import Separator from "./breadcrumb-separator.svelte";
import Link from "./breadcrumb-link.svelte";
import List from "./breadcrumb-list.svelte";
import Page from "./breadcrumb-page.svelte";
export {
Root,
Ellipsis,
Item,
Separator,
Link,
List,
Page,
//
Root as Breadcrumb,
Ellipsis as BreadcrumbEllipsis,
Item as BreadcrumbItem,
Separator as BreadcrumbSeparator,
Link as BreadcrumbLink,
List as BreadcrumbList,
Page as BreadcrumbPage,
};

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: CollapsiblePrimitive.ContentProps = $props();
</script>
<CollapsiblePrimitive.Content bind:ref data-slot="collapsible-content" {...restProps} />

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: CollapsiblePrimitive.TriggerProps = $props();
</script>
<CollapsiblePrimitive.Trigger bind:ref data-slot="collapsible-trigger" {...restProps} />

View File

@@ -0,0 +1,11 @@
<script lang="ts">
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
let {
ref = $bindable(null),
open = $bindable(false),
...restProps
}: CollapsiblePrimitive.RootProps = $props();
</script>
<CollapsiblePrimitive.Root bind:ref bind:open data-slot="collapsible" {...restProps} />

View File

@@ -0,0 +1,13 @@
import Root from "./collapsible.svelte";
import Trigger from "./collapsible-trigger.svelte";
import Content from "./collapsible-content.svelte";
export {
Root,
Content,
Trigger,
//
Root as Collapsible,
Content as CollapsibleContent,
Trigger as CollapsibleTrigger,
};

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: DialogPrimitive.CloseProps = $props();
</script>
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {...restProps} />

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import XIcon from "@lucide/svelte/icons/x";
import type { Snippet } from "svelte";
import * as Dialog from "./index.js";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
portalProps,
children,
showCloseButton = true,
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
portalProps?: DialogPrimitive.PortalProps;
children: Snippet;
showCloseButton?: boolean;
} = $props();
</script>
<Dialog.Portal {...portalProps}>
<Dialog.Overlay />
<DialogPrimitive.Content
bind:ref
data-slot="dialog-content"
class={cn(
"bg-background 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 fixed start-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...restProps}
>
{@render children?.()}
{#if showCloseButton}
<DialogPrimitive.Close
class="ring-offset-background focus:ring-ring rounded-xs focus:outline-hidden absolute end-4 top-4 opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
>
<XIcon />
<span class="sr-only">Close</span>
</DialogPrimitive.Close>
{/if}
</DialogPrimitive.Content>
</Dialog.Portal>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.DescriptionProps = $props();
</script>
<DialogPrimitive.Description
bind:ref
data-slot="dialog-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
/>

View File

@@ -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="dialog-footer"
class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="dialog-header"
class={cn("flex flex-col gap-2 text-center sm:text-start", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.OverlayProps = $props();
</script>
<DialogPrimitive.Overlay
bind:ref
data-slot="dialog-overlay"
class={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.TitleProps = $props();
</script>
<DialogPrimitive.Title
bind:ref
data-slot="dialog-title"
class={cn("text-lg font-semibold leading-none", className)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: DialogPrimitive.TriggerProps = $props();
</script>
<DialogPrimitive.Trigger bind:ref data-slot="dialog-trigger" {...restProps} />

View File

@@ -0,0 +1,37 @@
import { Dialog as DialogPrimitive } from "bits-ui";
import Title from "./dialog-title.svelte";
import Footer from "./dialog-footer.svelte";
import Header from "./dialog-header.svelte";
import Overlay from "./dialog-overlay.svelte";
import Content from "./dialog-content.svelte";
import Description from "./dialog-description.svelte";
import Trigger from "./dialog-trigger.svelte";
import Close from "./dialog-close.svelte";
const Root = DialogPrimitive.Root;
const Portal = DialogPrimitive.Portal;
export {
Root,
Title,
Portal,
Footer,
Header,
Trigger,
Overlay,
Content,
Description,
Close,
//
Root as Dialog,
Title as DialogTitle,
Portal as DialogPortal,
Footer as DialogFooter,
Header as DialogHeader,
Trigger as DialogTrigger,
Overlay as DialogOverlay,
Content as DialogContent,
Description as DialogDescription,
Close as DialogClose,
};

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let {
ref = $bindable(null),
value = $bindable(),
...restProps
}: DropdownMenuPrimitive.CheckboxGroupProps = $props();
</script>
<DropdownMenuPrimitive.CheckboxGroup
bind:ref
bind:value
data-slot="dropdown-menu-checkbox-group"
{...restProps}
/>

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import CheckIcon from "@lucide/svelte/icons/check";
import MinusIcon from "@lucide/svelte/icons/minus";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { Snippet } from "svelte";
let {
ref = $bindable(null),
checked = $bindable(false),
indeterminate = $bindable(false),
class: className,
children: childrenProp,
...restProps
}: WithoutChildrenOrChild<DropdownMenuPrimitive.CheckboxItemProps> & {
children?: Snippet;
} = $props();
</script>
<DropdownMenuPrimitive.CheckboxItem
bind:ref
bind:checked
bind:indeterminate
data-slot="dropdown-menu-checkbox-item"
class={cn(
"focus:bg-accent focus:text-accent-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pe-2 ps-8 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{#snippet children({ checked, indeterminate })}
<span
class="pointer-events-none absolute start-2 flex size-3.5 items-center justify-center"
>
{#if indeterminate}
<MinusIcon class="size-4" />
{:else}
<CheckIcon class={cn("size-4", !checked && "text-transparent")} />
{/if}
</span>
{@render childrenProp?.()}
{/snippet}
</DropdownMenuPrimitive.CheckboxItem>

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let {
ref = $bindable(null),
sideOffset = 4,
portalProps,
class: className,
...restProps
}: DropdownMenuPrimitive.ContentProps & {
portalProps?: DropdownMenuPrimitive.PortalProps;
} = $props();
</script>
<DropdownMenuPrimitive.Portal {...portalProps}>
<DropdownMenuPrimitive.Content
bind:ref
data-slot="dropdown-menu-content"
{sideOffset}
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 max-h-(--bits-dropdown-menu-content-available-height) origin-(--bits-dropdown-menu-content-transform-origin) z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border p-1 shadow-md outline-none",
className
)}
{...restProps}
/>
</DropdownMenuPrimitive.Portal>

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
inset,
...restProps
}: ComponentProps<typeof DropdownMenuPrimitive.GroupHeading> & {
inset?: boolean;
} = $props();
</script>
<DropdownMenuPrimitive.GroupHeading
bind:ref
data-slot="dropdown-menu-group-heading"
data-inset={inset}
class={cn("px-2 py-1.5 text-sm font-semibold data-[inset]:ps-8", className)}
{...restProps}
/>

View File

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

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
inset,
variant = "default",
...restProps
}: DropdownMenuPrimitive.ItemProps & {
inset?: boolean;
variant?: "default" | "destructive";
} = $props();
</script>
<DropdownMenuPrimitive.Item
bind:ref
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
class={cn(
"data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:data-highlighted:bg-destructive/10 dark:data-[variant=destructive]:data-highlighted:bg-destructive/20 data-[variant=destructive]:data-highlighted:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[disabled]:pointer-events-none data-[inset]:ps-8 data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
inset?: boolean;
} = $props();
</script>
<div
bind:this={ref}
data-slot="dropdown-menu-label"
data-inset={inset}
class={cn("px-2 py-1.5 text-sm font-semibold data-[inset]:ps-8", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let {
ref = $bindable(null),
value = $bindable(),
...restProps
}: DropdownMenuPrimitive.RadioGroupProps = $props();
</script>
<DropdownMenuPrimitive.RadioGroup
bind:ref
bind:value
data-slot="dropdown-menu-radio-group"
{...restProps}
/>

View File

@@ -0,0 +1,33 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import CircleIcon from "@lucide/svelte/icons/circle";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children: childrenProp,
...restProps
}: WithoutChild<DropdownMenuPrimitive.RadioItemProps> = $props();
</script>
<DropdownMenuPrimitive.RadioItem
bind:ref
data-slot="dropdown-menu-radio-item"
class={cn(
"focus:bg-accent focus:text-accent-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pe-2 ps-8 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{#snippet children({ checked })}
<span
class="pointer-events-none absolute start-2 flex size-3.5 items-center justify-center"
>
{#if checked}
<CircleIcon class="size-2 fill-current" />
{/if}
</span>
{@render childrenProp?.({ checked })}
{/snippet}
</DropdownMenuPrimitive.RadioItem>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DropdownMenuPrimitive.SeparatorProps = $props();
</script>
<DropdownMenuPrimitive.Separator
bind:ref
data-slot="dropdown-menu-separator"
class={cn("bg-border -mx-1 my-1 h-px", className)}
{...restProps}
/>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
data-slot="dropdown-menu-shortcut"
class={cn("text-muted-foreground ms-auto text-xs tracking-widest", className)}
{...restProps}
>
{@render children?.()}
</span>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DropdownMenuPrimitive.SubContentProps = $props();
</script>
<DropdownMenuPrimitive.SubContent
bind:ref
data-slot="dropdown-menu-sub-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 origin-(--bits-dropdown-menu-content-transform-origin) z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: DropdownMenuPrimitive.SubTriggerProps & {
inset?: boolean;
} = $props();
</script>
<DropdownMenuPrimitive.SubTrigger
bind:ref
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
class={cn(
"data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground outline-hidden [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[disabled]:pointer-events-none data-[inset]:ps-8 data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronRightIcon class="ms-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.TriggerProps = $props();
</script>
<DropdownMenuPrimitive.Trigger bind:ref data-slot="dropdown-menu-trigger" {...restProps} />

View File

@@ -0,0 +1,52 @@
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import CheckboxGroup from "./dropdown-menu-checkbox-group.svelte";
import CheckboxItem from "./dropdown-menu-checkbox-item.svelte";
import Content from "./dropdown-menu-content.svelte";
import Group from "./dropdown-menu-group.svelte";
import Item from "./dropdown-menu-item.svelte";
import Label from "./dropdown-menu-label.svelte";
import RadioGroup from "./dropdown-menu-radio-group.svelte";
import RadioItem from "./dropdown-menu-radio-item.svelte";
import Separator from "./dropdown-menu-separator.svelte";
import Shortcut from "./dropdown-menu-shortcut.svelte";
import Trigger from "./dropdown-menu-trigger.svelte";
import SubContent from "./dropdown-menu-sub-content.svelte";
import SubTrigger from "./dropdown-menu-sub-trigger.svelte";
import GroupHeading from "./dropdown-menu-group-heading.svelte";
const Sub = DropdownMenuPrimitive.Sub;
const Root = DropdownMenuPrimitive.Root;
export {
CheckboxGroup,
CheckboxItem,
Content,
Root as DropdownMenu,
CheckboxGroup as DropdownMenuCheckboxGroup,
CheckboxItem as DropdownMenuCheckboxItem,
Content as DropdownMenuContent,
Group as DropdownMenuGroup,
Item as DropdownMenuItem,
Label as DropdownMenuLabel,
RadioGroup as DropdownMenuRadioGroup,
RadioItem as DropdownMenuRadioItem,
Separator as DropdownMenuSeparator,
Shortcut as DropdownMenuShortcut,
Sub as DropdownMenuSub,
SubContent as DropdownMenuSubContent,
SubTrigger as DropdownMenuSubTrigger,
Trigger as DropdownMenuTrigger,
GroupHeading as DropdownMenuGroupHeading,
Group,
GroupHeading,
Item,
Label,
RadioGroup,
RadioItem,
Root,
Separator,
Shortcut,
Sub,
SubContent,
SubTrigger,
Trigger,
};

View File

@@ -0,0 +1,7 @@
import Root from "./input.svelte";
export {
Root,
//
Root as Input,
};

View File

@@ -0,0 +1,52 @@
<script lang="ts">
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
type Props = WithElementRef<
Omit<HTMLInputAttributes, "type"> &
({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
>;
let {
ref = $bindable(null),
value = $bindable(),
type,
files = $bindable(),
class: className,
"data-slot": dataSlot = "input",
...restProps
}: Props = $props();
</script>
{#if type === "file"}
<input
bind:this={ref}
data-slot={dataSlot}
class={cn(
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
type="file"
bind:files
bind:value
{...restProps}
/>
{:else}
<input
bind:this={ref}
data-slot={dataSlot}
class={cn(
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{type}
bind:value
{...restProps}
/>
{/if}

View File

@@ -0,0 +1,7 @@
import Root from "./label.svelte";
export {
Root,
//
Root as Label,
};

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Label as LabelPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: LabelPrimitive.RootProps = $props();
</script>
<LabelPrimitive.Root
bind:ref
data-slot="label"
class={cn(
"flex select-none items-center gap-2 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50 group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,10 @@
import Scrollbar from "./scroll-area-scrollbar.svelte";
import Root from "./scroll-area.svelte";
export {
Root,
Scrollbar,
//,
Root as ScrollArea,
Scrollbar as ScrollAreaScrollbar,
};

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import { ScrollArea as ScrollAreaPrimitive } from "bits-ui";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
orientation = "vertical",
children,
...restProps
}: WithoutChild<ScrollAreaPrimitive.ScrollbarProps> = $props();
</script>
<ScrollAreaPrimitive.Scrollbar
bind:ref
data-slot="scroll-area-scrollbar"
{orientation}
class={cn(
"flex touch-none select-none p-px transition-colors",
orientation === "vertical" && "h-full w-2.5 border-s border-s-transparent",
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent",
className
)}
{...restProps}
>
{@render children?.()}
<ScrollAreaPrimitive.Thumb
data-slot="scroll-area-thumb"
class="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.Scrollbar>

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import { ScrollArea as ScrollAreaPrimitive } from "bits-ui";
import { Scrollbar } from "./index.js";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
viewportRef = $bindable(null),
class: className,
orientation = "vertical",
scrollbarXClasses = "",
scrollbarYClasses = "",
children,
...restProps
}: WithoutChild<ScrollAreaPrimitive.RootProps> & {
orientation?: "vertical" | "horizontal" | "both" | undefined;
scrollbarXClasses?: string | undefined;
scrollbarYClasses?: string | undefined;
viewportRef?: HTMLElement | null;
} = $props();
</script>
<ScrollAreaPrimitive.Root
bind:ref
data-slot="scroll-area"
class={cn("relative", className)}
{...restProps}
>
<ScrollAreaPrimitive.Viewport
bind:ref={viewportRef}
data-slot="scroll-area-viewport"
class="ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] focus-visible:outline-1 focus-visible:ring-4"
>
{@render children?.()}
</ScrollAreaPrimitive.Viewport>
{#if orientation === "vertical" || orientation === "both"}
<Scrollbar orientation="vertical" class={scrollbarYClasses} />
{/if}
{#if orientation === "horizontal" || orientation === "both"}
<Scrollbar orientation="horizontal" class={scrollbarXClasses} />
{/if}
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>

View File

@@ -0,0 +1,37 @@
import { Select as SelectPrimitive } from "bits-ui";
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";
const Root = SelectPrimitive.Root;
export {
Root,
Group,
Label,
Item,
Content,
Trigger,
Separator,
ScrollDownButton,
ScrollUpButton,
GroupHeading,
//
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,
};

View File

@@ -0,0 +1,42 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import SelectScrollUpButton from "./select-scroll-up-button.svelte";
import SelectScrollDownButton from "./select-scroll-down-button.svelte";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
portalProps,
children,
preventScroll = true,
...restProps
}: WithoutChild<SelectPrimitive.ContentProps> & {
portalProps?: SelectPrimitive.PortalProps;
} = $props();
</script>
<SelectPrimitive.Portal {...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 max-h-(--bits-select-content-available-height) origin-(--bits-select-content-transform-origin) relative z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden 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) min-w-(--bits-select-anchor-width) w-full scroll-my-1 p-1"
)}
>
{@render children?.()}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>

View File

@@ -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>

View File

@@ -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} />

View File

@@ -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 outline-hidden *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 pe-8 ps-2 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}
/>

View File

@@ -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 shadow-xs flex w-fit select-none items-center justify-between gap-2 whitespace-nowrap rounded-md border bg-transparent px-3 py-2 text-sm outline-none transition-[color,box-shadow] 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:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronDownIcon class="size-4 opacity-50" />
</SelectPrimitive.Trigger>

View File

@@ -0,0 +1,7 @@
import Root from "./separator.svelte";
export {
Root,
//
Root as Separator,
};

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import { Separator as SeparatorPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
"data-slot": dataSlot = "separator",
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<SeparatorPrimitive.Root
bind:ref
data-slot={dataSlot}
class={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,36 @@
import { Dialog as SheetPrimitive } from "bits-ui";
import Trigger from "./sheet-trigger.svelte";
import Close from "./sheet-close.svelte";
import Overlay from "./sheet-overlay.svelte";
import Content from "./sheet-content.svelte";
import Header from "./sheet-header.svelte";
import Footer from "./sheet-footer.svelte";
import Title from "./sheet-title.svelte";
import Description from "./sheet-description.svelte";
const Root = SheetPrimitive.Root;
const Portal = SheetPrimitive.Portal;
export {
Root,
Close,
Trigger,
Portal,
Overlay,
Content,
Header,
Footer,
Title,
Description,
//
Root as Sheet,
Close as SheetClose,
Trigger as SheetTrigger,
Portal as SheetPortal,
Overlay as SheetOverlay,
Content as SheetContent,
Header as SheetHeader,
Footer as SheetFooter,
Title as SheetTitle,
Description as SheetDescription,
};

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: SheetPrimitive.CloseProps = $props();
</script>
<SheetPrimitive.Close bind:ref data-slot="sheet-close" {...restProps} />

View File

@@ -0,0 +1,58 @@
<script lang="ts" module>
import { tv, type VariantProps } from "tailwind-variants";
export const sheetVariants = tv({
base: "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
variants: {
side: {
top: "data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
bottom: "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
left: "data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 start-0 h-full w-3/4 border-e sm:max-w-sm",
right: "data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 end-0 h-full w-3/4 border-s sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
});
export type Side = VariantProps<typeof sheetVariants>["side"];
</script>
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
import XIcon from "@lucide/svelte/icons/x";
import type { Snippet } from "svelte";
import SheetOverlay from "./sheet-overlay.svelte";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
side = "right",
portalProps,
children,
...restProps
}: WithoutChildrenOrChild<SheetPrimitive.ContentProps> & {
portalProps?: SheetPrimitive.PortalProps;
side?: Side;
children: Snippet;
} = $props();
</script>
<SheetPrimitive.Portal {...portalProps}>
<SheetOverlay />
<SheetPrimitive.Content
bind:ref
data-slot="sheet-content"
class={cn(sheetVariants({ side }), className)}
{...restProps}
>
{@render children?.()}
<SheetPrimitive.Close
class="ring-offset-background focus-visible:ring-ring rounded-xs focus-visible:outline-hidden absolute end-4 top-4 opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none"
>
<XIcon class="size-4" />
<span class="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPrimitive.Portal>

Some files were not shown because too many files have changed in this diff Show More