Compare commits

..

46 Commits

Author SHA1 Message Date
Gregory Schier
ab785b18a4 Merge branch 'main' into codex-review/pr-457 2026-05-14 07:57:35 -07:00
Gregory Schier
dcfdf077e7 Add staged pre-commit checks 2026-05-13 14:22:43 -07:00
Gregory Schier
bde5a474cc Fix production OXC bundle output 2026-05-10 08:18:32 -07:00
Gregory Schier
21f1dad7a4 Add macOS window controls (upgrade Tauri) (#460) 2026-05-09 06:57:44 -07:00
dependabot[bot]
6dac1265f3 Bump fast-uri from 3.1.0 to 3.1.2 (#459)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
2026-05-09 06:57:28 -07:00
Gregory Schier
77ab293f87 Fix production client bundle exports 2026-05-09 06:15:27 -07:00
Gregory Schier
471a099b9b Refactor model sync scheduling 2026-05-08 12:57:22 -07:00
Gregory Schier
b0b282535f Cargo fmt 2026-05-08 12:03:34 -07:00
Gregory Schier
19ed8c2f0d Clean up update downloads after install
Removes the update downloads cache directory after a successful integrated update install so cached artifacts do not accumulate.\n\nFeedback: https://yaak.app/feedback/posts/updates-cache-directory-cleanup
2026-05-08 12:00:25 -07:00
Gregory Schier
d7e67cf13c Add live git status indicators (#458) 2026-05-08 11:25:39 -07:00
Gregory Schier
1b154ba550 Fix workspace content row sizing 2026-05-08 08:47:34 -07:00
Stijn Brouwers
947e3f2e97 Merge branch 'main' into feature/manual-cookies 2026-05-08 09:50:24 +02:00
Gregory Schier
10559c8f4f Split codebase (#455) 2026-05-07 15:50:10 -07:00
Stijn BROUWERS
8b1f5e807f feat(cookies): Allow manually creating cookies 2026-05-07 19:56:49 +02:00
dependabot[bot]
d2dc719cc6 Bump ip-address and express-rate-limit (#453)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-07 08:35:14 -07:00
Gregory Schier
50f33b45b9 Add domain filter to cookie template function (#452) 2026-05-07 07:06:21 -07:00
Gregory Schier
41fe01adb9 Update generated route tree formatting 2026-05-06 07:51:35 -07:00
Gregory Schier
a200410697 Fix gRPC Any response reflection (#451) 2026-05-06 07:42:35 -07:00
pandeb
4c15a49f8f fix: align HTTP method tags to the left (#450) 2026-05-06 07:13:22 -07:00
Gregory Schier
c901ad4cbd Some cleanup 2026-04-30 09:00:12 -07:00
Gregory Schier
d73d38f418 Surface error when failing importing a binary file 2026-04-29 07:33:21 -07:00
Gregory Schier
b0740770df Increase HTTP/2 response header limit
Set Yaak's reqwest request clients to accept HTTP/2 response header lists up to 1 MiB and wrap configured clients so the sender path cannot accidentally bypass the shared builder.

Feedback: https://yaak.app/feedback/posts/when-response-headers-exceed-a-certain-size-hyper-throws-error
2026-04-28 07:49:47 -07:00
dependabot[bot]
75d94da578 Bump quinn-proto from 0.11.12 to 0.11.14 (#421)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-23 06:43:06 -07:00
dependabot[bot]
79c49d8398 Bump vite-plus from 0.1.11 to 0.1.19 (#447)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-23 06:42:48 -07:00
dependabot[bot]
7c51510616 Bump rustls-webpki from 0.103.10 to 0.103.13 (#446)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-23 06:40:01 -07:00
dependabot[bot]
0b36ee56d2 Bump hono from 4.12.4 to 4.12.14 (#442)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-23 06:38:31 -07:00
dependabot[bot]
2c345fc2ca Bump picomatch from 4.0.3 to 4.0.4 (#436)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-23 06:38:13 -07:00
dependabot[bot]
909580c4a4 Bump rustls-webpki from 0.103.7 to 0.103.10 (#434)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-23 06:38:06 -07:00
dependabot[bot]
e805b225f7 Bump yaml from 2.8.2 to 2.8.3 (#435)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-23 06:38:00 -07:00
dependabot[bot]
0def693b63 Bump follow-redirects from 1.15.11 to 1.16.0 (#441)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-23 06:37:52 -07:00
dependabot[bot]
7109db911a Bump @xmldom/xmldom from 0.9.8 to 0.9.10 (#444)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-23 06:37:45 -07:00
dependabot[bot]
980f26f2f0 Bump uuid from 11.1.0 to 14.0.0 (#445)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-23 06:37:40 -07:00
dependabot[bot]
6b56ec569f Bump @hono/node-server from 1.19.10 to 1.19.13 (#439)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-23 06:37:00 -07:00
dependabot[bot]
36fa7a52fe Bump tar from 0.4.44 to 0.4.45 (#433)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-23 06:36:49 -07:00
Gregory Schier
c95099588f Fix duplicate request snapshotting URL as name (#429) 2026-04-23 06:36:33 -07:00
Nguyễn Huy Hoàng
929f6202a4 fix: bug where selection layer leaves a ghost residual line below wrapped lines after deselecting (#432)
Co-authored-by: hoangnh290 <hoangnh290@viettel.com.vn>
2026-04-23 06:30:18 -07:00
Julien Bourdeau
915af7e3de chore: Delete old .nvmrc (#443) 2026-04-23 06:28:49 -07:00
Gregory Schier
eb9b5b6bb6 Don't override user-defined Content-Type for GraphQL and form-urlencoded requests
The frontend already sets the appropriate Content-Type header when
selecting a body type, so the backend no longer needs to force it.
This allows users to override Content-Type for servers with
non-standard requirements.

Fixes https://yaak.app/feedback/posts/graphql-mode-ignores-manual-content-type-header-override

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 09:43:04 -07:00
Gregory Schier
b4a1c418bb Run oxfmt across repo, add format script and docs
Add .oxfmtignore to skip generated bindings and wasm-pack output.
Add npm format script, update DEVELOPMENT.md for Vite+ toolchain,
and format all non-generated files with oxfmt.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:15:49 -07:00
Gregory Schier
45262edfbd Migrate to Vite+ unified toolchain (#428) 2026-03-13 09:27:56 -07:00
Gregory Schier
aed7bd12ea Add react compiler 2026-03-13 06:49:14 -07:00
Gregory Schier
b5928af1d7 Bump react 2026-03-13 06:47:42 -07:00
Gregory Schier
6cc47bea38 Fixes for wasm 2026-03-13 06:46:27 -07:00
Gregory Schier
b83d9e6765 Vite 8 upgrade 2026-03-13 06:33:20 -07:00
Gregory Schier
c8ba35e268 Gracefully handle plugin init failures (#424)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 16:55:46 -07:00
Gregory Schier
8a330ad1ec Auto-detect WSL and resolve Windows data directory for CLI
When running the CLI in WSL, dirs::data_dir() returns the Linux path
instead of the Windows host path where the Yaak desktop app stores data.
This detects WSL via /proc/version, resolves %APPDATA% through cmd.exe,
and converts it to a WSL mount path using wslpath.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 17:23:00 -07:00
1106 changed files with 26977 additions and 19901 deletions

View File

@@ -1,23 +1,27 @@
# Claude Context: Detaching Tauri from Yaak # Claude Context: Detaching Tauri from Yaak
## Goal ## Goal
Make Yaak runnable as a standalone CLI without Tauri as a dependency. The core Rust crates in `crates/` should be usable independently, while Tauri-specific code lives in `crates-tauri/`. Make Yaak runnable as a standalone CLI without Tauri as a dependency. The core Rust crates in `crates/` should be usable independently, while Tauri-specific code lives in `crates-tauri/`.
## Project Structure ## Project Structure
``` ```
crates/ # Core crates - should NOT depend on Tauri crates/ # Core crates - should NOT depend on Tauri
crates-tauri/ # Tauri-specific crates (yaak-app, yaak-tauri-utils, etc.) crates-tauri/ # Tauri-specific crates (yaak-app-client, yaak-tauri-utils, etc.)
crates-cli/ # CLI crate (yaak-cli) crates-cli/ # CLI crate (yaak-cli)
``` ```
## Completed Work ## Completed Work
### 1. Folder Restructure ### 1. Folder Restructure
- Moved Tauri-dependent app code to `crates-tauri/yaak-app/`
- Moved Tauri-dependent app code to `crates-tauri/yaak-app-client/`
- Created `crates-tauri/yaak-tauri-utils/` for shared Tauri utilities (window traits, api_client, error handling) - Created `crates-tauri/yaak-tauri-utils/` for shared Tauri utilities (window traits, api_client, error handling)
- Created `crates-cli/yaak-cli/` for the standalone CLI - Created `crates-cli/yaak-cli/` for the standalone CLI
### 2. Decoupled Crates (no longer depend on Tauri) ### 2. Decoupled Crates (no longer depend on Tauri)
- **yaak-models**: Uses `init_standalone()` pattern for CLI database access - **yaak-models**: Uses `init_standalone()` pattern for CLI database access
- **yaak-http**: Removed Tauri plugin, HttpConnectionManager initialized in yaak-app setup - **yaak-http**: Removed Tauri plugin, HttpConnectionManager initialized in yaak-app setup
- **yaak-common**: Only contains Tauri-free utilities (serde, platform) - **yaak-common**: Only contains Tauri-free utilities (serde, platform)
@@ -25,6 +29,7 @@ crates-cli/ # CLI crate (yaak-cli)
- **yaak-grpc**: Replaced AppHandle with GrpcConfig struct, uses tokio::process::Command instead of Tauri sidecar - **yaak-grpc**: Replaced AppHandle with GrpcConfig struct, uses tokio::process::Command instead of Tauri sidecar
### 3. CLI Implementation ### 3. CLI Implementation
- Basic CLI at `crates-cli/yaak-cli/src/main.rs` - Basic CLI at `crates-cli/yaak-cli/src/main.rs`
- Commands: workspaces, requests, send (by ID), get (ad-hoc URL), create - Commands: workspaces, requests, send (by ID), get (ad-hoc URL), create
- Uses same database as Tauri app via `yaak_models::init_standalone()` - Uses same database as Tauri app via `yaak_models::init_standalone()`
@@ -32,31 +37,36 @@ crates-cli/ # CLI crate (yaak-cli)
## Remaining Work ## Remaining Work
### Crates Still Depending on Tauri (in `crates/`) ### Crates Still Depending on Tauri (in `crates/`)
1. **yaak-git** (3 files) - Moderate complexity 1. **yaak-git** (3 files) - Moderate complexity
2. **yaak-plugins** (13 files) - **Hardest** - deeply integrated with Tauri for plugin-to-window communication 2. **yaak-plugins** (13 files) - **Hardest** - deeply integrated with Tauri for plugin-to-window communication
3. **yaak-sync** (4 files) - Moderate complexity 3. **yaak-sync** (4 files) - Moderate complexity
4. **yaak-ws** (5 files) - Moderate complexity 4. **yaak-ws** (5 files) - Moderate complexity
### Pattern for Decoupling ### Pattern for Decoupling
1. Remove Tauri plugin `init()` function from the crate 1. Remove Tauri plugin `init()` function from the crate
2. Move commands to `yaak-app/src/commands.rs` or keep inline in `lib.rs` 2. Move commands to `yaak-app/src/commands.rs` or keep inline in `lib.rs`
3. Move extension traits (e.g., `SomethingManagerExt`) to yaak-app or yaak-tauri-utils 3. Move extension traits (e.g., `SomethingManagerExt`) to yaak-app or yaak-tauri-utils
4. Initialize managers in yaak-app's `.setup()` block 4. Initialize managers in yaak-app's `.setup()` block
5. Remove `tauri` from Cargo.toml dependencies 5. Remove `tauri` from Cargo.toml dependencies
6. Update `crates-tauri/yaak-app/capabilities/default.json` to remove the plugin permission 6. Update `crates-tauri/yaak-app-client/capabilities/default.json` to remove the plugin permission
7. Replace `tauri::async_runtime::block_on` with `tokio::runtime::Handle::current().block_on()` 7. Replace `tauri::async_runtime::block_on` with `tokio::runtime::Handle::current().block_on()`
## Key Files ## Key Files
- `crates-tauri/yaak-app/src/lib.rs` - Main Tauri app, setup block initializes managers
- `crates-tauri/yaak-app/src/commands.rs` - Migrated Tauri commands - `crates-tauri/yaak-app-client/src/lib.rs` - Main Tauri app, setup block initializes managers
- `crates-tauri/yaak-app/src/models_ext.rs` - Database plugin and extension traits - `crates-tauri/yaak-app-client/src/commands.rs` - Migrated Tauri commands
- `crates-tauri/yaak-app-client/src/models_ext.rs` - Database plugin and extension traits
- `crates-tauri/yaak-tauri-utils/src/window.rs` - WorkspaceWindowTrait for window state - `crates-tauri/yaak-tauri-utils/src/window.rs` - WorkspaceWindowTrait for window state
- `crates/yaak-models/src/lib.rs` - Contains `init_standalone()` for CLI usage - `crates/yaak-models/src/lib.rs` - Contains `init_standalone()` for CLI usage
## Git Branch ## Git Branch
Working on `detach-tauri` branch. Working on `detach-tauri` branch.
## Recent Commits ## Recent Commits
``` ```
c40cff40 Remove Tauri dependencies from yaak-crypto and yaak-grpc c40cff40 Remove Tauri dependencies from yaak-crypto and yaak-grpc
df495f1d Move Tauri utilities from yaak-common to yaak-tauri-utils df495f1d Move Tauri utilities from yaak-common to yaak-tauri-utils
@@ -67,6 +77,7 @@ e718a5f1 Refactor models_ext to use init_standalone from yaak-models
``` ```
## Testing ## Testing
- Run `cargo check -p <crate>` to verify a crate builds without Tauri - Run `cargo check -p <crate>` to verify a crate builds without Tauri
- Run `npm run app-dev` to test the Tauri app still works - Run `npm run client:dev` to test the Tauri app still works
- Run `cargo run -p yaak-cli -- --help` to test the CLI - Run `cargo run -p yaak-cli -- --help` to test the CLI

4
.gitattributes vendored
View File

@@ -1,5 +1,5 @@
crates-tauri/yaak-app/vendored/**/* linguist-generated=true crates-tauri/yaak-app-client/vendored/**/* linguist-generated=true
crates-tauri/yaak-app/gen/schemas/**/* linguist-generated=true crates-tauri/yaak-app-client/gen/schemas/**/* linguist-generated=true
**/bindings/* linguist-generated=true **/bindings/* linguist-generated=true
crates/yaak-templates/pkg/* linguist-generated=true crates/yaak-templates/pkg/* linguist-generated=true

View File

@@ -1,10 +1,9 @@
--- ---
name: Bug report name: Bug report
about: Create a report to help us improve about: Create a report to help us improve
title: '' title: ""
labels: '' labels: ""
assignees: '' assignees: ""
--- ---
**Describe the bug** **Describe the bug**
@@ -12,6 +11,7 @@ A clear and concise description of what the bug is.
**To Reproduce** **To Reproduce**
Steps to reproduce the behavior: Steps to reproduce the behavior:
1. Go to '...' 1. Go to '...'
2. Click on '....' 2. Click on '....'
3. Scroll down to '....' 3. Scroll down to '....'
@@ -24,15 +24,17 @@ A clear and concise description of what you expected to happen.
If applicable, add screenshots to help explain your problem. If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):** **Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari] - OS: [e.g. iOS]
- Version [e.g. 22] - Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):** **Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1] - Device: [e.g. iPhone6]
- Browser [e.g. stock browser, safari] - OS: [e.g. iOS8.1]
- Version [e.g. 22] - Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context** **Additional context**
Add any other context about the problem here. Add any other context about the problem here.

View File

@@ -11,6 +11,7 @@
- [ ] I added or updated tests when reasonable. - [ ] I added or updated tests when reasonable.
Approved feedback item (required if not a bug fix or small-scope improvement): Approved feedback item (required if not a bug fix or small-scope improvement):
<!-- https://yaak.app/feedback/... --> <!-- https://yaak.app/feedback/... -->
## Related ## Related

View File

@@ -14,17 +14,20 @@ jobs:
runs-on: macos-latest runs-on: macos-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: voidzero-dev/setup-vp@v1
with:
node-version: "24"
cache: true
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
with: with:
shared-key: ci shared-key: ci
cache-on-failure: true cache-on-failure: true
- run: npm ci - run: vp install
- run: npm run bootstrap - run: npm run bootstrap
- run: npm run lint - run: npm run lint
- name: Run JS Tests - name: Run JS Tests
run: npm test run: vp test
- name: Run Rust Tests - name: Run Rust Tests
run: cargo test --all run: cargo test --all

View File

@@ -47,4 +47,3 @@ jobs:
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options # or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)' # claude_args: '--allowed-tools Bash(gh pr:*)'

View File

@@ -50,8 +50,11 @@ jobs:
- name: Checkout yaakapp/app - name: Checkout yaakapp/app
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup Node - name: Setup Vite+
uses: actions/setup-node@v4 uses: voidzero-dev/setup-vp@v1
with:
node-version: "24"
cache: true
- name: install Rust stable - name: install Rust stable
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
@@ -87,13 +90,13 @@ jobs:
echo $dir >> $env:GITHUB_PATH echo $dir >> $env:GITHUB_PATH
& $exe --version & $exe --version
- run: npm ci - run: vp install
- run: npm run bootstrap - run: npm run bootstrap
env: env:
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }} YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
- run: npm run lint - run: npm run lint
- name: Run JS Tests - name: Run JS Tests
run: npm test run: vp test
- name: Run Rust Tests - name: Run Rust Tests
run: cargo test --all --exclude yaak-cli run: cargo test --all --exclude yaak-cli
@@ -122,8 +125,8 @@ jobs:
security list-keychain -d user -s $KEYCHAIN_PATH security list-keychain -d user -s $KEYCHAIN_PATH
# Sign vendored binaries with hardened runtime and their specific entitlements # Sign vendored binaries with hardened runtime and their specific entitlements
codesign --force --options runtime --entitlements crates-tauri/yaak-app/macos/entitlements.yaakprotoc.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app/vendored/protoc/yaakprotoc || true codesign --force --options runtime --entitlements crates-tauri/yaak-app-client/macos/entitlements.yaakprotoc.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app-client/vendored/protoc/yaakprotoc || true
codesign --force --options runtime --entitlements crates-tauri/yaak-app/macos/entitlements.yaaknode.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app/vendored/node/yaaknode || true codesign --force --options runtime --entitlements crates-tauri/yaak-app-client/macos/entitlements.yaaknode.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app-client/vendored/node/yaaknode || true
- uses: tauri-apps/tauri-action@v0 - uses: tauri-apps/tauri-action@v0
env: env:
@@ -152,7 +155,8 @@ jobs:
releaseBody: "[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)" releaseBody: "[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)"
releaseDraft: true releaseDraft: true
prerelease: true prerelease: true
args: "${{ matrix.args }} --config ./crates-tauri/yaak-app/tauri.release.conf.json" projectPath: ./crates-tauri/yaak-app-client
args: "${{ matrix.args }} --config ./tauri.release.conf.json"
# Build a per-machine NSIS installer for enterprise deployment (PDQ, SCCM, Intune) # Build a per-machine NSIS installer for enterprise deployment (PDQ, SCCM, Intune)
- name: Build and upload machine-wide installer (Windows only) - name: Build and upload machine-wide installer (Windows only)
@@ -168,7 +172,9 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
run: | run: |
Get-ChildItem -Recurse -Path target -File -Filter "*.exe.sig" | Remove-Item -Force Get-ChildItem -Recurse -Path target -File -Filter "*.exe.sig" | Remove-Item -Force
npx tauri bundle ${{ matrix.args }} --bundles nsis --config ./crates-tauri/yaak-app/tauri.release.conf.json --config '{"bundle":{"createUpdaterArtifacts":true,"windows":{"nsis":{"installMode":"perMachine"}}}}' Push-Location crates-tauri/yaak-app-client
npx tauri bundle ${{ matrix.args }} --bundles nsis --config ./tauri.release.conf.json --config '{"bundle":{"createUpdaterArtifacts":true,"windows":{"nsis":{"installMode":"perMachine"}}}}'
Pop-Location
$setup = Get-ChildItem -Recurse -Path target -Filter "*setup*.exe" | Select-Object -First 1 $setup = Get-ChildItem -Recurse -Path target -Filter "*setup*.exe" | Select-Object -First 1
$setupSig = "$($setup.FullName).sig" $setupSig = "$($setup.FullName).sig"
$dest = $setup.FullName -replace '-setup\.exe$', '-setup-machine.exe' $dest = $setup.FullName -replace '-setup\.exe$', '-setup-machine.exe'

View File

@@ -45,8 +45,8 @@ jobs:
with: with:
name: vendored-assets name: vendored-assets
path: | path: |
crates-tauri/yaak-app/vendored/plugin-runtime/index.cjs crates-tauri/yaak-app-client/vendored/plugin-runtime/index.cjs
crates-tauri/yaak-app/vendored/plugins crates-tauri/yaak-app-client/vendored/plugins
if-no-files-found: error if-no-files-found: error
build-binaries: build-binaries:
@@ -107,7 +107,7 @@ jobs:
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
name: vendored-assets name: vendored-assets
path: crates-tauri/yaak-app/vendored path: crates-tauri/yaak-app-client/vendored
- name: Set CLI build version - name: Set CLI build version
shell: bash shell: bash

View File

@@ -16,23 +16,23 @@ jobs:
uses: JamesIves/github-sponsors-readme-action@v1 uses: JamesIves/github-sponsors-readme-action@v1
with: with:
token: ${{ secrets.SPONSORS_PAT }} token: ${{ secrets.SPONSORS_PAT }}
file: 'README.md' file: "README.md"
maximum: 1999 maximum: 1999
template: '<a href="https://github.com/{{{ login }}}"><img src="{{{ avatarUrl }}}" width="50px" alt="User avatar: {{{ login }}}" /></a>&nbsp;&nbsp;' template: '<a href="https://github.com/{{{ login }}}"><img src="{{{ avatarUrl }}}" width="50px" alt="User avatar: {{{ login }}}" /></a>&nbsp;&nbsp;'
active-only: false active-only: false
include-private: true include-private: true
marker: 'sponsors-base' marker: "sponsors-base"
- name: Generate Sponsors - name: Generate Sponsors
uses: JamesIves/github-sponsors-readme-action@v1 uses: JamesIves/github-sponsors-readme-action@v1
with: with:
token: ${{ secrets.SPONSORS_PAT }} token: ${{ secrets.SPONSORS_PAT }}
file: 'README.md' file: "README.md"
minimum: 2000 minimum: 2000
template: '<a href="https://github.com/{{{ login }}}"><img src="{{{ avatarUrl }}}" width="80px" alt="User avatar: {{{ login }}}" /></a>&nbsp;&nbsp;' template: '<a href="https://github.com/{{{ login }}}"><img src="{{{ avatarUrl }}}" width="80px" alt="User avatar: {{{ login }}}" /></a>&nbsp;&nbsp;'
active-only: false active-only: false
include-private: true include-private: true
marker: 'sponsors-premium' marker: "sponsors-premium"
# ⚠️ Note: You can use any deployment step here to automatically push the README # ⚠️ Note: You can use any deployment step here to automatically push the README
# changes back to your branch. # changes back to your branch.
@@ -41,4 +41,4 @@ jobs:
with: with:
branch: main branch: main
force: false force: false
folder: '.' folder: "."

3
.gitignore vendored
View File

@@ -39,7 +39,8 @@ codebook.toml
target target
# Per-worktree Tauri config (generated by post-checkout hook) # Per-worktree Tauri config (generated by post-checkout hook)
crates-tauri/yaak-app/tauri.worktree.conf.json crates-tauri/yaak-app-client/tauri.worktree.conf.json
crates-tauri/yaak-app-proxy/tauri.worktree.conf.json
# Tauri auto-generated permission files # Tauri auto-generated permission files
**/permissions/autogenerated **/permissions/autogenerated

1
.node-version Normal file
View File

@@ -0,0 +1 @@
24.14.0

2
.npmrc Normal file
View File

@@ -0,0 +1,2 @@
# vite-plugin-wasm has not yet declared Vite 8 in its peerDependencies
legacy-peer-deps=true

1
.nvmrc
View File

@@ -1 +0,0 @@
20

3
.oxfmtignore Normal file
View File

@@ -0,0 +1,3 @@
**/bindings/**
**/routeTree.gen.ts
crates/yaak-templates/pkg/**

8
.oxfmtrc.json Normal file
View File

@@ -0,0 +1,8 @@
{
"printWidth": 100,
"ignorePatterns": [
"**/bindings/**",
"crates/yaak-templates/pkg/**",
"apps/yaak-client/routeTree.gen.ts"
]
}

2
.vite-hooks/pre-commit Normal file
View File

@@ -0,0 +1,2 @@
vp lint
vp staged

View File

@@ -1,3 +1,7 @@
{ {
"recommendations": ["biomejs.biome", "rust-lang.rust-analyzer", "bradlc.vscode-tailwindcss"] "recommendations": [
"rust-lang.rust-analyzer",
"bradlc.vscode-tailwindcss",
"VoidZero.vite-plus-extension-pack"
]
} }

View File

@@ -1,6 +1,8 @@
{ {
"editor.defaultFormatter": "biomejs.biome", "editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true, "editor.formatOnSave": true,
"biome.enabled": true, "editor.formatOnSaveMode": "file",
"biome.lint.format.enable": true "editor.codeActionsOnSave": {
"source.fixAll.oxc": "explicit"
}
} }

1173
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,30 +1,38 @@
[workspace] [workspace]
resolver = "2" resolver = "2"
members = [ members = [
"crates/yaak", "crates/yaak",
# Shared crates (no Tauri dependency) # Common/foundation crates
"crates/yaak-core", "crates/common/yaak-database",
"crates/yaak-common", "crates/common/yaak-rpc",
"crates/yaak-crypto", # Shared crates (no Tauri dependency)
"crates/yaak-git", "crates/yaak-core",
"crates/yaak-grpc", "crates/yaak-common",
"crates/yaak-http", "crates/yaak-crypto",
"crates/yaak-models", "crates/yaak-git",
"crates/yaak-plugins", "crates/yaak-grpc",
"crates/yaak-sse", "crates/yaak-http",
"crates/yaak-sync", "crates/yaak-models",
"crates/yaak-templates", "crates/yaak-plugins",
"crates/yaak-tls", "crates/yaak-sse",
"crates/yaak-ws", "crates/yaak-sync",
"crates/yaak-api", "crates/yaak-templates",
# CLI crates "crates/yaak-tls",
"crates-cli/yaak-cli", "crates/yaak-ws",
# Tauri-specific crates "crates/yaak-api",
"crates-tauri/yaak-app", "crates/yaak-proxy",
"crates-tauri/yaak-fonts", # Proxy-specific crates
"crates-tauri/yaak-license", "crates-proxy/yaak-proxy-lib",
"crates-tauri/yaak-mac-window", # CLI crates
"crates-tauri/yaak-tauri-utils", "crates-cli/yaak-cli",
# Tauri-specific crates
"crates-tauri/yaak-app-client",
"crates-tauri/yaak-app-proxy",
"crates-tauri/yaak-fonts",
"crates-tauri/yaak-license",
"crates-tauri/yaak-mac-window",
"crates-tauri/yaak-tauri-utils",
"crates-tauri/yaak-window",
] ]
[workspace.dependencies] [workspace.dependencies]
@@ -39,14 +47,18 @@ schemars = { version = "0.8.22", features = ["chrono"] }
serde = "1.0.228" serde = "1.0.228"
serde_json = "1.0.145" serde_json = "1.0.145"
sha2 = "0.10.9" sha2 = "0.10.9"
tauri = "2.9.5" tauri = "2.11.1"
tauri-plugin = "2.5.2" tauri-plugin = "2.6.1"
tauri-plugin-dialog = "2.4.2" tauri-plugin-dialog = "2.7.1"
tauri-plugin-shell = "2.3.3" tauri-plugin-shell = "2.3.5"
thiserror = "2.0.17" thiserror = "2.0.17"
tokio = "1.48.0" tokio = "1.48.0"
ts-rs = "11.1.0" ts-rs = "11.1.0"
# Internal crates - common/foundation
yaak-database = { path = "crates/common/yaak-database" }
yaak-rpc = { path = "crates/common/yaak-rpc" }
# Internal crates - shared # Internal crates - shared
yaak-core = { path = "crates/yaak-core" } yaak-core = { path = "crates/yaak-core" }
yaak = { path = "crates/yaak" } yaak = { path = "crates/yaak" }
@@ -63,12 +75,17 @@ yaak-templates = { path = "crates/yaak-templates" }
yaak-tls = { path = "crates/yaak-tls" } yaak-tls = { path = "crates/yaak-tls" }
yaak-ws = { path = "crates/yaak-ws" } yaak-ws = { path = "crates/yaak-ws" }
yaak-api = { path = "crates/yaak-api" } yaak-api = { path = "crates/yaak-api" }
yaak-proxy = { path = "crates/yaak-proxy" }
# Internal crates - proxy
yaak-proxy-lib = { path = "crates-proxy/yaak-proxy-lib" }
# Internal crates - Tauri-specific # Internal crates - Tauri-specific
yaak-fonts = { path = "crates-tauri/yaak-fonts" } yaak-fonts = { path = "crates-tauri/yaak-fonts" }
yaak-license = { path = "crates-tauri/yaak-license" } yaak-license = { path = "crates-tauri/yaak-license" }
yaak-mac-window = { path = "crates-tauri/yaak-mac-window" } yaak-mac-window = { path = "crates-tauri/yaak-mac-window" }
yaak-tauri-utils = { path = "crates-tauri/yaak-tauri-utils" } yaak-tauri-utils = { path = "crates-tauri/yaak-tauri-utils" }
yaak-window = { path = "crates-tauri/yaak-window" }
[profile.release] [profile.release]
strip = false strip = false

View File

@@ -11,14 +11,16 @@ begin.
Make sure you have the following tools installed: Make sure you have the following tools installed:
- [Node.js](https://nodejs.org/en/download/package-manager) - [Node.js](https://nodejs.org/en/download/package-manager) (v24+)
- [Rust](https://www.rust-lang.org/tools/install) - [Rust](https://www.rust-lang.org/tools/install)
- [Vite+](https://vite.dev/guide/vite-plus) (`vp` CLI)
Check the installations with the following commands: Check the installations with the following commands:
```shell ```shell
node -v node -v
npm -v npm -v
vp --version
rustc --version rustc --version
``` ```
@@ -61,9 +63,9 @@ _Note: For safety, development builds use a separate database location from prod
lezer-generator components/core/Editor/<LANG>/<LANG>.grammar > components/core/Editor/<LANG>/<LANG>.ts lezer-generator components/core/Editor/<LANG>/<LANG>.grammar > components/core/Editor/<LANG>/<LANG>.ts
``` ```
## Linting & Formatting ## Linting and Formatting
This repo uses Biome for linting and formatting (replacing ESLint + Prettier). This repo uses [Vite+](https://vite.dev/guide/vite-plus) for linting (oxlint) and formatting (oxfmt).
- Lint the entire repo: - Lint the entire repo:
@@ -71,12 +73,6 @@ This repo uses Biome for linting and formatting (replacing ESLint + Prettier).
npm run lint npm run lint
``` ```
- Auto-fix lint issues where possible:
```sh
npm run lint:fix
```
- Format code: - Format code:
```sh ```sh
@@ -84,5 +80,7 @@ npm run format
``` ```
Notes: Notes:
- Many workspace packages also expose the same scripts (`lint`, `lint:fix`, and `format`).
- TypeScript type-checking still runs separately via `tsc --noEmit` in relevant packages. - A pre-commit hook runs `vp lint` automatically on commit.
- Some workspace packages also run `tsc --noEmit` for type-checking.
- VS Code users should install the recommended extensions for format-on-save support.

View File

@@ -1,6 +1,6 @@
<p align="center"> <p align="center">
<a href="https://github.com/JamesIves/github-sponsors-readme-action"> <a href="https://github.com/JamesIves/github-sponsors-readme-action">
<img width="200px" src="https://github.com/mountain-loop/yaak/raw/main/crates-tauri/yaak-app/icons/icon.png"> <img width="200px" src="https://github.com/mountain-loop/yaak/raw/main/crates-tauri/yaak-app-client/icons/icon.png">
</a> </a>
</p> </p>
@@ -16,8 +16,6 @@
</p> </p>
<br> <br>
<p align="center"> <p align="center">
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https:&#x2F;&#x2F;github.com&#x2F;MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a>&nbsp;&nbsp;<a href="https://github.com/dharsanb"><img src="https:&#x2F;&#x2F;github.com&#x2F;dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a>&nbsp;&nbsp;<a href="https://github.com/railwayapp"><img src="https:&#x2F;&#x2F;github.com&#x2F;railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a>&nbsp;&nbsp;<a href="https://github.com/caseyamcl"><img src="https:&#x2F;&#x2F;github.com&#x2F;caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a>&nbsp;&nbsp;<a href="https://github.com/bytebase"><img src="https:&#x2F;&#x2F;github.com&#x2F;bytebase.png" width="80px" alt="User avatar: bytebase" /></a>&nbsp;&nbsp;<a href="https://github.com/"><img src="https:&#x2F;&#x2F;raw.githubusercontent.com&#x2F;JamesIves&#x2F;github-sponsors-readme-action&#x2F;dev&#x2F;.github&#x2F;assets&#x2F;placeholder.png" width="80px" alt="User avatar: " /></a>&nbsp;&nbsp;<!-- sponsors-premium --> <!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https:&#x2F;&#x2F;github.com&#x2F;MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a>&nbsp;&nbsp;<a href="https://github.com/dharsanb"><img src="https:&#x2F;&#x2F;github.com&#x2F;dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a>&nbsp;&nbsp;<a href="https://github.com/railwayapp"><img src="https:&#x2F;&#x2F;github.com&#x2F;railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a>&nbsp;&nbsp;<a href="https://github.com/caseyamcl"><img src="https:&#x2F;&#x2F;github.com&#x2F;caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a>&nbsp;&nbsp;<a href="https://github.com/bytebase"><img src="https:&#x2F;&#x2F;github.com&#x2F;bytebase.png" width="80px" alt="User avatar: bytebase" /></a>&nbsp;&nbsp;<a href="https://github.com/"><img src="https:&#x2F;&#x2F;raw.githubusercontent.com&#x2F;JamesIves&#x2F;github-sponsors-readme-action&#x2F;dev&#x2F;.github&#x2F;assets&#x2F;placeholder.png" width="80px" alt="User avatar: " /></a>&nbsp;&nbsp;<!-- sponsors-premium -->
</p> </p>
@@ -27,13 +25,11 @@
![Yaak API Client](https://yaak.app/static/screenshot.png) ![Yaak API Client](https://yaak.app/static/screenshot.png)
## Features ## Features
Yaak is an offline-first API client designed to stay out of your way while giving you everything you need when you need it. Yaak is an offline-first API client designed to stay out of your way while giving you everything you need when you need it.
Built with [Tauri](https://tauri.app), Rust, and React, its fast, lightweight, and private. No telemetry, no VC funding, and no cloud lock-in. Built with [Tauri](https://tauri.app), Rust, and React, its fast, lightweight, and private. No telemetry, no VC funding, and no cloud lock-in.
### 🌐 Work with any API ### 🌐 Work with any API
- Import collections from Postman, Insomnia, OpenAPI, Swagger, or Curl. - Import collections from Postman, Insomnia, OpenAPI, Swagger, or Curl.
@@ -41,21 +37,23 @@ Built with [Tauri](https://tauri.app), Rust, and React, its fast, lightweight
- Filter and inspect responses with JSONPath or XPath. - Filter and inspect responses with JSONPath or XPath.
### 🔐 Stay secure ### 🔐 Stay secure
- Use OAuth 2.0, JWT, Basic Auth, or custom plugins for authentication. - Use OAuth 2.0, JWT, Basic Auth, or custom plugins for authentication.
- Secure sensitive values with encrypted secrets. - Secure sensitive values with encrypted secrets.
- Store secrets in your OS keychain. - Store secrets in your OS keychain.
### ☁️ Organize & collaborate ### ☁️ Organize & collaborate
- Group requests into workspaces and nested folders. - Group requests into workspaces and nested folders.
- Use environment variables to switch between dev, staging, and prod. - Use environment variables to switch between dev, staging, and prod.
- Mirror workspaces to your filesystem for versioning in Git or syncing with Dropbox. - Mirror workspaces to your filesystem for versioning in Git or syncing with Dropbox.
### 🧩 Extend & customize ### 🧩 Extend & customize
- Insert dynamic values like UUIDs or timestamps with template tags. - Insert dynamic values like UUIDs or timestamps with template tags.
- Pick from built-in themes or build your own. - Pick from built-in themes or build your own.
- Create plugins to extend authentication, template tags, or the UI. - Create plugins to extend authentication, template tags, or the UI.
## Contribution Policy ## Contribution Policy
> [!IMPORTANT] > [!IMPORTANT]

View File

@@ -1,9 +1,9 @@
import { createWorkspaceModel, type Folder, modelTypeLabel } from '@yaakapp-internal/models'; import { createWorkspaceModel, type Folder, modelTypeLabel } from "@yaakapp-internal/models";
import { applySync, calculateSync } from '@yaakapp-internal/sync'; import { applySync, calculateSync } from "@yaakapp-internal/sync";
import { Banner } from '../components/core/Banner'; import { Button } from "../components/core/Button";
import { Button } from '../components/core/Button';
import { InlineCode } from '../components/core/InlineCode';
import { import {
Banner,
InlineCode,
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
@@ -11,21 +11,21 @@ import {
TableHeaderCell, TableHeaderCell,
TableRow, TableRow,
TruncatedWideTableCell, TruncatedWideTableCell,
} from '../components/core/Table'; } from "@yaakapp-internal/ui";
import { activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace'; import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
import { createFastMutation } from '../hooks/useFastMutation'; import { createFastMutation } from "../hooks/useFastMutation";
import { showDialog } from '../lib/dialog'; import { showDialog } from "../lib/dialog";
import { jotaiStore } from '../lib/jotai'; import { jotaiStore } from "../lib/jotai";
import { pluralizeCount } from '../lib/pluralize'; import { pluralizeCount } from "../lib/pluralize";
import { showPrompt } from '../lib/prompt'; import { showPrompt } from "../lib/prompt";
import { resolvedModelNameWithFolders } from '../lib/resolvedModelName'; import { resolvedModelNameWithFolders } from "../lib/resolvedModelName";
export const createFolder = createFastMutation< export const createFolder = createFastMutation<
string | null, string | null,
void, void,
Partial<Pick<Folder, 'name' | 'sortPriority' | 'folderId'>> Partial<Pick<Folder, "name" | "sortPriority" | "folderId">>
>({ >({
mutationKey: ['create_folder'], mutationKey: ["create_folder"],
mutationFn: async (patch) => { mutationFn: async (patch) => {
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom); const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
if (workspaceId == null) { if (workspaceId == null) {
@@ -34,12 +34,12 @@ export const createFolder = createFastMutation<
if (!patch.name) { if (!patch.name) {
const name = await showPrompt({ const name = await showPrompt({
id: 'new-folder', id: "new-folder",
label: 'Name', label: "Name",
defaultValue: 'Folder', defaultValue: "Folder",
title: 'New Folder', title: "New Folder",
confirmText: 'Create', confirmText: "Create",
placeholder: 'Name', placeholder: "Name",
}); });
if (name == null) return null; if (name == null) return null;
@@ -47,7 +47,7 @@ export const createFolder = createFastMutation<
} }
patch.sortPriority = patch.sortPriority || -Date.now(); patch.sortPriority = patch.sortPriority || -Date.now();
const id = await createWorkspaceModel({ model: 'folder', workspaceId, ...patch }); const id = await createWorkspaceModel({ model: "folder", workspaceId, ...patch });
return id; return id;
}, },
}); });
@@ -61,12 +61,12 @@ export const syncWorkspace = createFastMutation<
mutationFn: async ({ workspaceId, syncDir, force }) => { mutationFn: async ({ workspaceId, syncDir, force }) => {
const ops = (await calculateSync(workspaceId, syncDir)) ?? []; const ops = (await calculateSync(workspaceId, syncDir)) ?? [];
if (ops.length === 0) { if (ops.length === 0) {
console.log('Nothing to sync', workspaceId, syncDir); console.log("Nothing to sync", workspaceId, syncDir);
return; return;
} }
console.log('Syncing workspace', workspaceId, syncDir, ops); console.log("Syncing workspace", workspaceId, syncDir, ops);
const dbOps = ops.filter((o) => o.type.startsWith('db')); const dbOps = ops.filter((o) => o.type.startsWith("db"));
if (dbOps.length === 0) { if (dbOps.length === 0) {
await applySync(workspaceId, syncDir, ops); await applySync(workspaceId, syncDir, ops);
@@ -74,10 +74,10 @@ export const syncWorkspace = createFastMutation<
} }
const isDeletingWorkspace = ops.some( const isDeletingWorkspace = ops.some(
(o) => o.type === 'dbDelete' && o.model.model === 'workspace', (o) => o.type === "dbDelete" && o.model.model === "workspace",
); );
console.log('Directory changes detected', { dbOps, ops }); console.log("Directory changes detected", { dbOps, ops });
if (force) { if (force) {
await applySync(workspaceId, syncDir, ops); await applySync(workspaceId, syncDir, ops);
@@ -85,9 +85,9 @@ export const syncWorkspace = createFastMutation<
} }
showDialog({ showDialog({
id: 'commit-sync', id: "commit-sync",
title: 'Changes Detected', title: "Changes Detected",
size: 'md', size: "md",
render: ({ hide }) => ( render: ({ hide }) => (
<form <form
className="h-full grid grid-rows-[auto_auto_minmax(0,1fr)_auto] gap-3" className="h-full grid grid-rows-[auto_auto_minmax(0,1fr)_auto] gap-3"
@@ -105,8 +105,8 @@ export const syncWorkspace = createFastMutation<
<span /> <span />
)} )}
<p> <p>
{pluralizeCount('file', dbOps.length)} in the directory{' '} {pluralizeCount("file", dbOps.length)} in the directory{" "}
{dbOps.length === 1 ? 'has' : 'have'} changed. Do you want to update your workspace? {dbOps.length === 1 ? "has" : "have"} changed. Do you want to update your workspace?
</p> </p>
<Table scrollable className="my-4"> <Table scrollable className="my-4">
<TableHead> <TableHead>
@@ -123,27 +123,27 @@ export const syncWorkspace = createFastMutation<
let color: string; let color: string;
let model: string; let model: string;
if (op.type === 'dbCreate') { if (op.type === "dbCreate") {
label = 'create'; label = "create";
name = resolvedModelNameWithFolders(op.fs.model); name = resolvedModelNameWithFolders(op.fs.model);
color = 'text-success'; color = "text-success";
model = modelTypeLabel(op.fs.model); model = modelTypeLabel(op.fs.model);
} else if (op.type === 'dbUpdate') { } else if (op.type === "dbUpdate") {
label = 'update'; label = "update";
name = resolvedModelNameWithFolders(op.fs.model); name = resolvedModelNameWithFolders(op.fs.model);
color = 'text-info'; color = "text-info";
model = modelTypeLabel(op.fs.model); model = modelTypeLabel(op.fs.model);
} else if (op.type === 'dbDelete') { } else if (op.type === "dbDelete") {
label = 'delete'; label = "delete";
name = resolvedModelNameWithFolders(op.model); name = resolvedModelNameWithFolders(op.model);
color = 'text-danger'; color = "text-danger";
model = modelTypeLabel(op.model); model = modelTypeLabel(op.model);
} else { } else {
return null; return null;
} }
return ( return (
// biome-ignore lint/suspicious/noArrayIndexKey: none // oxlint-disable-next-line react/no-array-index-key
<TableRow key={i}> <TableRow key={i}>
<TableCell className="text-text-subtle">{model}</TableCell> <TableCell className="text-text-subtle">{model}</TableCell>
<TruncatedWideTableCell>{name}</TruncatedWideTableCell> <TruncatedWideTableCell>{name}</TruncatedWideTableCell>

View File

@@ -1,33 +1,33 @@
import type { Environment } from '@yaakapp-internal/models'; import type { Environment } from "@yaakapp-internal/models";
import { CreateEnvironmentDialog } from '../components/CreateEnvironmentDialog'; import { CreateEnvironmentDialog } from "../components/CreateEnvironmentDialog";
import { activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace'; import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
import { createFastMutation } from '../hooks/useFastMutation'; import { createFastMutation } from "../hooks/useFastMutation";
import { showDialog } from '../lib/dialog'; import { showDialog } from "../lib/dialog";
import { jotaiStore } from '../lib/jotai'; import { jotaiStore } from "../lib/jotai";
import { setWorkspaceSearchParams } from '../lib/setWorkspaceSearchParams'; import { setWorkspaceSearchParams } from "../lib/setWorkspaceSearchParams";
export const createSubEnvironmentAndActivate = createFastMutation< export const createSubEnvironmentAndActivate = createFastMutation<
string | null, string | null,
unknown, unknown,
Environment | null Environment | null
>({ >({
mutationKey: ['create_environment'], mutationKey: ["create_environment"],
mutationFn: async (baseEnvironment) => { mutationFn: async (baseEnvironment) => {
if (baseEnvironment == null) { if (baseEnvironment == null) {
throw new Error('No base environment passed'); throw new Error("No base environment passed");
} }
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom); const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
if (workspaceId == null) { if (workspaceId == null) {
throw new Error('Cannot create environment when no active workspace'); throw new Error("Cannot create environment when no active workspace");
} }
return new Promise<string | null>((resolve) => { return new Promise<string | null>((resolve) => {
showDialog({ showDialog({
id: 'new-environment', id: "new-environment",
title: 'New Environment', title: "New Environment",
description: 'Create multiple environments with different sets of variables', description: "Create multiple environments with different sets of variables",
size: 'sm', size: "sm",
onClose: () => resolve(null), onClose: () => resolve(null),
render: ({ hide }) => ( render: ({ hide }) => (
<CreateEnvironmentDialog <CreateEnvironmentDialog

View File

@@ -1,8 +1,8 @@
import type { WebsocketRequest } from '@yaakapp-internal/models'; import type { WebsocketRequest } from "@yaakapp-internal/models";
import { deleteWebsocketConnections as cmdDeleteWebsocketConnections } from '@yaakapp-internal/ws'; import { deleteWebsocketConnections as cmdDeleteWebsocketConnections } from "@yaakapp-internal/ws";
import { createFastMutation } from '../hooks/useFastMutation'; import { createFastMutation } from "../hooks/useFastMutation";
export const deleteWebsocketConnections = createFastMutation({ export const deleteWebsocketConnections = createFastMutation({
mutationKey: ['delete_websocket_connections'], mutationKey: ["delete_websocket_connections"],
mutationFn: async (request: WebsocketRequest) => cmdDeleteWebsocketConnections(request.id), mutationFn: async (request: WebsocketRequest) => cmdDeleteWebsocketConnections(request.id),
}); });

View File

@@ -1,28 +1,26 @@
import type { GrpcRequest, HttpRequest, WebsocketRequest } from '@yaakapp-internal/models'; import type { GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-internal/models";
import { MoveToWorkspaceDialog } from '../components/MoveToWorkspaceDialog'; import { MoveToWorkspaceDialog } from "../components/MoveToWorkspaceDialog";
import { activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace'; import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
import { createFastMutation } from '../hooks/useFastMutation'; import { createFastMutation } from "../hooks/useFastMutation";
import { pluralizeCount } from '../lib/pluralize'; import { pluralizeCount } from "../lib/pluralize";
import { showDialog } from '../lib/dialog'; import { showDialog } from "../lib/dialog";
import { jotaiStore } from '../lib/jotai'; import { jotaiStore } from "../lib/jotai";
export const moveToWorkspace = createFastMutation({ export const moveToWorkspace = createFastMutation({
mutationKey: ['move_workspace'], mutationKey: ["move_workspace"],
mutationFn: async (requests: (HttpRequest | GrpcRequest | WebsocketRequest)[]) => { mutationFn: async (requests: (HttpRequest | GrpcRequest | WebsocketRequest)[]) => {
const activeWorkspaceId = jotaiStore.get(activeWorkspaceIdAtom); const activeWorkspaceId = jotaiStore.get(activeWorkspaceIdAtom);
if (activeWorkspaceId == null) return; if (activeWorkspaceId == null) return;
if (requests.length === 0) return; if (requests.length === 0) return;
const title = const title =
requests.length === 1 requests.length === 1 ? "Move Request" : `Move ${pluralizeCount("Request", requests.length)}`;
? 'Move Request'
: `Move ${pluralizeCount('Request', requests.length)}`;
showDialog({ showDialog({
id: 'change-workspace', id: "change-workspace",
title, title,
size: 'sm', size: "sm",
render: ({ hide }) => ( render: ({ hide }) => (
<MoveToWorkspaceDialog <MoveToWorkspaceDialog
onDone={hide} onDone={hide}

View File

@@ -0,0 +1,17 @@
import { getModel } from "@yaakapp-internal/models";
import type { FolderSettingsTab } from "../components/FolderSettingsDialog";
import { FolderSettingsDialog } from "../components/FolderSettingsDialog";
import { showDialog } from "../lib/dialog";
export function openFolderSettings(folderId: string, tab?: FolderSettingsTab) {
const folder = getModel("folder", folderId);
if (folder == null) return;
showDialog({
id: "folder-settings",
title: null,
size: "lg",
className: "h-[50rem]",
noPadding: true,
render: () => <FolderSettingsDialog folderId={folderId} tab={tab} />,
});
}

View File

@@ -1,29 +1,29 @@
import type { SettingsTab } from '../components/Settings/Settings'; import type { SettingsTab } from "../components/Settings/Settings";
import { activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace'; import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
import { createFastMutation } from '../hooks/useFastMutation'; import { createFastMutation } from "../hooks/useFastMutation";
import { jotaiStore } from '../lib/jotai'; import { jotaiStore } from "../lib/jotai";
import { router } from '../lib/router'; import { router } from "../lib/router";
import { invokeCmd } from '../lib/tauri'; import { invokeCmd } from "../lib/tauri";
// Allow tab with optional subtab (e.g., "plugins:installed") // Allow tab with optional subtab (e.g., "plugins:installed")
type SettingsTabWithSubtab = SettingsTab | `${SettingsTab}:${string}` | null; type SettingsTabWithSubtab = SettingsTab | `${SettingsTab}:${string}` | null;
export const openSettings = createFastMutation<void, string, SettingsTabWithSubtab>({ export const openSettings = createFastMutation<void, string, SettingsTabWithSubtab>({
mutationKey: ['open_settings'], mutationKey: ["open_settings"],
mutationFn: async (tab) => { mutationFn: async (tab) => {
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom); const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
if (workspaceId == null) return; if (workspaceId == null) return;
const location = router.buildLocation({ const location = router.buildLocation({
to: '/workspaces/$workspaceId/settings', to: "/workspaces/$workspaceId/settings",
params: { workspaceId }, params: { workspaceId },
search: { tab: (tab ?? undefined) as SettingsTab | undefined }, search: { tab: (tab ?? undefined) as SettingsTab | undefined },
}); });
await invokeCmd('cmd_new_child_window', { await invokeCmd("cmd_new_child_window", {
url: location.href, url: location.href,
label: 'settings', label: "settings",
title: 'Yaak Settings', title: "Yaak Settings",
innerSize: [750, 600], innerSize: [750, 600],
}); });
}, },

View File

@@ -0,0 +1,27 @@
import { applySync, calculateSyncFsOnly } from "@yaakapp-internal/sync";
import { createFastMutation } from "../hooks/useFastMutation";
import { showSimpleAlert } from "../lib/alert";
import { router } from "../lib/router";
export const openWorkspaceFromSyncDir = createFastMutation<void, void, string>({
mutationKey: [],
mutationFn: async (dir) => {
const ops = await calculateSyncFsOnly(dir);
const workspace = ops
.map((o) => (o.type === "dbCreate" && o.fs.model.type === "workspace" ? o.fs.model : null))
.filter((m) => m)[0];
if (workspace == null) {
showSimpleAlert("Failed to Open", "No workspace found in directory");
return;
}
await applySync(workspace.id, dir, ops);
await router.navigate({
to: "/workspaces/$workspaceId",
params: { workspaceId: workspace.id },
});
},
});

View File

@@ -0,0 +1,19 @@
import type { WorkspaceSettingsTab } from "../components/WorkspaceSettingsDialog";
import { WorkspaceSettingsDialog } from "../components/WorkspaceSettingsDialog";
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
import { showDialog } from "../lib/dialog";
import { jotaiStore } from "../lib/jotai";
export function openWorkspaceSettings(tab?: WorkspaceSettingsTab) {
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
if (workspaceId == null) return;
showDialog({
id: "workspace-settings",
size: "md",
className: "h-[calc(100vh-5rem)] !max-h-[40rem]",
noPadding: true,
render: ({ hide }) => (
<WorkspaceSettingsDialog workspaceId={workspaceId} hide={hide} tab={tab} />
),
});
}

View File

@@ -1,9 +1,9 @@
import { createFastMutation } from '../hooks/useFastMutation'; import { createFastMutation } from "../hooks/useFastMutation";
import { getRecentCookieJars } from '../hooks/useRecentCookieJars'; import { getRecentCookieJars } from "../hooks/useRecentCookieJars";
import { getRecentEnvironments } from '../hooks/useRecentEnvironments'; import { getRecentEnvironments } from "../hooks/useRecentEnvironments";
import { getRecentRequests } from '../hooks/useRecentRequests'; import { getRecentRequests } from "../hooks/useRecentRequests";
import { router } from '../lib/router'; import { router } from "../lib/router";
import { invokeCmd } from '../lib/tauri'; import { invokeCmd } from "../lib/tauri";
export const switchWorkspace = createFastMutation< export const switchWorkspace = createFastMutation<
void, void,
@@ -13,7 +13,7 @@ export const switchWorkspace = createFastMutation<
inNewWindow: boolean; inNewWindow: boolean;
} }
>({ >({
mutationKey: ['open_workspace'], mutationKey: ["open_workspace"],
mutationFn: async ({ workspaceId, inNewWindow }) => { mutationFn: async ({ workspaceId, inNewWindow }) => {
const environmentId = (await getRecentEnvironments(workspaceId))[0] ?? undefined; const environmentId = (await getRecentEnvironments(workspaceId))[0] ?? undefined;
const requestId = (await getRecentRequests(workspaceId))[0] ?? undefined; const requestId = (await getRecentRequests(workspaceId))[0] ?? undefined;
@@ -26,16 +26,16 @@ export const switchWorkspace = createFastMutation<
if (inNewWindow) { if (inNewWindow) {
const location = router.buildLocation({ const location = router.buildLocation({
to: '/workspaces/$workspaceId', to: "/workspaces/$workspaceId",
params: { workspaceId }, params: { workspaceId },
search, search,
}); });
await invokeCmd<void>('cmd_new_main_window', { url: location.href }); await invokeCmd<void>("cmd_new_main_window", { url: location.href });
return; return;
} }
await router.navigate({ await router.navigate({
to: '/workspaces/$workspaceId', to: "/workspaces/$workspaceId",
params: { workspaceId }, params: { workspaceId },
search, search,
}); });

View File

@@ -1,17 +1,15 @@
import type { HttpRequest } from '@yaakapp-internal/models'; import type { HttpRequest } from "@yaakapp-internal/models";
import mime from 'mime'; import { Banner, HStack, InlineCode, VStack } from "@yaakapp-internal/ui";
import { useKeyValue } from '../hooks/useKeyValue'; import mime from "mime";
import { Banner } from './core/Banner'; import { useKeyValue } from "../hooks/useKeyValue";
import { Button } from './core/Button'; import { Button } from "./core/Button";
import { InlineCode } from './core/InlineCode'; import { SelectFile } from "./SelectFile";
import { HStack, VStack } from './core/Stacks';
import { SelectFile } from './SelectFile';
type Props = { type Props = {
requestId: string; requestId: string;
contentType: string | null; contentType: string | null;
body: HttpRequest['body']; body: HttpRequest["body"];
onChange: (body: HttpRequest['body']) => void; onChange: (body: HttpRequest["body"]) => void;
onChangeContentType: (contentType: string | null) => void; onChangeContentType: (contentType: string | null) => void;
}; };
@@ -23,8 +21,8 @@ export function BinaryFileEditor({
requestId, requestId,
}: Props) { }: Props) {
const ignoreContentType = useKeyValue<boolean>({ const ignoreContentType = useKeyValue<boolean>({
namespace: 'global', namespace: "global",
key: ['ignore_content_type', requestId], key: ["ignore_content_type", requestId],
fallback: false, fallback: false,
}); });
@@ -33,8 +31,8 @@ export function BinaryFileEditor({
onChange({ filePath: filePath ?? undefined }); onChange({ filePath: filePath ?? undefined });
}; };
const filePath = typeof body.filePath === 'string' ? body.filePath : null; const filePath = typeof body.filePath === "string" ? body.filePath : null;
const mimeType = mime.getType(filePath ?? '') ?? 'application/octet-stream'; const mimeType = mime.getType(filePath ?? "") ?? "application/octet-stream";
return ( return (
<VStack space={2}> <VStack space={2}>

View File

@@ -1,12 +1,12 @@
import type { ReactNode } from 'react'; import type { ReactNode } from "react";
import { appInfo } from '../lib/appInfo'; import { appInfo } from "../lib/appInfo";
interface Props { interface Props {
children: ReactNode; children: ReactNode;
feature: 'updater' | 'license'; feature: "updater" | "license";
} }
const featureMap: Record<Props['feature'], boolean> = { const featureMap: Record<Props["feature"], boolean> = {
updater: appInfo.featureUpdater, updater: appInfo.featureUpdater,
license: appInfo.featureLicense, license: appInfo.featureLicense,
}; };

View File

@@ -1,16 +1,15 @@
import { open } from '@tauri-apps/plugin-dialog'; import { open } from "@tauri-apps/plugin-dialog";
import { gitClone } from '@yaakapp-internal/git'; import { gitClone } from "@yaakapp-internal/git";
import { useState } from 'react'; import { Banner, VStack } from "@yaakapp-internal/ui";
import { openWorkspaceFromSyncDir } from '../commands/openWorkspaceFromSyncDir'; import { useState } from "react";
import { appInfo } from '../lib/appInfo'; import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
import { showErrorToast } from '../lib/toast'; import { appInfo } from "../lib/appInfo";
import { Banner } from './core/Banner'; import { showErrorToast } from "../lib/toast";
import { Button } from './core/Button'; import { Button } from "./core/Button";
import { Checkbox } from './core/Checkbox'; import { Checkbox } from "./core/Checkbox";
import { IconButton } from './core/IconButton'; import { IconButton } from "./core/IconButton";
import { PlainInput } from './core/PlainInput'; import { PlainInput } from "./core/PlainInput";
import { VStack } from './core/Stacks'; import { promptCredentials } from "./git/credentials";
import { promptCredentials } from './git/credentials';
interface Props { interface Props {
hide: () => void; hide: () => void;
@@ -18,15 +17,15 @@ interface Props {
// Detect path separator from an existing path (defaults to /) // Detect path separator from an existing path (defaults to /)
function getPathSeparator(path: string): string { function getPathSeparator(path: string): string {
return path.includes('\\') ? '\\' : '/'; return path.includes("\\") ? "\\" : "/";
} }
export function CloneGitRepositoryDialog({ hide }: Props) { export function CloneGitRepositoryDialog({ hide }: Props) {
const [url, setUrl] = useState<string>(''); const [url, setUrl] = useState<string>("");
const [baseDirectory, setBaseDirectory] = useState<string>(appInfo.defaultProjectDir); const [baseDirectory, setBaseDirectory] = useState<string>(appInfo.defaultProjectDir);
const [directoryOverride, setDirectoryOverride] = useState<string | null>(null); const [directoryOverride, setDirectoryOverride] = useState<string | null>(null);
const [hasSubdirectory, setHasSubdirectory] = useState(false); const [hasSubdirectory, setHasSubdirectory] = useState(false);
const [subdirectory, setSubdirectory] = useState<string>(''); const [subdirectory, setSubdirectory] = useState<string>("");
const [isCloning, setIsCloning] = useState(false); const [isCloning, setIsCloning] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -39,7 +38,7 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
const handleSelectDirectory = async () => { const handleSelectDirectory = async () => {
const dir = await open({ const dir = await open({
title: 'Select Directory', title: "Select Directory",
directory: true, directory: true,
multiple: false, multiple: false,
}); });
@@ -59,9 +58,9 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
try { try {
const result = await gitClone(url, directory, promptCredentials); const result = await gitClone(url, directory, promptCredentials);
if (result.type === 'needs_credentials') { if (result.type === "needs_credentials") {
setError( setError(
result.error ?? 'Authentication failed. Please check your credentials and try again.', result.error ?? "Authentication failed. Please check your credentials and try again.",
); );
return; return;
} }
@@ -73,8 +72,8 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
} catch (err) { } catch (err) {
setError(String(err)); setError(String(err));
showErrorToast({ showErrorToast({
id: 'git-clone-error', id: "git-clone-error",
title: 'Clone Failed', title: "Clone Failed",
message: String(err), message: String(err),
}); });
} finally { } finally {
@@ -137,7 +136,7 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
disabled={!url || !directory || isCloning} disabled={!url || !directory || isCloning}
isLoading={isCloning} isLoading={isCloning}
> >
{isCloning ? 'Cloning...' : 'Clone Repository'} {isCloning ? "Cloning..." : "Clone Repository"}
</Button> </Button>
</VStack> </VStack>
); );
@@ -157,5 +156,5 @@ function extractRepoName(url: string): string {
if (sshMatch?.[1]) { if (sshMatch?.[1]) {
return sshMatch[1]; return sshMatch[1];
} }
return ''; return "";
} }

View File

@@ -1,5 +1,5 @@
import classNames from 'classnames'; import classNames from "classnames";
import type { CSSProperties } from 'react'; import type { CSSProperties } from "react";
interface Props { interface Props {
color: string | null; color: string | null;
@@ -11,7 +11,7 @@ export function ColorIndicator({ color, onClick, className }: Props) {
const style: CSSProperties = { backgroundColor: color ?? undefined }; const style: CSSProperties = { backgroundColor: color ?? undefined };
const finalClassName = classNames( const finalClassName = classNames(
className, className,
'inline-block w-[0.75em] h-[0.75em] rounded-full mr-1.5 border border-transparent flex-shrink-0', "inline-block w-[0.75em] h-[0.75em] rounded-full mr-1.5 border border-transparent flex-shrink-0",
); );
if (onClick) { if (onClick) {
@@ -20,7 +20,7 @@ export function ColorIndicator({ color, onClick, className }: Props) {
type="button" type="button"
onClick={onClick} onClick={onClick}
style={style} style={style}
className={classNames(finalClassName, 'hover:border-text')} className={classNames(finalClassName, "hover:border-text")}
/> />
); );
} }

View File

@@ -1,7 +1,8 @@
import { workspacesAtom } from '@yaakapp-internal/models'; import { workspacesAtom } from "@yaakapp-internal/models";
import classNames from 'classnames'; import { Heading, Icon, useDebouncedState } from "@yaakapp-internal/ui";
import { fuzzyFilter } from 'fuzzbunny'; import classNames from "classnames";
import { useAtomValue } from 'jotai'; import { fuzzyFilter } from "fuzzbunny";
import { useAtomValue } from "jotai";
import { import {
Fragment, Fragment,
type KeyboardEvent, type KeyboardEvent,
@@ -10,48 +11,45 @@ import {
useMemo, useMemo,
useRef, useRef,
useState, useState,
} from 'react'; } from "react";
import { createFolder } from '../commands/commands'; import { createFolder } from "../commands/commands";
import { createSubEnvironmentAndActivate } from '../commands/createEnvironment'; import { createSubEnvironmentAndActivate } from "../commands/createEnvironment";
import { openSettings } from '../commands/openSettings'; import { openSettings } from "../commands/openSettings";
import { switchWorkspace } from '../commands/switchWorkspace'; import { switchWorkspace } from "../commands/switchWorkspace";
import { useActiveCookieJar } from '../hooks/useActiveCookieJar'; import { useActiveCookieJar } from "../hooks/useActiveCookieJar";
import { useActiveEnvironment } from '../hooks/useActiveEnvironment'; import { useActiveEnvironment } from "../hooks/useActiveEnvironment";
import { useActiveRequest } from '../hooks/useActiveRequest'; import { useActiveRequest } from "../hooks/useActiveRequest";
import { activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace'; import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
import { useAllRequests } from '../hooks/useAllRequests'; import { useAllRequests } from "../hooks/useAllRequests";
import { useCreateWorkspace } from '../hooks/useCreateWorkspace'; import { useCreateWorkspace } from "../hooks/useCreateWorkspace";
import { useDebouncedState } from '../hooks/useDebouncedState'; import { useEnvironmentsBreakdown } from "../hooks/useEnvironmentsBreakdown";
import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown'; import { useGrpcRequestActions } from "../hooks/useGrpcRequestActions";
import { useGrpcRequestActions } from '../hooks/useGrpcRequestActions'; import type { HotkeyAction } from "../hooks/useHotKey";
import type { HotkeyAction } from '../hooks/useHotKey'; import { useHttpRequestActions } from "../hooks/useHttpRequestActions";
import { useHttpRequestActions } from '../hooks/useHttpRequestActions'; import { useRecentEnvironments } from "../hooks/useRecentEnvironments";
import { useRecentEnvironments } from '../hooks/useRecentEnvironments'; import { useRecentRequests } from "../hooks/useRecentRequests";
import { useRecentRequests } from '../hooks/useRecentRequests'; import { useRecentWorkspaces } from "../hooks/useRecentWorkspaces";
import { useRecentWorkspaces } from '../hooks/useRecentWorkspaces'; import { useScrollIntoView } from "../hooks/useScrollIntoView";
import { useScrollIntoView } from '../hooks/useScrollIntoView'; import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
import { useSendAnyHttpRequest } from '../hooks/useSendAnyHttpRequest'; import { useSidebarHidden } from "../hooks/useSidebarHidden";
import { useSidebarHidden } from '../hooks/useSidebarHidden'; import { appInfo } from "../lib/appInfo";
import { appInfo } from '../lib/appInfo'; import { copyToClipboard } from "../lib/copy";
import { copyToClipboard } from '../lib/copy'; import { createRequestAndNavigate } from "../lib/createRequestAndNavigate";
import { createRequestAndNavigate } from '../lib/createRequestAndNavigate'; import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm'; import { showDialog } from "../lib/dialog";
import { showDialog } from '../lib/dialog'; import { editEnvironment } from "../lib/editEnvironment";
import { editEnvironment } from '../lib/editEnvironment'; import { renameModelWithPrompt } from "../lib/renameModelWithPrompt";
import { renameModelWithPrompt } from '../lib/renameModelWithPrompt';
import { import {
resolvedModelNameWithFolders, resolvedModelNameWithFolders,
resolvedModelNameWithFoldersArray, resolvedModelNameWithFoldersArray,
} from '../lib/resolvedModelName'; } from "../lib/resolvedModelName";
import { router } from '../lib/router'; import { router } from "../lib/router";
import { setWorkspaceSearchParams } from '../lib/setWorkspaceSearchParams'; import { setWorkspaceSearchParams } from "../lib/setWorkspaceSearchParams";
import { CookieDialog } from './CookieDialog'; import { CookieDialog } from "./CookieDialog";
import { Button } from './core/Button'; import { Button } from "./core/Button";
import { Heading } from './core/Heading'; import { Hotkey } from "./core/Hotkey";
import { Hotkey } from './core/Hotkey'; import { HttpMethodTag } from "./core/HttpMethodTag";
import { HttpMethodTag } from './core/HttpMethodTag'; import { PlainInput } from "./core/PlainInput";
import { Icon } from './core/Icon';
import { PlainInput } from './core/PlainInput';
interface CommandPaletteGroup { interface CommandPaletteGroup {
key: string; key: string;
@@ -68,7 +66,7 @@ type CommandPaletteItem = {
const MAX_PER_GROUP = 8; const MAX_PER_GROUP = 8;
export function CommandPaletteDialog({ onClose }: { onClose: () => void }) { export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
const [command, setCommand] = useDebouncedState<string>('', 150); const [command, setCommand] = useDebouncedState<string>("", 150);
const [selectedItemKey, setSelectedItemKey] = useState<string | null>(null); const [selectedItemKey, setSelectedItemKey] = useState<string | null>(null);
const activeEnvironment = useActiveEnvironment(); const activeEnvironment = useActiveEnvironment();
const httpRequestActions = useHttpRequestActions(); const httpRequestActions = useHttpRequestActions();
@@ -96,79 +94,79 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
const commands: CommandPaletteItem[] = [ const commands: CommandPaletteItem[] = [
{ {
key: 'settings.open', key: "settings.open",
label: 'Open Settings', label: "Open Settings",
action: 'settings.show', action: "settings.show",
onSelect: () => openSettings.mutate(null), onSelect: () => openSettings.mutate(null),
}, },
{ {
key: 'app.create', key: "app.create",
label: 'Create Workspace', label: "Create Workspace",
onSelect: createWorkspace, onSelect: createWorkspace,
}, },
{ {
key: 'model.create', key: "model.create",
label: 'Create HTTP Request', label: "Create HTTP Request",
onSelect: () => createRequestAndNavigate({ model: 'http_request', workspaceId }), onSelect: () => createRequestAndNavigate({ model: "http_request", workspaceId }),
}, },
{ {
key: 'grpc_request.create', key: "grpc_request.create",
label: 'Create GRPC Request', label: "Create GRPC Request",
onSelect: () => createRequestAndNavigate({ model: 'grpc_request', workspaceId }), onSelect: () => createRequestAndNavigate({ model: "grpc_request", workspaceId }),
}, },
{ {
key: 'websocket_request.create', key: "websocket_request.create",
label: 'Create Websocket Request', label: "Create Websocket Request",
onSelect: () => createRequestAndNavigate({ model: 'websocket_request', workspaceId }), onSelect: () => createRequestAndNavigate({ model: "websocket_request", workspaceId }),
}, },
{ {
key: 'folder.create', key: "folder.create",
label: 'Create Folder', label: "Create Folder",
onSelect: () => createFolder.mutate({}), onSelect: () => createFolder.mutate({}),
}, },
{ {
key: 'cookies.show', key: "cookies.show",
label: 'Show Cookies', label: "Show Cookies",
onSelect: async () => { onSelect: async () => {
showDialog({ showDialog({
id: 'cookies', id: "cookies",
title: 'Manage Cookies', title: "Manage Cookies",
size: 'full', size: "full",
render: () => <CookieDialog cookieJarId={activeCookieJar?.id ?? null} />, render: () => <CookieDialog cookieJarId={activeCookieJar?.id ?? null} />,
}); });
}, },
}, },
{ {
key: 'environment.edit', key: "environment.edit",
label: 'Edit Environment', label: "Edit Environment",
action: 'environment_editor.toggle', action: "environment_editor.toggle",
onSelect: () => editEnvironment(activeEnvironment), onSelect: () => editEnvironment(activeEnvironment),
}, },
{ {
key: 'environment.create', key: "environment.create",
label: 'Create Environment', label: "Create Environment",
onSelect: () => createSubEnvironmentAndActivate.mutate(baseEnvironment), onSelect: () => createSubEnvironmentAndActivate.mutate(baseEnvironment),
}, },
{ {
key: 'sidebar.toggle', key: "sidebar.toggle",
label: 'Toggle Sidebar', label: "Toggle Sidebar",
action: 'sidebar.focus', action: "sidebar.focus",
onSelect: () => setSidebarHidden((h) => !h), onSelect: () => setSidebarHidden((h) => !h),
}, },
]; ];
if (activeRequest?.model === 'http_request') { if (activeRequest?.model === "http_request") {
commands.push({ commands.push({
key: 'request.send', key: "request.send",
action: 'request.send', action: "request.send",
label: 'Send Request', label: "Send Request",
onSelect: () => sendRequest(activeRequest.id), onSelect: () => sendRequest(activeRequest.id),
}); });
if (appInfo.cliVersion != null) { if (appInfo.cliVersion != null) {
commands.push({ commands.push({
key: 'request.copy_cli_send', key: "request.copy_cli_send",
searchText: `copy cli send yaak request send ${activeRequest.id}`, searchText: `copy cli send yaak request send ${activeRequest.id}`,
label: 'Copy CLI Send Command', label: "Copy CLI Send Command",
onSelect: () => copyToClipboard(`yaak request send ${activeRequest.id}`), onSelect: () => copyToClipboard(`yaak request send ${activeRequest.id}`),
}); });
} }
@@ -181,7 +179,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
}); });
} }
if (activeRequest?.model === 'grpc_request') { if (activeRequest?.model === "grpc_request") {
grpcRequestActions.forEach((a, i) => { grpcRequestActions.forEach((a, i) => {
commands.push({ commands.push({
key: `grpc_request_action.${i}`, key: `grpc_request_action.${i}`,
@@ -193,21 +191,21 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
if (activeRequest != null) { if (activeRequest != null) {
commands.push({ commands.push({
key: 'http_request.rename', key: "http_request.rename",
label: 'Rename Request', label: "Rename Request",
onSelect: () => renameModelWithPrompt(activeRequest), onSelect: () => renameModelWithPrompt(activeRequest),
}); });
commands.push({ commands.push({
key: 'sidebar.selected.delete', key: "sidebar.selected.delete",
label: 'Delete Request', label: "Delete Request",
onSelect: () => deleteModelWithConfirm(activeRequest), onSelect: () => deleteModelWithConfirm(activeRequest),
}); });
} }
return commands.sort((a, b) => return commands.sort((a, b) =>
('searchText' in a ? a.searchText : a.label).localeCompare( ("searchText" in a ? a.searchText : a.label).localeCompare(
'searchText' in b ? b.searchText : b.label, "searchText" in b ? b.searchText : b.label,
), ),
); );
}, [ }, [
@@ -284,14 +282,14 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
const groups = useMemo<CommandPaletteGroup[]>(() => { const groups = useMemo<CommandPaletteGroup[]>(() => {
const actionsGroup: CommandPaletteGroup = { const actionsGroup: CommandPaletteGroup = {
key: 'actions', key: "actions",
label: 'Actions', label: "Actions",
items: workspaceCommands, items: workspaceCommands,
}; };
const requestGroup: CommandPaletteGroup = { const requestGroup: CommandPaletteGroup = {
key: 'requests', key: "requests",
label: 'Switch Request', label: "Switch Request",
items: [], items: [],
}; };
@@ -305,14 +303,14 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
{resolvedModelNameWithFoldersArray(r).map((name, i, all) => ( {resolvedModelNameWithFoldersArray(r).map((name, i, all) => (
<Fragment key={name}> <Fragment key={name}>
{i !== 0 && <Icon icon="chevron_right" className="opacity-80" />} {i !== 0 && <Icon icon="chevron_right" className="opacity-80" />}
<div className={classNames(i < all.length - 1 && 'truncate')}>{name}</div> <div className={classNames(i < all.length - 1 && "truncate")}>{name}</div>
</Fragment> </Fragment>
))} ))}
</div> </div>
), ),
onSelect: async () => { onSelect: async () => {
await router.navigate({ await router.navigate({
to: '/workspaces/$workspaceId', to: "/workspaces/$workspaceId",
params: { workspaceId: r.workspaceId }, params: { workspaceId: r.workspaceId },
search: (prev) => ({ ...prev, request_id: r.id }), search: (prev) => ({ ...prev, request_id: r.id }),
}); });
@@ -321,8 +319,8 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
} }
const environmentGroup: CommandPaletteGroup = { const environmentGroup: CommandPaletteGroup = {
key: 'environments', key: "environments",
label: 'Switch Environment', label: "Switch Environment",
items: [], items: [],
}; };
@@ -338,8 +336,8 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
} }
const workspaceGroup: CommandPaletteGroup = { const workspaceGroup: CommandPaletteGroup = {
key: 'workspaces', key: "workspaces",
label: 'Switch Workspace', label: "Switch Workspace",
items: [], items: [],
}; };
@@ -367,10 +365,10 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
? fuzzyFilter( ? fuzzyFilter(
allItems.map((i) => ({ allItems.map((i) => ({
...i, ...i,
filterBy: 'searchText' in i ? i.searchText : i.label, filterBy: "searchText" in i ? i.searchText : i.label,
})), })),
command, command,
{ fields: ['filterBy'] }, { fields: ["filterBy"] },
) )
.sort((a, b) => b.score - a.score) .sort((a, b) => b.score - a.score)
.map((v) => v.item) .map((v) => v.item)
@@ -408,13 +406,13 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => { (e: KeyboardEvent<HTMLInputElement>) => {
const index = filteredAllItems.findIndex((v) => v.key === selectedItem?.key); const index = filteredAllItems.findIndex((v) => v.key === selectedItem?.key);
if (e.key === 'ArrowDown' || (e.ctrlKey && e.key === 'n')) { if (e.key === "ArrowDown" || (e.ctrlKey && e.key === "n")) {
const next = filteredAllItems[index + 1] ?? filteredAllItems[0]; const next = filteredAllItems[index + 1] ?? filteredAllItems[0];
setSelectedItemKey(next?.key ?? null); setSelectedItemKey(next?.key ?? null);
} else if (e.key === 'ArrowUp' || (e.ctrlKey && e.key === 'k')) { } else if (e.key === "ArrowUp" || (e.ctrlKey && e.key === "k")) {
const prev = filteredAllItems[index - 1] ?? filteredAllItems[filteredAllItems.length - 1]; const prev = filteredAllItems[index - 1] ?? filteredAllItems[filteredAllItems.length - 1];
setSelectedItemKey(prev?.key ?? null); setSelectedItemKey(prev?.key ?? null);
} else if (e.key === 'Enter') { } else if (e.key === "Enter") {
const selected = filteredAllItems[index]; const selected = filteredAllItems[index];
setSelectedItemKey(selected?.key ?? null); setSelectedItemKey(selected?.key ?? null);
if (selected) { if (selected) {
@@ -491,10 +489,10 @@ function CommandPaletteItem({
color="custom" color="custom"
justify="start" justify="start"
className={classNames( className={classNames(
'w-full h-sm flex items-center rounded px-1.5', "w-full h-sm flex items-center rounded px-1.5",
'hover:text-text', "hover:text-text",
active && 'bg-surface-highlight', active && "bg-surface-highlight",
!active && 'text-text-subtle', !active && "text-text-subtle",
)} )}
> >
<span className="truncate">{children}</span> <span className="truncate">{children}</span>

View File

@@ -1,14 +1,12 @@
import type { HttpRequest } from '@yaakapp-internal/models'; import type { HttpRequest } from "@yaakapp-internal/models";
import { patchModel } from '@yaakapp-internal/models'; import { patchModel } from "@yaakapp-internal/models";
import type { ReactNode } from 'react'; import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui";
import { useToggle } from '../hooks/useToggle'; import type { ReactNode } from "react";
import { showConfirm } from '../lib/confirm'; import { useToggle } from "../hooks/useToggle";
import { Banner } from './core/Banner'; import { showConfirm } from "../lib/confirm";
import { Button } from './core/Button'; import { Button } from "./core/Button";
import { InlineCode } from './core/InlineCode'; import { Link } from "./core/Link";
import { Link } from './core/Link'; import { SizeTag } from "./core/SizeTag";
import { SizeTag } from './core/SizeTag';
import { HStack } from './core/Stacks';
interface Props { interface Props {
children: ReactNode; children: ReactNode;
@@ -31,17 +29,17 @@ export function ConfirmLargeRequestBody({ children, request }: Props) {
return ( return (
<Banner color="primary" className="flex flex-col gap-3"> <Banner color="primary" className="flex flex-col gap-3">
<p> <p>
Rendering content over{' '} Rendering content over{" "}
<InlineCode> <InlineCode>
<SizeTag contentLength={tooLargeBytes} /> <SizeTag contentLength={tooLargeBytes} />
</InlineCode>{' '} </InlineCode>{" "}
may impact performance. may impact performance.
</p> </p>
<p> <p>
See{' '} See{" "}
<Link href="https://feedback.yaak.app/en/help/articles/1198684-working-with-large-values"> <Link href="https://feedback.yaak.app/en/help/articles/1198684-working-with-large-values">
Working With Large Values Working With Large Values
</Link>{' '} </Link>{" "}
for tips. for tips.
</p> </p>
<HStack wrap space={2}> <HStack wrap space={2}>
@@ -55,13 +53,13 @@ export function ConfirmLargeRequestBody({ children, request }: Props) {
onClick={async () => { onClick={async () => {
const confirm = await showConfirm({ const confirm = await showConfirm({
id: `delete-body-${request.id}`, id: `delete-body-${request.id}`,
confirmText: 'Delete Body', confirmText: "Delete Body",
title: 'Delete Body Text', title: "Delete Body Text",
description: 'Are you sure you want to delete the request body text?', description: "Are you sure you want to delete the request body text?",
color: 'danger', color: "danger",
}); });
if (confirm) { if (confirm) {
await patchModel(request, { body: { ...request.body, text: '' } }); await patchModel(request, { body: { ...request.body, text: "" } });
} }
}} }}
> >

View File

@@ -1,16 +1,14 @@
import type { HttpResponse } from '@yaakapp-internal/models'; import type { HttpResponse } from "@yaakapp-internal/models";
import { type ReactNode, useMemo } from 'react'; import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui";
import { useSaveResponse } from '../hooks/useSaveResponse'; import { type ReactNode, useMemo } from "react";
import { useToggle } from '../hooks/useToggle'; import { useSaveResponse } from "../hooks/useSaveResponse";
import { isProbablyTextContentType } from '../lib/contentType'; import { useToggle } from "../hooks/useToggle";
import { getContentTypeFromHeaders } from '../lib/model_util'; import { isProbablyTextContentType } from "../lib/contentType";
import { getResponseBodyText } from '../lib/responseBody'; import { getContentTypeFromHeaders } from "../lib/model_util";
import { CopyButton } from './CopyButton'; import { getResponseBodyText } from "../lib/responseBody";
import { Banner } from './core/Banner'; import { CopyButton } from "./CopyButton";
import { Button } from './core/Button'; import { Button } from "./core/Button";
import { InlineCode } from './core/InlineCode'; import { SizeTag } from "./core/SizeTag";
import { SizeTag } from './core/SizeTag';
import { HStack } from './core/Stacks';
interface Props { interface Props {
children: ReactNode; children: ReactNode;
@@ -33,10 +31,10 @@ export function ConfirmLargeResponse({ children, response }: Props) {
return ( return (
<Banner color="primary" className="flex flex-col gap-3"> <Banner color="primary" className="flex flex-col gap-3">
<p> <p>
Showing responses over{' '} Showing responses over{" "}
<InlineCode> <InlineCode>
<SizeTag contentLength={LARGE_BYTES} /> <SizeTag contentLength={LARGE_BYTES} />
</InlineCode>{' '} </InlineCode>{" "}
may impact performance may impact performance
</p> </p>
<HStack wrap space={2}> <HStack wrap space={2}>

View File

@@ -1,15 +1,13 @@
import type { HttpResponse } from '@yaakapp-internal/models'; import type { HttpResponse } from "@yaakapp-internal/models";
import { type ReactNode, useMemo } from 'react'; import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui";
import { getRequestBodyText as getHttpResponseRequestBodyText } from '../hooks/useHttpRequestBody'; import { type ReactNode, useMemo } from "react";
import { useToggle } from '../hooks/useToggle'; import { getRequestBodyText as getHttpResponseRequestBodyText } from "../hooks/useHttpRequestBody";
import { isProbablyTextContentType } from '../lib/contentType'; import { useToggle } from "../hooks/useToggle";
import { getContentTypeFromHeaders } from '../lib/model_util'; import { isProbablyTextContentType } from "../lib/contentType";
import { CopyButton } from './CopyButton'; import { getContentTypeFromHeaders } from "../lib/model_util";
import { Banner } from './core/Banner'; import { CopyButton } from "./CopyButton";
import { Button } from './core/Button'; import { Button } from "./core/Button";
import { InlineCode } from './core/InlineCode'; import { SizeTag } from "./core/SizeTag";
import { SizeTag } from './core/SizeTag';
import { HStack } from './core/Stacks';
interface Props { interface Props {
children: ReactNode; children: ReactNode;
@@ -31,10 +29,10 @@ export function ConfirmLargeResponseRequest({ children, response }: Props) {
return ( return (
<Banner color="primary" className="flex flex-col gap-3"> <Banner color="primary" className="flex flex-col gap-3">
<p> <p>
Showing content over{' '} Showing content over{" "}
<InlineCode> <InlineCode>
<SizeTag contentLength={LARGE_BYTES} /> <SizeTag contentLength={LARGE_BYTES} />
</InlineCode>{' '} </InlineCode>{" "}
may impact performance may impact performance
</p> </p>
<HStack wrap space={2}> <HStack wrap space={2}>
@@ -46,7 +44,7 @@ export function ConfirmLargeResponseRequest({ children, response }: Props) {
color="secondary" color="secondary"
variant="border" variant="border"
size="xs" size="xs"
text={() => getHttpResponseRequestBodyText(response).then((d) => d?.bodyText ?? '')} text={() => getHttpResponseRequestBodyText(response).then((d) => d?.bodyText ?? "")}
/> />
)} )}
</HStack> </HStack>

View File

@@ -0,0 +1,190 @@
import type { Cookie, CookieDomain, CookieJar } from "@yaakapp-internal/models";
import { cookieJarsAtom, patchModelById } from "@yaakapp-internal/models";
import { useAtomValue } from "jotai";
import { cookieDomain } from "../lib/model_util";
import { showPromptForm } from "../lib/prompt-form";
import { Banner, InlineCode } from "@yaakapp-internal/ui";
import { IconButton } from "./core/IconButton";
interface Props {
cookieJarId: string | null;
}
async function showAddCookieForm(cookieJarId: string): Promise<void> {
const result = await showPromptForm({
id: "add-cookie",
title: "Add Cookie",
size: "md",
inputs: [
{
name: "cookie_pairs",
label: "Cookie Attributes",
type: "key_value",
description:
"Add key-value pairs for the cookie. These will be combined into the cookie string.",
},
{
name: "domain_value",
label: "Domain",
type: "text",
placeholder: "example.com",
},
{
name: "hostOnly",
label: "Host Only",
type: "checkbox",
defaultValue: "true",
description:
"If enabled, cookie is restricted to the exact host. Otherwise, it applies to the domain and its subdomains.",
},
{
name: "path",
label: "Path",
type: "text",
placeholder: "/",
defaultValue: "/",
},
{
name: "secure",
label: "Secure",
type: "checkbox",
defaultValue: "true",
description: "If enabled, cookie will only be sent over HTTPS connections.",
},
],
});
if (result == null) return;
// Parse the form results
const cookie_pairs_raw = result.cookie_pairs;
const domain_value = (result.domain_value as string) ?? "";
const path = (result.path as string) ?? "/";
const hostOnly = (result.hostOnly as string) === "true";
const secure = (result.secure as string) === "true";
// Convert key-value pairs to raw_cookie string format: key1=value1;key2=value2
// Parse cookie_pairs - it comes as a JSON string from the key_value input
let parsedPairs: Array<{ name: string; value: string }> = [];
try {
// Handle null, undefined, or string value
const pairsStr =
typeof cookie_pairs_raw === "string"
? cookie_pairs_raw
: cookie_pairs_raw != null
? JSON.stringify(cookie_pairs_raw)
: "[]";
if (pairsStr && pairsStr !== "") {
parsedPairs = JSON.parse(pairsStr);
}
} catch {
parsedPairs = [];
}
const validPairs = parsedPairs.filter((p) => p?.name?.trim());
// Ensure at least one valid pair exists
if (validPairs.length === 0) {
console.log("No valid cookie pairs provided");
return;
}
const raw_cookie = validPairs.map((p) => `${p.name}=${p.value}`).join(";");
const domain: CookieDomain = hostOnly
? { HostOnly: domain_value ?? "" }
: { Suffix: domain_value ?? "" };
// Build the new cookie with explicit tuple type for path
const newCookie: Cookie = {
raw_cookie,
domain,
expires: "SessionEnd",
path: [path, secure] as [string, boolean],
};
try {
await patchModelById<"cookie_jar", CookieJar>("cookie_jar", cookieJarId, (prev) => ({
...prev,
cookies: [...prev.cookies, newCookie],
}));
} catch (error) {
console.error("Failed to add cookie:", error);
throw error;
}
}
export const CookieDialog = ({ cookieJarId }: Props) => {
const cookieJars = useAtomValue(cookieJarsAtom);
const cookieJar = cookieJars?.find((c) => c.id === cookieJarId);
if (cookieJar == null) {
return <div>No cookie jar selected</div>;
}
const onAddCookie = () => showAddCookieForm(cookieJar.id);
let tableBody;
if (cookieJar.cookies.length === 0) {
tableBody = (
<tr>
<td colSpan={3}>
<Banner>
Cookies will appear when a response contains the <InlineCode>Set-Cookie</InlineCode>{" "}
header
</Banner>
</td>
</tr>
);
// );
} else {
tableBody = cookieJar?.cookies.map((c: Cookie) => (
<tr key={JSON.stringify(c)}>
<td className="py-2 select-text cursor-text font-mono font-semibold max-w-0">
{cookieDomain(c)}
</td>
<td className="py-2 pl-4 select-text cursor-text font-mono text-text-subtle whitespace-nowrap overflow-x-auto max-w-[200px] hide-scrollbars">
{c.raw_cookie}
</td>
<td className="max-w-0 w-10">
<IconButton
icon="trash"
size="xs"
iconSize="sm"
title="Delete"
className="ml-auto"
onClick={async () =>
await patchModelById<"cookie_jar", CookieJar>("cookie_jar", cookieJar.id, (prev) => ({
...prev,
cookies: prev.cookies.filter((c2: Cookie) => c2 !== c),
}))
}
/>
</td>
</tr>
));
}
return (
<div className="pb-2">
<table className="w-full text-sm mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
<thead>
<tr>
<th className="py-2 text-left">Domain</th>
<th className="py-2 text-left pl-4">Cookie</th>
<th className="py-2 pl-4 w-10">
<IconButton
icon="plus"
size="xs"
iconSize="sm"
title="Add Cookie"
className="ml-auto"
onClick={onAddCookie}
/>
</th>
</tr>
</thead>
<tbody className="divide-y divide-surface-highlight">{tableBody}</tbody>
</table>
</div>
);
};

View File

@@ -1,17 +1,16 @@
import { cookieJarsAtom, patchModel } from '@yaakapp-internal/models'; import { cookieJarsAtom, patchModel } from "@yaakapp-internal/models";
import { useAtomValue } from 'jotai'; import { useAtomValue } from "jotai";
import { memo, useMemo } from 'react'; import { memo, useMemo } from "react";
import { useActiveCookieJar } from '../hooks/useActiveCookieJar'; import { useActiveCookieJar } from "../hooks/useActiveCookieJar";
import { useCreateCookieJar } from '../hooks/useCreateCookieJar'; import { useCreateCookieJar } from "../hooks/useCreateCookieJar";
import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm'; import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
import { showDialog } from '../lib/dialog'; import { showDialog } from "../lib/dialog";
import { showPrompt } from '../lib/prompt'; import { showPrompt } from "../lib/prompt";
import { setWorkspaceSearchParams } from '../lib/setWorkspaceSearchParams'; import { setWorkspaceSearchParams } from "../lib/setWorkspaceSearchParams";
import { CookieDialog } from './CookieDialog'; import { CookieDialog } from "./CookieDialog";
import { Dropdown, type DropdownItem } from './core/Dropdown'; import { Dropdown, type DropdownItem } from "./core/Dropdown";
import { Icon } from './core/Icon'; import { Icon, InlineCode } from "@yaakapp-internal/ui";
import { IconButton } from './core/IconButton'; import { IconButton } from "./core/IconButton";
import { InlineCode } from './core/InlineCode';
export const CookieDropdown = memo(function CookieDropdown() { export const CookieDropdown = memo(function CookieDropdown() {
const activeCookieJar = useActiveCookieJar(); const activeCookieJar = useActiveCookieJar();
@@ -23,44 +22,44 @@ export const CookieDropdown = memo(function CookieDropdown() {
...(cookieJars ?? []).map((j) => ({ ...(cookieJars ?? []).map((j) => ({
key: j.id, key: j.id,
label: j.name, label: j.name,
leftSlot: <Icon icon={j.id === activeCookieJar?.id ? 'check' : 'empty'} />, leftSlot: <Icon icon={j.id === activeCookieJar?.id ? "check" : "empty"} />,
onSelect: () => { onSelect: () => {
setWorkspaceSearchParams({ cookie_jar_id: j.id }); setWorkspaceSearchParams({ cookie_jar_id: j.id });
}, },
})), })),
...(((cookieJars ?? []).length > 0 && activeCookieJar != null ...(((cookieJars ?? []).length > 0 && activeCookieJar != null
? [ ? [
{ type: 'separator', label: activeCookieJar.name }, { type: "separator", label: activeCookieJar.name },
{ {
key: 'manage', key: "manage",
label: 'Manage Cookies', label: "Manage Cookies",
leftSlot: <Icon icon="cookie" />, leftSlot: <Icon icon="cookie" />,
onSelect: () => { onSelect: () => {
if (activeCookieJar == null) return; if (activeCookieJar == null) return;
showDialog({ showDialog({
id: 'cookies', id: "cookies",
title: 'Manage Cookies', title: "Manage Cookies",
size: 'full', size: "full",
render: () => <CookieDialog cookieJarId={activeCookieJar.id} />, render: () => <CookieDialog cookieJarId={activeCookieJar.id} />,
}); });
}, },
}, },
{ {
key: 'rename', key: "rename",
label: 'Rename', label: "Rename",
leftSlot: <Icon icon="pencil" />, leftSlot: <Icon icon="pencil" />,
onSelect: async () => { onSelect: async () => {
const name = await showPrompt({ const name = await showPrompt({
id: 'rename-cookie-jar', id: "rename-cookie-jar",
title: 'Rename Cookie Jar', title: "Rename Cookie Jar",
description: ( description: (
<> <>
Enter a new name for <InlineCode>{activeCookieJar?.name}</InlineCode> Enter a new name for <InlineCode>{activeCookieJar?.name}</InlineCode>
</> </>
), ),
label: 'Name', label: "Name",
confirmText: 'Save', confirmText: "Save",
placeholder: 'New name', placeholder: "New name",
defaultValue: activeCookieJar?.name, defaultValue: activeCookieJar?.name,
}); });
if (name == null) return; if (name == null) return;
@@ -70,9 +69,9 @@ export const CookieDropdown = memo(function CookieDropdown() {
...(((cookieJars ?? []).length > 1 // Never delete the last one ...(((cookieJars ?? []).length > 1 // Never delete the last one
? [ ? [
{ {
label: 'Delete', label: "Delete",
leftSlot: <Icon icon="trash" />, leftSlot: <Icon icon="trash" />,
color: 'danger', color: "danger",
onSelect: async () => { onSelect: async () => {
await deleteModelWithConfirm(activeCookieJar); await deleteModelWithConfirm(activeCookieJar);
}, },
@@ -81,10 +80,10 @@ export const CookieDropdown = memo(function CookieDropdown() {
: []) as DropdownItem[]), : []) as DropdownItem[]),
] ]
: []) as DropdownItem[]), : []) as DropdownItem[]),
{ type: 'separator' }, { type: "separator" },
{ {
key: 'create-cookie-jar', key: "create-cookie-jar",
label: 'New Cookie Jar', label: "New Cookie Jar",
leftSlot: <Icon icon="plus" />, leftSlot: <Icon icon="plus" />,
onSelect: () => createCookieJar.mutate(), onSelect: () => createCookieJar.mutate(),
}, },

View File

@@ -0,0 +1,33 @@
import { useTimedBoolean } from "@yaakapp-internal/ui";
import { copyToClipboard } from "../lib/copy";
import { showToast } from "../lib/toast";
import type { ButtonProps } from "./core/Button";
import { Button } from "./core/Button";
interface Props extends Omit<ButtonProps, "onClick"> {
text: string | (() => Promise<string | null>);
}
export function CopyButton({ text, ...props }: Props) {
const [copied, setCopied] = useTimedBoolean();
return (
<Button
{...props}
onClick={async () => {
const content = typeof text === "function" ? await text() : text;
if (content == null) {
showToast({
id: "failed-to-copy",
color: "danger",
message: "Failed to copy",
});
} else {
copyToClipboard(content, { disableToast: true });
setCopied();
}
}}
>
{copied ? "Copied" : "Copy"}
</Button>
);
}

View File

@@ -0,0 +1,31 @@
import { IconButton, type IconButtonProps, useTimedBoolean } from "@yaakapp-internal/ui";
import { copyToClipboard } from "../lib/copy";
import { showToast } from "../lib/toast";
interface Props extends Omit<IconButtonProps, "onClick" | "icon"> {
text: string | (() => Promise<string | null>);
}
export function CopyIconButton({ text, ...props }: Props) {
const [copied, setCopied] = useTimedBoolean();
return (
<IconButton
{...props}
icon={copied ? "check" : "copy"}
showConfirm
onClick={async () => {
const content = typeof text === "function" ? await text() : text;
if (content == null) {
showToast({
id: "failed-to-copy",
color: "danger",
message: "Failed to copy",
});
} else {
copyToClipboard(content, { disableToast: true });
setCopied();
}
}}
/>
);
}

View File

@@ -1,8 +1,8 @@
import { useCreateDropdownItems } from '../hooks/useCreateDropdownItems'; import { useCreateDropdownItems } from "../hooks/useCreateDropdownItems";
import type { DropdownProps } from './core/Dropdown'; import type { DropdownProps } from "./core/Dropdown";
import { Dropdown } from './core/Dropdown'; import { Dropdown } from "./core/Dropdown";
interface Props extends Omit<DropdownProps, 'items'> { interface Props extends Omit<DropdownProps, "items"> {
hideFolder?: boolean; hideFolder?: boolean;
} }
@@ -10,7 +10,7 @@ export function CreateDropdown({ hideFolder, children, ...props }: Props) {
const getItems = useCreateDropdownItems({ const getItems = useCreateDropdownItems({
hideFolder, hideFolder,
hideIcons: true, hideIcons: true,
folderId: 'active-folder', folderId: "active-folder",
}); });
return ( return (

View File

@@ -1,12 +1,12 @@
import { createWorkspaceModel } from '@yaakapp-internal/models'; import { createWorkspaceModel } from "@yaakapp-internal/models";
import { useState } from 'react'; import { useState } from "react";
import { useToggle } from '../hooks/useToggle'; import { useToggle } from "../hooks/useToggle";
import { ColorIndicator } from './ColorIndicator'; import { ColorIndicator } from "./ColorIndicator";
import { Button } from './core/Button'; import { Button } from "./core/Button";
import { Checkbox } from './core/Checkbox'; import { Checkbox } from "./core/Checkbox";
import { ColorPickerWithThemeColors } from './core/ColorPicker'; import { ColorPickerWithThemeColors } from "./core/ColorPicker";
import { Label } from './core/Label'; import { Label } from "./core/Label";
import { PlainInput } from './core/PlainInput'; import { PlainInput } from "./core/PlainInput";
interface Props { interface Props {
onCreate: (id: string) => void; onCreate: (id: string) => void;
@@ -15,7 +15,7 @@ interface Props {
} }
export function CreateEnvironmentDialog({ workspaceId, hide, onCreate }: Props) { export function CreateEnvironmentDialog({ workspaceId, hide, onCreate }: Props) {
const [name, setName] = useState<string>(''); const [name, setName] = useState<string>("");
const [color, setColor] = useState<string | null>(null); const [color, setColor] = useState<string | null>(null);
const [sharable, toggleSharable] = useToggle(false); const [sharable, toggleSharable] = useToggle(false);
return ( return (
@@ -24,13 +24,13 @@ export function CreateEnvironmentDialog({ workspaceId, hide, onCreate }: Props)
onSubmit={async (e) => { onSubmit={async (e) => {
e.preventDefault(); e.preventDefault();
const id = await createWorkspaceModel({ const id = await createWorkspaceModel({
model: 'environment', model: "environment",
name, name,
color, color,
variables: [], variables: [],
public: sharable, public: sharable,
workspaceId, workspaceId,
parentModel: 'environment', parentModel: "environment",
}); });
hide(); hide();
onCreate(id); onCreate(id);

View File

@@ -1,26 +1,26 @@
import { gitMutations } from '@yaakapp-internal/git'; import { gitMutations } from "@yaakapp-internal/git";
import type { WorkspaceMeta } from '@yaakapp-internal/models'; import type { WorkspaceMeta } from "@yaakapp-internal/models";
import { createGlobalModel, updateModel } from '@yaakapp-internal/models'; import { createGlobalModel, updateModel } from "@yaakapp-internal/models";
import { useState } from 'react'; import { VStack } from "@yaakapp-internal/ui";
import { router } from '../lib/router'; import { useState } from "react";
import { setupOrConfigureEncryption } from '../lib/setupOrConfigureEncryption'; import { router } from "../lib/router";
import { invokeCmd } from '../lib/tauri'; import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
import { showErrorToast } from '../lib/toast'; import { invokeCmd } from "../lib/tauri";
import { Button } from './core/Button'; import { showErrorToast } from "../lib/toast";
import { Checkbox } from './core/Checkbox'; import { Button } from "./core/Button";
import { Label } from './core/Label'; import { Checkbox } from "./core/Checkbox";
import { PlainInput } from './core/PlainInput'; import { Label } from "./core/Label";
import { VStack } from './core/Stacks'; import { PlainInput } from "./core/PlainInput";
import { EncryptionHelp } from './EncryptionHelp'; import { EncryptionHelp } from "./EncryptionHelp";
import { gitCallbacks } from './git/callbacks'; import { gitCallbacks } from "./git/callbacks";
import { SyncToFilesystemSetting } from './SyncToFilesystemSetting'; import { SyncToFilesystemSetting } from "./SyncToFilesystemSetting";
interface Props { interface Props {
hide: () => void; hide: () => void;
} }
export function CreateWorkspaceDialog({ hide }: Props) { export function CreateWorkspaceDialog({ hide }: Props) {
const [name, setName] = useState<string>(''); const [name, setName] = useState<string>("");
const [syncConfig, setSyncConfig] = useState<{ const [syncConfig, setSyncConfig] = useState<{
filePath: string | null; filePath: string | null;
initGit?: boolean; initGit?: boolean;
@@ -34,12 +34,12 @@ export function CreateWorkspaceDialog({ hide }: Props) {
className="pb-3" className="pb-3"
onSubmit={async (e) => { onSubmit={async (e) => {
e.preventDefault(); e.preventDefault();
const workspaceId = await createGlobalModel({ model: 'workspace', name }); const workspaceId = await createGlobalModel({ model: "workspace", name });
if (workspaceId == null) return; if (workspaceId == null) return;
// Do getWorkspaceMeta instead of naively creating one because it might have // Do getWorkspaceMeta instead of naively creating one because it might have
// been created already when the store refreshes the workspace meta after // been created already when the store refreshes the workspace meta after
const workspaceMeta = await invokeCmd<WorkspaceMeta>('cmd_get_workspace_meta', { const workspaceMeta = await invokeCmd<WorkspaceMeta>("cmd_get_workspace_meta", {
workspaceId, workspaceId,
}); });
await updateModel({ await updateModel({
@@ -52,8 +52,8 @@ export function CreateWorkspaceDialog({ hide }: Props) {
.init.mutateAsync() .init.mutateAsync()
.catch((err) => { .catch((err) => {
showErrorToast({ showErrorToast({
id: 'git-init-error', id: "git-init-error",
title: 'Error initializing Git', title: "Error initializing Git",
message: String(err), message: String(err),
}); });
}); });
@@ -61,7 +61,7 @@ export function CreateWorkspaceDialog({ hide }: Props) {
// Navigate to workspace // Navigate to workspace
await router.navigate({ await router.navigate({
to: '/workspaces/$workspaceId', to: "/workspaces/$workspaceId",
params: { workspaceId }, params: { workspaceId },
}); });

View File

@@ -1,14 +1,14 @@
import { useAtomValue } from 'jotai'; import { useAtomValue } from "jotai";
import type { ComponentType } from 'react'; import type { ComponentType } from "react";
import { useCallback } from 'react'; import { useCallback } from "react";
import { dialogsAtom, hideDialog } from '../lib/dialog'; import { dialogsAtom, hideDialog } from "../lib/dialog";
import { Dialog, type DialogProps } from './core/Dialog'; import { Dialog, type DialogProps } from "./core/Dialog";
import { ErrorBoundary } from './ErrorBoundary'; import { ErrorBoundary } from "./ErrorBoundary";
export type DialogInstance = { export type DialogInstance = {
id: string; id: string;
render: ComponentType<{ hide: () => void }>; render: ComponentType<{ hide: () => void }>;
} & Omit<DialogProps, 'open' | 'children'>; } & Omit<DialogProps, "open" | "children">;
export function Dialogs() { export function Dialogs() {
const dialogs = useAtomValue(dialogsAtom); const dialogs = useAtomValue(dialogsAtom);

View File

@@ -1,12 +1,21 @@
import type { DnsOverride, Workspace } from '@yaakapp-internal/models'; import type { DnsOverride, Workspace } from "@yaakapp-internal/models";
import { patchModel } from '@yaakapp-internal/models'; import { patchModel } from "@yaakapp-internal/models";
import { useCallback, useId, useMemo } from 'react'; import { fireAndForget } from "../lib/fireAndForget";
import { Button } from './core/Button'; import {
import { Checkbox } from './core/Checkbox'; HStack,
import { IconButton } from './core/IconButton'; Table,
import { PlainInput } from './core/PlainInput'; TableBody,
import { HStack, VStack } from './core/Stacks'; TableCell,
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from './core/Table'; TableHead,
TableHeaderCell,
TableRow,
VStack,
} from "@yaakapp-internal/ui";
import { useCallback, useId, useMemo } from "react";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { IconButton } from "./core/IconButton";
import { PlainInput } from "./core/PlainInput";
interface Props { interface Props {
workspace: Workspace; workspace: Workspace;
@@ -29,15 +38,15 @@ export function DnsOverridesEditor({ workspace }: Props) {
const handleChange = useCallback( const handleChange = useCallback(
(overrides: DnsOverride[]) => { (overrides: DnsOverride[]) => {
patchModel(workspace, { settingDnsOverrides: overrides }); fireAndForget(patchModel(workspace, { settingDnsOverrides: overrides }));
}, },
[workspace], [workspace],
); );
const handleAdd = useCallback(() => { const handleAdd = useCallback(() => {
const newOverride: DnsOverride = { const newOverride: DnsOverride = {
hostname: '', hostname: "",
ipv4: [''], ipv4: [""],
ipv6: [], ipv6: [],
enabled: true, enabled: true,
}; };
@@ -65,9 +74,9 @@ export function DnsOverridesEditor({ workspace }: Props) {
return ( return (
<VStack space={3} className="pb-3"> <VStack space={3} className="pb-3">
<div className="text-text-subtle text-sm"> <div className="text-text-subtle text-sm">
Override DNS resolution for specific hostnames. This works like{' '} Override DNS resolution for specific hostnames. This works like{" "}
<code className="text-text-subtlest bg-surface-highlight px-1 rounded">/etc/hosts</code>{' '} <code className="text-text-subtlest bg-surface-highlight px-1 rounded">/etc/hosts</code> but
but only for requests made from this workspace. only for requests made from this workspace.
</div> </div>
{overridesWithIds.length > 0 && ( {overridesWithIds.length > 0 && (
@@ -110,15 +119,15 @@ interface DnsOverrideRowProps {
} }
function DnsOverrideRow({ override, onUpdate, onDelete }: DnsOverrideRowProps) { function DnsOverrideRow({ override, onUpdate, onDelete }: DnsOverrideRowProps) {
const ipv4Value = override.ipv4.join(', '); const ipv4Value = override.ipv4.join(", ");
const ipv6Value = override.ipv6.join(', '); const ipv6Value = override.ipv6.join(", ");
return ( return (
<TableRow> <TableRow>
<TableCell> <TableCell>
<Checkbox <Checkbox
hideLabel hideLabel
title={override.enabled ? 'Disable override' : 'Enable override'} title={override.enabled ? "Disable override" : "Enable override"}
checked={override.enabled ?? true} checked={override.enabled ?? true}
onChange={(enabled) => onUpdate({ enabled })} onChange={(enabled) => onUpdate({ enabled })}
/> />
@@ -143,7 +152,7 @@ function DnsOverrideRow({ override, onUpdate, onDelete }: DnsOverrideRowProps) {
onChange={(value) => onChange={(value) =>
onUpdate({ onUpdate({
ipv4: value ipv4: value
.split(',') .split(",")
.map((s) => s.trim()) .map((s) => s.trim())
.filter(Boolean), .filter(Boolean),
}) })
@@ -160,7 +169,7 @@ function DnsOverrideRow({ override, onUpdate, onDelete }: DnsOverrideRowProps) {
onChange={(value) => onChange={(value) =>
onUpdate({ onUpdate({
ipv6: value ipv6: value
.split(',') .split(",")
.map((s) => s.trim()) .map((s) => s.trim())
.filter(Boolean), .filter(Boolean),
}) })

View File

@@ -0,0 +1,34 @@
import classNames from "classnames";
import type { CSSProperties } from "react";
import { memo } from "react";
interface Props {
className?: string;
style?: CSSProperties;
orientation?: "horizontal" | "vertical";
}
export const DropMarker = memo(
function DropMarker({ className, style, orientation = "horizontal" }: Props) {
return (
<div
style={style}
className={classNames(
className,
"absolute pointer-events-none z-50",
orientation === "horizontal" && "w-full",
orientation === "vertical" && "w-0 top-0 bottom-0",
)}
>
<div
className={classNames(
"absolute bg-primary rounded-full",
orientation === "horizontal" && "left-2 right-2 -bottom-[0.1rem] h-[0.2rem]",
orientation === "vertical" && "-left-[0.1rem] top-0 bottom-0 w-[0.2rem]",
)}
/>
</div>
);
},
() => true,
);

View File

@@ -1,5 +1,5 @@
import type { Folder, HttpRequest } from '@yaakapp-internal/models'; import type { Folder, HttpRequest } from "@yaakapp-internal/models";
import { foldersAtom, httpRequestsAtom } from '@yaakapp-internal/models'; import { foldersAtom, httpRequestsAtom } from "@yaakapp-internal/models";
import type { import type {
FormInput, FormInput,
FormInputCheckbox, FormInputCheckbox,
@@ -10,33 +10,32 @@ import type {
FormInputSelect, FormInputSelect,
FormInputText, FormInputText,
JsonPrimitive, JsonPrimitive,
} from '@yaakapp-internal/plugins'; } from "@yaakapp-internal/plugins";
import classNames from 'classnames'; import { Banner, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from 'jotai'; import classNames from "classnames";
import { useCallback, useEffect, useMemo } from 'react'; import { useAtomValue } from "jotai";
import { useActiveRequest } from '../hooks/useActiveRequest'; import { useCallback, useEffect, useMemo } from "react";
import { useRandomKey } from '../hooks/useRandomKey'; import { useActiveRequest } from "../hooks/useActiveRequest";
import { capitalize } from '../lib/capitalize'; import { useRandomKey } from "../hooks/useRandomKey";
import { showDialog } from '../lib/dialog'; import { capitalize } from "../lib/capitalize";
import { resolvedModelName } from '../lib/resolvedModelName'; import { showDialog } from "../lib/dialog";
import { Banner } from './core/Banner'; import { resolvedModelName } from "../lib/resolvedModelName";
import { Checkbox } from './core/Checkbox'; import { Checkbox } from "./core/Checkbox";
import { DetailsBanner } from './core/DetailsBanner'; import { DetailsBanner } from "./core/DetailsBanner";
import { Editor } from './core/Editor/LazyEditor'; import { Editor } from "./core/Editor/LazyEditor";
import { IconButton } from './core/IconButton'; import { IconButton } from "./core/IconButton";
import type { InputProps } from './core/Input'; import type { InputProps } from "./core/Input";
import { Input } from './core/Input'; import { Input } from "./core/Input";
import { Label } from './core/Label'; import { Label } from "./core/Label";
import type { Pair } from './core/PairEditor'; import type { Pair } from "./core/PairEditor";
import { PairEditor } from './core/PairEditor'; import { PairEditor } from "./core/PairEditor";
import { PlainInput } from './core/PlainInput'; import { PlainInput } from "./core/PlainInput";
import { Select } from './core/Select'; import { Select } from "./core/Select";
import { VStack } from './core/Stacks'; import { Markdown } from "./Markdown";
import { Markdown } from './Markdown'; import { SelectFile } from "./SelectFile";
import { SelectFile } from './SelectFile';
export const DYNAMIC_FORM_NULL_ARG = '__NULL__'; export const DYNAMIC_FORM_NULL_ARG = "__NULL__";
const INPUT_SIZE = 'sm'; const INPUT_SIZE = "sm";
interface Props<T> { interface Props<T> {
inputs: FormInput[] | undefined | null; inputs: FormInput[] | undefined | null;
@@ -75,7 +74,7 @@ export function DynamicForm<T extends Record<string, JsonPrimitive>>({
autocompleteFunctions={autocompleteFunctions} autocompleteFunctions={autocompleteFunctions}
autocompleteVariables={autocompleteVariables} autocompleteVariables={autocompleteVariables}
data={data} data={data}
className={classNames(className, 'pb-4')} // Pad the bottom to look nice className={classNames(className, "pb-4")} // Pad the bottom to look nice
/> />
); );
} }
@@ -89,8 +88,8 @@ function FormInputsStack<T extends Record<string, JsonPrimitive>>({
space={3} space={3}
className={classNames( className={classNames(
className, className,
'h-full overflow-auto', "h-full overflow-auto",
'pr-1', // A bit of space between inputs and scrollbar "pr-1", // A bit of space between inputs and scrollbar
)} )}
> >
<FormInputs {...props} /> <FormInputs {...props} />
@@ -100,7 +99,7 @@ function FormInputsStack<T extends Record<string, JsonPrimitive>>({
type FormInputsProps<T> = Pick< type FormInputsProps<T> = Pick<
Props<T>, Props<T>,
'inputs' | 'autocompleteFunctions' | 'autocompleteVariables' | 'stateKey' | 'data' "inputs" | "autocompleteFunctions" | "autocompleteVariables" | "stateKey" | "data"
> & { > & {
setDataAttr: (name: string, value: JsonPrimitive) => void; setDataAttr: (name: string, value: JsonPrimitive) => void;
disabled?: boolean; disabled?: boolean;
@@ -118,16 +117,16 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
return ( return (
<> <>
{inputs?.map((input, i) => { {inputs?.map((input, i) => {
if ('hidden' in input && input.hidden) { if ("hidden" in input && input.hidden) {
return null; return null;
} }
if ('disabled' in input && disabled != null) { if ("disabled" in input && disabled != null) {
input.disabled = disabled; input.disabled = disabled;
} }
switch (input.type) { switch (input.type) {
case 'select': case "select":
return ( return (
<SelectArg <SelectArg
key={i + stateKey} key={i + stateKey}
@@ -140,7 +139,7 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
} }
/> />
); );
case 'text': case "text":
return ( return (
<TextArg <TextArg
key={i + stateKey} key={i + stateKey}
@@ -150,11 +149,11 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
autocompleteVariables={autocompleteVariables || false} autocompleteVariables={autocompleteVariables || false}
onChange={(v) => setDataAttr(input.name, v)} onChange={(v) => setDataAttr(input.name, v)}
value={ value={
data[input.name] != null ? String(data[input.name]) : (input.defaultValue ?? '') data[input.name] != null ? String(data[input.name]) : (input.defaultValue ?? "")
} }
/> />
); );
case 'editor': case "editor":
return ( return (
<EditorArg <EditorArg
key={i + stateKey} key={i + stateKey}
@@ -164,11 +163,11 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
autocompleteVariables={autocompleteVariables || false} autocompleteVariables={autocompleteVariables || false}
onChange={(v) => setDataAttr(input.name, v)} onChange={(v) => setDataAttr(input.name, v)}
value={ value={
data[input.name] != null ? String(data[input.name]) : (input.defaultValue ?? '') data[input.name] != null ? String(data[input.name]) : (input.defaultValue ?? "")
} }
/> />
); );
case 'checkbox': case "checkbox":
return ( return (
<CheckboxArg <CheckboxArg
key={i + stateKey} key={i + stateKey}
@@ -177,7 +176,7 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
value={data[input.name] != null ? data[input.name] === true : false} value={data[input.name] != null ? data[input.name] === true : false}
/> />
); );
case 'http_request': case "http_request":
return ( return (
<HttpRequestArg <HttpRequestArg
key={i + stateKey} key={i + stateKey}
@@ -186,7 +185,7 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
value={data[input.name] != null ? String(data[input.name]) : DYNAMIC_FORM_NULL_ARG} value={data[input.name] != null ? String(data[input.name]) : DYNAMIC_FORM_NULL_ARG}
/> />
); );
case 'file': case "file":
return ( return (
<FileArg <FileArg
key={i + stateKey} key={i + stateKey}
@@ -197,7 +196,7 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
} }
/> />
); );
case 'accordion': case "accordion":
if (!hasVisibleInputs(input.inputs)) { if (!hasVisibleInputs(input.inputs)) {
return null; return null;
} }
@@ -205,7 +204,7 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
<div key={i + stateKey}> <div key={i + stateKey}>
<DetailsBanner <DetailsBanner
summary={input.label} summary={input.label}
className={classNames('!mb-auto', disabled && 'opacity-disabled')} className={classNames("!mb-auto", disabled && "opacity-disabled")}
> >
<div className="mt-3"> <div className="mt-3">
<FormInputsStack <FormInputsStack
@@ -221,7 +220,7 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
</DetailsBanner> </DetailsBanner>
</div> </div>
); );
case 'h_stack': case "h_stack":
if (!hasVisibleInputs(input.inputs)) { if (!hasVisibleInputs(input.inputs)) {
return null; return null;
} }
@@ -238,7 +237,7 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
/> />
</div> </div>
); );
case 'banner': case "banner":
if (!hasVisibleInputs(input.inputs)) { if (!hasVisibleInputs(input.inputs)) {
return null; return null;
} }
@@ -246,7 +245,7 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
<Banner <Banner
key={i + stateKey} key={i + stateKey}
color={input.color} color={input.color}
className={classNames(disabled && 'opacity-disabled')} className={classNames(disabled && "opacity-disabled")}
> >
<FormInputsStack <FormInputsStack
data={data} data={data}
@@ -259,9 +258,9 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
/> />
</Banner> </Banner>
); );
case 'markdown': case "markdown":
return <Markdown key={i + stateKey}>{input.content}</Markdown>; return <Markdown key={i + stateKey}>{input.content}</Markdown>;
case 'key_value': case "key_value":
return ( return (
<KeyValueArg <KeyValueArg
key={i + stateKey} key={i + stateKey}
@@ -269,7 +268,7 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
stateKey={stateKey} stateKey={stateKey}
onChange={(v) => setDataAttr(input.name, v)} onChange={(v) => setDataAttr(input.name, v)}
value={ value={
data[input.name] != null ? String(data[input.name]) : (input.defaultValue ?? '[]') data[input.name] != null ? String(data[input.name]) : (input.defaultValue ?? "[]")
} }
/> />
); );
@@ -301,12 +300,12 @@ function TextArg({
onChange, onChange,
name: arg.name, name: arg.name,
multiLine: arg.multiLine, multiLine: arg.multiLine,
className: arg.multiLine ? 'min-h-[4rem]' : undefined, className: arg.multiLine ? "min-h-[4rem]" : undefined,
defaultValue: value === DYNAMIC_FORM_NULL_ARG ? arg.defaultValue : value, defaultValue: value === DYNAMIC_FORM_NULL_ARG ? arg.defaultValue : value,
required: !arg.optional, required: !arg.optional,
disabled: arg.disabled, disabled: arg.disabled,
help: arg.description, help: arg.description,
type: arg.password ? 'password' : 'text', type: arg.password ? "password" : "text",
label: arg.label ?? arg.name, label: arg.label ?? arg.name,
size: INPUT_SIZE, size: INPUT_SIZE,
hideLabel: arg.hideLabel ?? arg.label == null, hideLabel: arg.hideLabel ?? arg.label == null,
@@ -358,9 +357,9 @@ function EditorArg({
</Label> </Label>
<div <div
className={classNames( className={classNames(
'border border-border rounded-md overflow-hidden px-2 py-1', "border border-border rounded-md overflow-hidden px-2 py-1",
'focus-within:border-border-focus', "focus-within:border-border-focus",
!arg.rows && 'max-h-[10rem]', // So it doesn't take up too much space !arg.rows && "max-h-[10rem]", // So it doesn't take up too much space
)} )}
style={arg.rows ? { height: `${arg.rows * 1.4 + 0.75}rem` } : undefined} style={arg.rows ? { height: `${arg.rows * 1.4 + 0.75}rem` } : undefined}
> >
@@ -390,10 +389,10 @@ function EditorArg({
title="Pop out to large editor" title="Pop out to large editor"
onClick={() => { onClick={() => {
showDialog({ showDialog({
id: 'id', id: "id",
size: 'full', size: "full",
title: arg.readOnly ? 'View Value' : 'Edit Value', title: arg.readOnly ? "View Value" : "Edit Value",
className: '!max-w-[50rem] !max-h-[60rem]', className: "!max-w-[50rem] !max-h-[60rem]",
description: arg.label && ( description: arg.label && (
<Label <Label
htmlFor={id} htmlFor={id}
@@ -496,7 +495,7 @@ function HttpRequestArg({
}) { }) {
const folders = useAtomValue(foldersAtom); const folders = useAtomValue(foldersAtom);
const httpRequests = useAtomValue(httpRequestsAtom); const httpRequests = useAtomValue(httpRequestsAtom);
const activeHttpRequest = useActiveRequest('http_request'); const activeHttpRequest = useActiveRequest("http_request");
useEffect(() => { useEffect(() => {
if (value === DYNAMIC_FORM_NULL_ARG && activeHttpRequest) { if (value === DYNAMIC_FORM_NULL_ARG && activeHttpRequest) {
@@ -512,16 +511,14 @@ function HttpRequestArg({
help={arg.description} help={arg.description}
value={value} value={value}
disabled={arg.disabled} disabled={arg.disabled}
options={[ options={httpRequests.map((r) => {
...httpRequests.map((r) => { return {
return { label:
label: buildRequestBreadcrumbs(r, folders).join(" / ") +
buildRequestBreadcrumbs(r, folders).join(' / ') + (r.id === activeHttpRequest?.id ? " (current)" : ""),
(r.id === activeHttpRequest?.id ? ' (current)' : ''), value: r.id,
value: r.id, };
}; })}
}),
]}
/> />
); );
} }
@@ -541,7 +538,7 @@ function buildRequestBreadcrumbs(request: HttpRequest, folders: Folder[]): strin
}; };
next(); next();
return ancestors.map((a) => (a.model === 'folder' ? a.name : resolvedModelName(a))); return ancestors.map((a) => (a.model === "folder" ? a.name : resolvedModelName(a)));
} }
function CheckboxArg({ function CheckboxArg({
@@ -618,7 +615,7 @@ function hasVisibleInputs(inputs: FormInput[] | undefined): boolean {
if (!inputs) return false; if (!inputs) return false;
for (const input of inputs) { for (const input of inputs) {
if ('inputs' in input && !hasVisibleInputs(input.inputs)) { if ("inputs" in input && !hasVisibleInputs(input.inputs)) {
// Has children, but none are visible // Has children, but none are visible
return false; return false;
} }

View File

@@ -1,5 +1,5 @@
import classNames from 'classnames'; import classNames from "classnames";
import type { ReactNode } from 'react'; import type { ReactNode } from "react";
interface Props { interface Props {
children: ReactNode; children: ReactNode;
@@ -12,8 +12,8 @@ export function EmptyStateText({ children, className }: Props) {
<div <div
className={classNames( className={classNames(
className, className,
'rounded-lg border border-dashed border-border-subtle', "rounded-lg border border-dashed border-border-subtle",
'h-full py-2 text-text-subtlest flex items-center justify-center italic', "h-full py-2 text-text-subtlest flex items-center justify-center italic",
)} )}
> >
{children} {children}

View File

@@ -1,4 +1,4 @@
import { VStack } from './core/Stacks'; import { VStack } from "@yaakapp-internal/ui";
export function EncryptionHelp() { export function EncryptionHelp() {
return ( return (

View File

@@ -1,19 +1,19 @@
import classNames from 'classnames'; import classNames from "classnames";
import { memo, useMemo } from 'react'; import { memo, useMemo } from "react";
import { useActiveEnvironment } from '../hooks/useActiveEnvironment'; import { useActiveEnvironment } from "../hooks/useActiveEnvironment";
import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown'; import { useEnvironmentsBreakdown } from "../hooks/useEnvironmentsBreakdown";
import { editEnvironment } from '../lib/editEnvironment'; import { editEnvironment } from "../lib/editEnvironment";
import { setWorkspaceSearchParams } from '../lib/setWorkspaceSearchParams'; import { setWorkspaceSearchParams } from "../lib/setWorkspaceSearchParams";
import type { ButtonProps } from './core/Button'; import type { ButtonProps } from "./core/Button";
import { Button } from './core/Button'; import { Button } from "./core/Button";
import type { DropdownItem } from './core/Dropdown'; import type { DropdownItem } from "./core/Dropdown";
import { Dropdown } from './core/Dropdown'; import { Dropdown } from "./core/Dropdown";
import { Icon } from './core/Icon'; import { Icon } from "@yaakapp-internal/ui";
import { EnvironmentColorIndicator } from './EnvironmentColorIndicator'; import { EnvironmentColorIndicator } from "./EnvironmentColorIndicator";
type Props = { type Props = {
className?: string; className?: string;
} & Pick<ButtonProps, 'forDropdown' | 'leftSlot'>; } & Pick<ButtonProps, "forDropdown" | "leftSlot">;
export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdown({ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdown({
className, className,
@@ -41,11 +41,11 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
[activeEnvironment?.id], [activeEnvironment?.id],
), ),
...((subEnvironments.length > 0 ...((subEnvironments.length > 0
? [{ type: 'separator', label: 'Environments' }] ? [{ type: "separator", label: "Environments" }]
: []) as DropdownItem[]), : []) as DropdownItem[]),
{ {
label: 'Manage Environments', label: "Manage Environments",
hotKeyAction: 'environment_editor.toggle', hotKeyAction: "environment_editor.toggle",
leftSlot: <Icon icon="box" />, leftSlot: <Icon icon="box" />,
onSelect: () => editEnvironment(activeEnvironment), onSelect: () => editEnvironment(activeEnvironment),
}, },
@@ -62,8 +62,8 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
size="sm" size="sm"
className={classNames( className={classNames(
className, className,
'text !px-2 truncate', "text !px-2 truncate",
!activeEnvironment && !hasBaseVars && 'text-text-subtlest italic', !activeEnvironment && !hasBaseVars && "text-text-subtlest italic",
)} )}
// If no environments, the button simply opens the dialog. // If no environments, the button simply opens the dialog.
// NOTE: We don't create a new button because we want to reuse the hotkey from the menu items // NOTE: We don't create a new button because we want to reuse the hotkey from the menu items
@@ -71,7 +71,7 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
{...buttonProps} {...buttonProps}
> >
<EnvironmentColorIndicator environment={activeEnvironment ?? null} /> <EnvironmentColorIndicator environment={activeEnvironment ?? null} />
{activeEnvironment?.name ?? (hasBaseVars ? 'Environment' : 'No Environment')} {activeEnvironment?.name ?? (hasBaseVars ? "Environment" : "No Environment")}
</Button> </Button>
</Dropdown> </Dropdown>
); );

View File

@@ -1,6 +1,6 @@
import type { Environment } from '@yaakapp-internal/models'; import type { Environment } from "@yaakapp-internal/models";
import { showColorPicker } from '../lib/showColorPicker'; import { showColorPicker } from "../lib/showColorPicker";
import { ColorIndicator } from './ColorIndicator'; import { ColorIndicator } from "./ColorIndicator";
export function EnvironmentColorIndicator({ export function EnvironmentColorIndicator({
environment, environment,

View File

@@ -1,8 +1,8 @@
import { useState } from 'react'; import { useState } from "react";
import { ColorIndicator } from './ColorIndicator'; import { ColorIndicator } from "./ColorIndicator";
import { Banner } from './core/Banner'; import { Banner } from "@yaakapp-internal/ui";
import { Button } from './core/Button'; import { Button } from "./core/Button";
import { ColorPickerWithThemeColors } from './core/ColorPicker'; import { ColorPickerWithThemeColors } from "./core/ColorPicker";
export function EnvironmentColorPicker({ export function EnvironmentColorPicker({
color: defaultColor, color: defaultColor,

View File

@@ -1,32 +1,37 @@
import type { Environment, Workspace } from '@yaakapp-internal/models'; import type { Environment, Workspace } from "@yaakapp-internal/models";
import { duplicateModel, patchModel } from '@yaakapp-internal/models'; import { duplicateModel, patchModel } from "@yaakapp-internal/models";
import { atom, useAtomValue } from 'jotai'; import type { TreeHandle, TreeNode, TreeProps } from "@yaakapp-internal/ui";
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { Banner, Icon, InlineCode, SplitLayout, Tree } from "@yaakapp-internal/ui";
import { createSubEnvironmentAndActivate } from '../commands/createEnvironment'; import { atom, useAtomValue } from "jotai";
import { activeWorkspaceAtom, activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace'; import { atomFamily } from "jotai-family";
import { useCallback, useLayoutEffect, useRef, useState } from "react";
import { createSubEnvironmentAndActivate } from "../commands/createEnvironment";
import { activeWorkspaceAtom, activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
import { import {
environmentsBreakdownAtom, environmentsBreakdownAtom,
useEnvironmentsBreakdown, useEnvironmentsBreakdown,
} from '../hooks/useEnvironmentsBreakdown'; } from "../hooks/useEnvironmentsBreakdown";
import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm'; import { useHotKey } from "../hooks/useHotKey";
import { jotaiStore } from '../lib/jotai'; import { atomWithKVStorage } from "../lib/atoms/atomWithKVStorage";
import { isBaseEnvironment, isSubEnvironment } from '../lib/model_util'; import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
import { resolvedModelName } from '../lib/resolvedModelName'; import { fireAndForget } from "../lib/fireAndForget";
import { showColorPicker } from '../lib/showColorPicker'; import { jotaiStore } from "../lib/jotai";
import { Banner } from './core/Banner'; import { isBaseEnvironment, isSubEnvironment } from "../lib/model_util";
import type { ContextMenuProps, DropdownItem } from './core/Dropdown'; import { resolvedModelName } from "../lib/resolvedModelName";
import { Icon } from './core/Icon'; import { showColorPicker } from "../lib/showColorPicker";
import { IconButton } from './core/IconButton'; import type { ContextMenuProps, DropdownItem } from "./core/Dropdown";
import { IconTooltip } from './core/IconTooltip'; import { ContextMenu } from "./core/Dropdown";
import { InlineCode } from './core/InlineCode'; import { IconButton } from "./core/IconButton";
import type { PairEditorHandle } from './core/PairEditor'; import { IconTooltip } from "./core/IconTooltip";
import { SplitLayout } from './core/SplitLayout'; import type { PairEditorHandle } from "./core/PairEditor";
import type { TreeNode } from './core/tree/common'; import { EnvironmentColorIndicator } from "./EnvironmentColorIndicator";
import type { TreeHandle, TreeProps } from './core/tree/Tree'; import { EnvironmentEditor } from "./EnvironmentEditor";
import { Tree } from './core/tree/Tree'; import { EnvironmentSharableTooltip } from "./EnvironmentSharableTooltip";
import { EnvironmentColorIndicator } from './EnvironmentColorIndicator';
import { EnvironmentEditor } from './EnvironmentEditor'; const collapsedFamily = atomFamily((treeId: string) => {
import { EnvironmentSharableTooltip } from './EnvironmentSharableTooltip'; const key = ["env_collapsed", treeId ?? "n/a"];
return atomWithKVStorage<Record<string, boolean>>(key, {});
});
interface Props { interface Props {
initialEnvironmentId: string | null; initialEnvironmentId: string | null;
@@ -48,7 +53,7 @@ export function EnvironmentEditDialog({ initialEnvironmentId, setRef }: Props) {
return ( return (
<SplitLayout <SplitLayout
name="env_editor" storageKey="env_editor"
defaultRatio={0.75} defaultRatio={0.75}
layout="horizontal" layout="horizontal"
className="gap-0" className="gap-0"
@@ -107,12 +112,12 @@ function EnvironmentEditDialogSidebar({
selectedEnvironmentId: string | null; selectedEnvironmentId: string | null;
setSelectedEnvironmentId: (id: string | null) => void; setSelectedEnvironmentId: (id: string | null) => void;
}) { }) {
const activeWorkspaceId = useAtomValue(activeWorkspaceIdAtom) ?? ''; const activeWorkspaceId = useAtomValue(activeWorkspaceIdAtom) ?? "";
const treeId = `environment.${activeWorkspaceId}.sidebar`; const treeId = `environment.${activeWorkspaceId}.sidebar`;
const treeRef = useRef<TreeHandle>(null); const treeRef = useRef<TreeHandle>(null);
const { baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown(); const { baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown();
// biome-ignore lint/correctness/useExhaustiveDependencies: none // oxlint-disable-next-line react-hooks/exhaustive-deps -- none
useLayoutEffect(() => { useLayoutEffect(() => {
if (selectedEnvironmentId == null) return; if (selectedEnvironmentId == null) return;
treeRef.current?.selectItem(selectedEnvironmentId); treeRef.current?.selectItem(selectedEnvironmentId);
@@ -129,57 +134,73 @@ function EnvironmentEditDialogSidebar({
[baseEnvironment?.id, selectedEnvironmentId, setSelectedEnvironmentId], [baseEnvironment?.id, selectedEnvironmentId, setSelectedEnvironmentId],
); );
const actions = useMemo(() => { const treeHasFocus = useCallback(() => treeRef.current?.hasFocus() ?? false, []);
const enable = () => treeRef.current?.hasFocus() ?? false;
const actions = { const getSelectedTreeModels = useCallback(
'sidebar.selected.rename': { () => treeRef.current?.getSelectedItems() as TreeModel[] | undefined,
enable, [],
allowDefault: true, );
priority: 100,
cb: async (items: TreeModel[]) => {
const item = items[0];
if (items.length === 1 && item != null) {
treeRef.current?.renameItem(item.id);
}
},
},
'sidebar.selected.delete': {
priority: 100,
enable,
cb: (items: TreeModel[]) => deleteModelWithConfirm(items),
},
'sidebar.selected.duplicate': {
priority: 100,
enable,
cb: async (items: TreeModel[]) => {
if (items.length === 1 && items[0]) {
const item = items[0];
const newId = await duplicateModel(item);
setSelectedEnvironmentId(newId);
} else {
await Promise.all(items.map(duplicateModel));
}
},
},
} as const;
return actions;
}, [setSelectedEnvironmentId]);
const hotkeys = useMemo<TreeProps<TreeModel>['hotkeys']>(() => ({ actions }), [actions]); const handleRenameSelected = useCallback(() => {
const items = getSelectedTreeModels();
if (items?.length === 1 && items[0] != null) {
treeRef.current?.renameItem(items[0].id);
}
}, [getSelectedTreeModels]);
const handleDeleteSelected = useCallback(
(items: TreeModel[]) => deleteModelWithConfirm(items),
[],
);
const handleDuplicateSelected = useCallback(
async (items: TreeModel[]) => {
if (items.length === 1 && items[0]) {
const newId = await duplicateModel(items[0]);
setSelectedEnvironmentId(newId);
} else {
await Promise.all(items.map(duplicateModel));
}
},
[setSelectedEnvironmentId],
);
useHotKey("sidebar.selected.rename", handleRenameSelected, {
enable: treeHasFocus,
allowDefault: true,
priority: 100,
});
useHotKey(
"sidebar.selected.delete",
useCallback(() => {
const items = getSelectedTreeModels();
if (items) {
fireAndForget(handleDeleteSelected(items));
}
}, [getSelectedTreeModels, handleDeleteSelected]),
{ enable: treeHasFocus, priority: 100 },
);
useHotKey(
"sidebar.selected.duplicate",
useCallback(async () => {
const items = getSelectedTreeModels();
if (items) await handleDuplicateSelected(items);
}, [getSelectedTreeModels, handleDuplicateSelected]),
{ enable: treeHasFocus, priority: 100 },
);
const getContextMenu = useCallback( const getContextMenu = useCallback(
(items: TreeModel[]): ContextMenuProps['items'] => { (items: TreeModel[]): ContextMenuProps["items"] => {
const environment = items[0]; const environment = items[0];
const addEnvironmentItem: DropdownItem = { const addEnvironmentItem: DropdownItem = {
label: 'Create Sub Environment', label: "Create Sub Environment",
leftSlot: <Icon icon="plus" />, leftSlot: <Icon icon="plus" />,
onSelect: async () => { onSelect: async () => {
await createSubEnvironment(); await createSubEnvironment();
}, },
}; };
if (environment == null || environment.model !== 'environment') { if (environment == null || environment.model !== "environment") {
return [addEnvironmentItem]; return [addEnvironmentItem];
} }
@@ -190,36 +211,34 @@ function EnvironmentEditDialogSidebar({
const menuItems: DropdownItem[] = [ const menuItems: DropdownItem[] = [
{ {
label: 'Rename', label: "Rename",
leftSlot: <Icon icon="pencil" />, leftSlot: <Icon icon="pencil" />,
hidden: isBaseEnvironment(environment) || !singleEnvironment, hidden: isBaseEnvironment(environment) || !singleEnvironment,
hotKeyAction: 'sidebar.selected.rename', hotKeyAction: "sidebar.selected.rename",
hotKeyLabelOnly: true, hotKeyLabelOnly: true,
onSelect: async () => { onSelect: () => {
// Not sure why this is needed, but without it the // Not sure why this is needed, but without it the
// edit input blurs immediately after opening. // edit input blurs immediately after opening.
requestAnimationFrame(() => { requestAnimationFrame(() => handleRenameSelected());
actions['sidebar.selected.rename'].cb(items);
});
}, },
}, },
{ {
label: 'Duplicate', label: "Duplicate",
leftSlot: <Icon icon="copy" />, leftSlot: <Icon icon="copy" />,
hidden: isBaseEnvironment(environment), hidden: isBaseEnvironment(environment),
hotKeyAction: 'sidebar.selected.duplicate', hotKeyAction: "sidebar.selected.duplicate",
hotKeyLabelOnly: true, hotKeyLabelOnly: true,
onSelect: () => actions['sidebar.selected.duplicate'].cb(items), onSelect: () => handleDuplicateSelected(items),
}, },
{ {
label: environment.color ? 'Change Color' : 'Assign Color', label: environment.color ? "Change Color" : "Assign Color",
leftSlot: <Icon icon="palette" />, leftSlot: <Icon icon="palette" />,
hidden: isBaseEnvironment(environment) || !singleEnvironment, hidden: isBaseEnvironment(environment) || !singleEnvironment,
onSelect: async () => showColorPicker(environment), onSelect: async () => showColorPicker(environment),
}, },
{ {
label: `Make ${environment.public ? 'Private' : 'Sharable'}`, label: `Make ${environment.public ? "Private" : "Sharable"}`,
leftSlot: <Icon icon={environment.public ? 'eye_closed' : 'eye'} />, leftSlot: <Icon icon={environment.public ? "eye_closed" : "eye"} />,
rightSlot: <EnvironmentSharableTooltip />, rightSlot: <EnvironmentSharableTooltip />,
hidden: items.length > 1, hidden: items.length > 1,
onSelect: async () => { onSelect: async () => {
@@ -227,9 +246,9 @@ function EnvironmentEditDialogSidebar({
}, },
}, },
{ {
color: 'danger', color: "danger",
label: 'Delete', label: "Delete",
hotKeyAction: 'sidebar.selected.delete', hotKeyAction: "sidebar.selected.delete",
hotKeyLabelOnly: true, hotKeyLabelOnly: true,
hidden: !canDeleteEnvironment, hidden: !canDeleteEnvironment,
leftSlot: <Icon icon="trash" />, leftSlot: <Icon icon="trash" />,
@@ -239,13 +258,18 @@ function EnvironmentEditDialogSidebar({
// Add sub environment to base environment // Add sub environment to base environment
if (isBaseEnvironment(environment) && singleEnvironment) { if (isBaseEnvironment(environment) && singleEnvironment) {
menuItems.push({ type: 'separator' }); menuItems.push({ type: "separator" });
menuItems.push(addEnvironmentItem); menuItems.push(addEnvironmentItem);
} }
return menuItems; return menuItems;
}, },
[actions, baseEnvironments.length, handleDeleteEnvironment], [
baseEnvironments.length,
handleDeleteEnvironment,
handleDuplicateSelected,
handleRenameSelected,
],
); );
const handleDragEnd = useCallback(async function handleDragEnd({ const handleDragEnd = useCallback(async function handleDragEnd({
@@ -292,6 +316,13 @@ function EnvironmentEditDialogSidebar({
[setSelectedEnvironmentId], [setSelectedEnvironmentId],
); );
const renderContextMenuFn = useCallback<NonNullable<TreeProps<TreeModel>["renderContextMenu"]>>(
({ items, position, onClose }) => (
<ContextMenu items={items as DropdownItem[]} triggerPosition={position} onClose={onClose} />
),
[],
);
const tree = useAtomValue(treeAtom); const tree = useAtomValue(treeAtom);
return ( return (
<aside className="x-theme-sidebar h-full w-full min-w-0 grid overflow-y-auto border-r border-border-subtle "> <aside className="x-theme-sidebar h-full w-full min-w-0 grid overflow-y-auto border-r border-border-subtle ">
@@ -300,10 +331,11 @@ function EnvironmentEditDialogSidebar({
<Tree <Tree
ref={treeRef} ref={treeRef}
treeId={treeId} treeId={treeId}
collapsedAtom={collapsedFamily(treeId)}
className="px-2 pb-10" className="px-2 pb-10"
hotkeys={hotkeys}
root={tree} root={tree}
getContextMenu={getContextMenu} getContextMenu={getContextMenu}
renderContextMenu={renderContextMenuFn}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
getItemKey={(i) => `${i.id}::${i.name}`} getItemKey={(i) => `${i.id}::${i.name}`}
ItemLeftSlotInner={ItemLeftSlotInner} ItemLeftSlotInner={ItemLeftSlotInner}
@@ -357,7 +389,7 @@ function ItemLeftSlotInner({ item }: { item: TreeModel }) {
return baseEnvironments.length > 1 ? ( return baseEnvironments.length > 1 ? (
<Icon icon="alert_triangle" color="notice" /> <Icon icon="alert_triangle" color="notice" />
) : ( ) : (
item.model === 'environment' && item.color && <EnvironmentColorIndicator environment={item} /> item.model === "environment" && item.color && <EnvironmentColorIndicator environment={item} />
); );
} }
@@ -365,7 +397,7 @@ function ItemRightSlot({ item }: { item: TreeModel }) {
const { baseEnvironments } = useEnvironmentsBreakdown(); const { baseEnvironments } = useEnvironmentsBreakdown();
return ( return (
<> <>
{item.model === 'environment' && baseEnvironments.length <= 1 && isBaseEnvironment(item) && ( {item.model === "environment" && baseEnvironments.length <= 1 && isBaseEnvironment(item) && (
<IconButton <IconButton
size="sm" size="sm"
color="custom" color="custom"
@@ -383,7 +415,7 @@ function ItemRightSlot({ item }: { item: TreeModel }) {
function ItemInner({ item }: { item: TreeModel }) { function ItemInner({ item }: { item: TreeModel }) {
return ( return (
<div className="grid grid-cols-[auto_minmax(0,1fr)] w-full items-center"> <div className="grid grid-cols-[auto_minmax(0,1fr)] w-full items-center">
{item.model === 'environment' && item.public ? ( {item.model === "environment" && item.public ? (
<div className="mr-2 flex items-center">{sharableTooltip}</div> <div className="mr-2 flex items-center">{sharableTooltip}</div>
) : ( ) : (
<span aria-hidden /> <span aria-hidden />
@@ -401,9 +433,9 @@ async function createSubEnvironment() {
} }
function getEditOptions(item: TreeModel) { function getEditOptions(item: TreeModel) {
const options: ReturnType<NonNullable<TreeProps<TreeModel>['getEditOptions']>> = { const options: ReturnType<NonNullable<TreeProps<TreeModel>["getEditOptions"]>> = {
defaultValue: item.name, defaultValue: item.name,
placeholder: 'Name', placeholder: "Name",
async onChange(item, name) { async onChange(item, name) {
await patchModel(item, { name }); await patchModel(item, { name });
}, },

View File

@@ -1,27 +1,27 @@
import type { Environment } from '@yaakapp-internal/models'; import type { Environment } from "@yaakapp-internal/models";
import { patchModel } from '@yaakapp-internal/models'; import { patchModel } from "@yaakapp-internal/models";
import type { GenericCompletionOption } from '@yaakapp-internal/plugins'; import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
import classNames from 'classnames'; import { Heading } from "@yaakapp-internal/ui";
import { useCallback, useMemo } from 'react'; import classNames from "classnames";
import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown'; import { useCallback, useMemo } from "react";
import { useIsEncryptionEnabled } from '../hooks/useIsEncryptionEnabled'; import { useEnvironmentsBreakdown } from "../hooks/useEnvironmentsBreakdown";
import { useKeyValue } from '../hooks/useKeyValue'; import { useIsEncryptionEnabled } from "../hooks/useIsEncryptionEnabled";
import { useRandomKey } from '../hooks/useRandomKey'; import { useKeyValue } from "../hooks/useKeyValue";
import { analyzeTemplate, convertTemplateToSecure } from '../lib/encryption'; import { useRandomKey } from "../hooks/useRandomKey";
import { isBaseEnvironment } from '../lib/model_util'; import { analyzeTemplate, convertTemplateToSecure } from "../lib/encryption";
import { isBaseEnvironment } from "../lib/model_util";
import { import {
setupOrConfigureEncryption, setupOrConfigureEncryption,
withEncryptionEnabled, withEncryptionEnabled,
} from '../lib/setupOrConfigureEncryption'; } from "../lib/setupOrConfigureEncryption";
import { DismissibleBanner } from './core/DismissibleBanner'; import { DismissibleBanner } from "./core/DismissibleBanner";
import type { GenericCompletionConfig } from './core/Editor/genericCompletion'; import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
import { Heading } from './core/Heading'; import type { PairEditorHandle, PairWithId } from "./core/PairEditor";
import type { PairEditorHandle, PairWithId } from './core/PairEditor'; import { ensurePairId } from "./core/PairEditor.util";
import { ensurePairId } from './core/PairEditor.util'; import { PairOrBulkEditor } from "./core/PairOrBulkEditor";
import { PairOrBulkEditor } from './core/PairOrBulkEditor'; import { PillButton } from "./core/PillButton";
import { PillButton } from './core/PillButton'; import { EnvironmentColorIndicator } from "./EnvironmentColorIndicator";
import { EnvironmentColorIndicator } from './EnvironmentColorIndicator'; import { EnvironmentSharableTooltip } from "./EnvironmentSharableTooltip";
import { EnvironmentSharableTooltip } from './EnvironmentSharableTooltip';
interface Props { interface Props {
environment: Environment; environment: Environment;
@@ -34,8 +34,8 @@ export function EnvironmentEditor({ environment, hideName, className, setRef }:
const workspaceId = environment.workspaceId; const workspaceId = environment.workspaceId;
const isEncryptionEnabled = useIsEncryptionEnabled(); const isEncryptionEnabled = useIsEncryptionEnabled();
const valueVisibility = useKeyValue<boolean>({ const valueVisibility = useKeyValue<boolean>({
namespace: 'global', namespace: "global",
key: ['environmentValueVisibility', workspaceId], key: ["environmentValueVisibility", workspaceId],
fallback: false, fallback: false,
}); });
const { allEnvironments } = useEnvironmentsBreakdown(); const { allEnvironments } = useEnvironmentsBreakdown();
@@ -64,8 +64,8 @@ export function EnvironmentEditor({ environment, hideName, className, setRef }:
} }
options.push({ options.push({
label: name, label: name,
type: 'constant', type: "constant",
detail: containingEnvs.map((e) => e.name).join(', '), detail: containingEnvs.map((e) => e.name).join(", "),
}); });
} }
return { options }; return { options };
@@ -73,14 +73,14 @@ export function EnvironmentEditor({ environment, hideName, className, setRef }:
const validateName = useCallback((name: string) => { const validateName = useCallback((name: string) => {
// Empty just means the variable doesn't have a name yet and is unusable // Empty just means the variable doesn't have a name yet and is unusable
if (name === '') return true; if (name === "") return true;
return name.match(/^[a-z_][a-z0-9_.-]*$/i) != null; return name.match(/^[a-z_][a-z0-9_.-]*$/i) != null;
}, []); }, []);
const valueType = !isEncryptionEnabled && valueVisibility.value ? 'text' : 'password'; const valueType = !isEncryptionEnabled && valueVisibility.value ? "text" : "password";
const allVariableAreEncrypted = useMemo( const allVariableAreEncrypted = useMemo(
() => () =>
environment.variables.every((v) => v.value === '' || analyzeTemplate(v.value) !== 'insecure'), environment.variables.every((v) => v.value === "" || analyzeTemplate(v.value) !== "insecure"),
[environment.variables], [environment.variables],
); );
@@ -88,7 +88,7 @@ export function EnvironmentEditor({ environment, hideName, className, setRef }:
withEncryptionEnabled(async () => { withEncryptionEnabled(async () => {
const encryptedVariables: PairWithId[] = []; const encryptedVariables: PairWithId[] = [];
for (const variable of environment.variables) { for (const variable of environment.variables) {
const value = variable.value ? await convertTemplateToSecure(variable.value) : ''; const value = variable.value ? await convertTemplateToSecure(variable.value) : "";
encryptedVariables.push(ensurePairId({ ...variable, value })); encryptedVariables.push(ensurePairId({ ...variable, value }));
} }
await handleChange(encryptedVariables); await handleChange(encryptedVariables);
@@ -100,7 +100,7 @@ export function EnvironmentEditor({ environment, hideName, className, setRef }:
<div <div
className={classNames( className={classNames(
className, className,
'h-full grid grid-rows-[auto_minmax(0,1fr)] gap-2 pr-3 pb-3', "h-full grid grid-rows-[auto_minmax(0,1fr)] gap-2 pr-3 pb-3",
)} )}
> >
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
@@ -123,7 +123,7 @@ export function EnvironmentEditor({ environment, hideName, className, setRef }:
) )
) : ( ) : (
<PillButton color="secondary" onClick={() => valueVisibility.set((v) => !v)}> <PillButton color="secondary" onClick={() => valueVisibility.set((v) => !v)}>
{valueVisibility.value ? 'Hide Values' : 'Show Values'} {valueVisibility.value ? "Hide Values" : "Show Values"}
</PillButton> </PillButton>
)} )}
<PillButton <PillButton
@@ -133,7 +133,7 @@ export function EnvironmentEditor({ environment, hideName, className, setRef }:
await patchModel(environment, { public: !environment.public }); await patchModel(environment, { public: !environment.public });
}} }}
> >
{environment.public ? 'Sharable' : 'Private'} {environment.public ? "Sharable" : "Private"}
</PillButton> </PillButton>
</Heading> </Heading>
{environment.public && (!isEncryptionEnabled || !allVariableAreEncrypted) && ( {environment.public && (!isEncryptionEnabled || !allVariableAreEncrypted) && (
@@ -143,9 +143,9 @@ export function EnvironmentEditor({ environment, hideName, className, setRef }:
className="mr-3" className="mr-3"
actions={[ actions={[
{ {
label: 'Encrypt Variables', label: "Encrypt Variables",
onClick: () => encryptEnvironment(environment), onClick: () => encryptEnvironment(environment),
color: 'success', color: "success",
}, },
]} ]}
> >

View File

@@ -1,4 +1,4 @@
import { IconTooltip } from './core/IconTooltip'; import { IconTooltip } from "./core/IconTooltip";
export function EnvironmentSharableTooltip() { export function EnvironmentSharableTooltip() {
return ( return (

View File

@@ -1,10 +1,8 @@
import type { ErrorInfo, ReactNode } from 'react'; import { Banner, Button, InlineCode } from "@yaakapp-internal/ui";
import { Component, useEffect } from 'react'; import type { ErrorInfo, ReactNode } from "react";
import { showDialog } from '../lib/dialog'; import { Component, useEffect } from "react";
import { Banner } from './core/Banner'; import { showDialog } from "../lib/dialog";
import { Button } from './core/Button'; import RouteError from "./RouteError";
import { InlineCode } from './core/InlineCode';
import RouteError from './RouteError';
interface ErrorBoundaryProps { interface ErrorBoundaryProps {
name: string; name: string;
@@ -27,7 +25,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
} }
componentDidCatch(error: Error, info: ErrorInfo) { componentDidCatch(error: Error, info: ErrorInfo) {
console.warn('Error caught by ErrorBoundary:', error, info); console.warn("Error caught by ErrorBoundary:", error, info);
} }
render() { render() {
@@ -44,7 +42,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
size="2xs" size="2xs"
onClick={() => { onClick={() => {
showDialog({ showDialog({
id: 'error-boundary', id: "error-boundary",
render: () => <RouteError error={this.state.error} />, render: () => <RouteError error={this.state.error} />,
}); });
}} }}
@@ -61,7 +59,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
export function ErrorBoundaryTestThrow() { export function ErrorBoundaryTestThrow() {
useEffect(() => { useEffect(() => {
throw new Error('test error'); throw new Error("test error");
}); });
return <div>Hello</div>; return <div>Hello</div>;

View File

@@ -1,17 +1,17 @@
import { save } from '@tauri-apps/plugin-dialog'; import { save } from "@tauri-apps/plugin-dialog";
import type { Workspace } from '@yaakapp-internal/models'; import type { Workspace } from "@yaakapp-internal/models";
import { workspacesAtom } from '@yaakapp-internal/models'; import { workspacesAtom } from "@yaakapp-internal/models";
import { useAtomValue } from 'jotai'; import { HStack, VStack } from "@yaakapp-internal/ui";
import { useCallback, useMemo, useState } from 'react'; import { useAtomValue } from "jotai";
import slugify from 'slugify'; import { useCallback, useMemo, useState } from "react";
import { activeWorkspaceAtom } from '../hooks/useActiveWorkspace'; import slugify from "slugify";
import { pluralizeCount } from '../lib/pluralize'; import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
import { invokeCmd } from '../lib/tauri'; import { pluralizeCount } from "../lib/pluralize";
import { Button } from './core/Button'; import { invokeCmd } from "../lib/tauri";
import { Checkbox } from './core/Checkbox'; import { Button } from "./core/Button";
import { DetailsBanner } from './core/DetailsBanner'; import { Checkbox } from "./core/Checkbox";
import { Link } from './core/Link'; import { DetailsBanner } from "./core/DetailsBanner";
import { HStack, VStack } from './core/Stacks'; import { Link } from "./core/Link";
interface Props { interface Props {
onHide: () => void; onHide: () => void;
@@ -55,7 +55,7 @@ function ExportDataDialogContent({
const handleToggleAll = () => { const handleToggleAll = () => {
setSelectedWorkspaces( setSelectedWorkspaces(
// biome-ignore lint/performance/noAccumulatingSpread: none // oxlint-disable-next-line no-accumulating-spread
allSelected ? {} : workspaces.reduce((acc, w) => ({ ...acc, [w.id]: true }), {}), allSelected ? {} : workspaces.reduce((acc, w) => ({ ...acc, [w.id]: true }), {}),
); );
}; };
@@ -63,16 +63,16 @@ function ExportDataDialogContent({
const handleExport = useCallback(async () => { const handleExport = useCallback(async () => {
const ids = Object.keys(selectedWorkspaces).filter((k) => selectedWorkspaces[k]); const ids = Object.keys(selectedWorkspaces).filter((k) => selectedWorkspaces[k]);
const workspace = ids.length === 1 ? workspaces.find((w) => w.id === ids[0]) : undefined; const workspace = ids.length === 1 ? workspaces.find((w) => w.id === ids[0]) : undefined;
const slug = workspace ? slugify(workspace.name, { lower: true }) : 'workspaces'; const slug = workspace ? slugify(workspace.name, { lower: true }) : "workspaces";
const exportPath = await save({ const exportPath = await save({
title: 'Export Data', title: "Export Data",
defaultPath: `yaak.${slug}.json`, defaultPath: `yaak.${slug}.json`,
}); });
if (exportPath == null) { if (exportPath == null) {
return; return;
} }
await invokeCmd('cmd_export_data', { await invokeCmd("cmd_export_data", {
workspaceIds: ids, workspaceIds: ids,
exportPath, exportPath,
includePrivateEnvironments: includePrivateEnvironments, includePrivateEnvironments: includePrivateEnvironments,
@@ -92,7 +92,7 @@ function ExportDataDialogContent({
<tr> <tr>
<th className="w-6 min-w-0 py-2 text-left pl-1"> <th className="w-6 min-w-0 py-2 text-left pl-1">
<Checkbox <Checkbox
checked={!allSelected && !noneSelected ? 'indeterminate' : allSelected} checked={!allSelected && !noneSelected ? "indeterminate" : allSelected}
hideLabel hideLabel
title="All workspaces" title="All workspaces"
onChange={handleToggleAll} onChange={handleToggleAll}
@@ -122,7 +122,7 @@ function ExportDataDialogContent({
setSelectedWorkspaces((prev) => ({ ...prev, [w.id]: !prev[w.id] })) setSelectedWorkspaces((prev) => ({ ...prev, [w.id]: !prev[w.id] }))
} }
> >
{w.name} {w.id === activeWorkspace.id ? '(current workspace)' : ''} {w.name} {w.id === activeWorkspace.id ? "(current workspace)" : ""}
</td> </td>
</tr> </tr>
))} ))}
@@ -155,8 +155,8 @@ function ExportDataDialogContent({
disabled={noneSelected} disabled={noneSelected}
onClick={() => handleExport()} onClick={() => handleExport()}
> >
Export{' '} Export{" "}
{pluralizeCount('Workspace', numSelected, { omitSingle: true, noneWord: 'Nothing' })} {pluralizeCount("Workspace", numSelected, { omitSingle: true, noneWord: "Nothing" })}
</Button> </Button>
</HStack> </HStack>
</footer> </footer>

View File

@@ -1,27 +1,24 @@
import type { Folder, GrpcRequest, HttpRequest, WebsocketRequest } from '@yaakapp-internal/models'; import type { Folder, GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-internal/models";
import { foldersAtom } from '@yaakapp-internal/models'; import { foldersAtom } from "@yaakapp-internal/models";
import classNames from 'classnames'; import { Heading, HStack, Icon, LoadingIcon } from "@yaakapp-internal/ui";
import { useAtomValue } from 'jotai'; import classNames from "classnames";
import type { CSSProperties, ReactNode } from 'react'; import { useAtomValue } from "jotai";
import { useCallback, useMemo } from 'react'; import type { CSSProperties, ReactNode } from "react";
import { allRequestsAtom } from '../hooks/useAllRequests'; import { useCallback, useMemo } from "react";
import { useFolderActions } from '../hooks/useFolderActions'; import { allRequestsAtom } from "../hooks/useAllRequests";
import { useLatestHttpResponse } from '../hooks/useLatestHttpResponse'; import { useFolderActions } from "../hooks/useFolderActions";
import { sendAnyHttpRequest } from '../hooks/useSendAnyHttpRequest'; import { useLatestHttpResponse } from "../hooks/useLatestHttpResponse";
import { showDialog } from '../lib/dialog'; import { sendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
import { resolvedModelName } from '../lib/resolvedModelName'; import { showDialog } from "../lib/dialog";
import { router } from '../lib/router'; import { resolvedModelName } from "../lib/resolvedModelName";
import { Button } from './core/Button'; import { router } from "../lib/router";
import { Heading } from './core/Heading'; import { Button } from "./core/Button";
import { HttpResponseDurationTag } from './core/HttpResponseDurationTag'; import { HttpResponseDurationTag } from "./core/HttpResponseDurationTag";
import { HttpStatusTag } from './core/HttpStatusTag'; import { HttpStatusTag } from "./core/HttpStatusTag";
import { Icon } from './core/Icon'; import { IconButton } from "./core/IconButton";
import { IconButton } from './core/IconButton'; import { Separator } from "./core/Separator";
import { LoadingIcon } from './core/LoadingIcon'; import { SizeTag } from "./core/SizeTag";
import { Separator } from './core/Separator'; import { HttpResponsePane } from "./HttpResponsePane";
import { SizeTag } from './core/SizeTag';
import { HStack } from './core/Stacks';
import { HttpResponsePane } from './HttpResponsePane';
interface Props { interface Props {
folder: Folder; folder: Folder;
@@ -33,7 +30,7 @@ export function FolderLayout({ folder, style }: Props) {
const requests = useAtomValue(allRequestsAtom); const requests = useAtomValue(allRequestsAtom);
const folderActions = useFolderActions(); const folderActions = useFolderActions();
const sendAllAction = useMemo( const sendAllAction = useMemo(
() => folderActions.find((a) => a.label === 'Send All'), () => folderActions.find((a) => a.label === "Send All"),
[folderActions], [folderActions],
); );
@@ -45,7 +42,7 @@ export function FolderLayout({ folder, style }: Props) {
}, [folder.id, folders, requests]); }, [folder.id, folders, requests]);
const handleSendAll = useCallback(() => { const handleSendAll = useCallback(() => {
sendAllAction?.call(folder); void sendAllAction?.call(folder);
}, [sendAllAction, folder]); }, [sendAllAction, folder]);
return ( return (
@@ -78,13 +75,13 @@ export function FolderLayout({ folder, style }: Props) {
function ChildCard({ child }: { child: Folder | HttpRequest | GrpcRequest | WebsocketRequest }) { function ChildCard({ child }: { child: Folder | HttpRequest | GrpcRequest | WebsocketRequest }) {
let card: ReactNode; let card: ReactNode;
if (child.model === 'folder') { if (child.model === "folder") {
card = <FolderCard folder={child} />; card = <FolderCard folder={child} />;
} else if (child.model === 'http_request') { } else if (child.model === "http_request") {
card = <HttpRequestCard request={child} />; card = <HttpRequestCard request={child} />;
} else if (child.model === 'grpc_request') { } else if (child.model === "grpc_request") {
card = <RequestCard request={child} />; card = <RequestCard request={child} />;
} else if (child.model === 'websocket_request') { } else if (child.model === "websocket_request") {
card = <RequestCard request={child} />; card = <RequestCard request={child} />;
} else { } else {
card = <div>Unknown model</div>; card = <div>Unknown model</div>;
@@ -92,7 +89,7 @@ function ChildCard({ child }: { child: Folder | HttpRequest | GrpcRequest | Webs
const navigate = useCallback(async () => { const navigate = useCallback(async () => {
await router.navigate({ await router.navigate({
to: '/workspaces/$workspaceId', to: "/workspaces/$workspaceId",
params: { workspaceId: child.workspaceId }, params: { workspaceId: child.workspaceId },
search: (prev) => ({ ...prev, request_id: child.id }), search: (prev) => ({ ...prev, request_id: child.id }),
}); });
@@ -101,12 +98,12 @@ function ChildCard({ child }: { child: Folder | HttpRequest | GrpcRequest | Webs
return ( return (
<div <div
className={classNames( className={classNames(
'rounded-lg bg-surface-highlight p-3 pt-1 border border-border', "rounded-lg bg-surface-highlight p-3 pt-1 border border-border",
'flex flex-col gap-3', "flex flex-col gap-3",
)} )}
> >
<HStack space={2}> <HStack space={2}>
{child.model === 'folder' && <Icon icon="folder" size="lg" />} {child.model === "folder" && <Icon icon="folder" size="lg" />}
<Heading className="truncate" level={2}> <Heading className="truncate" level={2}>
{resolvedModelName(child)} {resolvedModelName(child)}
</Heading> </Heading>
@@ -143,7 +140,7 @@ function FolderCard({ folder }: { folder: Folder }) {
color="primary" color="primary"
onClick={async () => { onClick={async () => {
await router.navigate({ await router.navigate({
to: '/workspaces/$workspaceId', to: "/workspaces/$workspaceId",
params: { workspaceId: folder.workspaceId }, params: { workspaceId: folder.workspaceId },
search: (prev) => { search: (prev) => {
return { ...prev, request_id: null, folder_id: folder.id }; return { ...prev, request_id: null, folder_id: folder.id };
@@ -177,10 +174,10 @@ function HttpRequestCard({ request }: { request: HttpRequest }) {
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
showDialog({ showDialog({
id: 'response-preview', id: "response-preview",
title: 'Response Preview', title: "Response Preview",
size: 'md', size: "md",
className: 'h-full', className: "h-full",
render: () => { render: () => {
return <HttpResponsePane activeRequestId={request.id} />; return <HttpResponsePane activeRequestId={request.id} />;
}, },
@@ -191,12 +188,12 @@ function HttpRequestCard({ request }: { request: HttpRequest }) {
space={2} space={2}
alignItems="center" alignItems="center"
className={classNames( className={classNames(
'cursor-default select-none', "cursor-default select-none",
'whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars', "whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars",
'font-mono text-editor border rounded px-1.5 py-0.5 truncate w-full', "font-mono text-editor border rounded px-1.5 py-0.5 truncate w-full",
)} )}
> >
{latestResponse.state !== 'closed' && <LoadingIcon size="sm" />} {latestResponse.state !== "closed" && <LoadingIcon size="sm" />}
<HttpStatusTag showReason response={latestResponse} /> <HttpStatusTag showReason response={latestResponse} />
<span>&bull;</span> <span>&bull;</span>
<HttpResponseDurationTag response={latestResponse} /> <HttpResponseDurationTag response={latestResponse} />

View File

@@ -1,42 +1,36 @@
import { import { createWorkspaceModel, foldersAtom, patchModel } from "@yaakapp-internal/models";
createWorkspaceModel, import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
foldersAtom, import { useAtomValue } from "jotai";
patchModel, import { Fragment, useMemo } from "react";
} from '@yaakapp-internal/models'; import { useAuthTab } from "../hooks/useAuthTab";
import { useAtomValue } from 'jotai'; import { useEnvironmentsBreakdown } from "../hooks/useEnvironmentsBreakdown";
import { Fragment, useMemo } from 'react'; import { useHeadersTab } from "../hooks/useHeadersTab";
import { useAuthTab } from '../hooks/useAuthTab'; import { useInheritedHeaders } from "../hooks/useInheritedHeaders";
import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown'; import { useModelAncestors } from "../hooks/useModelAncestors";
import { useHeadersTab } from '../hooks/useHeadersTab'; import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
import { useInheritedHeaders } from '../hooks/useInheritedHeaders'; import { hideDialog } from "../lib/dialog";
import { useModelAncestors } from '../hooks/useModelAncestors'; import { CopyIconButton } from "./CopyIconButton";
import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm'; import { Button } from "./core/Button";
import { hideDialog } from '../lib/dialog'; import { CountBadge } from "./core/CountBadge";
import { CopyIconButton } from './CopyIconButton'; import { Input } from "./core/Input";
import { Button } from './core/Button'; import { Link } from "./core/Link";
import { CountBadge } from './core/CountBadge'; import type { TabItem } from "./core/Tabs/Tabs";
import { Icon } from './core/Icon'; import { TabContent, Tabs } from "./core/Tabs/Tabs";
import { InlineCode } from './core/InlineCode'; import { EmptyStateText } from "./EmptyStateText";
import { Input } from './core/Input'; import { EnvironmentEditor } from "./EnvironmentEditor";
import { Link } from './core/Link'; import { HeadersEditor } from "./HeadersEditor";
import { HStack, VStack } from './core/Stacks'; import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
import type { TabItem } from './core/Tabs/Tabs'; import { MarkdownEditor } from "./MarkdownEditor";
import { TabContent, Tabs } from './core/Tabs/Tabs';
import { EmptyStateText } from './EmptyStateText';
import { EnvironmentEditor } from './EnvironmentEditor';
import { HeadersEditor } from './HeadersEditor';
import { HttpAuthenticationEditor } from './HttpAuthenticationEditor';
import { MarkdownEditor } from './MarkdownEditor';
interface Props { interface Props {
folderId: string | null; folderId: string | null;
tab?: FolderSettingsTab; tab?: FolderSettingsTab;
} }
const TAB_AUTH = 'auth'; const TAB_AUTH = "auth";
const TAB_HEADERS = 'headers'; const TAB_HEADERS = "headers";
const TAB_VARIABLES = 'variables'; const TAB_VARIABLES = "variables";
const TAB_GENERAL = 'general'; const TAB_GENERAL = "general";
export type FolderSettingsTab = export type FolderSettingsTab =
| typeof TAB_AUTH | typeof TAB_AUTH
@@ -54,7 +48,7 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
const inheritedHeaders = useInheritedHeaders(folder); const inheritedHeaders = useInheritedHeaders(folder);
const environments = useEnvironmentsBreakdown(); const environments = useEnvironmentsBreakdown();
const folderEnvironment = environments.allEnvironments.find( const folderEnvironment = environments.allEnvironments.find(
(e) => e.parentModel === 'folder' && e.parentId === folderId, (e) => e.parentModel === "folder" && e.parentId === folderId,
); );
const numVars = (folderEnvironment?.variables ?? []).filter((v) => v.name).length; const numVars = (folderEnvironment?.variables ?? []).filter((v) => v.name).length;
@@ -64,13 +58,13 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
return [ return [
{ {
value: TAB_GENERAL, value: TAB_GENERAL,
label: 'General', label: "General",
}, },
...headersTab, ...headersTab,
...authTab, ...authTab,
{ {
value: TAB_VARIABLES, value: TAB_VARIABLES,
label: 'Variables', label: "Variables",
rightSlot: numVars > 0 ? <CountBadge count={numVars} /> : null, rightSlot: numVars > 0 ? <CountBadge count={numVars} /> : null,
}, },
]; ];
@@ -86,11 +80,7 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
{breadcrumbs.map((item, index) => ( {breadcrumbs.map((item, index) => (
<Fragment key={item.id}> <Fragment key={item.id}>
{index > 0 && ( {index > 0 && (
<Icon <Icon icon="chevron_right" size="lg" className="opacity-50 flex-shrink-0" />
icon="chevron_right"
size="lg"
className="opacity-50 flex-shrink-0"
/>
)} )}
<span className="text-text-subtle truncate min-w-0" title={item.name}> <span className="text-text-subtle truncate min-w-0" title={item.name}>
{item.name} {item.name}
@@ -100,10 +90,7 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
{breadcrumbs.length > 0 && ( {breadcrumbs.length > 0 && (
<Icon icon="chevron_right" size="lg" className="opacity-50 flex-shrink-0" /> <Icon icon="chevron_right" size="lg" className="opacity-50 flex-shrink-0" />
)} )}
<span <span className="whitespace-nowrap" title={folder.name}>
className="whitespace-nowrap"
title={folder.name}
>
{folder.name} {folder.name}
</span> </span>
</div> </div>
@@ -141,7 +128,7 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
onClick={async () => { onClick={async () => {
const didDelete = await deleteModelWithConfirm(folder); const didDelete = await deleteModelWithConfirm(folder);
if (didDelete) { if (didDelete) {
hideDialog('folder-settings'); hideDialog("folder-settings");
} }
}} }}
color="danger" color="danger"
@@ -177,10 +164,10 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
<EmptyStateText> <EmptyStateText>
<VStack alignItems="center" space={1.5}> <VStack alignItems="center" space={1.5}>
<p> <p>
Override{' '} Override{" "}
<Link href="https://yaak.app/docs/using-yaak/environments-and-variables"> <Link href="https://yaak.app/docs/using-yaak/environments-and-variables">
Variables Variables
</Link>{' '} </Link>{" "}
for requests within this folder. for requests within this folder.
</p> </p>
<Button <Button
@@ -189,10 +176,10 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
onClick={async () => { onClick={async () => {
await createWorkspaceModel({ await createWorkspaceModel({
workspaceId: folder.workspaceId, workspaceId: folder.workspaceId,
parentModel: 'folder', parentModel: "folder",
parentId: folder.id, parentId: folder.id,
model: 'environment', model: "environment",
name: 'Folder Environment', name: "Folder Environment",
}); });
}} }}
> >

View File

@@ -1,12 +1,12 @@
import type { HttpRequest } from '@yaakapp-internal/models'; import type { HttpRequest } from "@yaakapp-internal/models";
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from "react";
import type { Pair, PairEditorProps } from './core/PairEditor'; import type { Pair, PairEditorProps } from "./core/PairEditor";
import { PairEditor } from './core/PairEditor'; import { PairEditor } from "./core/PairEditor";
type Props = { type Props = {
forceUpdateKey: string; forceUpdateKey: string;
request: HttpRequest; request: HttpRequest;
onChange: (body: HttpRequest['body']) => void; onChange: (body: HttpRequest["body"]) => void;
}; };
export function FormMultipartEditor({ request, forceUpdateKey, onChange }: Props) { export function FormMultipartEditor({ request, forceUpdateKey, onChange }: Props) {
@@ -24,7 +24,7 @@ export function FormMultipartEditor({ request, forceUpdateKey, onChange }: Props
[request.body.form], [request.body.form],
); );
const handleChange = useCallback<PairEditorProps['onChange']>( const handleChange = useCallback<PairEditorProps["onChange"]>(
(pairs) => (pairs) =>
onChange({ onChange({
form: pairs.map((p) => ({ form: pairs.map((p) => ({

View File

@@ -1,12 +1,12 @@
import type { HttpRequest } from '@yaakapp-internal/models'; import type { HttpRequest } from "@yaakapp-internal/models";
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from "react";
import type { Pair, PairEditorProps } from './core/PairEditor'; import type { Pair, PairEditorProps } from "./core/PairEditor";
import { PairOrBulkEditor } from './core/PairOrBulkEditor'; import { PairOrBulkEditor } from "./core/PairOrBulkEditor";
type Props = { type Props = {
forceUpdateKey: string; forceUpdateKey: string;
request: HttpRequest; request: HttpRequest;
onChange: (headers: HttpRequest['body']) => void; onChange: (headers: HttpRequest["body"]) => void;
}; };
export function FormUrlencodedEditor({ request, forceUpdateKey, onChange }: Props) { export function FormUrlencodedEditor({ request, forceUpdateKey, onChange }: Props) {
@@ -14,14 +14,14 @@ export function FormUrlencodedEditor({ request, forceUpdateKey, onChange }: Prop
() => () =>
(Array.isArray(request.body.form) ? request.body.form : []).map((p) => ({ (Array.isArray(request.body.form) ? request.body.form : []).map((p) => ({
enabled: !!p.enabled, enabled: !!p.enabled,
name: p.name || '', name: p.name || "",
value: p.value || '', value: p.value || "",
id: p.id, id: p.id,
})), })),
[request.body.form], [request.body.form],
); );
const handleChange = useCallback<PairEditorProps['onChange']>( const handleChange = useCallback<PairEditorProps["onChange"]>(
(pairs) => (pairs) =>
onChange({ form: pairs.map((p) => ({ enabled: p.enabled, name: p.name, value: p.value })) }), onChange({ form: pairs.map((p) => ({ enabled: p.enabled, name: p.name, value: p.value })) }),
[onChange], [onChange],

View File

@@ -0,0 +1,38 @@
import { activeRequestAtom } from "../hooks/useActiveRequest";
import { useSubscribeActiveWorkspaceId } from "../hooks/useActiveWorkspace";
import { useActiveWorkspaceChangedToast } from "../hooks/useActiveWorkspaceChangedToast";
import { useHotKey, useSubscribeHotKeys } from "../hooks/useHotKey";
import { useSubscribeHttpAuthentication } from "../hooks/useHttpAuthentication";
import { useSyncFontSizeSetting } from "../hooks/useSyncFontSizeSetting";
import { useSyncWorkspaceChildModels } from "../hooks/useSyncWorkspaceChildModels";
import { useSyncZoomSetting } from "../hooks/useSyncZoomSetting";
import { useSubscribeTemplateFunctions } from "../hooks/useTemplateFunctions";
import { jotaiStore } from "../lib/jotai";
import { renameModelWithPrompt } from "../lib/renameModelWithPrompt";
export function GlobalHooks() {
useSyncZoomSetting();
useSyncFontSizeSetting();
useSubscribeActiveWorkspaceId();
useSyncWorkspaceChildModels();
useSubscribeTemplateFunctions();
useSubscribeHttpAuthentication();
// Other useful things
useActiveWorkspaceChangedToast();
useSubscribeHotKeys();
useHotKey(
"request.rename",
async () => {
const model = jotaiStore.get(activeRequestAtom);
if (model == null) return;
await renameModelWithPrompt(model);
},
{ allowDefault: true },
);
return null;
}

View File

@@ -1,18 +1,18 @@
import { patchModel } from '@yaakapp-internal/models'; import { patchModel } from "@yaakapp-internal/models";
import classNames from 'classnames'; import classNames from "classnames";
import { useAtomValue } from 'jotai'; import { useAtomValue } from "jotai";
import type { CSSProperties } from 'react'; import type { CSSProperties } from "react";
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo } from "react";
import { useActiveRequest } from '../hooks/useActiveRequest'; import { useActiveRequest } from "../hooks/useActiveRequest";
import { useGrpc } from '../hooks/useGrpc'; import { useGrpc } from "../hooks/useGrpc";
import { useGrpcProtoFiles } from '../hooks/useGrpcProtoFiles'; import { useGrpcProtoFiles } from "../hooks/useGrpcProtoFiles";
import { activeGrpcConnectionAtom, useGrpcEvents } from '../hooks/usePinnedGrpcConnection'; import { activeGrpcConnectionAtom, useGrpcEvents } from "../hooks/usePinnedGrpcConnection";
import { workspaceLayoutAtom } from '../lib/atoms'; import { Banner, SplitLayout } from "@yaakapp-internal/ui";
import { Banner } from './core/Banner'; import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
import { HotkeyList } from './core/HotkeyList'; import { workspaceLayoutAtom } from "../lib/atoms";
import { SplitLayout } from './core/SplitLayout'; import { HotkeyList } from "./core/HotkeyList";
import { GrpcRequestPane } from './GrpcRequestPane'; import { GrpcRequestPane } from "./GrpcRequestPane";
import { GrpcResponsePane } from './GrpcResponsePane'; import { GrpcResponsePane } from "./GrpcResponsePane";
interface Props { interface Props {
style: CSSProperties; style: CSSProperties;
@@ -22,7 +22,9 @@ const emptyArray: string[] = [];
export function GrpcConnectionLayout({ style }: Props) { export function GrpcConnectionLayout({ style }: Props) {
const workspaceLayout = useAtomValue(workspaceLayoutAtom); const workspaceLayout = useAtomValue(workspaceLayoutAtom);
const activeRequest = useActiveRequest('grpc_request'); const activeWorkspace = useAtomValue(activeWorkspaceAtom);
const wsId = activeWorkspace?.id ?? "n/a";
const activeRequest = useActiveRequest("grpc_request");
const activeConnection = useAtomValue(activeGrpcConnectionAtom); const activeConnection = useAtomValue(activeGrpcConnectionAtom);
const grpcEvents = useGrpcEvents(activeConnection?.id ?? null); const grpcEvents = useGrpcEvents(activeConnection?.id ?? null);
const protoFilesKv = useGrpcProtoFiles(activeRequest?.id ?? null); const protoFilesKv = useGrpcProtoFiles(activeRequest?.id ?? null);
@@ -59,18 +61,18 @@ export function GrpcConnectionLayout({ style }: Props) {
}, [activeRequest, services]); }, [activeRequest, services]);
const methodType: const methodType:
| 'unary' | "unary"
| 'server_streaming' | "server_streaming"
| 'client_streaming' | "client_streaming"
| 'streaming' | "streaming"
| 'no-schema' | "no-schema"
| 'no-method' = useMemo(() => { | "no-method" = useMemo(() => {
if (services == null) return 'no-schema'; if (services == null) return "no-schema";
if (activeMethod == null) return 'no-method'; if (activeMethod == null) return "no-method";
if (activeMethod.clientStreaming && activeMethod.serverStreaming) return 'streaming'; if (activeMethod.clientStreaming && activeMethod.serverStreaming) return "streaming";
if (activeMethod.clientStreaming) return 'client_streaming'; if (activeMethod.clientStreaming) return "client_streaming";
if (activeMethod.serverStreaming) return 'server_streaming'; if (activeMethod.serverStreaming) return "server_streaming";
return 'unary'; return "unary";
}, [activeMethod, services]); }, [activeMethod, services]);
if (activeRequest == null) { if (activeRequest == null) {
@@ -79,7 +81,7 @@ export function GrpcConnectionLayout({ style }: Props) {
return ( return (
<SplitLayout <SplitLayout
name="grpc_layout" storageKey={`grpc_layout::${wsId}`}
className="p-3 gap-1.5" className="p-3 gap-1.5"
style={style} style={style}
layout={workspaceLayout} layout={workspaceLayout}
@@ -104,10 +106,10 @@ export function GrpcConnectionLayout({ style }: Props) {
<div <div
style={style} style={style}
className={classNames( className={classNames(
'x-theme-responsePane', "x-theme-responsePane",
'max-h-full h-full grid grid-rows-[minmax(0,1fr)] grid-cols-1', "max-h-full h-full grid grid-rows-[minmax(0,1fr)] grid-cols-1",
'bg-surface rounded-md border border-border-subtle', "bg-surface rounded-md border border-border-subtle",
'shadow relative', "shadow relative",
)} )}
> >
{grpc.go.error ? ( {grpc.go.error ? (
@@ -117,7 +119,7 @@ export function GrpcConnectionLayout({ style }: Props) {
) : grpcEvents.length >= 0 ? ( ) : grpcEvents.length >= 0 ? (
<GrpcResponsePane activeRequest={activeRequest} methodType={methodType} /> <GrpcResponsePane activeRequest={activeRequest} methodType={methodType} />
) : ( ) : (
<HotkeyList hotkeys={['request.send', 'sidebar.focus', 'url_bar.focus']} /> <HotkeyList hotkeys={["request.send", "sidebar.focus", "url_bar.focus"]} />
)} )}
</div> </div>
) )

View File

@@ -1,29 +1,27 @@
import { jsoncLanguage } from '@shopify/lang-jsonc'; import { linter } from "@codemirror/lint";
import { linter } from '@codemirror/lint'; import type { EditorView } from "@codemirror/view";
import type { EditorView } from '@codemirror/view'; import { jsoncLanguage } from "@shopify/lang-jsonc";
import type { GrpcRequest } from '@yaakapp-internal/models'; import type { GrpcRequest } from "@yaakapp-internal/models";
import classNames from 'classnames'; import { FormattedError, InlineCode, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { import {
handleRefresh, handleRefresh,
jsonCompletion, jsonCompletion,
jsonSchemaLinter, jsonSchemaLinter,
stateExtensions, stateExtensions,
updateSchema, updateSchema,
} from 'codemirror-json-schema'; } from "codemirror-json-schema";
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from "react";
import type { ReflectResponseService } from '../hooks/useGrpc'; import type { ReflectResponseService } from "../hooks/useGrpc";
import { showAlert } from '../lib/alert'; import { showAlert } from "../lib/alert";
import { showDialog } from '../lib/dialog'; import { showDialog } from "../lib/dialog";
import { pluralizeCount } from '../lib/pluralize'; import { pluralizeCount } from "../lib/pluralize";
import { Button } from './core/Button'; import { Button } from "./core/Button";
import type { EditorProps } from './core/Editor/Editor'; import type { EditorProps } from "./core/Editor/Editor";
import { Editor } from './core/Editor/LazyEditor'; import { Editor } from "./core/Editor/LazyEditor";
import { FormattedError } from './core/FormattedError'; import { GrpcProtoSelectionDialog } from "./GrpcProtoSelectionDialog";
import { InlineCode } from './core/InlineCode';
import { VStack } from './core/Stacks';
import { GrpcProtoSelectionDialog } from './GrpcProtoSelectionDialog';
type Props = Pick<EditorProps, 'heightMode' | 'onChange' | 'className' | 'forceUpdateKey'> & { type Props = Pick<EditorProps, "heightMode" | "onChange" | "className" | "forceUpdateKey"> & {
services: ReflectResponseService[] | null; services: ReflectResponseService[] | null;
reflectionError?: string; reflectionError?: string;
reflectionLoading?: boolean; reflectionLoading?: boolean;
@@ -57,9 +55,9 @@ export function GrpcEditor({
const s = services.find((s) => s.name === request.service); const s = services.find((s) => s.name === request.service);
if (s == null) { if (s == null) {
console.log('Failed to find service', { service: request.service, services }); console.log("Failed to find service", { service: request.service, services });
showAlert({ showAlert({
id: 'grpc-find-service-error', id: "grpc-find-service-error",
title: "Couldn't Find Service", title: "Couldn't Find Service",
body: ( body: (
<> <>
@@ -72,13 +70,13 @@ export function GrpcEditor({
const schema = s.methods.find((m) => m.name === request.method)?.schema; const schema = s.methods.find((m) => m.name === request.method)?.schema;
if (request.method != null && schema == null) { if (request.method != null && schema == null) {
console.log('Failed to find method', { method: request.method, methods: s?.methods }); console.log("Failed to find method", { method: request.method, methods: s?.methods });
showAlert({ showAlert({
id: 'grpc-find-schema-error', id: "grpc-find-schema-error",
title: "Couldn't Find Method", title: "Couldn't Find Method",
body: ( body: (
<> <>
Failed to find method <InlineCode>{request.method}</InlineCode> for{' '} Failed to find method <InlineCode>{request.method}</InlineCode> for{" "}
<InlineCode>{request.service}</InlineCode> in schema <InlineCode>{request.service}</InlineCode> in schema
</> </>
), ),
@@ -94,12 +92,12 @@ export function GrpcEditor({
updateSchema(editorView, JSON.parse(schema)); updateSchema(editorView, JSON.parse(schema));
} catch (err) { } catch (err) {
showAlert({ showAlert({
id: 'grpc-parse-schema-error', id: "grpc-parse-schema-error",
title: 'Failed to Parse Schema', title: "Failed to Parse Schema",
body: ( body: (
<VStack space={4}> <VStack space={4}>
<p> <p>
For service <InlineCode>{request.service}</InlineCode> and method{' '} For service <InlineCode>{request.service}</InlineCode> and method{" "}
<InlineCode>{request.method}</InlineCode> <InlineCode>{request.method}</InlineCode>
</p> </p>
<FormattedError>{String(err)}</FormattedError> <FormattedError>{String(err)}</FormattedError>
@@ -128,39 +126,39 @@ export function GrpcEditor({
const actions = useMemo( const actions = useMemo(
() => [ () => [
<div key="reflection" className={classNames(services == null && '!opacity-100')}> <div key="reflection" className={classNames(services == null && "!opacity-100")}>
<Button <Button
size="xs" size="xs"
color={ color={
reflectionLoading reflectionLoading
? 'secondary' ? "secondary"
: reflectionUnavailable : reflectionUnavailable
? 'info' ? "info"
: reflectionError : reflectionError
? 'danger' ? "danger"
: 'secondary' : "secondary"
} }
isLoading={reflectionLoading} isLoading={reflectionLoading}
onClick={() => { onClick={() => {
showDialog({ showDialog({
title: 'Configure Schema', title: "Configure Schema",
size: 'md', size: "md",
id: 'reflection-failed', id: "reflection-failed",
render: ({ hide }) => <GrpcProtoSelectionDialog onDone={hide} />, render: ({ hide }) => <GrpcProtoSelectionDialog onDone={hide} />,
}); });
}} }}
> >
{reflectionLoading {reflectionLoading
? 'Inspecting Schema' ? "Inspecting Schema"
: reflectionUnavailable : reflectionUnavailable
? 'Select Proto Files' ? "Select Proto Files"
: reflectionError : reflectionError
? 'Server Error' ? "Server Error"
: protoFiles.length > 0 : protoFiles.length > 0
? pluralizeCount('File', protoFiles.length) ? pluralizeCount("File", protoFiles.length)
: services != null && protoFiles.length === 0 : services != null && protoFiles.length === 0
? 'Schema Detected' ? "Schema Detected"
: 'Select Schema'} : "Select Schema"}
</Button> </Button>
</div>, </div>,
], ],

View File

@@ -1,16 +1,13 @@
import { open } from '@tauri-apps/plugin-dialog'; import { open } from "@tauri-apps/plugin-dialog";
import type { GrpcRequest } from '@yaakapp-internal/models'; import type { GrpcRequest } from "@yaakapp-internal/models";
import { useActiveRequest } from '../hooks/useActiveRequest'; import { Banner, HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import { useGrpc } from '../hooks/useGrpc'; import { useActiveRequest } from "../hooks/useActiveRequest";
import { useGrpcProtoFiles } from '../hooks/useGrpcProtoFiles'; import { useGrpc } from "../hooks/useGrpc";
import { pluralizeCount } from '../lib/pluralize'; import { useGrpcProtoFiles } from "../hooks/useGrpcProtoFiles";
import { Banner } from './core/Banner'; import { pluralizeCount } from "../lib/pluralize";
import { Button } from './core/Button'; import { Button } from "./core/Button";
import { Icon } from './core/Icon'; import { IconButton } from "./core/IconButton";
import { IconButton } from './core/IconButton'; import { Link } from "./core/Link";
import { InlineCode } from './core/InlineCode';
import { Link } from './core/Link';
import { HStack, VStack } from './core/Stacks';
interface Props { interface Props {
onDone: () => void; onDone: () => void;
@@ -18,7 +15,7 @@ interface Props {
export function GrpcProtoSelectionDialog(props: Props) { export function GrpcProtoSelectionDialog(props: Props) {
const request = useActiveRequest(); const request = useActiveRequest();
if (request?.model !== 'grpc_request') return null; if (request?.model !== "grpc_request") return null;
return <GrpcProtoSelectionDialogWithRequest request={request} {...props} />; return <GrpcProtoSelectionDialogWithRequest request={request} {...props} />;
} }
@@ -30,7 +27,7 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
const services = grpc.reflect.data; const services = grpc.reflect.data;
const serverReflection = protoFiles.length === 0 && services != null; const serverReflection = protoFiles.length === 0 && services != null;
let reflectError = grpc.reflect.error ?? null; let reflectError = grpc.reflect.error ?? null;
const reflectionUnimplemented = `${reflectError}`.match(/unimplemented/i); const reflectionUnimplemented = String(reflectError).match(/unimplemented/i);
if (reflectionUnimplemented) { if (reflectionUnimplemented) {
reflectError = null; reflectError = null;
@@ -49,9 +46,9 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
variant="border" variant="border"
onClick={async () => { onClick={async () => {
const selected = await open({ const selected = await open({
title: 'Select Proto Files', title: "Select Proto Files",
multiple: true, multiple: true,
filters: [{ name: 'Proto Files', extensions: ['proto'] }], filters: [{ name: "Proto Files", extensions: ["proto"] }],
}); });
if (selected == null) return; if (selected == null) return;
@@ -67,7 +64,7 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
color="primary" color="primary"
onClick={async () => { onClick={async () => {
const selected = await open({ const selected = await open({
title: 'Select Proto Directory', title: "Select Proto Directory",
directory: true, directory: true,
}); });
if (selected == null) return; if (selected == null) return;
@@ -92,7 +89,7 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
{reflectError && ( {reflectError && (
<Banner color="warning"> <Banner color="warning">
<h1 className="font-bold"> <h1 className="font-bold">
Reflection failed on URL <InlineCode>{request.url || 'n/a'}</InlineCode> Reflection failed on URL <InlineCode>{request.url || "n/a"}</InlineCode>
</h1> </h1>
<p>{reflectError.trim()}</p> <p>{reflectError.trim()}</p>
</Banner> </Banner>
@@ -100,16 +97,16 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
{!serverReflection && services != null && services.length > 0 && ( {!serverReflection && services != null && services.length > 0 && (
<Banner className="flex flex-col gap-2"> <Banner className="flex flex-col gap-2">
<p> <p>
Found services{' '} Found services{" "}
{services?.slice(0, 5).map((s, i) => { {services?.slice(0, 5).map((s, i) => {
return ( return (
<span key={s.name + s.methods.join(',')}> <span key={s.name + s.methods.map((m) => m.name).join(",")}>
<InlineCode>{s.name}</InlineCode> <InlineCode>{s.name}</InlineCode>
{i === services.length - 1 ? '' : i === services.length - 2 ? ' and ' : ', '} {i === services.length - 1 ? "" : i === services.length - 2 ? " and " : ", "}
</span> </span>
); );
})} })}
{services?.length > 5 && pluralizeCount('other', services?.length - 5)} {services?.length > 5 && pluralizeCount("other", services?.length - 5)}
</p> </p>
</Banner> </Banner>
)} )}
@@ -119,13 +116,13 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
Server reflection found services Server reflection found services
{services?.map((s, i) => { {services?.map((s, i) => {
return ( return (
<span key={s.name + s.methods.join(',')}> <span key={s.name + s.methods.map((m) => m.name).join(",")}>
<InlineCode>{s.name}</InlineCode> <InlineCode>{s.name}</InlineCode>
{i === services.length - 1 ? '' : i === services.length - 2 ? ' and ' : ', '} {i === services.length - 1 ? "" : i === services.length - 2 ? " and " : ", "}
</span> </span>
); );
})} })}
. You can override this schema by manually selecting <InlineCode>*.proto</InlineCode>{' '} . You can override this schema by manually selecting <InlineCode>*.proto</InlineCode>{" "}
files. files.
</p> </p>
</Banner> </Banner>
@@ -142,16 +139,16 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
</thead> </thead>
<tbody className="divide-y divide-surface-highlight"> <tbody className="divide-y divide-surface-highlight">
{protoFiles.map((f, i) => { {protoFiles.map((f, i) => {
const parts = f.split('/'); const parts = f.split("/");
// oxlint-disable-next-line no-array-index-key -- none
return ( return (
// biome-ignore lint/suspicious/noArrayIndexKey: none
<tr key={f + i} className="group"> <tr key={f + i} className="group">
<td> <td>
<Icon icon={f.endsWith('.proto') ? 'file_code' : 'folder_code'} /> <Icon icon={f.endsWith(".proto") ? "file_code" : "folder_code"} />
</td> </td>
<td className="pl-1 font-mono text-sm" title={f}> <td className="pl-1 font-mono text-sm" title={f}>
{parts.length > 3 && '.../'} {parts.length > 3 && ".../"}
{parts.slice(-3).join('/')} {parts.slice(-3).join("/")}
</td> </td>
<td className="w-0 py-0.5"> <td className="w-0 py-0.5">
<IconButton <IconButton
@@ -173,10 +170,10 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
)} )}
{reflectionUnimplemented && protoFiles.length === 0 && ( {reflectionUnimplemented && protoFiles.length === 0 && (
<Banner> <Banner>
<InlineCode>{request.url}</InlineCode> doesn&apos;t implement{' '} <InlineCode>{request.url}</InlineCode> doesn&apos;t implement{" "}
<Link href="https://github.com/grpc/grpc/blob/9aa3c5835a4ed6afae9455b63ed45c761d695bca/doc/server-reflection.md"> <Link href="https://github.com/grpc/grpc/blob/9aa3c5835a4ed6afae9455b63ed45c761d695bca/doc/server-reflection.md">
Server Reflection Server Reflection
</Link>{' '} </Link>{" "}
. Please manually add the <InlineCode>.proto</InlineCode> file to get started. . Please manually add the <InlineCode>.proto</InlineCode> file to get started.
</Banner> </Banner>
)} )}

View File

@@ -1,28 +1,26 @@
import { type GrpcRequest, type HttpRequestHeader, patchModel } from '@yaakapp-internal/models'; import { type GrpcRequest, type HttpRequestHeader, patchModel } from "@yaakapp-internal/models";
import classNames from 'classnames'; import { HStack, Icon, useContainerSize, VStack } from "@yaakapp-internal/ui";
import type { CSSProperties } from 'react'; import classNames from "classnames";
import { useCallback, useMemo, useRef } from 'react'; import type { CSSProperties } from "react";
import { useAuthTab } from '../hooks/useAuthTab'; import { useCallback, useMemo, useRef } from "react";
import { useContainerSize } from '../hooks/useContainerQuery'; import { useAuthTab } from "../hooks/useAuthTab";
import type { ReflectResponseService } from '../hooks/useGrpc'; import type { ReflectResponseService } from "../hooks/useGrpc";
import { useHeadersTab } from '../hooks/useHeadersTab'; import { useHeadersTab } from "../hooks/useHeadersTab";
import { useInheritedHeaders } from '../hooks/useInheritedHeaders'; import { useInheritedHeaders } from "../hooks/useInheritedHeaders";
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey'; import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
import { resolvedModelName } from '../lib/resolvedModelName'; import { resolvedModelName } from "../lib/resolvedModelName";
import { Button } from './core/Button'; import { Button } from "./core/Button";
import { CountBadge } from './core/CountBadge'; import { CountBadge } from "./core/CountBadge";
import { Icon } from './core/Icon'; import { IconButton } from "./core/IconButton";
import { IconButton } from './core/IconButton'; import { PlainInput } from "./core/PlainInput";
import { PlainInput } from './core/PlainInput'; import { RadioDropdown } from "./core/RadioDropdown";
import { RadioDropdown } from './core/RadioDropdown'; import type { TabItem } from "./core/Tabs/Tabs";
import { HStack, VStack } from './core/Stacks'; import { TabContent, Tabs } from "./core/Tabs/Tabs";
import type { TabItem } from './core/Tabs/Tabs'; import { GrpcEditor } from "./GrpcEditor";
import { TabContent, Tabs } from './core/Tabs/Tabs'; import { HeadersEditor } from "./HeadersEditor";
import { GrpcEditor } from './GrpcEditor'; import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
import { HeadersEditor } from './HeadersEditor'; import { MarkdownEditor } from "./MarkdownEditor";
import { HttpAuthenticationEditor } from './HttpAuthenticationEditor'; import { UrlBar } from "./UrlBar";
import { MarkdownEditor } from './MarkdownEditor';
import { UrlBar } from './UrlBar';
interface Props { interface Props {
style?: CSSProperties; style?: CSSProperties;
@@ -32,12 +30,12 @@ interface Props {
reflectionError?: string; reflectionError?: string;
reflectionLoading?: boolean; reflectionLoading?: boolean;
methodType: methodType:
| 'unary' | "unary"
| 'client_streaming' | "client_streaming"
| 'server_streaming' | "server_streaming"
| 'streaming' | "streaming"
| 'no-schema' | "no-schema"
| 'no-method'; | "no-method";
isStreaming: boolean; isStreaming: boolean;
onCommit: () => void; onCommit: () => void;
onCancel: () => void; onCancel: () => void;
@@ -46,10 +44,10 @@ interface Props {
services: ReflectResponseService[] | null; services: ReflectResponseService[] | null;
} }
const TAB_MESSAGE = 'message'; const TAB_MESSAGE = "message";
const TAB_METADATA = 'metadata'; const TAB_METADATA = "metadata";
const TAB_AUTH = 'auth'; const TAB_AUTH = "auth";
const TAB_DESCRIPTION = 'description'; const TAB_DESCRIPTION = "description";
export function GrpcRequestPane({ export function GrpcRequestPane({
style, style,
@@ -66,7 +64,7 @@ export function GrpcRequestPane({
onSend, onSend,
}: Props) { }: Props) {
const authTab = useAuthTab(TAB_AUTH, activeRequest); const authTab = useAuthTab(TAB_AUTH, activeRequest);
const metadataTab = useHeadersTab(TAB_METADATA, activeRequest, 'Metadata'); const metadataTab = useHeadersTab(TAB_METADATA, activeRequest, "Metadata");
const inheritedHeaders = useInheritedHeaders(activeRequest); const inheritedHeaders = useInheritedHeaders(activeRequest);
const forceUpdateKey = useRequestUpdateKey(activeRequest.id ?? null); const forceUpdateKey = useRequestUpdateKey(activeRequest.id ?? null);
@@ -87,18 +85,18 @@ export function GrpcRequestPane({
const options = const options =
services?.flatMap((s) => services?.flatMap((s) =>
s.methods.map((m) => ({ s.methods.map((m) => ({
label: `${s.name.split('.').pop() ?? s.name}/${m.name}`, label: `${s.name.split(".").pop() ?? s.name}/${m.name}`,
value: `${s.name}/${m.name}`, value: `${s.name}/${m.name}`,
})), })),
) ?? []; ) ?? [];
const value = `${activeRequest?.service ?? ''}/${activeRequest?.method ?? ''}`; const value = `${activeRequest?.service ?? ""}/${activeRequest?.method ?? ""}`;
return { value, options }; return { value, options };
}, [activeRequest?.method, activeRequest?.service, services]); }, [activeRequest?.method, activeRequest?.service, services]);
const handleChangeService = useCallback( const handleChangeService = useCallback(
async (v: string) => { async (v: string) => {
const [serviceName, methodName] = v.split('/', 2); const [serviceName, methodName] = v.split("/", 2);
if (serviceName == null || methodName == null) throw new Error('Should never happen'); if (serviceName == null || methodName == null) throw new Error("Should never happen");
await patchModel(activeRequest, { await patchModel(activeRequest, {
service: serviceName, service: serviceName,
method: methodName, method: methodName,
@@ -112,9 +110,9 @@ export function GrpcRequestPane({
if (activeRequest.service == null || activeRequest.method == null) { if (activeRequest.service == null || activeRequest.method == null) {
alert({ alert({
id: 'grpc-invalid-service-method', id: "grpc-invalid-service-method",
title: 'Error', title: "Error",
body: 'Service or method not selected', body: "Service or method not selected",
}); });
} }
onGo(); onGo();
@@ -127,12 +125,12 @@ export function GrpcRequestPane({
const tabs: TabItem[] = useMemo( const tabs: TabItem[] = useMemo(
() => [ () => [
{ value: TAB_MESSAGE, label: 'Message' }, { value: TAB_MESSAGE, label: "Message" },
...metadataTab, ...metadataTab,
...authTab, ...authTab,
{ {
value: TAB_DESCRIPTION, value: TAB_DESCRIPTION,
label: 'Info', label: "Info",
rightSlot: activeRequest.description && <CountBadge count={true} />, rightSlot: activeRequest.description && <CountBadge count={true} />,
}, },
], ],
@@ -154,14 +152,14 @@ export function GrpcRequestPane({
<div <div
ref={urlContainerEl} ref={urlContainerEl}
className={classNames( className={classNames(
'grid grid-cols-[minmax(0,1fr)_auto] gap-1.5', "grid grid-cols-[minmax(0,1fr)_auto] gap-1.5",
paneWidth === 0 && 'opacity-0', paneWidth === 0 && "opacity-0",
paneWidth > 0 && paneWidth < 400 && '!grid-cols-1', paneWidth > 0 && paneWidth < 400 && "!grid-cols-1",
)} )}
> >
<UrlBar <UrlBar
key={forceUpdateKey} key={forceUpdateKey}
url={activeRequest.url ?? ''} url={activeRequest.url ?? ""}
submitIcon={null} submitIcon={null}
forceUpdateKey={forceUpdateKey} forceUpdateKey={forceUpdateKey}
placeholder="localhost:50051" placeholder="localhost:50051"
@@ -178,13 +176,13 @@ export function GrpcRequestPane({
items={select.options.map((o) => ({ items={select.options.map((o) => ({
label: o.label, label: o.label,
value: o.value, value: o.value,
type: 'default', type: "default",
shortLabel: o.label, shortLabel: o.label,
}))} }))}
itemsAfter={[ itemsAfter={[
{ {
label: 'Refresh', label: "Refresh",
type: 'default', type: "default",
leftSlot: <Icon size="sm" icon="refresh" />, leftSlot: <Icon size="sm" icon="refresh" />,
}, },
]} ]}
@@ -195,14 +193,14 @@ export function GrpcRequestPane({
rightSlot={<Icon size="sm" icon="chevron_down" />} rightSlot={<Icon size="sm" icon="chevron_down" />}
disabled={isStreaming || services == null} disabled={isStreaming || services == null}
className={classNames( className={classNames(
'font-mono text-editor min-w-[5rem] !ring-0', "font-mono text-editor min-w-[5rem] !ring-0",
paneWidth < 400 && 'flex-1', paneWidth < 400 && "flex-1",
)} )}
> >
{select.options.find((o) => o.value === select.value)?.label ?? 'No Schema'} {select.options.find((o) => o.value === select.value)?.label ?? "No Schema"}
</Button> </Button>
</RadioDropdown> </RadioDropdown>
{methodType === 'client_streaming' || methodType === 'streaming' ? ( {methodType === "client_streaming" || methodType === "streaming" ? (
<> <>
{isStreaming && ( {isStreaming && (
<> <>
@@ -225,26 +223,26 @@ export function GrpcRequestPane({
<IconButton <IconButton
size="sm" size="sm"
variant="border" variant="border"
title={isStreaming ? 'Connect' : 'Send'} title={isStreaming ? "Connect" : "Send"}
hotkeyAction="request.send" hotkeyAction="request.send"
onClick={isStreaming ? handleSend : handleConnect} onClick={isStreaming ? handleSend : handleConnect}
icon={isStreaming ? 'send_horizontal' : 'arrow_up_down'} icon={isStreaming ? "send_horizontal" : "arrow_up_down"}
/> />
</> </>
) : ( ) : (
<IconButton <IconButton
size="sm" size="sm"
variant="border" variant="border"
title={methodType === 'unary' ? 'Send' : 'Connect'} title={methodType === "unary" ? "Send" : "Connect"}
hotkeyAction="request.send" hotkeyAction="request.send"
onClick={isStreaming ? onCancel : handleConnect} onClick={isStreaming ? onCancel : handleConnect}
disabled={methodType === 'no-schema' || methodType === 'no-method'} disabled={methodType === "no-schema" || methodType === "no-method"}
icon={ icon={
isStreaming isStreaming
? 'x' ? "x"
: methodType.includes('streaming') : methodType.includes("streaming")
? 'arrow_up_down' ? "arrow_up_down"
: 'send_horizontal' : "send_horizontal"
} }
/> />
)} )}

View File

@@ -1,38 +1,36 @@
import type { GrpcEvent, GrpcRequest } from '@yaakapp-internal/models'; import type { GrpcEvent, GrpcRequest } from "@yaakapp-internal/models";
import { useAtomValue, useSetAtom } from 'jotai'; import { HStack, Icon, type IconProps, LoadingIcon, VStack } from "@yaakapp-internal/ui";
import type { CSSProperties } from 'react'; import { useAtomValue, useSetAtom } from "jotai";
import { useEffect, useMemo, useState } from 'react'; import type { CSSProperties } from "react";
import { useEffect, useMemo, useState } from "react";
import { import {
activeGrpcConnectionAtom, activeGrpcConnectionAtom,
activeGrpcConnections, activeGrpcConnections,
pinnedGrpcConnectionIdAtom, pinnedGrpcConnectionIdAtom,
useGrpcEvents, useGrpcEvents,
} from '../hooks/usePinnedGrpcConnection'; } from "../hooks/usePinnedGrpcConnection";
import { useStateWithDeps } from '../hooks/useStateWithDeps'; import { useStateWithDeps } from "../hooks/useStateWithDeps";
import { Button } from './core/Button'; import { Button } from "./core/Button";
import { Editor } from './core/Editor/LazyEditor'; import { Editor } from "./core/Editor/LazyEditor";
import { EventDetailHeader, EventViewer } from './core/EventViewer'; import { EventDetailHeader, EventViewer } from "./core/EventViewer";
import { EventViewerRow } from './core/EventViewerRow'; import { EventViewerRow } from "./core/EventViewerRow";
import { HotkeyList } from './core/HotkeyList'; import { HotkeyList } from "./core/HotkeyList";
import { Icon, type IconProps } from './core/Icon'; import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow'; import { EmptyStateText } from "./EmptyStateText";
import { LoadingIcon } from './core/LoadingIcon'; import { ErrorBoundary } from "./ErrorBoundary";
import { HStack, VStack } from './core/Stacks'; import { RecentGrpcConnectionsDropdown } from "./RecentGrpcConnectionsDropdown";
import { EmptyStateText } from './EmptyStateText';
import { ErrorBoundary } from './ErrorBoundary';
import { RecentGrpcConnectionsDropdown } from './RecentGrpcConnectionsDropdown';
interface Props { interface Props {
style?: CSSProperties; style?: CSSProperties;
className?: string; className?: string;
activeRequest: GrpcRequest; activeRequest: GrpcRequest;
methodType: methodType:
| 'unary' | "unary"
| 'client_streaming' | "client_streaming"
| 'server_streaming' | "server_streaming"
| 'streaming' | "streaming"
| 'no-schema' | "no-schema"
| 'no-method'; | "no-method";
} }
export function GrpcResponsePane({ style, methodType, activeRequest }: Props) { export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
@@ -50,12 +48,12 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
); );
// Set the active message to the first message received if unary // Set the active message to the first message received if unary
// biome-ignore lint/correctness/useExhaustiveDependencies: none // oxlint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { useEffect(() => {
if (events.length === 0 || activeEvent != null || methodType !== 'unary') { if (events.length === 0 || activeEvent != null || methodType !== "unary") {
return; return;
} }
const firstServerMessageIndex = events.findIndex((m) => m.eventType === 'server_message'); const firstServerMessageIndex = events.findIndex((m) => m.eventType === "server_message");
if (firstServerMessageIndex !== -1) { if (firstServerMessageIndex !== -1) {
setActiveEventIndex(firstServerMessageIndex); setActiveEventIndex(firstServerMessageIndex);
} }
@@ -63,7 +61,7 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
if (activeConnection == null) { if (activeConnection == null) {
return ( return (
<HotkeyList hotkeys={['request.send', 'model.create', 'sidebar.focus', 'url_bar.focus']} /> <HotkeyList hotkeys={["request.send", "model.create", "sidebar.focus", "url_bar.focus"]} />
); );
} }
@@ -71,7 +69,7 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
<HStack className="pl-3 mb-1 font-mono text-sm text-text-subtle overflow-x-auto hide-scrollbars"> <HStack className="pl-3 mb-1 font-mono text-sm text-text-subtle overflow-x-auto hide-scrollbars">
<HStack space={2}> <HStack space={2}>
<span className="whitespace-nowrap">{events.length} Messages</span> <span className="whitespace-nowrap">{events.length} Messages</span>
{activeConnection.state !== 'closed' && ( {activeConnection.state !== "closed" && (
<LoadingIcon size="sm" className="text-text-subtlest" /> <LoadingIcon size="sm" className="text-text-subtlest" />
)} )}
</HStack> </HStack>
@@ -93,7 +91,7 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
getEventKey={(event) => event.id} getEventKey={(event) => event.id}
error={activeConnection.error} error={activeConnection.error}
header={header} header={header}
splitLayoutName="grpc_events" splitLayoutStorageKey="grpc_events"
defaultRatio={0.4} defaultRatio={0.4}
renderRow={({ event, isActive, onClick }) => ( renderRow={({ event, isActive, onClick }) => (
<GrpcEventRow event={event} isActive={isActive} onClick={onClick} /> <GrpcEventRow event={event} isActive={isActive} onClick={onClick} />
@@ -157,8 +155,8 @@ function GrpcEventDetail({
setShowingLarge: (v: boolean) => void; setShowingLarge: (v: boolean) => void;
onClose: () => void; onClose: () => void;
}) { }) {
if (event.eventType === 'client_message' || event.eventType === 'server_message') { if (event.eventType === "client_message" || event.eventType === "server_message") {
const title = `Message ${event.eventType === 'client_message' ? 'Sent' : 'Received'}`; const title = `Message ${event.eventType === "client_message" ? "Sent" : "Received"}`;
return ( return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]"> <div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
@@ -192,7 +190,7 @@ function GrpcEventDetail({
) : ( ) : (
<Editor <Editor
language="json" language="json"
defaultValue={event.content ?? ''} defaultValue={event.content ?? ""}
wrapLines={false} wrapLines={false}
readOnly={true} readOnly={true}
stateKey={null} stateKey={null}
@@ -214,7 +212,7 @@ function GrpcEventDetail({
<div className="py-2 h-full"> <div className="py-2 h-full">
{Object.keys(event.metadata).length === 0 ? ( {Object.keys(event.metadata).length === 0 ? (
<EmptyStateText> <EmptyStateText>
No {event.eventType === 'connection_end' ? 'trailers' : 'metadata'} No {event.eventType === "connection_end" ? "trailers" : "metadata"}
</EmptyStateText> </EmptyStateText>
) : ( ) : (
<KeyValueRows> <KeyValueRows>
@@ -231,20 +229,20 @@ function GrpcEventDetail({
} }
function getEventDisplay( function getEventDisplay(
eventType: GrpcEvent['eventType'], eventType: GrpcEvent["eventType"],
status: GrpcEvent['status'], status: GrpcEvent["status"],
): { icon: IconProps['icon']; color: IconProps['color']; title: string } { ): { icon: IconProps["icon"]; color: IconProps["color"]; title: string } {
if (eventType === 'server_message') { if (eventType === "server_message") {
return { icon: 'arrow_big_down_dash', color: 'info', title: 'Server message' }; return { icon: "arrow_big_down_dash", color: "info", title: "Server message" };
} }
if (eventType === 'client_message') { if (eventType === "client_message") {
return { icon: 'arrow_big_up_dash', color: 'primary', title: 'Client message' }; return { icon: "arrow_big_up_dash", color: "primary", title: "Client message" };
} }
if (eventType === 'error' || (status != null && status > 0)) { if (eventType === "error" || (status != null && status > 0)) {
return { icon: 'alert_triangle', color: 'danger', title: 'Error' }; return { icon: "alert_triangle", color: "danger", title: "Error" };
} }
if (eventType === 'connection_end') { if (eventType === "connection_end") {
return { icon: 'check', color: 'success', title: 'Connection response' }; return { icon: "check", color: "success", title: "Connection response" };
} }
return { icon: 'info', color: undefined, title: 'Event' }; return { icon: "info", color: undefined, title: "Event" };
} }

View File

@@ -1,19 +1,19 @@
import type { HttpRequestHeader } from '@yaakapp-internal/models'; import type { HttpRequestHeader } from "@yaakapp-internal/models";
import type { GenericCompletionOption } from '@yaakapp-internal/plugins'; import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
import { charsets } from '../lib/data/charsets'; import { HStack } from "@yaakapp-internal/ui";
import { connections } from '../lib/data/connections'; import { charsets } from "../lib/data/charsets";
import { encodings } from '../lib/data/encodings'; import { connections } from "../lib/data/connections";
import { headerNames } from '../lib/data/headerNames'; import { encodings } from "../lib/data/encodings";
import { mimeTypes } from '../lib/data/mimetypes'; import { headerNames } from "../lib/data/headerNames";
import { CountBadge } from './core/CountBadge'; import { mimeTypes } from "../lib/data/mimetypes";
import { DetailsBanner } from './core/DetailsBanner'; import { CountBadge } from "./core/CountBadge";
import type { GenericCompletionConfig } from './core/Editor/genericCompletion'; import { DetailsBanner } from "./core/DetailsBanner";
import type { InputProps } from './core/Input'; import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
import type { Pair, PairEditorProps } from './core/PairEditor'; import type { InputProps } from "./core/Input";
import { PairEditorRow } from './core/PairEditor'; import type { Pair, PairEditorProps } from "./core/PairEditor";
import { ensurePairId } from './core/PairEditor.util'; import { PairEditorRow } from "./core/PairEditor";
import { PairOrBulkEditor } from './core/PairOrBulkEditor'; import { ensurePairId } from "./core/PairEditor.util";
import { HStack } from './core/Stacks'; import { PairOrBulkEditor } from "./core/PairOrBulkEditor";
type Props = { type Props = {
forceUpdateKey: string; forceUpdateKey: string;
@@ -29,7 +29,7 @@ export function HeadersEditor({
stateKey, stateKey,
headers, headers,
inheritedHeaders, inheritedHeaders,
inheritedHeadersLabel = 'Inherited', inheritedHeadersLabel = "Inherited",
onChange, onChange,
forceUpdateKey, forceUpdateKey,
}: Props) { }: Props) {
@@ -41,15 +41,17 @@ export function HeadersEditor({
const validInheritedHeaders = const validInheritedHeaders =
inheritedHeaders?.filter( inheritedHeaders?.filter(
(pair) => (pair) =>
pair.enabled && (pair.name || pair.value) && !currentHeaderNames.has(pair.name.toLowerCase()), pair.enabled &&
(pair.name || pair.value) &&
!currentHeaderNames.has(pair.name.toLowerCase()),
) ?? []; ) ?? [];
const hasInheritedHeaders = validInheritedHeaders.length > 0; const hasInheritedHeaders = validInheritedHeaders.length > 0;
return ( return (
<div <div
className={ className={
hasInheritedHeaders hasInheritedHeaders
? '@container w-full h-full grid grid-rows-[auto_minmax(0,1fr)] gap-y-1.5' ? "@container w-full h-full grid grid-rows-[auto_minmax(0,1fr)] gap-y-1.5"
: '@container w-full h-full' : "@container w-full h-full"
} }
> >
{hasInheritedHeaders && ( {hasInheritedHeaders && (
@@ -104,28 +106,28 @@ export function HeadersEditor({
const MIN_MATCH = 3; const MIN_MATCH = 3;
const headerOptionsMap: Record<string, string[]> = { const headerOptionsMap: Record<string, string[]> = {
'content-type': mimeTypes, "content-type": mimeTypes,
accept: ['*/*', ...mimeTypes], accept: ["*/*", ...mimeTypes],
'accept-encoding': encodings, "accept-encoding": encodings,
connection: connections, connection: connections,
'accept-charset': charsets, "accept-charset": charsets,
}; };
const valueType = (pair: Pair): InputProps['type'] => { const valueType = (pair: Pair): InputProps["type"] => {
const name = pair.name.toLowerCase().trim(); const name = pair.name.toLowerCase().trim();
if ( if (
name.includes('authorization') || name.includes("authorization") ||
name.includes('api-key') || name.includes("api-key") ||
name.includes('access-token') || name.includes("access-token") ||
name.includes('auth') || name.includes("auth") ||
name.includes('secret') || name.includes("secret") ||
name.includes('token') || name.includes("token") ||
name === 'cookie' || name === "cookie" ||
name === 'set-cookie' name === "set-cookie"
) { ) {
return 'password'; return "password";
} }
return 'text'; return "text";
}; };
const valueAutocomplete = (headerName: string): GenericCompletionConfig | undefined => { const valueAutocomplete = (headerName: string): GenericCompletionConfig | undefined => {
@@ -133,19 +135,19 @@ const valueAutocomplete = (headerName: string): GenericCompletionConfig | undefi
const options: GenericCompletionOption[] = const options: GenericCompletionOption[] =
headerOptionsMap[name]?.map((o) => ({ headerOptionsMap[name]?.map((o) => ({
label: o, label: o,
type: 'constant', type: "constant",
boost: 1, // Put above other completions boost: 1, // Put above other completions
})) ?? []; })) ?? [];
return { minMatch: MIN_MATCH, options }; return { minMatch: MIN_MATCH, options };
}; };
const nameAutocomplete: PairEditorProps['nameAutocomplete'] = { const nameAutocomplete: PairEditorProps["nameAutocomplete"] = {
minMatch: MIN_MATCH, minMatch: MIN_MATCH,
options: headerNames.map((t) => options: headerNames.map((t) =>
typeof t === 'string' typeof t === "string"
? { ? {
label: t, label: t,
type: 'constant', type: "constant",
boost: 1, // Put above other completions boost: 1, // Put above other completions
} }
: { : {
@@ -156,11 +158,11 @@ const nameAutocomplete: PairEditorProps['nameAutocomplete'] = {
}; };
const validateHttpHeader = (v: string) => { const validateHttpHeader = (v: string) => {
if (v === '') { if (v === "") {
return true; return true;
} }
// Template strings are not allowed so we replace them with a valid example string // Template strings are not allowed so we replace them with a valid example string
const withoutTemplateStrings = v.replace(/\$\{\[\s*[^\]\s]+\s*]}/gi, '123'); const withoutTemplateStrings = v.replace(/\$\{\[\s*[^\]\s]+\s*]}/gi, "123");
return withoutTemplateStrings.match(/^[a-zA-Z0-9-_]+$/) !== null; return withoutTemplateStrings.match(/^[a-zA-Z0-9-_]+$/) !== null;
}; };

View File

@@ -4,25 +4,23 @@ import type {
HttpRequest, HttpRequest,
WebsocketRequest, WebsocketRequest,
Workspace, Workspace,
} from '@yaakapp-internal/models'; } from "@yaakapp-internal/models";
import { patchModel } from '@yaakapp-internal/models'; import { patchModel } from "@yaakapp-internal/models";
import { useCallback } from 'react'; import { HStack, Icon, InlineCode } from "@yaakapp-internal/ui";
import { openFolderSettings } from '../commands/openFolderSettings'; import { useCallback } from "react";
import { openWorkspaceSettings } from '../commands/openWorkspaceSettings'; import { openFolderSettings } from "../commands/openFolderSettings";
import { useHttpAuthenticationConfig } from '../hooks/useHttpAuthenticationConfig'; import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
import { useInheritedAuthentication } from '../hooks/useInheritedAuthentication'; import { useHttpAuthenticationConfig } from "../hooks/useHttpAuthenticationConfig";
import { useRenderTemplate } from '../hooks/useRenderTemplate'; import { useInheritedAuthentication } from "../hooks/useInheritedAuthentication";
import { resolvedModelName } from '../lib/resolvedModelName'; import { useRenderTemplate } from "../hooks/useRenderTemplate";
import { Dropdown, type DropdownItem } from './core/Dropdown'; import { resolvedModelName } from "../lib/resolvedModelName";
import { Icon } from './core/Icon'; import { Dropdown, type DropdownItem } from "./core/Dropdown";
import { IconButton } from './core/IconButton'; import { IconButton } from "./core/IconButton";
import { InlineCode } from './core/InlineCode'; import { Input, type InputProps } from "./core/Input";
import { Input, type InputProps } from './core/Input'; import { Link } from "./core/Link";
import { Link } from './core/Link'; import { SegmentedControl } from "./core/SegmentedControl";
import { SegmentedControl } from './core/SegmentedControl'; import { DynamicForm } from "./DynamicForm";
import { HStack } from './core/Stacks'; import { EmptyStateText } from "./EmptyStateText";
import { DynamicForm } from './DynamicForm';
import { EmptyStateText } from './EmptyStateText';
interface Props { interface Props {
model: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace; model: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace;
@@ -41,7 +39,7 @@ export function HttpAuthenticationEditor({ model }: Props) {
[model], [model],
); );
if (model.authenticationType === 'none') { if (model.authenticationType === "none") {
return <EmptyStateText>No authentication</EmptyStateText>; return <EmptyStateText>No authentication</EmptyStateText>;
} }
@@ -56,7 +54,7 @@ export function HttpAuthenticationEditor({ model }: Props) {
} }
if (inheritedAuth == null) { if (inheritedAuth == null) {
if (model.model === 'workspace' || model.model === 'folder') { if (model.model === "workspace" || model.model === "folder") {
return ( return (
<EmptyStateText className="flex-col gap-1"> <EmptyStateText className="flex-col gap-1">
<p> <p>
@@ -69,24 +67,24 @@ export function HttpAuthenticationEditor({ model }: Props) {
return <EmptyStateText>No authentication</EmptyStateText>; return <EmptyStateText>No authentication</EmptyStateText>;
} }
if (inheritedAuth.authenticationType === 'none') { if (inheritedAuth.authenticationType === "none") {
return <EmptyStateText>No authentication</EmptyStateText>; return <EmptyStateText>No authentication</EmptyStateText>;
} }
const wasAuthInherited = inheritedAuth?.id !== model.id; const wasAuthInherited = inheritedAuth?.id !== model.id;
if (wasAuthInherited) { if (wasAuthInherited) {
const name = resolvedModelName(inheritedAuth); const name = resolvedModelName(inheritedAuth);
const cta = inheritedAuth.model === 'workspace' ? 'Workspace' : name; const cta = inheritedAuth.model === "workspace" ? "Workspace" : name;
return ( return (
<EmptyStateText> <EmptyStateText>
<p> <p>
Inherited from{' '} Inherited from{" "}
<button <button
type="submit" type="submit"
className="underline hover:text-text" className="underline hover:text-text"
onClick={() => { onClick={() => {
if (inheritedAuth.model === 'folder') openFolderSettings(inheritedAuth.id, 'auth'); if (inheritedAuth.model === "folder") openFolderSettings(inheritedAuth.id, "auth");
else openWorkspaceSettings('auth'); else openWorkspaceSettings("auth");
}} }}
> >
{cta} {cta}
@@ -106,24 +104,24 @@ export function HttpAuthenticationEditor({ model }: Props) {
name="enabled" name="enabled"
value={ value={
model.authentication.disabled === false || model.authentication.disabled == null model.authentication.disabled === false || model.authentication.disabled == null
? '__TRUE__' ? "__TRUE__"
: model.authentication.disabled === true : model.authentication.disabled === true
? '__FALSE__' ? "__FALSE__"
: '__DYNAMIC__' : "__DYNAMIC__"
} }
options={[ options={[
{ label: 'Enabled', value: '__TRUE__' }, { label: "Enabled", value: "__TRUE__" },
{ label: 'Disabled', value: '__FALSE__' }, { label: "Disabled", value: "__FALSE__" },
{ label: 'Enabled when...', value: '__DYNAMIC__' }, { label: "Enabled when...", value: "__DYNAMIC__" },
]} ]}
onChange={async (enabled) => { onChange={async (enabled) => {
let disabled: boolean | string; let disabled: boolean | string;
if (enabled === '__TRUE__') { if (enabled === "__TRUE__") {
disabled = false; disabled = false;
} else if (enabled === '__FALSE__') { } else if (enabled === "__FALSE__") {
disabled = true; disabled = true;
} else { } else {
disabled = ''; disabled = "";
} }
await handleChange({ ...model.authentication, disabled }); await handleChange({ ...model.authentication, disabled });
}} }}
@@ -147,7 +145,7 @@ export function HttpAuthenticationEditor({ model }: Props) {
</Dropdown> </Dropdown>
)} )}
</HStack> </HStack>
{typeof model.authentication.disabled === 'string' && ( {typeof model.authentication.disabled === "string" && (
<div className="mt-3"> <div className="mt-3">
<AuthenticationDisabledInput <AuthenticationDisabledInput
className="w-full" className="w-full"
@@ -178,14 +176,14 @@ function AuthenticationDisabledInput({
className, className,
}: { }: {
value: string; value: string;
onChange: InputProps['onChange']; onChange: InputProps["onChange"];
stateKey: string; stateKey: string;
className?: string; className?: string;
}) { }) {
const rendered = useRenderTemplate({ const rendered = useRenderTemplate({
template: value, template: value,
enabled: true, enabled: true,
purpose: 'preview', purpose: "preview",
refreshKey: value, refreshKey: value,
}); });
@@ -200,7 +198,7 @@ function AuthenticationDisabledInput({
rightSlot={ rightSlot={
<div className="px-1 flex items-center"> <div className="px-1 flex items-center">
<div className="rounded-full bg-surface-highlight text-xs px-1.5 py-0.5 text-text-subtle whitespace-nowrap"> <div className="rounded-full bg-surface-highlight text-xs px-1.5 py-0.5 text-text-subtle whitespace-nowrap">
{rendered.isPending ? 'loading' : rendered.data ? 'enabled' : 'disabled'} {rendered.isPending ? "loading" : rendered.data ? "enabled" : "disabled"}
</div> </div>
</div> </div>
} }

View File

@@ -1,15 +1,16 @@
import type { HttpRequest } from '@yaakapp-internal/models'; import type { HttpRequest } from "@yaakapp-internal/models";
import classNames from 'classnames'; import type { SlotProps } from "@yaakapp-internal/ui";
import { useAtomValue } from 'jotai'; import { SplitLayout } from "@yaakapp-internal/ui";
import type { CSSProperties } from 'react'; import classNames from "classnames";
import { useCurrentGraphQLSchema } from '../hooks/useIntrospectGraphQL'; import { useAtomValue } from "jotai";
import { workspaceLayoutAtom } from '../lib/atoms'; import type { CSSProperties } from "react";
import type { SlotProps } from './core/SplitLayout'; import { useCurrentGraphQLSchema } from "../hooks/useIntrospectGraphQL";
import { SplitLayout } from './core/SplitLayout'; import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
import { GraphQLDocsExplorer } from './graphql/GraphQLDocsExplorer'; import { workspaceLayoutAtom } from "../lib/atoms";
import { showGraphQLDocExplorerAtom } from './graphql/graphqlAtoms'; import { GraphQLDocsExplorer } from "./graphql/GraphQLDocsExplorer";
import { HttpRequestPane } from './HttpRequestPane'; import { showGraphQLDocExplorerAtom } from "./graphql/graphqlAtoms";
import { HttpResponsePane } from './HttpResponsePane'; import { HttpRequestPane } from "./HttpRequestPane";
import { HttpResponsePane } from "./HttpResponsePane";
interface Props { interface Props {
activeRequest: HttpRequest; activeRequest: HttpRequest;
@@ -20,10 +21,12 @@ export function HttpRequestLayout({ activeRequest, style }: Props) {
const showGraphQLDocExplorer = useAtomValue(showGraphQLDocExplorerAtom); const showGraphQLDocExplorer = useAtomValue(showGraphQLDocExplorerAtom);
const graphQLSchema = useCurrentGraphQLSchema(activeRequest); const graphQLSchema = useCurrentGraphQLSchema(activeRequest);
const workspaceLayout = useAtomValue(workspaceLayoutAtom); const workspaceLayout = useAtomValue(workspaceLayoutAtom);
const activeWorkspace = useAtomValue(activeWorkspaceAtom);
const wsId = activeWorkspace?.id ?? "n/a";
const requestResponseSplit = ({ style }: Pick<SlotProps, 'style'>) => ( const requestResponseSplit = ({ style }: Pick<SlotProps, "style">) => (
<SplitLayout <SplitLayout
name="http_layout" storageKey={`http_layout::${wsId}`}
className="p-3 gap-1.5" className="p-3 gap-1.5"
style={style} style={style}
layout={workspaceLayout} layout={workspaceLayout}
@@ -31,7 +34,7 @@ export function HttpRequestLayout({ activeRequest, style }: Props) {
<HttpRequestPane <HttpRequestPane
style={style} style={style}
activeRequest={activeRequest} activeRequest={activeRequest}
fullHeight={orientation === 'horizontal'} fullHeight={orientation === "horizontal"}
/> />
)} )}
secondSlot={({ style }) => ( secondSlot={({ style }) => (
@@ -41,20 +44,20 @@ export function HttpRequestLayout({ activeRequest, style }: Props) {
); );
if ( if (
activeRequest.bodyType === 'graphql' && activeRequest.bodyType === "graphql" &&
showGraphQLDocExplorer[activeRequest.id] !== undefined && showGraphQLDocExplorer[activeRequest.id] !== undefined &&
graphQLSchema != null graphQLSchema != null
) { ) {
return ( return (
<SplitLayout <SplitLayout
name="graphql_layout" storageKey={`graphql_layout::${wsId}`}
defaultRatio={1 / 3} defaultRatio={1 / 3}
firstSlot={requestResponseSplit} firstSlot={requestResponseSplit}
secondSlot={({ style, orientation }) => ( secondSlot={({ style, orientation }) => (
<GraphQLDocsExplorer <GraphQLDocsExplorer
requestId={activeRequest.id} requestId={activeRequest.id}
schema={graphQLSchema} schema={graphQLSchema}
className={classNames(orientation === 'horizontal' && '!ml-0')} className={classNames(orientation === "horizontal" && "!ml-0")}
style={style} style={style}
/> />
)} )}

View File

@@ -1,24 +1,24 @@
import type { HttpRequest } from '@yaakapp-internal/models'; import type { HttpRequest } from "@yaakapp-internal/models";
import { patchModel } from '@yaakapp-internal/models'; import { patchModel } from "@yaakapp-internal/models";
import type { GenericCompletionOption } from '@yaakapp-internal/plugins'; import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
import classNames from 'classnames'; import classNames from "classnames";
import { atom, useAtomValue } from 'jotai'; import { atom, useAtomValue } from "jotai";
import type { CSSProperties } from 'react'; import type { CSSProperties } from "react";
import { lazy, Suspense, useCallback, useMemo, useRef, useState } from 'react'; import { lazy, Suspense, useCallback, useMemo, useRef, useState } from "react";
import { activeRequestIdAtom } from '../hooks/useActiveRequestId'; import { activeRequestIdAtom } from "../hooks/useActiveRequestId";
import { allRequestsAtom } from '../hooks/useAllRequests'; import { allRequestsAtom } from "../hooks/useAllRequests";
import { useAuthTab } from '../hooks/useAuthTab'; import { useAuthTab } from "../hooks/useAuthTab";
import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse'; import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
import { useHeadersTab } from '../hooks/useHeadersTab'; import { useHeadersTab } from "../hooks/useHeadersTab";
import { useImportCurl } from '../hooks/useImportCurl'; import { useImportCurl } from "../hooks/useImportCurl";
import { useInheritedHeaders } from '../hooks/useInheritedHeaders'; import { useInheritedHeaders } from "../hooks/useInheritedHeaders";
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse'; import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { useRequestEditor, useRequestEditorEvent } from '../hooks/useRequestEditor'; import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey'; import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
import { useSendAnyHttpRequest } from '../hooks/useSendAnyHttpRequest'; import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
import { deepEqualAtom } from '../lib/atoms'; import { deepEqualAtom } from "../lib/atoms";
import { languageFromContentType } from '../lib/contentType'; import { languageFromContentType } from "../lib/contentType";
import { generateId } from '../lib/generateId'; import { generateId } from "../lib/generateId";
import { import {
BODY_TYPE_BINARY, BODY_TYPE_BINARY,
BODY_TYPE_FORM_MULTIPART, BODY_TYPE_FORM_MULTIPART,
@@ -29,33 +29,33 @@ import {
BODY_TYPE_OTHER, BODY_TYPE_OTHER,
BODY_TYPE_XML, BODY_TYPE_XML,
getContentTypeFromHeaders, getContentTypeFromHeaders,
} from '../lib/model_util'; } from "../lib/model_util";
import { prepareImportQuerystring } from '../lib/prepareImportQuerystring'; import { prepareImportQuerystring } from "../lib/prepareImportQuerystring";
import { resolvedModelName } from '../lib/resolvedModelName'; import { resolvedModelName } from "../lib/resolvedModelName";
import { showToast } from '../lib/toast'; import { showToast } from "../lib/toast";
import { BinaryFileEditor } from './BinaryFileEditor'; import { BinaryFileEditor } from "./BinaryFileEditor";
import { ConfirmLargeRequestBody } from './ConfirmLargeRequestBody'; import { ConfirmLargeRequestBody } from "./ConfirmLargeRequestBody";
import { CountBadge } from './core/CountBadge'; import { CountBadge } from "./core/CountBadge";
import type { GenericCompletionConfig } from './core/Editor/genericCompletion'; import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
import { Editor } from './core/Editor/LazyEditor'; import { Editor } from "./core/Editor/LazyEditor";
import { InlineCode } from './core/InlineCode'; import { InlineCode } from "@yaakapp-internal/ui";
import type { Pair } from './core/PairEditor'; import type { Pair } from "./core/PairEditor";
import { PlainInput } from './core/PlainInput'; import { PlainInput } from "./core/PlainInput";
import type { TabItem, TabsRef } from './core/Tabs/Tabs'; import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
import { setActiveTab, TabContent, Tabs } from './core/Tabs/Tabs'; import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs";
import { EmptyStateText } from './EmptyStateText'; import { EmptyStateText } from "./EmptyStateText";
import { FormMultipartEditor } from './FormMultipartEditor'; import { FormMultipartEditor } from "./FormMultipartEditor";
import { FormUrlencodedEditor } from './FormUrlencodedEditor'; import { FormUrlencodedEditor } from "./FormUrlencodedEditor";
import { HeadersEditor } from './HeadersEditor'; import { HeadersEditor } from "./HeadersEditor";
import { HttpAuthenticationEditor } from './HttpAuthenticationEditor'; import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
import { JsonBodyEditor } from './JsonBodyEditor'; import { JsonBodyEditor } from "./JsonBodyEditor";
import { MarkdownEditor } from './MarkdownEditor'; import { MarkdownEditor } from "./MarkdownEditor";
import { RequestMethodDropdown } from './RequestMethodDropdown'; import { RequestMethodDropdown } from "./RequestMethodDropdown";
import { UrlBar } from './UrlBar'; import { UrlBar } from "./UrlBar";
import { UrlParametersEditor } from './UrlParameterEditor'; import { UrlParametersEditor } from "./UrlParameterEditor";
const GraphQLEditor = lazy(() => const GraphQLEditor = lazy(() =>
import('./graphql/GraphQLEditor').then((m) => ({ default: m.GraphQLEditor })), import("./graphql/GraphQLEditor").then((m) => ({ default: m.GraphQLEditor })),
); );
interface Props { interface Props {
@@ -65,19 +65,19 @@ interface Props {
activeRequest: HttpRequest; activeRequest: HttpRequest;
} }
const TAB_BODY = 'body'; const TAB_BODY = "body";
const TAB_PARAMS = 'params'; const TAB_PARAMS = "params";
const TAB_HEADERS = 'headers'; const TAB_HEADERS = "headers";
const TAB_AUTH = 'auth'; const TAB_AUTH = "auth";
const TAB_DESCRIPTION = 'description'; const TAB_DESCRIPTION = "description";
const TABS_STORAGE_KEY = 'http_request_tabs'; const TABS_STORAGE_KEY = "http_request_tabs";
const nonActiveRequestUrlsAtom = atom((get) => { const nonActiveRequestUrlsAtom = atom((get) => {
const activeRequestId = get(activeRequestIdAtom); const activeRequestId = get(activeRequestIdAtom);
const requests = get(allRequestsAtom); const requests = get(allRequestsAtom);
return requests return requests
.filter((r) => r.id !== activeRequestId) .filter((r) => r.id !== activeRequestId)
.map((r): GenericCompletionOption => ({ type: 'constant', label: r.url })); .map((r): GenericCompletionOption => ({ type: "constant", label: r.url }));
}); });
const memoNotActiveRequestUrlsAtom = deepEqualAtom(nonActiveRequestUrlsAtom); const memoNotActiveRequestUrlsAtom = deepEqualAtom(nonActiveRequestUrlsAtom);
@@ -94,22 +94,26 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
const inheritedHeaders = useInheritedHeaders(activeRequest); const inheritedHeaders = useInheritedHeaders(activeRequest);
// Listen for event to focus the params tab (e.g., when clicking a :param in the URL) // Listen for event to focus the params tab (e.g., when clicking a :param in the URL)
useRequestEditorEvent('request_pane.focus_tab', () => { useRequestEditorEvent(
tabsRef.current?.setActiveTab(TAB_PARAMS); "request_pane.focus_tab",
}, []); () => {
tabsRef.current?.setActiveTab(TAB_PARAMS);
},
[],
);
const handleContentTypeChange = useCallback( const handleContentTypeChange = useCallback(
async (contentType: string | null, patch: Partial<Omit<HttpRequest, 'headers'>> = {}) => { async (contentType: string | null, patch: Partial<Omit<HttpRequest, "headers">> = {}) => {
if (activeRequest == null) { if (activeRequest == null) {
console.error('Failed to get active request to update', activeRequest); console.error("Failed to get active request to update", activeRequest);
return; return;
} }
const headers = activeRequest.headers.filter((h) => h.name.toLowerCase() !== 'content-type'); const headers = activeRequest.headers.filter((h) => h.name.toLowerCase() !== "content-type");
if (contentType != null) { if (contentType != null) {
headers.push({ headers.push({
name: 'Content-Type', name: "Content-Type",
value: contentType, value: contentType,
enabled: true, enabled: true,
id: generateId(), id: generateId(),
@@ -125,7 +129,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
const { urlParameterPairs, urlParametersKey } = useMemo(() => { const { urlParameterPairs, urlParametersKey } = useMemo(() => {
const placeholderNames = Array.from(activeRequest.url.matchAll(/\/(:[^/]+)/g)).map( const placeholderNames = Array.from(activeRequest.url.matchAll(/\/(:[^/]+)/g)).map(
(m) => m[1] ?? '', (m) => m[1] ?? "",
); );
const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value); const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);
const items: Pair[] = [...nonEmptyParameters]; const items: Pair[] = [...nonEmptyParameters];
@@ -134,10 +138,10 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
if (item) { if (item) {
item.readOnlyName = true; item.readOnlyName = true;
} else { } else {
items.push({ name, value: '', enabled: true, readOnlyName: true, id: generateId() }); items.push({ name, value: "", enabled: true, readOnlyName: true, id: generateId() });
} }
} }
return { urlParameterPairs: items, urlParametersKey: placeholderNames.join(',') }; return { urlParameterPairs: items, urlParametersKey: placeholderNames.join(",") };
}, [activeRequest.url, activeRequest.urlParameters]); }, [activeRequest.url, activeRequest.urlParameters]);
let numParams = 0; let numParams = 0;
@@ -158,21 +162,21 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
options: { options: {
value: activeRequest.bodyType, value: activeRequest.bodyType,
items: [ items: [
{ type: 'separator', label: 'Form Data' }, { type: "separator", label: "Form Data" },
{ label: 'Url Encoded', value: BODY_TYPE_FORM_URLENCODED }, { label: "Url Encoded", value: BODY_TYPE_FORM_URLENCODED },
{ label: 'Multi-Part', value: BODY_TYPE_FORM_MULTIPART }, { label: "Multi-Part", value: BODY_TYPE_FORM_MULTIPART },
{ type: 'separator', label: 'Text Content' }, { type: "separator", label: "Text Content" },
{ label: 'GraphQL', value: BODY_TYPE_GRAPHQL }, { label: "GraphQL", value: BODY_TYPE_GRAPHQL },
{ label: 'JSON', value: BODY_TYPE_JSON }, { label: "JSON", value: BODY_TYPE_JSON },
{ label: 'XML', value: BODY_TYPE_XML }, { label: "XML", value: BODY_TYPE_XML },
{ {
label: 'Other', label: "Other",
value: BODY_TYPE_OTHER, value: BODY_TYPE_OTHER,
shortLabel: nameOfContentTypeOr(contentType, 'Other'), shortLabel: nameOfContentTypeOr(contentType, "Other"),
}, },
{ type: 'separator', label: 'Other' }, { type: "separator", label: "Other" },
{ label: 'Binary File', value: BODY_TYPE_BINARY }, { label: "Binary File", value: BODY_TYPE_BINARY },
{ label: 'No Body', shortLabel: 'Body', value: BODY_TYPE_NONE }, { label: "No Body", shortLabel: "Body", value: BODY_TYPE_NONE },
], ],
onChange: async (bodyType) => { onChange: async (bodyType) => {
if (bodyType === activeRequest.bodyType) return; if (bodyType === activeRequest.bodyType) return;
@@ -180,7 +184,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
const showMethodToast = (newMethod: string) => { const showMethodToast = (newMethod: string) => {
if (activeRequest.method.toLowerCase() === newMethod.toLowerCase()) return; if (activeRequest.method.toLowerCase() === newMethod.toLowerCase()) return;
showToast({ showToast({
id: 'switched-method', id: "switched-method",
message: ( message: (
<> <>
Request method switched to <InlineCode>POST</InlineCode> Request method switched to <InlineCode>POST</InlineCode>
@@ -202,16 +206,16 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
) { ) {
const isDefaultishRequest = const isDefaultishRequest =
activeRequest.bodyType === BODY_TYPE_NONE && activeRequest.bodyType === BODY_TYPE_NONE &&
activeRequest.method.toLowerCase() === 'get'; activeRequest.method.toLowerCase() === "get";
const requiresPost = bodyType === BODY_TYPE_FORM_MULTIPART; const requiresPost = bodyType === BODY_TYPE_FORM_MULTIPART;
if (isDefaultishRequest || requiresPost) { if (isDefaultishRequest || requiresPost) {
patch.method = 'POST'; patch.method = "POST";
showMethodToast(patch.method); showMethodToast(patch.method);
} }
newContentType = bodyType === BODY_TYPE_OTHER ? 'text/plain' : bodyType; newContentType = bodyType === BODY_TYPE_OTHER ? "text/plain" : bodyType;
} else if (bodyType === BODY_TYPE_GRAPHQL) { } else if (bodyType === BODY_TYPE_GRAPHQL) {
patch.method = 'POST'; patch.method = "POST";
newContentType = 'application/json'; newContentType = "application/json";
showMethodToast(patch.method); showMethodToast(patch.method);
} }
@@ -226,13 +230,13 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
{ {
value: TAB_PARAMS, value: TAB_PARAMS,
rightSlot: <CountBadge count={urlParameterPairs.length} />, rightSlot: <CountBadge count={urlParameterPairs.length} />,
label: 'Params', label: "Params",
}, },
...headersTab, ...headersTab,
...authTab, ...authTab,
{ {
value: TAB_DESCRIPTION, value: TAB_DESCRIPTION,
label: 'Info', label: "Info",
}, },
], ],
[ [
@@ -253,7 +257,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
const { mutate: importCurl } = useImportCurl(); const { mutate: importCurl } = useImportCurl();
const handleBodyChange = useCallback( const handleBodyChange = useCallback(
(body: HttpRequest['body']) => patchModel(activeRequest, { body }), (body: HttpRequest["body"]) => patchModel(activeRequest, { body }),
[activeRequest], [activeRequest],
); );
@@ -271,8 +275,8 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
autocompleteUrls.length > 0 autocompleteUrls.length > 0
? autocompleteUrls ? autocompleteUrls
: [ : [
{ label: 'http://', type: 'constant' }, { label: "http://", type: "constant" },
{ label: 'https://', type: 'constant' }, { label: "https://", type: "constant" },
], ],
}), }),
[autocompleteUrls], [autocompleteUrls],
@@ -280,7 +284,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
const handlePaste = useCallback( const handlePaste = useCallback(
async (e: ClipboardEvent, text: string) => { async (e: ClipboardEvent, text: string) => {
if (text.startsWith('curl ')) { if (text.startsWith("curl ")) {
importCurl({ overwriteRequestId: activeRequestId, command: text }); importCurl({ overwriteRequestId: activeRequestId, command: text });
} else { } else {
const patch = prepareImportQuerystring(text); const patch = prepareImportQuerystring(text);
@@ -318,7 +322,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
return ( return (
<div <div
style={style} style={style}
className={classNames(className, 'h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1')} className={classNames(className, "h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1")}
> >
{activeRequest && ( {activeRequest && (
<> <>
@@ -338,7 +342,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
</div> </div>
} }
forceUpdateKey={updateKey} forceUpdateKey={updateKey}
isLoading={activeResponse != null && activeResponse.state !== 'closed'} isLoading={activeResponse != null && activeResponse.state !== "closed"}
/> />
<Tabs <Tabs
ref={tabsRef} ref={tabsRef}
@@ -373,7 +377,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
{activeRequest.bodyType === BODY_TYPE_JSON ? ( {activeRequest.bodyType === BODY_TYPE_JSON ? (
<JsonBodyEditor <JsonBodyEditor
forceUpdateKey={forceUpdateKey} forceUpdateKey={forceUpdateKey}
heightMode={fullHeight ? 'full' : 'auto'} heightMode={fullHeight ? "full" : "auto"}
request={activeRequest} request={activeRequest}
/> />
) : activeRequest.bodyType === BODY_TYPE_XML ? ( ) : activeRequest.bodyType === BODY_TYPE_XML ? (
@@ -382,8 +386,8 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
autocompleteFunctions autocompleteFunctions
autocompleteVariables autocompleteVariables
placeholder="..." placeholder="..."
heightMode={fullHeight ? 'full' : 'auto'} heightMode={fullHeight ? "full" : "auto"}
defaultValue={`${activeRequest.body?.text ?? ''}`} defaultValue={`${activeRequest.body?.text ?? ""}`}
language="xml" language="xml"
onChange={handleBodyTextChange} onChange={handleBodyTextChange}
stateKey={`xml.${activeRequest.id}`} stateKey={`xml.${activeRequest.id}`}
@@ -417,15 +421,15 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
onChange={(body) => patchModel(activeRequest, { body })} onChange={(body) => patchModel(activeRequest, { body })}
onChangeContentType={handleContentTypeChange} onChangeContentType={handleContentTypeChange}
/> />
) : typeof activeRequest.bodyType === 'string' ? ( ) : typeof activeRequest.bodyType === "string" ? (
<Editor <Editor
forceUpdateKey={forceUpdateKey} forceUpdateKey={forceUpdateKey}
autocompleteFunctions autocompleteFunctions
autocompleteVariables autocompleteVariables
language={languageFromContentType(contentType)} language={languageFromContentType(contentType)}
placeholder="..." placeholder="..."
heightMode={fullHeight ? 'full' : 'auto'} heightMode={fullHeight ? "full" : "auto"}
defaultValue={`${activeRequest.body?.text ?? ''}`} defaultValue={`${activeRequest.body?.text ?? ""}`}
onChange={handleBodyTextChange} onChange={handleBodyTextChange}
stateKey={`other.${activeRequest.id}`} stateKey={`other.${activeRequest.id}`}
/> />
@@ -465,8 +469,8 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
function nameOfContentTypeOr(contentType: string | null, fallback: string) { function nameOfContentTypeOr(contentType: string | null, fallback: string) {
const language = languageFromContentType(contentType); const language = languageFromContentType(contentType);
if (language === 'markdown') { if (language === "markdown") {
return 'Markdown'; return "Markdown";
} }
return fallback; return fallback;
} }

View File

@@ -1,49 +1,46 @@
import type { HttpResponse, HttpResponseEvent } from '@yaakapp-internal/models'; import type { HttpResponse, HttpResponseEvent } from "@yaakapp-internal/models";
import classNames from 'classnames'; import { Banner, HStack, Icon, LoadingIcon, VStack } from "@yaakapp-internal/ui";
import type { ComponentType, CSSProperties } from 'react'; import classNames from "classnames";
import { lazy, Suspense, useMemo } from 'react'; import type { ComponentType, CSSProperties } from "react";
import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse'; import { lazy, Suspense, useMemo } from "react";
import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents'; import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse'; import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
import { useResponseBodyBytes, useResponseBodyText } from '../hooks/useResponseBodyText'; import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { useResponseViewMode } from '../hooks/useResponseViewMode'; import { useResponseBodyBytes, useResponseBodyText } from "../hooks/useResponseBodyText";
import { useTimelineViewMode } from '../hooks/useTimelineViewMode'; import { useResponseViewMode } from "../hooks/useResponseViewMode";
import { getMimeTypeFromContentType } from '../lib/contentType'; import { useTimelineViewMode } from "../hooks/useTimelineViewMode";
import { getContentTypeFromHeaders, getCookieCounts } from '../lib/model_util'; import { getMimeTypeFromContentType } from "../lib/contentType";
import { ConfirmLargeResponse } from './ConfirmLargeResponse'; import { getContentTypeFromHeaders, getCookieCounts } from "../lib/model_util";
import { ConfirmLargeResponseRequest } from './ConfirmLargeResponseRequest'; import { ConfirmLargeResponse } from "./ConfirmLargeResponse";
import { Banner } from './core/Banner'; import { ConfirmLargeResponseRequest } from "./ConfirmLargeResponseRequest";
import { Button } from './core/Button'; import { Button } from "./core/Button";
import { CountBadge } from './core/CountBadge'; import { CountBadge } from "./core/CountBadge";
import { HotkeyList } from './core/HotkeyList'; import { HotkeyList } from "./core/HotkeyList";
import { HttpResponseDurationTag } from './core/HttpResponseDurationTag'; import { HttpResponseDurationTag } from "./core/HttpResponseDurationTag";
import { HttpStatusTag } from './core/HttpStatusTag'; import { HttpStatusTag } from "./core/HttpStatusTag";
import { Icon } from './core/Icon'; import { PillButton } from "./core/PillButton";
import { LoadingIcon } from './core/LoadingIcon'; import { SizeTag } from "./core/SizeTag";
import { PillButton } from './core/PillButton'; import type { TabItem } from "./core/Tabs/Tabs";
import { SizeTag } from './core/SizeTag'; import { TabContent, Tabs } from "./core/Tabs/Tabs";
import { HStack, VStack } from './core/Stacks'; import { Tooltip } from "./core/Tooltip";
import type { TabItem } from './core/Tabs/Tabs'; import { EmptyStateText } from "./EmptyStateText";
import { TabContent, Tabs } from './core/Tabs/Tabs'; import { ErrorBoundary } from "./ErrorBoundary";
import { Tooltip } from './core/Tooltip'; import { HttpResponseTimeline } from "./HttpResponseTimeline";
import { EmptyStateText } from './EmptyStateText'; import { RecentHttpResponsesDropdown } from "./RecentHttpResponsesDropdown";
import { ErrorBoundary } from './ErrorBoundary'; import { RequestBodyViewer } from "./RequestBodyViewer";
import { HttpResponseTimeline } from './HttpResponseTimeline'; import { ResponseCookies } from "./ResponseCookies";
import { RecentHttpResponsesDropdown } from './RecentHttpResponsesDropdown'; import { ResponseHeaders } from "./ResponseHeaders";
import { RequestBodyViewer } from './RequestBodyViewer'; import { AudioViewer } from "./responseViewers/AudioViewer";
import { ResponseCookies } from './ResponseCookies'; import { CsvViewer } from "./responseViewers/CsvViewer";
import { ResponseHeaders } from './ResponseHeaders'; import { EventStreamViewer } from "./responseViewers/EventStreamViewer";
import { AudioViewer } from './responseViewers/AudioViewer'; import { HTMLOrTextViewer } from "./responseViewers/HTMLOrTextViewer";
import { CsvViewer } from './responseViewers/CsvViewer'; import { ImageViewer } from "./responseViewers/ImageViewer";
import { EventStreamViewer } from './responseViewers/EventStreamViewer'; import { MultipartViewer } from "./responseViewers/MultipartViewer";
import { HTMLOrTextViewer } from './responseViewers/HTMLOrTextViewer'; import { SvgViewer } from "./responseViewers/SvgViewer";
import { ImageViewer } from './responseViewers/ImageViewer'; import { VideoViewer } from "./responseViewers/VideoViewer";
import { MultipartViewer } from './responseViewers/MultipartViewer';
import { SvgViewer } from './responseViewers/SvgViewer';
import { VideoViewer } from './responseViewers/VideoViewer';
const PdfViewer = lazy(() => const PdfViewer = lazy(() =>
import('./responseViewers/PdfViewer').then((m) => ({ default: m.PdfViewer })), import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
); );
interface Props { interface Props {
@@ -52,13 +49,13 @@ interface Props {
activeRequestId: string; activeRequestId: string;
} }
const TAB_BODY = 'body'; const TAB_BODY = "body";
const TAB_REQUEST = 'request'; const TAB_REQUEST = "request";
const TAB_HEADERS = 'headers'; const TAB_HEADERS = "headers";
const TAB_COOKIES = 'cookies'; const TAB_COOKIES = "cookies";
const TAB_TIMELINE = 'timeline'; const TAB_TIMELINE = "timeline";
export type TimelineViewMode = 'timeline' | 'text'; export type TimelineViewMode = "timeline" | "text";
interface RedirectDropWarning { interface RedirectDropWarning {
droppedBodyCount: number; droppedBodyCount: number;
@@ -78,7 +75,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
[responseEvents.data], [responseEvents.data],
); );
const shouldShowRedirectDropWarning = const shouldShowRedirectDropWarning =
activeResponse?.state === 'closed' && redirectDropWarning != null; activeResponse?.state === "closed" && redirectDropWarning != null;
const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]); const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]);
@@ -86,27 +83,27 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
() => [ () => [
{ {
value: TAB_BODY, value: TAB_BODY,
label: 'Response', label: "Response",
options: { options: {
value: viewMode, value: viewMode,
onChange: setViewMode, onChange: setViewMode,
items: [ items: [
{ label: 'Response', value: 'pretty' }, { label: "Response", value: "pretty" },
...(mimeType?.startsWith('image') ...(mimeType?.startsWith("image")
? [] ? []
: [{ label: 'Response (Raw)', shortLabel: 'Raw', value: 'raw' }]), : [{ label: "Response (Raw)", shortLabel: "Raw", value: "raw" }]),
], ],
}, },
}, },
{ {
value: TAB_REQUEST, value: TAB_REQUEST,
label: 'Request', label: "Request",
rightSlot: rightSlot:
(activeResponse?.requestContentLength ?? 0) > 0 ? <CountBadge count={true} /> : null, (activeResponse?.requestContentLength ?? 0) > 0 ? <CountBadge count={true} /> : null,
}, },
{ {
value: TAB_HEADERS, value: TAB_HEADERS,
label: 'Headers', label: "Headers",
rightSlot: ( rightSlot: (
<CountBadge <CountBadge
count={activeResponse?.requestHeaders.length ?? 0} count={activeResponse?.requestHeaders.length ?? 0}
@@ -117,7 +114,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
}, },
{ {
value: TAB_COOKIES, value: TAB_COOKIES,
label: 'Cookies', label: "Cookies",
rightSlot: rightSlot:
cookieCounts.sent > 0 || cookieCounts.received > 0 ? ( cookieCounts.sent > 0 || cookieCounts.received > 0 ? (
<CountBadge count={cookieCounts.sent} count2={cookieCounts.received} showZero /> <CountBadge count={cookieCounts.sent} count2={cookieCounts.received} showZero />
@@ -128,10 +125,10 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
rightSlot: <CountBadge count={responseEvents.data?.length ?? 0} />, rightSlot: <CountBadge count={responseEvents.data?.length ?? 0} />,
options: { options: {
value: timelineViewMode, value: timelineViewMode,
onChange: (v) => setTimelineViewMode((v as TimelineViewMode) ?? 'timeline'), onChange: (v) => setTimelineViewMode((v as TimelineViewMode) ?? "timeline"),
items: [ items: [
{ label: 'Timeline', value: 'timeline' }, { label: "Timeline", value: "timeline" },
{ label: 'Timeline (Text)', shortLabel: 'Timeline', value: 'text' }, { label: "Timeline (Text)", shortLabel: "Timeline", value: "text" },
], ],
}, },
}, },
@@ -158,33 +155,33 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
style={style} style={style}
className={classNames( className={classNames(
className, className,
'x-theme-responsePane', "x-theme-responsePane",
'max-h-full h-full', "max-h-full h-full",
'bg-surface rounded-md border border-border-subtle overflow-hidden', "bg-surface rounded-md border border-border-subtle overflow-hidden",
'relative', "relative",
)} )}
> >
{activeResponse == null ? ( {activeResponse == null ? (
<HotkeyList hotkeys={['request.send', 'model.create', 'sidebar.focus', 'url_bar.focus']} /> <HotkeyList hotkeys={["request.send", "model.create", "sidebar.focus", "url_bar.focus"]} />
) : ( ) : (
<div className="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1"> <div className="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1">
<HStack <HStack
className={classNames( className={classNames(
'text-text-subtle w-full flex-shrink-0', "text-text-subtle w-full flex-shrink-0",
// Remove a bit of space because the tabs have lots too // Remove a bit of space because the tabs have lots too
'-mb-1.5', "-mb-1.5",
)} )}
> >
{activeResponse && ( {activeResponse && (
<div <div
className={classNames( className={classNames(
'grid grid-cols-[auto_minmax(4rem,1fr)_auto]', "grid grid-cols-[auto_minmax(4rem,1fr)_auto]",
'cursor-default select-none', "cursor-default select-none",
'whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars', "whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars",
)} )}
> >
<HStack space={2} className="w-full flex-shrink-0"> <HStack space={2} className="w-full flex-shrink-0">
{activeResponse.state !== 'closed' && <LoadingIcon size="sm" />} {activeResponse.state !== "closed" && <LoadingIcon size="sm" />}
<HttpStatusTag showReason response={activeResponse} /> <HttpStatusTag showReason response={activeResponse} />
<span>&bull;</span> <span>&bull;</span>
<HttpResponseDurationTag response={activeResponse} /> <HttpResponseDurationTag response={activeResponse} />
@@ -205,17 +202,17 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
</span> </span>
{redirectDropWarning.droppedBodyCount > 0 && ( {redirectDropWarning.droppedBodyCount > 0 && (
<span> <span>
Body dropped on {redirectDropWarning.droppedBodyCount}{' '} Body dropped on {redirectDropWarning.droppedBodyCount}{" "}
{redirectDropWarning.droppedBodyCount === 1 {redirectDropWarning.droppedBodyCount === 1
? 'redirect hop' ? "redirect hop"
: 'redirect hops'} : "redirect hops"}
</span> </span>
)} )}
{redirectDropWarning.droppedHeaders.length > 0 && ( {redirectDropWarning.droppedHeaders.length > 0 && (
<span> <span>
Headers dropped:{' '} Headers dropped:{" "}
<span className="font-mono"> <span className="font-mono">
{redirectDropWarning.droppedHeaders.join(', ')} {redirectDropWarning.droppedHeaders.join(", ")}
</span> </span>
</span> </span>
)} )}
@@ -269,7 +266,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
<ErrorBoundary name="Http Response Viewer"> <ErrorBoundary name="Http Response Viewer">
<Suspense> <Suspense>
<ConfirmLargeResponse response={activeResponse}> <ConfirmLargeResponse response={activeResponse}>
{activeResponse.state === 'initialized' ? ( {activeResponse.state === "initialized" ? (
<EmptyStateText> <EmptyStateText>
<VStack space={3}> <VStack space={3}>
<HStack space={3}> <HStack space={3}>
@@ -281,10 +278,10 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
</Button> </Button>
</VStack> </VStack>
</EmptyStateText> </EmptyStateText>
) : activeResponse.state === 'closed' && ) : activeResponse.state === "closed" &&
(activeResponse.contentLength ?? 0) === 0 ? ( (activeResponse.contentLength ?? 0) === 0 ? (
<EmptyStateText>Empty</EmptyStateText> <EmptyStateText>Empty</EmptyStateText>
) : mimeType?.match(/^text\/event-stream/i) && viewMode === 'pretty' ? ( ) : mimeType?.match(/^text\/event-stream/i) && viewMode === "pretty" ? (
<EventStreamViewer response={activeResponse} /> <EventStreamViewer response={activeResponse} />
) : mimeType?.match(/^image\/svg/) ? ( ) : mimeType?.match(/^image\/svg/) ? (
<HttpSvgViewer response={activeResponse} /> <HttpSvgViewer response={activeResponse} />
@@ -294,17 +291,17 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
<EnsureCompleteResponse response={activeResponse} Component={AudioViewer} /> <EnsureCompleteResponse response={activeResponse} Component={AudioViewer} />
) : mimeType?.match(/^video/i) ? ( ) : mimeType?.match(/^video/i) ? (
<EnsureCompleteResponse response={activeResponse} Component={VideoViewer} /> <EnsureCompleteResponse response={activeResponse} Component={VideoViewer} />
) : mimeType?.match(/^multipart/i) && viewMode === 'pretty' ? ( ) : mimeType?.match(/^multipart/i) && viewMode === "pretty" ? (
<HttpMultipartViewer response={activeResponse} /> <HttpMultipartViewer response={activeResponse} />
) : mimeType?.match(/pdf/i) ? ( ) : mimeType?.match(/pdf/i) ? (
<EnsureCompleteResponse response={activeResponse} Component={PdfViewer} /> <EnsureCompleteResponse response={activeResponse} Component={PdfViewer} />
) : mimeType?.match(/csv|tab-separated/i) && viewMode === 'pretty' ? ( ) : mimeType?.match(/csv|tab-separated/i) && viewMode === "pretty" ? (
<HttpCsvViewer className="pb-2" response={activeResponse} /> <HttpCsvViewer className="pb-2" response={activeResponse} />
) : ( ) : (
<HTMLOrTextViewer <HTMLOrTextViewer
textViewerClassName="-mr-2 bg-surface" // Pull to the right textViewerClassName="-mr-2 bg-surface" // Pull to the right
response={activeResponse} response={activeResponse}
pretty={viewMode === 'pretty'} pretty={viewMode === "pretty"}
/> />
)} )}
</ConfirmLargeResponse> </ConfirmLargeResponse>
@@ -342,7 +339,7 @@ function getRedirectDropWarning(
const droppedHeaders = new Set<string>(); const droppedHeaders = new Set<string>();
for (const e of events) { for (const e of events) {
const event = e.event; const event = e.event;
if (event.type !== 'redirect') { if (event.type !== "redirect") {
continue; continue;
} }
@@ -373,12 +370,12 @@ function pushHeaderName(headers: Set<string>, headerName: string): void {
function getRedirectWarningLabel(warning: RedirectDropWarning): string { function getRedirectWarningLabel(warning: RedirectDropWarning): string {
if (warning.droppedBodyCount > 0 && warning.droppedHeaders.length > 0) { if (warning.droppedBodyCount > 0 && warning.droppedHeaders.length > 0) {
return 'Dropped body and headers'; return "Dropped body and headers";
} }
if (warning.droppedBodyCount > 0) { if (warning.droppedBodyCount > 0) {
return 'Dropped body'; return "Dropped body";
} }
return 'Dropped headers'; return "Dropped headers";
} }
function EnsureCompleteResponse({ function EnsureCompleteResponse({
@@ -393,7 +390,7 @@ function EnsureCompleteResponse({
} }
// Wait until the response has been fully-downloaded // Wait until the response has been fully-downloaded
if (response.state !== 'closed') { if (response.state !== "closed") {
return ( return (
<EmptyStateText> <EmptyStateText>
<LoadingIcon /> <LoadingIcon />
@@ -424,7 +421,7 @@ function HttpMultipartViewer({ response }: { response: HttpResponse }) {
if (body.data == null) return null; if (body.data == null) return null;
const contentTypeHeader = getContentTypeFromHeaders(response.headers); const contentTypeHeader = getContentTypeFromHeaders(response.headers);
const boundary = contentTypeHeader?.split('boundary=')[1] ?? 'unknown'; const boundary = contentTypeHeader?.split("boundary=")[1] ?? "unknown";
return <MultipartViewer data={body.data} boundary={boundary} idPrefix={response.id} />; return <MultipartViewer data={body.data} boundary={boundary} idPrefix={response.id} />;
} }

View File

@@ -2,16 +2,16 @@ import type {
HttpResponse, HttpResponse,
HttpResponseEvent, HttpResponseEvent,
HttpResponseEventData, HttpResponseEventData,
} from '@yaakapp-internal/models'; } from "@yaakapp-internal/models";
import { type ReactNode, useMemo, useState } from 'react'; import { type ReactNode, useMemo, useState } from "react";
import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents'; import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
import { Editor } from './core/Editor/LazyEditor'; import { Editor } from "./core/Editor/LazyEditor";
import { type EventDetailAction, EventDetailHeader, EventViewer } from './core/EventViewer'; import { type EventDetailAction, EventDetailHeader, EventViewer } from "./core/EventViewer";
import { EventViewerRow } from './core/EventViewerRow'; import { EventViewerRow } from "./core/EventViewerRow";
import { HttpStatusTagRaw } from './core/HttpStatusTag'; import { HttpStatusTagRaw } from "./core/HttpStatusTag";
import { Icon, type IconProps } from './core/Icon'; import { Icon, type IconProps } from "@yaakapp-internal/ui";
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow'; import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
import type { TimelineViewMode } from './HttpResponsePane'; import type { TimelineViewMode } from "./HttpResponsePane";
interface Props { interface Props {
response: HttpResponse; response: HttpResponse;
@@ -28,12 +28,12 @@ function Inner({ response, viewMode }: Props) {
// Generate plain text representation of all events (with prefixes for timeline view) // Generate plain text representation of all events (with prefixes for timeline view)
const plainText = useMemo(() => { const plainText = useMemo(() => {
if (!events || events.length === 0) return ''; if (!events || events.length === 0) return "";
return events.map((event) => formatEventText(event.event, true)).join('\n'); return events.map((event) => formatEventText(event.event, true)).join("\n");
}, [events]); }, [events]);
// Plain text view - show all events as text in an editor // Plain text view - show all events as text in an editor
if (viewMode === 'text') { if (viewMode === "text") {
if (isLoading) { if (isLoading) {
return <div className="p-4 text-text-subtlest">Loading events...</div>; return <div className="p-4 text-text-subtlest">Loading events...</div>;
} else if (error) { } else if (error) {
@@ -55,7 +55,7 @@ function Inner({ response, viewMode }: Props) {
isLoading={isLoading} isLoading={isLoading}
loadingMessage="Loading events..." loadingMessage="Loading events..."
emptyMessage="No events recorded" emptyMessage="No events recorded"
splitLayoutName="http_response_events" splitLayoutStorageKey="http_response_events"
defaultRatio={0.25} defaultRatio={0.25}
renderRow={({ event, isActive, onClick }) => { renderRow={({ event, isActive, onClick }) => {
const display = getEventDisplay(event.event); const display = getEventDisplay(event.event);
@@ -98,8 +98,8 @@ function EventDetails({
const actions: EventDetailAction[] = [ const actions: EventDetailAction[] = [
{ {
key: 'toggle-raw', key: "toggle-raw",
label: showRaw ? 'Formatted' : 'Text', label: showRaw ? "Formatted" : "Text",
onClick: () => setShowRaw(!showRaw), onClick: () => setShowRaw(!showRaw),
}, },
]; ];
@@ -107,24 +107,24 @@ function EventDetails({
// Determine the title based on event type // Determine the title based on event type
const title = (() => { const title = (() => {
switch (e.type) { switch (e.type) {
case 'header_up': case "header_up":
return 'Header Sent'; return "Header Sent";
case 'header_down': case "header_down":
return 'Header Received'; return "Header Received";
case 'send_url': case "send_url":
return 'Request'; return "Request";
case 'receive_url': case "receive_url":
return 'Response'; return "Response";
case 'redirect': case "redirect":
return 'Redirect'; return "Redirect";
case 'setting': case "setting":
return 'Apply Setting'; return "Apply Setting";
case 'chunk_sent': case "chunk_sent":
return 'Data Sent'; return "Data Sent";
case 'chunk_received': case "chunk_received":
return 'Data Received'; return "Data Received";
case 'dns_resolved': case "dns_resolved":
return e.overridden ? 'DNS Override' : 'DNS Resolution'; return e.overridden ? "DNS Override" : "DNS Resolution";
default: default:
return label; return label;
} }
@@ -139,7 +139,7 @@ function EventDetails({
} }
// Headers - show name and value // Headers - show name and value
if (e.type === 'header_up' || e.type === 'header_down') { if (e.type === "header_up" || e.type === "header_down") {
return ( return (
<KeyValueRows> <KeyValueRows>
<KeyValueRow label="Header">{e.name}</KeyValueRow> <KeyValueRow label="Header">{e.name}</KeyValueRow>
@@ -149,13 +149,13 @@ function EventDetails({
} }
// Request URL - show all URL parts separately // Request URL - show all URL parts separately
if (e.type === 'send_url') { if (e.type === "send_url") {
const auth = e.username || e.password ? `${e.username}:${e.password}@` : ''; const auth = e.username || e.password ? `${e.username}:${e.password}@` : "";
const isDefaultPort = const isDefaultPort =
(e.scheme === 'http' && e.port === 80) || (e.scheme === 'https' && e.port === 443); (e.scheme === "http" && e.port === 80) || (e.scheme === "https" && e.port === 443);
const portStr = isDefaultPort ? '' : `:${e.port}`; const portStr = isDefaultPort ? "" : `:${e.port}`;
const query = e.query ? `?${e.query}` : ''; const query = e.query ? `?${e.query}` : "";
const fragment = e.fragment ? `#${e.fragment}` : ''; const fragment = e.fragment ? `#${e.fragment}` : "";
const fullUrl = `${e.scheme}://${auth}${e.host}${portStr}${e.path}${query}${fragment}`; const fullUrl = `${e.scheme}://${auth}${e.host}${portStr}${e.path}${query}${fragment}`;
return ( return (
<KeyValueRows> <KeyValueRows>
@@ -174,7 +174,7 @@ function EventDetails({
} }
// Response status - show version and status separately // Response status - show version and status separately
if (e.type === 'receive_url') { if (e.type === "receive_url") {
return ( return (
<KeyValueRows> <KeyValueRows>
<KeyValueRow label="HTTP Version">{e.version}</KeyValueRow> <KeyValueRow label="HTTP Version">{e.version}</KeyValueRow>
@@ -186,7 +186,7 @@ function EventDetails({
} }
// Redirect - show status, URL, and behavior // Redirect - show status, URL, and behavior
if (e.type === 'redirect') { if (e.type === "redirect") {
const droppedHeaders = e.dropped_headers ?? []; const droppedHeaders = e.dropped_headers ?? [];
return ( return (
<KeyValueRows> <KeyValueRows>
@@ -195,18 +195,18 @@ function EventDetails({
</KeyValueRow> </KeyValueRow>
<KeyValueRow label="Location">{e.url}</KeyValueRow> <KeyValueRow label="Location">{e.url}</KeyValueRow>
<KeyValueRow label="Behavior"> <KeyValueRow label="Behavior">
{e.behavior === 'drop_body' ? 'Drop body, change to GET' : 'Preserve method and body'} {e.behavior === "drop_body" ? "Drop body, change to GET" : "Preserve method and body"}
</KeyValueRow> </KeyValueRow>
<KeyValueRow label="Body Dropped">{e.dropped_body ? 'Yes' : 'No'}</KeyValueRow> <KeyValueRow label="Body Dropped">{e.dropped_body ? "Yes" : "No"}</KeyValueRow>
<KeyValueRow label="Headers Dropped"> <KeyValueRow label="Headers Dropped">
{droppedHeaders.length > 0 ? droppedHeaders.join(', ') : '--'} {droppedHeaders.length > 0 ? droppedHeaders.join(", ") : "--"}
</KeyValueRow> </KeyValueRow>
</KeyValueRows> </KeyValueRows>
); );
} }
// Settings - show as key/value // Settings - show as key/value
if (e.type === 'setting') { if (e.type === "setting") {
return ( return (
<KeyValueRows> <KeyValueRows>
<KeyValueRow label="Setting">{e.name}</KeyValueRow> <KeyValueRow label="Setting">{e.name}</KeyValueRow>
@@ -216,16 +216,16 @@ function EventDetails({
} }
// Chunks - show formatted bytes // Chunks - show formatted bytes
if (e.type === 'chunk_sent' || e.type === 'chunk_received') { if (e.type === "chunk_sent" || e.type === "chunk_received") {
return <div className="font-mono text-editor">{formatBytes(e.bytes)}</div>; return <div className="font-mono text-editor">{formatBytes(e.bytes)}</div>;
} }
// DNS Resolution - show hostname, addresses, and timing // DNS Resolution - show hostname, addresses, and timing
if (e.type === 'dns_resolved') { if (e.type === "dns_resolved") {
return ( return (
<KeyValueRows> <KeyValueRows>
<KeyValueRow label="Hostname">{e.hostname}</KeyValueRow> <KeyValueRow label="Hostname">{e.hostname}</KeyValueRow>
<KeyValueRow label="Addresses">{e.addresses.join(', ')}</KeyValueRow> <KeyValueRow label="Addresses">{e.addresses.join(", ")}</KeyValueRow>
<KeyValueRow label="Duration"> <KeyValueRow label="Duration">
{e.overridden ? ( {e.overridden ? (
<span className="text-text-subtlest">--</span> <span className="text-text-subtlest">--</span>
@@ -255,57 +255,57 @@ function EventDetails({
); );
} }
type EventTextParts = { prefix: '>' | '<' | '*'; text: string }; type EventTextParts = { prefix: ">" | "<" | "*"; text: string };
/** Get the prefix and text for an event */ /** Get the prefix and text for an event */
function getEventTextParts(event: HttpResponseEventData): EventTextParts { function getEventTextParts(event: HttpResponseEventData): EventTextParts {
switch (event.type) { switch (event.type) {
case 'send_url': case "send_url":
return { return {
prefix: '>', prefix: ">",
text: `${event.method} ${event.path}${event.query ? `?${event.query}` : ''}${event.fragment ? `#${event.fragment}` : ''}`, text: `${event.method} ${event.path}${event.query ? `?${event.query}` : ""}${event.fragment ? `#${event.fragment}` : ""}`,
}; };
case 'receive_url': case "receive_url":
return { prefix: '<', text: `${event.version} ${event.status}` }; return { prefix: "<", text: `${event.version} ${event.status}` };
case 'header_up': case "header_up":
return { prefix: '>', text: `${event.name}: ${event.value}` }; return { prefix: ">", text: `${event.name}: ${event.value}` };
case 'header_down': case "header_down":
return { prefix: '<', text: `${event.name}: ${event.value}` }; return { prefix: "<", text: `${event.name}: ${event.value}` };
case 'redirect': { case "redirect": {
const behavior = event.behavior === 'drop_body' ? 'drop body' : 'preserve'; const behavior = event.behavior === "drop_body" ? "drop body" : "preserve";
const droppedHeaders = event.dropped_headers ?? []; const droppedHeaders = event.dropped_headers ?? [];
const dropped = [ const dropped = [
event.dropped_body ? 'body dropped' : null, event.dropped_body ? "body dropped" : null,
droppedHeaders.length > 0 ? `headers dropped: ${droppedHeaders.join(', ')}` : null, droppedHeaders.length > 0 ? `headers dropped: ${droppedHeaders.join(", ")}` : null,
] ]
.filter(Boolean) .filter(Boolean)
.join(', '); .join(", ");
return { return {
prefix: '*', prefix: "*",
text: `Redirect ${event.status} -> ${event.url} (${behavior}${dropped ? `, ${dropped}` : ''})`, text: `Redirect ${event.status} -> ${event.url} (${behavior}${dropped ? `, ${dropped}` : ""})`,
}; };
} }
case 'setting': case "setting":
return { prefix: '*', text: `Setting ${event.name}=${event.value}` }; return { prefix: "*", text: `Setting ${event.name}=${event.value}` };
case 'info': case "info":
return { prefix: '*', text: event.message }; return { prefix: "*", text: event.message };
case 'chunk_sent': case "chunk_sent":
return { prefix: '*', text: `[${formatBytes(event.bytes)} sent]` }; return { prefix: "*", text: `[${formatBytes(event.bytes)} sent]` };
case 'chunk_received': case "chunk_received":
return { prefix: '*', text: `[${formatBytes(event.bytes)} received]` }; return { prefix: "*", text: `[${formatBytes(event.bytes)} received]` };
case 'dns_resolved': case "dns_resolved":
if (event.overridden) { if (event.overridden) {
return { return {
prefix: '*', prefix: "*",
text: `DNS override ${event.hostname} -> ${event.addresses.join(', ')}`, text: `DNS override ${event.hostname} -> ${event.addresses.join(", ")}`,
}; };
} }
return { return {
prefix: '*', prefix: "*",
text: `DNS resolved ${event.hostname} to ${event.addresses.join(', ')} (${event.duration}ms)`, text: `DNS resolved ${event.hostname} to ${event.addresses.join(", ")} (${event.duration}ms)`,
}; };
default: default:
return { prefix: '*', text: '[unknown event]' }; return { prefix: "*", text: "[unknown event]" };
} }
} }
@@ -316,103 +316,103 @@ function formatEventText(event: HttpResponseEventData, includePrefix: boolean):
} }
type EventDisplay = { type EventDisplay = {
icon: IconProps['icon']; icon: IconProps["icon"];
color: IconProps['color']; color: IconProps["color"];
label: string; label: string;
summary: ReactNode; summary: ReactNode;
}; };
function getEventDisplay(event: HttpResponseEventData): EventDisplay { function getEventDisplay(event: HttpResponseEventData): EventDisplay {
switch (event.type) { switch (event.type) {
case 'setting': case "setting":
return { return {
icon: 'settings', icon: "settings",
color: 'secondary', color: "secondary",
label: 'Setting', label: "Setting",
summary: `${event.name} = ${event.value}`, summary: `${event.name} = ${event.value}`,
}; };
case 'info': case "info":
return { return {
icon: 'info', icon: "info",
color: 'secondary', color: "secondary",
label: 'Info', label: "Info",
summary: event.message, summary: event.message,
}; };
case 'redirect': { case "redirect": {
const droppedHeaders = event.dropped_headers ?? []; const droppedHeaders = event.dropped_headers ?? [];
const dropped = [ const dropped = [
event.dropped_body ? 'drop body' : null, event.dropped_body ? "drop body" : null,
droppedHeaders.length > 0 droppedHeaders.length > 0
? `drop ${droppedHeaders.length} ${droppedHeaders.length === 1 ? 'header' : 'headers'}` ? `drop ${droppedHeaders.length} ${droppedHeaders.length === 1 ? "header" : "headers"}`
: null, : null,
] ]
.filter(Boolean) .filter(Boolean)
.join(', '); .join(", ");
return { return {
icon: 'arrow_big_right_dash', icon: "arrow_big_right_dash",
color: 'success', color: "success",
label: 'Redirect', label: "Redirect",
summary: `Redirecting ${event.status} ${event.url}${dropped ? ` (${dropped})` : ''}`, summary: `Redirecting ${event.status} ${event.url}${dropped ? ` (${dropped})` : ""}`,
}; };
} }
case 'send_url': case "send_url":
return { return {
icon: 'arrow_big_up_dash', icon: "arrow_big_up_dash",
color: 'primary', color: "primary",
label: 'Request', label: "Request",
summary: `${event.method} ${event.path}${event.query ? `?${event.query}` : ''}${event.fragment ? `#${event.fragment}` : ''}`, summary: `${event.method} ${event.path}${event.query ? `?${event.query}` : ""}${event.fragment ? `#${event.fragment}` : ""}`,
}; };
case 'receive_url': case "receive_url":
return { return {
icon: 'arrow_big_down_dash', icon: "arrow_big_down_dash",
color: 'info', color: "info",
label: 'Response', label: "Response",
summary: `${event.version} ${event.status}`, summary: `${event.version} ${event.status}`,
}; };
case 'header_up': case "header_up":
return { return {
icon: 'arrow_big_up_dash', icon: "arrow_big_up_dash",
color: 'primary', color: "primary",
label: 'Header', label: "Header",
summary: `${event.name}: ${event.value}`, summary: `${event.name}: ${event.value}`,
}; };
case 'header_down': case "header_down":
return { return {
icon: 'arrow_big_down_dash', icon: "arrow_big_down_dash",
color: 'info', color: "info",
label: 'Header', label: "Header",
summary: `${event.name}: ${event.value}`, summary: `${event.name}: ${event.value}`,
}; };
case 'chunk_sent': case "chunk_sent":
return { return {
icon: 'info', icon: "info",
color: 'secondary', color: "secondary",
label: 'Chunk', label: "Chunk",
summary: `${formatBytes(event.bytes)} chunk sent`, summary: `${formatBytes(event.bytes)} chunk sent`,
}; };
case 'chunk_received': case "chunk_received":
return { return {
icon: 'info', icon: "info",
color: 'secondary', color: "secondary",
label: 'Chunk', label: "Chunk",
summary: `${formatBytes(event.bytes)} chunk received`, summary: `${formatBytes(event.bytes)} chunk received`,
}; };
case 'dns_resolved': case "dns_resolved":
return { return {
icon: 'globe', icon: "globe",
color: event.overridden ? 'success' : 'secondary', color: event.overridden ? "success" : "secondary",
label: event.overridden ? 'DNS Override' : 'DNS', label: event.overridden ? "DNS Override" : "DNS",
summary: event.overridden summary: event.overridden
? `${event.hostname}${event.addresses.join(', ')} (overridden)` ? `${event.hostname}${event.addresses.join(", ")} (overridden)`
: `${event.hostname}${event.addresses.join(', ')} (${event.duration}ms)`, : `${event.hostname}${event.addresses.join(", ")} (${event.duration}ms)`,
}; };
default: default:
return { return {
icon: 'info', icon: "info",
color: 'secondary', color: "secondary",
label: 'Unknown', label: "Unknown",
summary: 'Unknown event', summary: "Unknown event",
}; };
} }
} }

View File

@@ -1,24 +1,24 @@
import { clear, readText } from '@tauri-apps/plugin-clipboard-manager'; import { clear, readText } from "@tauri-apps/plugin-clipboard-manager";
import * as m from 'motion/react-m'; import * as m from "motion/react-m";
import { useEffect, useState } from 'react'; import { useEffect, useState } from "react";
import { useImportCurl } from '../hooks/useImportCurl'; import { useImportCurl } from "../hooks/useImportCurl";
import { useWindowFocus } from '../hooks/useWindowFocus'; import { useWindowFocus } from "../hooks/useWindowFocus";
import { Button } from './core/Button'; import { Button } from "./core/Button";
import { Icon } from './core/Icon'; import { Icon } from "@yaakapp-internal/ui";
export function ImportCurlButton() { export function ImportCurlButton() {
const focused = useWindowFocus(); const focused = useWindowFocus();
const [clipboardText, setClipboardText] = useState(''); const [clipboardText, setClipboardText] = useState("");
const importCurl = useImportCurl(); const importCurl = useImportCurl();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
// biome-ignore lint/correctness/useExhaustiveDependencies: none // oxlint-disable-next-line react-hooks/exhaustive-deps -- none
useEffect(() => { useEffect(() => {
readText().then(setClipboardText); void readText().then(setClipboardText);
}, [focused]); }, [focused]);
if (!clipboardText?.trim().startsWith('curl ')) { if (!clipboardText?.trim().startsWith("curl ")) {
return null; return null;
} }
@@ -41,9 +41,9 @@ export function ImportCurlButton() {
try { try {
await importCurl.mutateAsync({ command: clipboardText }); await importCurl.mutateAsync({ command: clipboardText });
await clear(); // Clear the clipboard so the button goes away await clear(); // Clear the clipboard so the button goes away
setClipboardText(''); setClipboardText("");
} catch (e) { } catch (e) {
console.log('Failed to import curl', e); console.log("Failed to import curl", e);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }

View File

@@ -1,8 +1,8 @@
import { useState } from 'react'; import { VStack } from "@yaakapp-internal/ui";
import { useLocalStorage } from 'react-use'; import { useState } from "react";
import { Button } from './core/Button'; import { useLocalStorage } from "react-use";
import { VStack } from './core/Stacks'; import { Button } from "./core/Button";
import { SelectFile } from './SelectFile'; import { SelectFile } from "./SelectFile";
interface Props { interface Props {
importData: (filePath: string) => Promise<void>; importData: (filePath: string) => Promise<void>;
@@ -10,7 +10,7 @@ interface Props {
export function ImportDataDialog({ importData }: Props) { export function ImportDataDialog({ importData }: Props) {
const [isLoading, setIsLoading] = useState<boolean>(false); const [isLoading, setIsLoading] = useState<boolean>(false);
const [filePath, setFilePath] = useLocalStorage<string | null>('importFilePath', null); const [filePath, setFilePath] = useLocalStorage<string | null>("importFilePath", null);
return ( return (
<VStack space={5} className="pb-4"> <VStack space={5} className="pb-4">
@@ -45,7 +45,7 @@ export function ImportDataDialog({ importData }: Props) {
} }
}} }}
> >
{isLoading ? 'Importing' : 'Import'} {isLoading ? "Importing" : "Import"}
</Button> </Button>
)} )}
</VStack> </VStack>

View File

@@ -1,5 +1,5 @@
import type { ReactNode } from 'react'; import type { ReactNode } from "react";
import { appInfo } from '../lib/appInfo'; import { appInfo } from "../lib/appInfo";
interface Props { interface Props {
children: ReactNode; children: ReactNode;

View File

@@ -1,22 +1,22 @@
import { linter } from '@codemirror/lint'; import { linter } from "@codemirror/lint";
import type { HttpRequest } from '@yaakapp-internal/models'; import type { HttpRequest } from "@yaakapp-internal/models";
import { patchModel } from '@yaakapp-internal/models'; import { patchModel } from "@yaakapp-internal/models";
import { useCallback, useMemo } from 'react'; import { Banner, Icon } from "@yaakapp-internal/ui";
import { useKeyValue } from '../hooks/useKeyValue'; import { useCallback, useMemo } from "react";
import { textLikelyContainsJsonComments } from '../lib/jsonComments'; import { useKeyValue } from "../hooks/useKeyValue";
import { Banner } from './core/Banner'; import { fireAndForget } from "../lib/fireAndForget";
import type { DropdownItem } from './core/Dropdown'; import { textLikelyContainsJsonComments } from "../lib/jsonComments";
import { Dropdown } from './core/Dropdown'; import type { DropdownItem } from "./core/Dropdown";
import type { EditorProps } from './core/Editor/Editor'; import { Dropdown } from "./core/Dropdown";
import { jsonParseLinter } from './core/Editor/json-lint'; import type { EditorProps } from "./core/Editor/Editor";
import { Editor } from './core/Editor/LazyEditor'; import { jsonParseLinter } from "./core/Editor/json-lint";
import { Icon } from './core/Icon'; import { Editor } from "./core/Editor/LazyEditor";
import { IconButton } from './core/IconButton'; import { IconButton } from "./core/IconButton";
import { IconTooltip } from './core/IconTooltip'; import { IconTooltip } from "./core/IconTooltip";
interface Props { interface Props {
forceUpdateKey: string; forceUpdateKey: string;
heightMode: EditorProps['heightMode']; heightMode: EditorProps["heightMode"];
request: HttpRequest; request: HttpRequest;
} }
@@ -41,13 +41,13 @@ export function JsonBodyEditor({ forceUpdateKey, heightMode, request }: Props) {
); );
const hasComments = useMemo( const hasComments = useMemo(
() => textLikelyContainsJsonComments(request.body?.text ?? ''), () => textLikelyContainsJsonComments(request.body?.text ?? ""),
[request.body?.text], [request.body?.text],
); );
const { value: bannerDismissed, set: setBannerDismissed } = useKeyValue<boolean>({ const { value: bannerDismissed, set: setBannerDismissed } = useKeyValue<boolean>({
namespace: 'no_sync', namespace: "no_sync",
key: ['json-fix-3', request.workspaceId], key: ["json-fix-3", request.workspaceId],
fallback: false, fallback: false,
}); });
@@ -58,19 +58,19 @@ export function JsonBodyEditor({ forceUpdateKey, heightMode, request }: Props) {
} else { } else {
delete newBody.sendJsonComments; delete newBody.sendJsonComments;
} }
patchModel(request, { body: newBody }); fireAndForget(patchModel(request, { body: newBody }));
}, [request, autoFix]); }, [request, autoFix]);
const handleDropdownOpen = useCallback(() => { const handleDropdownOpen = useCallback(() => {
if (!bannerDismissed) { if (!bannerDismissed) {
setBannerDismissed(true); fireAndForget(setBannerDismissed(true));
} }
}, [bannerDismissed, setBannerDismissed]); }, [bannerDismissed, setBannerDismissed]);
const showBanner = hasComments && autoFix && !bannerDismissed; const showBanner = hasComments && autoFix && !bannerDismissed;
const stripMessage = 'Automatically strip comments and trailing commas before sending'; const stripMessage = "Automatically strip comments and trailing commas before sending";
const actions = useMemo<EditorProps['actions']>( const actions = useMemo<EditorProps["actions"]>(
() => [ () => [
showBanner && ( showBanner && (
<Banner color="notice" className="!opacity-100 h-sm !py-0 !px-2 flex items-center text-xs"> <Banner color="notice" className="!opacity-100 h-sm !py-0 !px-2 flex items-center text-xs">
@@ -86,12 +86,12 @@ export function JsonBodyEditor({ forceUpdateKey, heightMode, request }: Props) {
items={ items={
[ [
{ {
label: 'Automatically Fix JSON', label: "Automatically Fix JSON",
keepOpenOnSelect: true, keepOpenOnSelect: true,
onSelect: handleToggleAutoFix, onSelect: handleToggleAutoFix,
rightSlot: <IconTooltip content={stripMessage} />, rightSlot: <IconTooltip content={stripMessage} />,
leftSlot: ( leftSlot: (
<Icon icon={autoFix ? 'check_square_checked' : 'check_square_unchecked'} /> <Icon icon={autoFix ? "check_square_checked" : "check_square_unchecked"} />
), ),
}, },
] satisfies DropdownItem[] ] satisfies DropdownItem[]
@@ -111,7 +111,7 @@ export function JsonBodyEditor({ forceUpdateKey, heightMode, request }: Props) {
autocompleteVariables autocompleteVariables
placeholder="..." placeholder="..."
heightMode={heightMode} heightMode={heightMode}
defaultValue={`${request.body?.text ?? ''}`} defaultValue={`${request.body?.text ?? ""}`}
language="json" language="json"
onChange={handleChange} onChange={handleChange}
stateKey={`json.${request.id}`} stateKey={`json.${request.id}`}

View File

@@ -1,5 +1,5 @@
import { hotkeyActions } from '../hooks/useHotKey'; import { hotkeyActions } from "../hooks/useHotKey";
import { HotkeyList } from './core/HotkeyList'; import { HotkeyList } from "./core/HotkeyList";
export function KeyboardShortcutsDialog() { export function KeyboardShortcutsDialog() {
return ( return (

View File

@@ -1,62 +1,62 @@
import { openUrl } from '@tauri-apps/plugin-opener'; import { openUrl } from "@tauri-apps/plugin-opener";
import type { LicenseCheckStatus } from '@yaakapp-internal/license'; import type { LicenseCheckStatus } from "@yaakapp-internal/license";
import { useLicense } from '@yaakapp-internal/license'; import { useLicense } from "@yaakapp-internal/license";
import { settingsAtom } from '@yaakapp-internal/models'; import { settingsAtom } from "@yaakapp-internal/models";
import { differenceInCalendarDays } from 'date-fns'; import { differenceInCalendarDays } from "date-fns";
import { formatDate } from 'date-fns/format'; import { formatDate } from "date-fns/format";
import { useAtomValue } from 'jotai'; import { useAtomValue } from "jotai";
import type { ReactNode } from 'react'; import type { ReactNode } from "react";
import { openSettings } from '../commands/openSettings'; import { openSettings } from "../commands/openSettings";
import { atomWithKVStorage } from '../lib/atoms/atomWithKVStorage'; import { atomWithKVStorage } from "../lib/atoms/atomWithKVStorage";
import { jotaiStore } from '../lib/jotai'; import { jotaiStore } from "../lib/jotai";
import { CargoFeature } from './CargoFeature'; import { CargoFeature } from "./CargoFeature";
import type { ButtonProps } from './core/Button'; import type { ButtonProps } from "./core/Button";
import { Dropdown, type DropdownItem } from './core/Dropdown'; import { Dropdown, type DropdownItem } from "./core/Dropdown";
import { Icon } from './core/Icon'; import { Icon } from "@yaakapp-internal/ui";
import { PillButton } from './core/PillButton'; import { PillButton } from "./core/PillButton";
const dismissedAtom = atomWithKVStorage<string | null>('dismissed_license_expired', null); const dismissedAtom = atomWithKVStorage<string | null>("dismissed_license_expired", null);
function getDetail( function getDetail(
data: LicenseCheckStatus, data: LicenseCheckStatus,
dismissedExpired: string | null, dismissedExpired: string | null,
): { label: ReactNode; color: ButtonProps['color']; options?: DropdownItem[] } | null | undefined { ): { label: ReactNode; color: ButtonProps["color"]; options?: DropdownItem[] } | null | undefined {
const dismissedAt = dismissedExpired ? new Date(dismissedExpired).getTime() : null; const dismissedAt = dismissedExpired ? new Date(dismissedExpired).getTime() : null;
switch (data.status) { switch (data.status) {
case 'active': case "active":
return null; return null;
case 'personal_use': case "personal_use":
return { label: 'Personal Use', color: 'notice' }; return { label: "Personal Use", color: "notice" };
case 'trialing': case "trialing":
return { label: 'Commercial Trial', color: 'secondary' }; return { label: "Commercial Trial", color: "secondary" };
case 'error': case "error":
return { label: 'Error', color: 'danger' }; return { label: "Error", color: "danger" };
case 'inactive': case "inactive":
return { label: 'Personal Use', color: 'notice' }; return { label: "Personal Use", color: "notice" };
case 'past_due': case "past_due":
return { label: 'Past Due', color: 'danger' }; return { label: "Past Due", color: "danger" };
case 'expired': case "expired":
// Don't show the expired message if it's been less than 14 days since the last dismissal // Don't show the expired message if it's been less than 14 days since the last dismissal
if (dismissedAt && differenceInCalendarDays(new Date(), dismissedAt) < 14) { if (dismissedAt && differenceInCalendarDays(new Date(), dismissedAt) < 14) {
return null; return null;
} }
return { return {
color: 'notice', color: "notice",
label: data.data.changes > 0 ? 'Updates Paused' : 'License Expired', label: data.data.changes > 0 ? "Updates Paused" : "License Expired",
options: [ options: [
{ {
label: `${data.data.changes} New Updates`, label: `${data.data.changes} New Updates`,
color: 'success', color: "success",
leftSlot: <Icon icon="gift" />, leftSlot: <Icon icon="gift" />,
rightSlot: <Icon icon="external_link" size="sm" className="opacity-disabled" />, rightSlot: <Icon icon="external_link" size="sm" className="opacity-disabled" />,
hidden: data.data.changes === 0 || data.data.changesUrl == null, hidden: data.data.changes === 0 || data.data.changesUrl == null,
onSelect: () => openUrl(data.data.changesUrl ?? ''), onSelect: () => openUrl(data.data.changesUrl ?? ""),
}, },
{ {
type: 'separator', type: "separator",
label: `License expired ${formatDate(data.data.periodEnd, 'MMM dd, yyyy')}`, label: `License expired ${formatDate(data.data.periodEnd, "MMM dd, yyyy")}`,
}, },
{ {
label: <div className="min-w-[12rem]">Renew License</div>, label: <div className="min-w-[12rem]">Renew License</div>,
@@ -66,12 +66,12 @@ function getDetail(
onSelect: () => openUrl(data.data.billingUrl), onSelect: () => openUrl(data.data.billingUrl),
}, },
{ {
label: 'Enter License Key', label: "Enter License Key",
leftSlot: <Icon icon="key_round" />, leftSlot: <Icon icon="key_round" />,
hidden: data.data.changesUrl == null, hidden: data.data.changesUrl == null,
onSelect: openLicenseDialog, onSelect: openLicenseDialog,
}, },
{ type: 'separator' }, { type: "separator" },
{ {
label: <span className="text-text-subtle">Remind me Later</span>, label: <span className="text-text-subtle">Remind me Later</span>,
leftSlot: <Icon icon="alarm_clock" className="text-text-subtle" />, leftSlot: <Icon icon="alarm_clock" className="text-text-subtle" />,
@@ -135,5 +135,5 @@ function LicenseBadgeCmp() {
} }
function openLicenseDialog() { function openLicenseDialog() {
openSettings.mutate('license'); openSettings.mutate("license");
} }

View File

@@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from "@tanstack/react-query";
import { convertFileSrc } from '@tauri-apps/api/core'; import { convertFileSrc } from "@tauri-apps/api/core";
import { resolveResource } from '@tauri-apps/api/path'; import { resolveResource } from "@tauri-apps/api/path";
import classNames from 'classnames'; import classNames from "classnames";
interface Props { interface Props {
src: string; src: string;
@@ -10,7 +10,7 @@ interface Props {
export function LocalImage({ src: srcPath, className }: Props) { export function LocalImage({ src: srcPath, className }: Props) {
const src = useQuery({ const src = useQuery({
queryKey: ['local-image', srcPath], queryKey: ["local-image", srcPath],
queryFn: async () => { queryFn: async () => {
const p = await resolveResource(srcPath); const p = await resolveResource(srcPath);
return convertFileSrc(p); return convertFileSrc(p);
@@ -23,8 +23,8 @@ export function LocalImage({ src: srcPath, className }: Props) {
alt="Response preview" alt="Response preview"
className={classNames( className={classNames(
className, className,
'transition-opacity', "transition-opacity",
src.data == null ? 'opacity-0' : 'opacity-100', src.data == null ? "opacity-0" : "opacity-100",
)} )}
/> />
); );

View File

@@ -0,0 +1,113 @@
import type { CSSProperties } from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import { PrismLight as SyntaxHighlighter } from "react-syntax-highlighter";
import remarkGfm from "remark-gfm";
import { ErrorBoundary } from "./ErrorBoundary";
import { Prose } from "./Prose";
interface Props {
children: string | null;
className?: string;
}
export function Markdown({ children, className }: Props) {
if (children == null) return null;
return (
<Prose className={className}>
<ErrorBoundary name="Markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{children}
</ReactMarkdown>
</ErrorBoundary>
</Prose>
);
}
const prismTheme = {
'pre[class*="language-"]': {
// Needs to be here, so the lib doesn't add its own
},
// Syntax tokens
comment: { color: "var(--textSubtle)" },
prolog: { color: "var(--textSubtle)" },
doctype: { color: "var(--textSubtle)" },
cdata: { color: "var(--textSubtle)" },
punctuation: { color: "var(--textSubtle)" },
property: { color: "var(--primary)" },
"attr-name": { color: "var(--primary)" },
string: { color: "var(--notice)" },
char: { color: "var(--notice)" },
number: { color: "var(--info)" },
constant: { color: "var(--info)" },
symbol: { color: "var(--info)" },
boolean: { color: "var(--warning)" },
"attr-value": { color: "var(--warning)" },
variable: { color: "var(--success)" },
tag: { color: "var(--info)" },
operator: { color: "var(--danger)" },
keyword: { color: "var(--danger)" },
function: { color: "var(--success)" },
"class-name": { color: "var(--primary)" },
builtin: { color: "var(--danger)" },
selector: { color: "var(--danger)" },
inserted: { color: "var(--success)" },
deleted: { color: "var(--danger)" },
regex: { color: "var(--warning)" },
important: { color: "var(--danger)", fontWeight: "bold" },
italic: { fontStyle: "italic" },
bold: { fontWeight: "bold" },
entity: { cursor: "help" },
};
const lineStyle: CSSProperties = {
paddingRight: "1.5em",
paddingLeft: "0",
opacity: 0.5,
};
const markdownComponents: Partial<Components> = {
// Ensure links open in external browser by adding target="_blank"
a: ({ href, children, ...rest }) => {
if (href && !href.match(/https?:\/\//)) {
href = `http://${href}`;
}
return (
<a target="_blank" rel="noreferrer noopener" href={href} {...rest}>
{children}
</a>
);
},
code(props) {
const { children, className, ref, ...extraProps } = props;
extraProps.node = undefined;
const match = /language-(\w+)/.exec(className || "");
return match ? (
<SyntaxHighlighter
{...extraProps}
CodeTag="code"
showLineNumbers
PreTag="div"
lineNumberStyle={lineStyle}
language={match[1]}
style={prismTheme}
>
{String(children as string).replace(/\n$/, "")}
</SyntaxHighlighter>
) : (
<code {...extraProps} ref={ref} className={className}>
{children}
</code>
);
},
};

View File

@@ -1,13 +1,13 @@
import classNames from 'classnames'; import classNames from "classnames";
import { useRef, useState } from 'react'; import { useRef, useState } from "react";
import type { EditorProps } from './core/Editor/Editor'; import type { EditorProps } from "./core/Editor/Editor";
import { Editor } from './core/Editor/LazyEditor'; import { Editor } from "./core/Editor/LazyEditor";
import { SegmentedControl } from './core/SegmentedControl'; import { SegmentedControl } from "./core/SegmentedControl";
import { Markdown } from './Markdown'; import { Markdown } from "./Markdown";
type ViewMode = 'edit' | 'preview'; type ViewMode = "edit" | "preview";
interface Props extends Pick<EditorProps, 'heightMode' | 'stateKey' | 'forceUpdateKey'> { interface Props extends Pick<EditorProps, "heightMode" | "stateKey" | "forceUpdateKey"> {
placeholder: string; placeholder: string;
className?: string; className?: string;
editorClassName?: string; editorClassName?: string;
@@ -25,7 +25,7 @@ export function MarkdownEditor({
forceUpdateKey, forceUpdateKey,
...editorProps ...editorProps
}: Props) { }: Props) {
const [viewMode, setViewMode] = useState<ViewMode>(defaultValue ? 'preview' : 'edit'); const [viewMode, setViewMode] = useState<ViewMode>(defaultValue ? "preview" : "edit");
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@@ -33,7 +33,7 @@ export function MarkdownEditor({
<Editor <Editor
hideGutter hideGutter
wrapLines wrapLines
className={classNames(editorClassName, '[&_.cm-line]:!max-w-lg max-h-full')} className={classNames(editorClassName, "[&_.cm-line]:!max-w-lg max-h-full")}
language="markdown" language="markdown"
defaultValue={defaultValue} defaultValue={defaultValue}
onChange={onChange} onChange={onChange}
@@ -51,15 +51,15 @@ export function MarkdownEditor({
</div> </div>
); );
const contents = viewMode === 'preview' ? preview : editor; const contents = viewMode === "preview" ? preview : editor;
return ( return (
<div <div
ref={containerRef} ref={containerRef}
className={classNames( className={classNames(
'group/markdown', "group/markdown",
'relative w-full h-full pt-1.5 rounded-md gap-x-1.5', "relative w-full h-full pt-1.5 rounded-md gap-x-1.5",
'min-w-0', // Not sure why this is needed "min-w-0", // Not sure why this is needed
className, className,
)} )}
> >
@@ -73,8 +73,8 @@ export function MarkdownEditor({
value={viewMode} value={viewMode}
className="opacity-0 group-focus-within/markdown:opacity-100 group-hover/markdown:opacity-100" className="opacity-0 group-focus-within/markdown:opacity-100 group-hover/markdown:opacity-100"
options={[ options={[
{ icon: 'eye', label: 'Preview mode', value: 'preview' }, { icon: "eye", label: "Preview mode", value: "preview" },
{ icon: 'pencil', label: 'Edit mode', value: 'edit' }, { icon: "pencil", label: "Edit mode", value: "edit" },
]} ]}
/> />
</div> </div>

View File

@@ -1,15 +1,14 @@
import type { GrpcRequest, HttpRequest, WebsocketRequest } from '@yaakapp-internal/models'; import type { GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-internal/models";
import { patchModel, workspacesAtom } from '@yaakapp-internal/models'; import { patchModel, workspacesAtom } from "@yaakapp-internal/models";
import { useAtomValue } from 'jotai'; import { InlineCode, VStack } from "@yaakapp-internal/ui";
import { useState } from 'react'; import { useAtomValue } from "jotai";
import { pluralizeCount } from '../lib/pluralize'; import { useState } from "react";
import { resolvedModelName } from '../lib/resolvedModelName'; import { pluralizeCount } from "../lib/pluralize";
import { router } from '../lib/router'; import { resolvedModelName } from "../lib/resolvedModelName";
import { showToast } from '../lib/toast'; import { router } from "../lib/router";
import { Button } from './core/Button'; import { showToast } from "../lib/toast";
import { InlineCode } from './core/InlineCode'; import { Button } from "./core/Button";
import { Select } from './core/Select'; import { Select } from "./core/Select";
import { VStack } from './core/Stacks';
interface Props { interface Props {
activeWorkspaceId: string; activeWorkspaceId: string;
@@ -50,17 +49,17 @@ export function MoveToWorkspaceDialog({ onDone, requests, activeWorkspaceId }: P
// Hide after a moment, to give time for requests to disappear // Hide after a moment, to give time for requests to disappear
setTimeout(onDone, 100); setTimeout(onDone, 100);
showToast({ showToast({
id: 'workspace-moved', id: "workspace-moved",
message: message:
requests.length === 1 && requests[0] != null ? ( requests.length === 1 && requests[0] != null ? (
<> <>
<InlineCode>{resolvedModelName(requests[0])}</InlineCode> moved to{' '} <InlineCode>{resolvedModelName(requests[0])}</InlineCode> moved to{" "}
<InlineCode>{targetWorkspace?.name ?? 'unknown'}</InlineCode> <InlineCode>{targetWorkspace?.name ?? "unknown"}</InlineCode>
</> </>
) : ( ) : (
<> <>
{pluralizeCount('request', requests.length)} moved to{' '} {pluralizeCount("request", requests.length)} moved to{" "}
<InlineCode>{targetWorkspace?.name ?? 'unknown'}</InlineCode> <InlineCode>{targetWorkspace?.name ?? "unknown"}</InlineCode>
</> </>
), ),
action: ({ hide }) => ( action: ({ hide }) => (
@@ -70,7 +69,7 @@ export function MoveToWorkspaceDialog({ onDone, requests, activeWorkspaceId }: P
className="mr-auto min-w-[5rem]" className="mr-auto min-w-[5rem]"
onClick={async () => { onClick={async () => {
await router.navigate({ await router.navigate({
to: '/workspaces/$workspaceId', to: "/workspaces/$workspaceId",
params: { workspaceId: selectedWorkspaceId }, params: { workspaceId: selectedWorkspaceId },
}); });
hide(); hide();
@@ -82,7 +81,7 @@ export function MoveToWorkspaceDialog({ onDone, requests, activeWorkspaceId }: P
}); });
}} }}
> >
{requests.length === 1 ? 'Move' : `Move ${pluralizeCount('Request', requests.length)}`} {requests.length === 1 ? "Move" : `Move ${pluralizeCount("Request", requests.length)}`}
</Button> </Button>
</VStack> </VStack>
); );

View File

@@ -0,0 +1,12 @@
import classNames from "classnames";
import type { ReactNode } from "react";
import "./Prose.css";
interface Props {
children: ReactNode;
className?: string;
}
export function Prose({ className, ...props }: Props) {
return <div className={classNames("prose", className)} {...props} />;
}

View File

@@ -1,12 +1,11 @@
import type { GrpcConnection } from '@yaakapp-internal/models'; import type { GrpcConnection } from "@yaakapp-internal/models";
import { deleteModel } from '@yaakapp-internal/models'; import { deleteModel } from "@yaakapp-internal/models";
import { formatDistanceToNowStrict } from 'date-fns'; import { HStack, Icon } from "@yaakapp-internal/ui";
import { useDeleteGrpcConnections } from '../hooks/useDeleteGrpcConnections'; import { formatDistanceToNowStrict } from "date-fns";
import { pluralizeCount } from '../lib/pluralize'; import { useDeleteGrpcConnections } from "../hooks/useDeleteGrpcConnections";
import { Dropdown } from './core/Dropdown'; import { pluralizeCount } from "../lib/pluralize";
import { Icon } from './core/Icon'; import { Dropdown } from "./core/Dropdown";
import { IconButton } from './core/IconButton'; import { IconButton } from "./core/IconButton";
import { HStack } from './core/Stacks';
interface Props { interface Props {
connections: GrpcConnection[]; connections: GrpcConnection[];
@@ -20,27 +19,27 @@ export function RecentGrpcConnectionsDropdown({
onPinnedConnectionId, onPinnedConnectionId,
}: Props) { }: Props) {
const deleteAllConnections = useDeleteGrpcConnections(activeConnection?.requestId); const deleteAllConnections = useDeleteGrpcConnections(activeConnection?.requestId);
const latestConnectionId = connections[0]?.id ?? 'n/a'; const latestConnectionId = connections[0]?.id ?? "n/a";
return ( return (
<Dropdown <Dropdown
items={[ items={[
{ {
label: 'Clear Connection', label: "Clear Connection",
onSelect: () => deleteModel(activeConnection), onSelect: () => deleteModel(activeConnection),
disabled: connections.length === 0, disabled: connections.length === 0,
}, },
{ {
label: `Clear ${pluralizeCount('Connection', connections.length)}`, label: `Clear ${pluralizeCount("Connection", connections.length)}`,
onSelect: deleteAllConnections.mutate, onSelect: deleteAllConnections.mutate,
hidden: connections.length <= 1, hidden: connections.length <= 1,
disabled: connections.length === 0, disabled: connections.length === 0,
}, },
{ type: 'separator', label: 'History' }, { type: "separator", label: "History" },
...connections.map((c) => ({ ...connections.map((c) => ({
label: ( label: (
<HStack space={2}> <HStack space={2}>
{formatDistanceToNowStrict(`${c.createdAt}Z`)} ago &bull;{' '} {formatDistanceToNowStrict(`${c.createdAt}Z`)} ago &bull;{" "}
<span className="font-mono text-sm">{c.elapsed}ms</span> <span className="font-mono text-sm">{c.elapsed}ms</span>
</HStack> </HStack>
), ),
@@ -51,7 +50,7 @@ export function RecentGrpcConnectionsDropdown({
> >
<IconButton <IconButton
title="Show connection history" title="Show connection history"
icon={activeConnection?.id === latestConnectionId ? 'history' : 'pin'} icon={activeConnection?.id === latestConnectionId ? "history" : "pin"}
className="m-0.5 text-text-subtle" className="m-0.5 text-text-subtle"
size="sm" size="sm"
iconSize="md" iconSize="md"

View File

@@ -1,14 +1,13 @@
import type { HttpResponse } from '@yaakapp-internal/models'; import type { HttpResponse } from "@yaakapp-internal/models";
import { deleteModel } from '@yaakapp-internal/models'; import { deleteModel } from "@yaakapp-internal/models";
import { useCopyHttpResponse } from '../hooks/useCopyHttpResponse'; import { HStack, Icon } from "@yaakapp-internal/ui";
import { useDeleteHttpResponses } from '../hooks/useDeleteHttpResponses'; import { useCopyHttpResponse } from "../hooks/useCopyHttpResponse";
import { useSaveResponse } from '../hooks/useSaveResponse'; import { useDeleteHttpResponses } from "../hooks/useDeleteHttpResponses";
import { pluralize } from '../lib/pluralize'; import { useSaveResponse } from "../hooks/useSaveResponse";
import { Dropdown } from './core/Dropdown'; import { pluralize } from "../lib/pluralize";
import { HttpStatusTag } from './core/HttpStatusTag'; import { Dropdown } from "./core/Dropdown";
import { Icon } from './core/Icon'; import { HttpStatusTag } from "./core/HttpStatusTag";
import { IconButton } from './core/IconButton'; import { IconButton } from "./core/IconButton";
import { HStack } from './core/Stacks';
interface Props { interface Props {
responses: HttpResponse[]; responses: HttpResponse[];
@@ -23,7 +22,7 @@ export const RecentHttpResponsesDropdown = function ResponsePane({
onPinnedResponseId, onPinnedResponseId,
}: Props) { }: Props) {
const deleteAllResponses = useDeleteHttpResponses(activeResponse?.requestId); const deleteAllResponses = useDeleteHttpResponses(activeResponse?.requestId);
const latestResponseId = responses[0]?.id ?? 'n/a'; const latestResponseId = responses[0]?.id ?? "n/a";
const saveResponse = useSaveResponse(activeResponse); const saveResponse = useSaveResponse(activeResponse);
const copyResponse = useCopyHttpResponse(activeResponse); const copyResponse = useCopyHttpResponse(activeResponse);
@@ -31,45 +30,45 @@ export const RecentHttpResponsesDropdown = function ResponsePane({
<Dropdown <Dropdown
items={[ items={[
{ {
label: 'Save to File', label: "Save to File",
onSelect: saveResponse.mutate, onSelect: saveResponse.mutate,
leftSlot: <Icon icon="save" />, leftSlot: <Icon icon="save" />,
hidden: responses.length === 0 || !!activeResponse.error, hidden: responses.length === 0 || !!activeResponse.error,
disabled: activeResponse.state !== 'closed' && activeResponse.status >= 100, disabled: activeResponse.state !== "closed" && activeResponse.status >= 100,
}, },
{ {
label: 'Copy Body', label: "Copy Body",
onSelect: copyResponse.mutate, onSelect: copyResponse.mutate,
leftSlot: <Icon icon="copy" />, leftSlot: <Icon icon="copy" />,
hidden: responses.length === 0 || !!activeResponse.error, hidden: responses.length === 0 || !!activeResponse.error,
disabled: activeResponse.state !== 'closed' && activeResponse.status >= 100, disabled: activeResponse.state !== "closed" && activeResponse.status >= 100,
}, },
{ {
label: 'Delete', label: "Delete",
leftSlot: <Icon icon="trash" />, leftSlot: <Icon icon="trash" />,
onSelect: () => deleteModel(activeResponse), onSelect: () => deleteModel(activeResponse),
}, },
{ {
label: 'Unpin Response', label: "Unpin Response",
onSelect: () => onPinnedResponseId(activeResponse.id), onSelect: () => onPinnedResponseId(activeResponse.id),
leftSlot: <Icon icon="unpin" />, leftSlot: <Icon icon="unpin" />,
hidden: latestResponseId === activeResponse.id, hidden: latestResponseId === activeResponse.id,
disabled: responses.length === 0, disabled: responses.length === 0,
}, },
{ type: 'separator', label: 'History' }, { type: "separator", label: "History" },
{ {
label: `Delete ${responses.length} ${pluralize('Response', responses.length)}`, label: `Delete ${responses.length} ${pluralize("Response", responses.length)}`,
onSelect: deleteAllResponses.mutate, onSelect: deleteAllResponses.mutate,
hidden: responses.length === 0, hidden: responses.length === 0,
disabled: responses.length === 0, disabled: responses.length === 0,
}, },
{ type: 'separator' }, { type: "separator" },
...responses.map((r: HttpResponse) => ({ ...responses.map((r: HttpResponse) => ({
label: ( label: (
<HStack space={2}> <HStack space={2}>
<HttpStatusTag short className="text-xs" response={r} /> <HttpStatusTag short className="text-xs" response={r} />
<span className="text-text-subtle">&rarr;</span>{' '} <span className="text-text-subtle">&rarr;</span>{" "}
<span className="font-mono text-sm">{r.elapsed >= 0 ? `${r.elapsed}ms` : 'n/a'}</span> <span className="font-mono text-sm">{r.elapsed >= 0 ? `${r.elapsed}ms` : "n/a"}</span>
</HStack> </HStack>
), ),
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <Icon icon="empty" />, leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <Icon icon="empty" />,
@@ -79,7 +78,7 @@ export const RecentHttpResponsesDropdown = function ResponsePane({
> >
<IconButton <IconButton
title="Show response history" title="Show response history"
icon={activeResponse?.id === latestResponseId ? 'history' : 'pin'} icon={activeResponse?.id === latestResponseId ? "history" : "pin"}
className="m-0.5 text-text-subtle" className="m-0.5 text-text-subtle"
size="sm" size="sm"
iconSize="md" iconSize="md"

View File

@@ -1,18 +1,18 @@
import classNames from 'classnames'; import classNames from "classnames";
import { useMemo, useRef } from 'react'; import { useMemo, useRef } from "react";
import { useActiveRequest } from '../hooks/useActiveRequest'; import { useActiveRequest } from "../hooks/useActiveRequest";
import { activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace'; import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
import { allRequestsAtom } from '../hooks/useAllRequests'; import { allRequestsAtom } from "../hooks/useAllRequests";
import { useHotKey } from '../hooks/useHotKey'; import { useHotKey } from "../hooks/useHotKey";
import { useKeyboardEvent } from '../hooks/useKeyboardEvent'; import { useKeyboardEvent } from "../hooks/useKeyboardEvent";
import { useRecentRequests } from '../hooks/useRecentRequests'; import { useRecentRequests } from "../hooks/useRecentRequests";
import { jotaiStore } from '../lib/jotai'; import { jotaiStore } from "../lib/jotai";
import { resolvedModelName } from '../lib/resolvedModelName'; import { resolvedModelName } from "../lib/resolvedModelName";
import { router } from '../lib/router'; import { router } from "../lib/router";
import { Button } from './core/Button'; import { Button } from "./core/Button";
import type { DropdownItem, DropdownRef } from './core/Dropdown'; import type { DropdownItem, DropdownRef } from "./core/Dropdown";
import { Dropdown } from './core/Dropdown'; import { Dropdown } from "./core/Dropdown";
import { HttpMethodTag } from './core/HttpMethodTag'; import { HttpMethodTag } from "./core/HttpMethodTag";
interface Props { interface Props {
className?: string; className?: string;
@@ -26,13 +26,13 @@ export function RecentRequestsDropdown({ className }: Props) {
// Handle key-up // Handle key-up
// TODO: Somehow make useHotKey have this functionality. Note: e.key does not work // TODO: Somehow make useHotKey have this functionality. Note: e.key does not work
// on Linux, for example, when Control is mapped to CAPS. This will never fire. // on Linux, for example, when Control is mapped to CAPS. This will never fire.
useKeyboardEvent('keyup', 'Control', () => { useKeyboardEvent("keyup", "Control", () => {
if (dropdownRef.current?.isOpen) { if (dropdownRef.current?.isOpen) {
dropdownRef.current?.select?.(); dropdownRef.current?.select?.();
} }
}); });
useHotKey('switcher.prev', () => { useHotKey("switcher.prev", () => {
if (!dropdownRef.current?.isOpen) { if (!dropdownRef.current?.isOpen) {
// Select the second because the first is the current request // Select the second because the first is the current request
dropdownRef.current?.open(1); dropdownRef.current?.open(1);
@@ -41,7 +41,7 @@ export function RecentRequestsDropdown({ className }: Props) {
} }
}); });
useHotKey('switcher.next', () => { useHotKey("switcher.next", () => {
if (!dropdownRef.current?.isOpen) dropdownRef.current?.open(); if (!dropdownRef.current?.isOpen) dropdownRef.current?.open();
dropdownRef.current?.prev?.(); dropdownRef.current?.prev?.();
}); });
@@ -61,7 +61,7 @@ export function RecentRequestsDropdown({ className }: Props) {
leftSlot: <HttpMethodTag short className="text-xs" request={request} />, leftSlot: <HttpMethodTag short className="text-xs" request={request} />,
onSelect: async () => { onSelect: async () => {
await router.navigate({ await router.navigate({
to: '/workspaces/$workspaceId', to: "/workspaces/$workspaceId",
params: { workspaceId: activeWorkspaceId }, params: { workspaceId: activeWorkspaceId },
search: (prev) => ({ ...prev, request_id: request.id }), search: (prev) => ({ ...prev, request_id: request.id }),
}); });
@@ -73,8 +73,8 @@ export function RecentRequestsDropdown({ className }: Props) {
if (recentRequestItems.length === 0) { if (recentRequestItems.length === 0) {
return [ return [
{ {
key: 'no-recent-requests', key: "no-recent-requests",
label: 'No recent requests', label: "No recent requests",
disabled: true, disabled: true,
}, },
]; ];
@@ -90,8 +90,8 @@ export function RecentRequestsDropdown({ className }: Props) {
hotkeyAction="switcher.toggle" hotkeyAction="switcher.toggle"
className={classNames( className={classNames(
className, className,
'truncate pointer-events-auto', "truncate pointer-events-auto",
activeRequest == null && 'text-text-subtlest italic', activeRequest == null && "text-text-subtlest italic",
)} )}
> >
{resolvedModelName(activeRequest)} {resolvedModelName(activeRequest)}

View File

@@ -1,12 +1,11 @@
import type { WebsocketConnection } from '@yaakapp-internal/models'; import type { WebsocketConnection } from "@yaakapp-internal/models";
import { deleteModel, getModel } from '@yaakapp-internal/models'; import { deleteModel, getModel } from "@yaakapp-internal/models";
import { formatDistanceToNowStrict } from 'date-fns'; import { HStack, Icon } from "@yaakapp-internal/ui";
import { deleteWebsocketConnections } from '../commands/deleteWebsocketConnections'; import { formatDistanceToNowStrict } from "date-fns";
import { pluralizeCount } from '../lib/pluralize'; import { deleteWebsocketConnections } from "../commands/deleteWebsocketConnections";
import { Dropdown } from './core/Dropdown'; import { pluralizeCount } from "../lib/pluralize";
import { Icon } from './core/Icon'; import { Dropdown } from "./core/Dropdown";
import { IconButton } from './core/IconButton'; import { IconButton } from "./core/IconButton";
import { HStack } from './core/Stacks';
interface Props { interface Props {
connections: WebsocketConnection[]; connections: WebsocketConnection[];
@@ -19,20 +18,20 @@ export function RecentWebsocketConnectionsDropdown({
connections, connections,
onPinnedConnectionId, onPinnedConnectionId,
}: Props) { }: Props) {
const latestConnectionId = connections[0]?.id ?? 'n/a'; const latestConnectionId = connections[0]?.id ?? "n/a";
return ( return (
<Dropdown <Dropdown
items={[ items={[
{ {
label: 'Clear Connection', label: "Clear Connection",
onSelect: () => deleteModel(activeConnection), onSelect: () => deleteModel(activeConnection),
disabled: connections.length === 0, disabled: connections.length === 0,
}, },
{ {
label: `Clear ${pluralizeCount('Connection', connections.length)}`, label: `Clear ${pluralizeCount("Connection", connections.length)}`,
onSelect: () => { onSelect: () => {
const request = getModel('websocket_request', activeConnection.requestId); const request = getModel("websocket_request", activeConnection.requestId);
if (request != null) { if (request != null) {
deleteWebsocketConnections.mutate(request); deleteWebsocketConnections.mutate(request);
} }
@@ -40,11 +39,11 @@ export function RecentWebsocketConnectionsDropdown({
hidden: connections.length <= 1, hidden: connections.length <= 1,
disabled: connections.length === 0, disabled: connections.length === 0,
}, },
{ type: 'separator', label: 'History' }, { type: "separator", label: "History" },
...connections.map((c) => ({ ...connections.map((c) => ({
label: ( label: (
<HStack space={2}> <HStack space={2}>
{formatDistanceToNowStrict(`${c.createdAt}Z`)} ago &bull;{' '} {formatDistanceToNowStrict(`${c.createdAt}Z`)} ago &bull;{" "}
<span className="font-mono text-sm">{c.elapsed}ms</span> <span className="font-mono text-sm">{c.elapsed}ms</span>
</HStack> </HStack>
), ),
@@ -55,7 +54,7 @@ export function RecentWebsocketConnectionsDropdown({
> >
<IconButton <IconButton
title="Show connection history" title="Show connection history"
icon={activeConnection?.id === latestConnectionId ? 'history' : 'pin'} icon={activeConnection?.id === latestConnectionId ? "history" : "pin"}
className="m-0.5 text-text-subtle" className="m-0.5 text-text-subtle"
size="sm" size="sm"
iconSize="md" iconSize="md"

View File

@@ -0,0 +1,44 @@
import { workspacesAtom } from "@yaakapp-internal/models";
import { useAtomValue } from "jotai";
import { useEffect } from "react";
import { getRecentCookieJars } from "../hooks/useRecentCookieJars";
import { getRecentEnvironments } from "../hooks/useRecentEnvironments";
import { getRecentRequests } from "../hooks/useRecentRequests";
import { useRecentWorkspaces } from "../hooks/useRecentWorkspaces";
import { fireAndForget } from "../lib/fireAndForget";
import { router } from "../lib/router";
export function RedirectToLatestWorkspace() {
const workspaces = useAtomValue(workspacesAtom);
const recentWorkspaces = useRecentWorkspaces();
useEffect(() => {
if (workspaces.length === 0 || recentWorkspaces == null) {
console.log("No workspaces found to redirect to. Skipping.", {
workspaces,
recentWorkspaces,
});
return;
}
fireAndForget(
(async () => {
const workspaceId = recentWorkspaces[0] ?? workspaces[0]?.id ?? "n/a";
const environmentId = (await getRecentEnvironments(workspaceId))[0] ?? null;
const cookieJarId = (await getRecentCookieJars(workspaceId))[0] ?? null;
const requestId = (await getRecentRequests(workspaceId))[0] ?? null;
const params = { workspaceId };
const search = {
cookie_jar_id: cookieJarId,
environment_id: environmentId,
request_id: requestId,
};
console.log("Redirecting to workspace", params, search);
await router.navigate({ to: "/workspaces/$workspaceId", params, search });
})(),
);
}, [recentWorkspaces, workspaces, workspaces.length]);
return null;
}

View File

@@ -1,20 +1,20 @@
import type { HttpResponse } from '@yaakapp-internal/models'; import type { HttpResponse } from "@yaakapp-internal/models";
import { lazy, Suspense } from 'react'; import { lazy, Suspense } from "react";
import { useHttpRequestBody } from '../hooks/useHttpRequestBody'; import { useHttpRequestBody } from "../hooks/useHttpRequestBody";
import { getMimeTypeFromContentType, languageFromContentType } from '../lib/contentType'; import { getMimeTypeFromContentType, languageFromContentType } from "../lib/contentType";
import { LoadingIcon } from './core/LoadingIcon'; import { LoadingIcon } from "@yaakapp-internal/ui";
import { EmptyStateText } from './EmptyStateText'; import { EmptyStateText } from "./EmptyStateText";
import { AudioViewer } from './responseViewers/AudioViewer'; import { AudioViewer } from "./responseViewers/AudioViewer";
import { CsvViewer } from './responseViewers/CsvViewer'; import { CsvViewer } from "./responseViewers/CsvViewer";
import { ImageViewer } from './responseViewers/ImageViewer'; import { ImageViewer } from "./responseViewers/ImageViewer";
import { MultipartViewer } from './responseViewers/MultipartViewer'; import { MultipartViewer } from "./responseViewers/MultipartViewer";
import { SvgViewer } from './responseViewers/SvgViewer'; import { SvgViewer } from "./responseViewers/SvgViewer";
import { TextViewer } from './responseViewers/TextViewer'; import { TextViewer } from "./responseViewers/TextViewer";
import { VideoViewer } from './responseViewers/VideoViewer'; import { VideoViewer } from "./responseViewers/VideoViewer";
import { WebPageViewer } from './responseViewers/WebPageViewer'; import { WebPageViewer } from "./responseViewers/WebPageViewer";
const PdfViewer = lazy(() => const PdfViewer = lazy(() =>
import('./responseViewers/PdfViewer').then((m) => ({ default: m.PdfViewer })), import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
); );
interface Props { interface Props {
@@ -48,7 +48,7 @@ function RequestBodyViewerInner({ response }: Props) {
// Try to detect language from content-type header that was sent // Try to detect language from content-type header that was sent
const contentTypeHeader = response.requestHeaders.find( const contentTypeHeader = response.requestHeaders.find(
(h) => h.name.toLowerCase() === 'content-type', (h) => h.name.toLowerCase() === "content-type",
); );
const contentType = contentTypeHeader?.value ?? null; const contentType = contentTypeHeader?.value ?? null;
const mimeType = contentType ? getMimeTypeFromContentType(contentType).essence : null; const mimeType = contentType ? getMimeTypeFromContentType(contentType).essence : null;
@@ -56,7 +56,7 @@ function RequestBodyViewerInner({ response }: Props) {
// Route to appropriate viewer based on content type // Route to appropriate viewer based on content type
if (mimeType?.match(/^multipart/i)) { if (mimeType?.match(/^multipart/i)) {
const boundary = contentType?.split('boundary=')[1] ?? 'unknown'; const boundary = contentType?.split("boundary=")[1] ?? "unknown";
// Create a copy because parseMultipart may detach the buffer // Create a copy because parseMultipart may detach the buffer
const bodyCopy = new Uint8Array(body); const bodyCopy = new Uint8Array(body);
return ( return (

View File

@@ -1,14 +1,14 @@
import type { HttpRequest } from '@yaakapp-internal/models'; import type { HttpRequest } from "@yaakapp-internal/models";
import { patchModel } from '@yaakapp-internal/models'; import { patchModel } from "@yaakapp-internal/models";
import classNames from 'classnames'; import classNames from "classnames";
import { memo, useCallback, useMemo } from 'react'; import { memo, useCallback, useMemo } from "react";
import { showPrompt } from '../lib/prompt'; import { showPrompt } from "../lib/prompt";
import { Button } from './core/Button'; import { Button } from "./core/Button";
import type { DropdownItem } from './core/Dropdown'; import type { DropdownItem } from "./core/Dropdown";
import { HttpMethodTag, HttpMethodTagRaw } from './core/HttpMethodTag'; import { HttpMethodTag, HttpMethodTagRaw } from "./core/HttpMethodTag";
import { Icon } from './core/Icon'; import { Icon } from "@yaakapp-internal/ui";
import type { RadioDropdownItem } from './core/RadioDropdown'; import type { RadioDropdownItem } from "./core/RadioDropdown";
import { RadioDropdown } from './core/RadioDropdown'; import { RadioDropdown } from "./core/RadioDropdown";
type Props = { type Props = {
request: HttpRequest; request: HttpRequest;
@@ -16,14 +16,14 @@ type Props = {
}; };
const radioItems: RadioDropdownItem<string>[] = [ const radioItems: RadioDropdownItem<string>[] = [
'GET', "GET",
'PUT', "PUT",
'POST', "POST",
'PATCH', "PATCH",
'DELETE', "DELETE",
'OPTIONS', "OPTIONS",
'QUERY', "QUERY",
'HEAD', "HEAD",
].map((m) => ({ ].map((m) => ({
value: m, value: m,
label: <HttpMethodTagRaw method={m} />, label: <HttpMethodTagRaw method={m} />,
@@ -43,17 +43,17 @@ export const RequestMethodDropdown = memo(function RequestMethodDropdown({
const itemsAfter = useMemo<DropdownItem[]>( const itemsAfter = useMemo<DropdownItem[]>(
() => [ () => [
{ {
key: 'custom', key: "custom",
label: 'CUSTOM', label: "CUSTOM",
leftSlot: <Icon icon="sparkles" />, leftSlot: <Icon icon="sparkles" />,
onSelect: async () => { onSelect: async () => {
const newMethod = await showPrompt({ const newMethod = await showPrompt({
id: 'custom-method', id: "custom-method",
label: 'Http Method', label: "Http Method",
title: 'Custom Method', title: "Custom Method",
confirmText: 'Save', confirmText: "Save",
description: 'Enter a custom method name', description: "Enter a custom method name",
placeholder: 'CUSTOM', placeholder: "CUSTOM",
}); });
if (newMethod == null) return; if (newMethod == null) return;
await handleChange(newMethod); await handleChange(newMethod);
@@ -70,7 +70,7 @@ export const RequestMethodDropdown = memo(function RequestMethodDropdown({
itemsAfter={itemsAfter} itemsAfter={itemsAfter}
onChange={handleChange} onChange={handleChange}
> >
<Button size="xs" className={classNames(className, 'text-text-subtle hover:text')}> <Button size="xs" className={classNames(className, "text-text-subtle hover:text")}>
<HttpMethodTag request={request} noAlias /> <HttpMethodTag request={request} noAlias />
</Button> </Button>
</RadioDropdown> </RadioDropdown>

View File

@@ -1,11 +1,11 @@
import type { HttpResponse } from '@yaakapp-internal/models'; import type { HttpResponse } from "@yaakapp-internal/models";
import classNames from 'classnames'; import classNames from "classnames";
import { useMemo } from 'react'; import { useMemo } from "react";
import type { JSX } from 'react/jsx-runtime'; import type { JSX } from "react/jsx-runtime";
import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents'; import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
import { CountBadge } from './core/CountBadge'; import { CountBadge } from "./core/CountBadge";
import { DetailsBanner } from './core/DetailsBanner'; import { DetailsBanner } from "./core/DetailsBanner";
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow'; import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
interface Props { interface Props {
response: HttpResponse; response: HttpResponse;
@@ -26,37 +26,37 @@ interface ParsedCookie {
function parseCookieHeader(cookieHeader: string): Array<{ name: string; value: string }> { function parseCookieHeader(cookieHeader: string): Array<{ name: string; value: string }> {
// Parse "Cookie: name=value; name2=value2" format // Parse "Cookie: name=value; name2=value2" format
return cookieHeader.split(';').map((pair) => { return cookieHeader.split(";").map((pair) => {
const [name = '', ...valueParts] = pair.split('='); const [name = "", ...valueParts] = pair.split("=");
return { return {
name: name.trim(), name: name.trim(),
value: valueParts.join('=').trim(), value: valueParts.join("=").trim(),
}; };
}); });
} }
function parseSetCookieHeader(setCookieHeader: string): ParsedCookie { function parseSetCookieHeader(setCookieHeader: string): ParsedCookie {
// Parse "Set-Cookie: name=value; Domain=...; Path=..." format // Parse "Set-Cookie: name=value; Domain=...; Path=..." format
const parts = setCookieHeader.split(';').map((p) => p.trim()); const parts = setCookieHeader.split(";").map((p) => p.trim());
const [nameValue = '', ...attributes] = parts; const [nameValue = "", ...attributes] = parts;
const [name = '', ...valueParts] = nameValue.split('='); const [name = "", ...valueParts] = nameValue.split("=");
const cookie: ParsedCookie = { const cookie: ParsedCookie = {
name: name.trim(), name: name.trim(),
value: valueParts.join('=').trim(), value: valueParts.join("=").trim(),
}; };
for (const attr of attributes) { for (const attr of attributes) {
const [key = '', val] = attr.split('=').map((s) => s.trim()); const [key = "", val] = attr.split("=").map((s) => s.trim());
const lowerKey = key.toLowerCase(); const lowerKey = key.toLowerCase();
if (lowerKey === 'domain') cookie.domain = val; if (lowerKey === "domain") cookie.domain = val;
else if (lowerKey === 'path') cookie.path = val; else if (lowerKey === "path") cookie.path = val;
else if (lowerKey === 'expires') cookie.expires = val; else if (lowerKey === "expires") cookie.expires = val;
else if (lowerKey === 'max-age') cookie.maxAge = val; else if (lowerKey === "max-age") cookie.maxAge = val;
else if (lowerKey === 'secure') cookie.secure = true; else if (lowerKey === "secure") cookie.secure = true;
else if (lowerKey === 'httponly') cookie.httpOnly = true; else if (lowerKey === "httponly") cookie.httpOnly = true;
else if (lowerKey === 'samesite') cookie.sameSite = val; else if (lowerKey === "samesite") cookie.sameSite = val;
} }
// Detect if cookie is being deleted // Detect if cookie is being deleted
@@ -94,7 +94,7 @@ export function ResponseCookies({ response }: Props) {
const e = event.event; const e = event.event;
// Cookie headers sent (header_up with name=cookie) // Cookie headers sent (header_up with name=cookie)
if (e.type === 'header_up' && e.name.toLowerCase() === 'cookie') { if (e.type === "header_up" && e.name.toLowerCase() === "cookie") {
const cookies = parseCookieHeader(e.value); const cookies = parseCookieHeader(e.value);
for (const cookie of cookies) { for (const cookie of cookies) {
sentMap.set(cookie.name, cookie); sentMap.set(cookie.name, cookie);
@@ -102,7 +102,7 @@ export function ResponseCookies({ response }: Props) {
} }
// Set-Cookie headers received (header_down with name=set-cookie) // Set-Cookie headers received (header_down with name=set-cookie)
if (e.type === 'header_down' && e.name.toLowerCase() === 'set-cookie') { if (e.type === "header_down" && e.name.toLowerCase() === "set-cookie") {
const cookie = parseSetCookieHeader(e.value); const cookie = parseSetCookieHeader(e.value);
receivedMap.set(cookie.name, cookie); receivedMap.set(cookie.name, cookie);
} }
@@ -130,7 +130,7 @@ export function ResponseCookies({ response }: Props) {
) : ( ) : (
<KeyValueRows> <KeyValueRows>
{sentCookies.map((cookie, i) => ( {sentCookies.map((cookie, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: none // oxlint-disable-next-line react/no-array-index-key
<KeyValueRow labelColor="primary" key={i} label={cookie.name}> <KeyValueRow labelColor="primary" key={i} label={cookie.name}>
{cookie.value} {cookie.value}
</KeyValueRow> </KeyValueRow>
@@ -153,13 +153,13 @@ export function ResponseCookies({ response }: Props) {
) : ( ) : (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{receivedCookies.map((cookie, i) => ( {receivedCookies.map((cookie, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: none // oxlint-disable-next-line react/no-array-index-key
<div key={i} className="flex flex-col gap-1"> <div key={i} className="flex flex-col gap-1">
<div className="flex items-center gap-2 my-1"> <div className="flex items-center gap-2 my-1">
<span <span
className={classNames( className={classNames(
'font-mono text-editor select-auto cursor-auto', "font-mono text-editor select-auto cursor-auto",
cookie.isDeleted ? 'line-through opacity-60 text-text-subtle' : 'text-text', cookie.isDeleted ? "line-through opacity-60 text-text-subtle" : "text-text",
)} )}
> >
{cookie.name} {cookie.name}

View File

@@ -1,10 +1,10 @@
import { openUrl } from '@tauri-apps/plugin-opener'; import { openUrl } from "@tauri-apps/plugin-opener";
import type { HttpResponse } from '@yaakapp-internal/models'; import type { HttpResponse } from "@yaakapp-internal/models";
import { useMemo } from 'react'; import { useMemo } from "react";
import { CountBadge } from './core/CountBadge'; import { CountBadge } from "./core/CountBadge";
import { DetailsBanner } from './core/DetailsBanner'; import { DetailsBanner } from "./core/DetailsBanner";
import { IconButton } from './core/IconButton'; import { IconButton } from "./core/IconButton";
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow'; import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
interface Props { interface Props {
response: HttpResponse; response: HttpResponse;
@@ -62,7 +62,7 @@ export function ResponseHeaders({ response }: Props) {
) : ( ) : (
<KeyValueRows> <KeyValueRows>
{requestHeaders.map((h, i) => ( {requestHeaders.map((h, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: none // oxlint-disable-next-line react/no-array-index-key
<KeyValueRow labelColor="primary" key={i} label={h.name}> <KeyValueRow labelColor="primary" key={i} label={h.name}>
{h.value} {h.value}
</KeyValueRow> </KeyValueRow>
@@ -84,7 +84,7 @@ export function ResponseHeaders({ response }: Props) {
) : ( ) : (
<KeyValueRows> <KeyValueRows>
{responseHeaders.map((h, i) => ( {responseHeaders.map((h, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: none // oxlint-disable-next-line react/no-array-index-key
<KeyValueRow labelColor="info" key={i} label={h.name}> <KeyValueRow labelColor="info" key={i} label={h.name}>
{h.value} {h.value}
</KeyValueRow> </KeyValueRow>

View File

@@ -1,7 +1,7 @@
import { openUrl } from '@tauri-apps/plugin-opener'; import { openUrl } from "@tauri-apps/plugin-opener";
import type { HttpResponse } from '@yaakapp-internal/models'; import type { HttpResponse } from "@yaakapp-internal/models";
import { IconButton } from './core/IconButton'; import { IconButton } from "./core/IconButton";
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow'; import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
interface Props { interface Props {
response: HttpResponse; response: HttpResponse;

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