Compare commits

..

86 Commits

Author SHA1 Message Date
Gregory Schier
d2000c86d8 Ignore routes file from fmt 2026-03-13 13:25:38 -07:00
Gregory Schier
7577846369 Fix lint warnings: redundant type and floating promises
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:23:08 -07:00
Gregory Schier
903f57a415 Align branch with main: switch to vite-plus and reformat
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:20:06 -07:00
Gregory Schier
d0f1708017 Replace void with fireAndForget in proxy main.tsx
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:04:09 -07:00
Gregory Schier
ee69db0f12 Align lint fixes with main and resolve merge conflicts
- Convert biome-ignore to oxlint-disable-next-line across client app
- Fix no-base-to-string with type narrowing instead of suppressions
- Fix no-floating-promises with fireAndForget() in proxy app
- Fix restrict-template-expressions with String() wrapping
- Resolve leftover merge conflict markers in manager.rs
- Remove duplicate cmd_plugin_init_errors from lib.rs
- Add graphql as explicit dependency in yaak-client

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:02:29 -07:00
Gregory Schier
7314aedc71 Merge main into proxy branch (formatting and docs)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 12:09:59 -07:00
Gregory Schier
3c4035097a FormattedError 2026-03-12 19:48:43 -07:00
Gregory Schier
77ec87ea17 Stacks and InlineCode 2026-03-12 19:45:19 -07:00
Gregory Schier
4d792c7f9f Move inlinecode to shared ui package 2026-03-12 19:32:04 -07:00
Gregory Schier
d253d1605c Move banner to ui package 2026-03-12 19:18:13 -07:00
Gregory Schier
bc8a449b8a Get pane ready 2026-03-12 18:56:20 -07:00
Gregory Schier
7fbce4e808 Counts to the right 2026-03-12 18:55:03 -07:00
Gregory Schier
f91f40e3a1 Update Sidebar.tsx 2026-03-12 15:43:52 -07:00
Gregory Schier
024b0a3cd3 Fix sidebar counts 2026-03-12 15:40:45 -07:00
Gregory Schier
9e0a708011 Font size and other fixes 2026-03-12 15:35:55 -07:00
Gregory Schier
d8ce5c9d1a Fix styles 2026-03-12 15:19:02 -07:00
Gregory Schier
f7ff964fe5 Floating sidebar refactor 2026-03-12 15:12:49 -07:00
Gregory Schier
cc504e0a1c Move portal to shared ui lib 2026-03-12 14:28:15 -07:00
Gregory Schier
47f0daabff Shared sidebar layout 2026-03-12 14:19:29 -07:00
Gregory Schier
87e60372fe Fix sidebar width 2026-03-12 14:02:32 -07:00
Gregory Schier
7e7faa69df Move split layout 2026-03-12 14:00:29 -07:00
Gregory Schier
0b7705d915 More tweaking 2026-03-12 08:59:02 -07:00
Gregory Schier
5e3ef70d93 Refactor proxy codebase 2026-03-12 08:31:05 -07:00
Gregory Schier
4968237ece Use native TLS when certificate validation is disabled for legacy server compatibility
When "Validate TLS certificates" is disabled, use the OS native TLS stack
(Secure Transport/SChannel/OpenSSL) instead of rustls. This adds support for
TLS 1.0+ connections to legacy servers like IBM WebSphere, which rustls cannot
handle since it only implements TLS 1.2+.

Ref: https://yaak.app/feedback/posts/tls-handshake-eof-when-connecting-to-private-ibm-websphere-endpoint-works-when-s

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:32:45 -07:00
Gregory Schier
568a1b80ed Some fixes 2026-03-11 16:00:24 -07:00
Gregory Schier
d4a6735881 Sidebar filtering 2026-03-11 15:55:25 -07:00
Gregory Schier
0c52fd03e2 Shared Table component 2026-03-11 15:51:57 -07:00
Gregory Schier
3e7d04b2f3 Fix theme 2026-03-11 15:44:32 -07:00
Gregory Schier
6600116b1a Merge branch 'main' into wip/yaak-proxy-foundation
# Conflicts:
#	apps/yaak-client/components/JsonBodyEditor.tsx
#	apps/yaak-client/lib/jsonComments.ts
#	package-lock.json
#	packages/theme/src/window.ts
#	packages/ui/src/components/HeaderSize.tsx
2026-03-11 15:36:57 -07:00
Gregory Schier
7be53ca330 WIP: Add yaak-mac-window to proxy app
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 15:35:54 -07:00
Gregory Schier
f51f72a332 Show proxy status in UI 2026-03-11 15:09:21 -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
Gregory Schier
90365f0723 Create Sidebar.tsx 2026-03-09 09:51:37 -07:00
Gregory Schier
e87c3291e7 Add sidebar to proxy app 2026-03-09 09:33:35 -07:00
Gregory Schier
a0442fb42b lint 2026-03-08 22:33:47 -07:00
Gregory Schier
12ece44197 Move Tree component to @yaakapp-internal/ui package
Decouple Tree from client app's hotkey system by adding
getSelectedItems() to TreeHandle and having callers register
hotkeys externally. Extract shared action callbacks to eliminate
duplication between hotkey and context menu handlers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:32:49 -07:00
Gregory Schier
4c041e68a9 Consolidate RPC commands into unified execute_action dispatcher
Replace individual RPC commands (proxy_start, proxy_stop) with a single
execute_action(ActionInvocation) handler. This simplifies the RPC interface
and enables action chaining through events for workflows like duplicate-then-navigate.
2026-03-08 19:04:31 -07:00
Gregory Schier
6534421733 Start extracting Tree component 2026-03-08 16:37:25 -07:00
Gregory Schier
6e11894f79 Lint stuff 2026-03-08 15:50:13 -07:00
Gregory Schier
96a22c68f2 Model store hooked up 2026-03-08 15:42:18 -07:00
Gregory Schier
0a616eb5e2 Got models and event system working 2026-03-08 15:18:31 -07:00
Gregory Schier
7382287bef Initial DB implementation 2026-03-08 14:39:00 -07:00
Gregory Schier
a5433fbc74 Remove unused yaak-proxy-models crate
Replaced by a purpose-built ProxyEntry model to be added to
yaak-proxy-lib, which better handles multi-protocol capture
(HTTP, gRPC, GraphQL, WebSocket) without REST-specific assumptions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 11:38:16 -07:00
Gregory Schier
6f8c4c06bb Add transport-agnostic RPC layer for proxy app
Introduces yaak-rpc (shared RPC infrastructure) and yaak-proxy-lib
(proxy app logic decoupled from any frontend). A single Tauri command
dispatches all RPC calls through a typed router, with TypeScript types
auto-generated via ts-rs and a generic rpc() function for type-safe
calls from the frontend.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 11:27:51 -07:00
Gregory Schier
4c37e62146 Start extracting DBContext 2026-03-08 08:56:08 -07:00
Gregory Schier
cf28229f5f New yaak-databases crate for shared core logic 2026-03-08 08:10:37 -07:00
Gregory Schier
3586c8fe24 Move Icon and LoadingIcon to shared package 2026-03-07 08:00:14 -08:00
Gregory Schier
d99898f39b Move some more stuff over 2026-03-07 07:44:50 -08:00
Gregory Schier
ff6686f982 HeaderSize as shared component 2026-03-07 07:32:58 -08:00
Gregory Schier
6f9e4ada15 Shared window crate 2026-03-07 06:50:11 -08:00
Gregory Schier
fd100330a6 Extract shared UI and theme packages 2026-03-06 10:30:31 -08:00
Gregory Schier
6915778c06 Refactor desktop app into separate client and proxy apps 2026-03-06 09:23:19 -08:00
Gregory Schier
e26705f016 Use separated client/proxy dev ports across worktrees 2026-03-06 09:20:49 -08:00
Gregory Schier
32f22aad67 Add initial yaak-proxy crate 2026-03-06 06:58:45 -08:00
Gregory Schier
b563319bed Fix biome lint: update schema to 2.3.13, exclude npm dir, fix lint errors
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:19:05 -08:00
Gregory Schier
3d577dd7d9 Update release skills for CLI 2026-03-05 16:06:40 -08:00
Gregory Schier
591c68c59c Revert macOS CI runners back to macos-latest
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:59:10 -08:00
Gregory Schier
a0cb7f813f Replace format-graphql with pretty_graphql for comment-preserving GraphQL formatting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:53:13 -08:00
Gregory Schier
cfab62707e Exclude yaak-cli from app release tests and remove stale lint comments
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:36:09 -08:00
Gregory Schier
267508e533 Support comments in JSON body (#419)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:05:09 -08:00
Gregory Schier
242f55b609 Fix macOS Tahoe stoplight positioning and build on macOS 26
On macOS Tahoe (26+), the default title bar is 32px with 14px buttons,
so the old formula (button_height + PAD_Y = 14 + 18 = 32) produced no
change. Add TITLEBAR_EXTRA_HEIGHT to push the title bar taller than
the Tahoe default. Use OnceLock to capture the original default height
so repeated calls don't accumulate extra pixels.

Also update CI runners to macos-26 for Tahoe SDK builds and adjust
frontend padding for larger stoplights.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:56:19 -08:00
dependabot[bot]
67a3dd15ac Bump @hono/node-server from 1.19.9 to 1.19.10 (#417)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-05 13:24:21 -08:00
dependabot[bot]
543325613b Bump hono from 4.11.10 to 4.12.4 (#416)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-05 13:24:04 -08:00
Gregory Schier
88f5f0e045 Add redirect drop metadata and warning UI (#418) 2026-03-05 06:14:11 -08:00
Gregory Schier
615f3134d2 Fix plugin settings layout 2026-03-04 09:21:15 -08:00
Gregory Schier
0c7051d59c Better external OAuth callback format 2026-03-04 09:10:49 -08:00
Gregory Schier
30f006401a CLI plugin host: handle send/render HTTP requests (#415)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:41:53 -08:00
Gregory Schier
3c12074db6 Mention CLI in main dropdown 2026-03-03 06:50:25 -08:00
Gregory Schier
851f12f149 app: detect CLI availability and add command palette copy action 2026-03-02 14:55:04 -08:00
Gregory Schier
cc0d31fdbb cli: use update.yaak.app /cli/check for version checks 2026-03-02 14:52:23 -08:00
Gregory Schier
bab4fe899b Upgrade Yaak CLI 2026-03-02 08:01:02 -08:00
Gregory Schier
0b250ff5b5 Fix lint 2026-03-02 07:52:08 -08:00
Gregory Schier
fbf0473b20 Fix plugin dev rebuild signaling and reload toast timing 2026-03-02 07:02:28 -08:00
Gregory Schier
876b7ef454 Add top-level generate and publish CLI aliases 2026-03-02 06:28:04 -08:00
Gregory Schier
96e8572758 Add CLI update check and API client kind identity 2026-03-02 06:21:00 -08:00
Gregory Schier
f302dc39a2 Move local plugin install command into plugins_ext 2026-03-01 16:42:13 -08:00
Gregory Schier
2ca51125a4 Improve plugin source modeling and runtime dedup (#414) 2026-03-01 16:30:43 -08:00
Gregory Schier
2d99e26f19 plugin-events: route model/find requests through shared handler (#409) 2026-02-28 14:16:32 -08:00
Gregory Schier
da1e04d99e Fix Copy as gRPCurl with template-tag payloads (#413) 2026-02-28 07:39:44 -08:00
dependabot[bot]
d875eaa5bf Bump minimatch from 3.1.2 to 3.1.5 (#411)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-28 07:29:04 -08:00
Gregory Schier
5fa2469cd6 Some cleanup 2026-02-28 07:24:30 -08:00
Gregory Schier
49053cb423 Fix [object Object] request descriptions after OpenAPI import (#412) 2026-02-27 15:36:46 -08:00
Gregory Schier
37d0cabb22 fix: preserve drive letter in Windows plugin paths (#410)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 16:23:24 -08:00
Gregory Schier
435ee54140 docs: clarify app vs cli tag naming 2026-02-26 09:40:58 -08:00
dependabot[bot]
407f2c9921 Bump rollup from 4.55.1 to 4.59.0 (#406)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-26 09:31:25 -08:00
Gregory Schier
3a6630a14d tests(cli): prevent request send test teardown hang (#408) 2026-02-26 08:58:13 -08:00
1117 changed files with 27748 additions and 18884 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

View File

@@ -1,62 +0,0 @@
---
description: Review a PR in a new worktree
allowed-tools: Bash(git worktree:*), Bash(gh pr:*), Bash(git branch:*)
---
Check out a GitHub pull request for review.
## Usage
```
/check-out-pr <PR_NUMBER>
```
## What to do
1. If no PR number is provided, list all open pull requests and ask the user to select one
2. Get PR information using `gh pr view <PR_NUMBER> --json number,headRefName`
3. **Ask the user** whether they want to:
- **A) Check out in current directory** — simple `gh pr checkout <PR_NUMBER>`
- **B) Create a new worktree** — isolated copy at `../yaak-worktrees/pr-<PR_NUMBER>`
4. Follow the appropriate path below
## Option A: Check out in current directory
1. Run `gh pr checkout <PR_NUMBER>`
2. Inform the user which branch they're now on
## Option B: Create a new worktree
1. Create a new worktree at `../yaak-worktrees/pr-<PR_NUMBER>` using `git worktree add` with a timeout of at least 300000ms (5 minutes) since the post-checkout hook runs a bootstrap script
2. Checkout the PR branch in the new worktree using `gh pr checkout <PR_NUMBER>`
3. The post-checkout hook will automatically:
- Create `.env.local` with unique ports
- Copy editor config folders
- Run `npm install && npm run bootstrap`
4. Inform the user:
- Where the worktree was created
- What ports were assigned
- How to access it (cd command)
- How to run the dev server
- How to remove the worktree when done
### Example worktree output
```
Created worktree for PR #123 at ../yaak-worktrees/pr-123
Branch: feature-auth
Ports: Vite (1421), MCP (64344)
To start working:
cd ../yaak-worktrees/pr-123
npm run app-dev
To remove when done:
git worktree remove ../yaak-worktrees/pr-123
```
## Error Handling
- If the PR doesn't exist, show a helpful error
- If the worktree already exists, inform the user and ask if they want to remove and recreate it
- If `gh` CLI is not available, inform the user to install it

View File

@@ -37,6 +37,7 @@ The skill generates markdown-formatted release notes following this structure:
**IMPORTANT**: Always add a blank lines around the markdown code fence and output the markdown code block last **IMPORTANT**: Always add a blank lines around the markdown code fence and output the markdown code block last
**IMPORTANT**: PRs by `@gschier` should not mention the @username **IMPORTANT**: PRs by `@gschier` should not mention the @username
**IMPORTANT**: These are app release notes. Exclude CLI-only changes (commits prefixed with `cli:` or only touching `crates-cli/`) since the CLI has its own release process.
## After Generating Release Notes ## After Generating Release Notes

View File

@@ -1,35 +0,0 @@
# Worktree Management Skill
## Creating Worktrees
When creating git worktrees for this project, ALWAYS use the path format:
```
../yaak-worktrees/<NAME>
```
For example:
- `git worktree add ../yaak-worktrees/feature-auth`
- `git worktree add ../yaak-worktrees/bugfix-login`
- `git worktree add ../yaak-worktrees/refactor-api`
## What Happens Automatically
The post-checkout hook will automatically:
1. Create `.env.local` with unique ports (YAAK_DEV_PORT and YAAK_PLUGIN_MCP_SERVER_PORT)
2. Copy gitignored editor config folders (.zed, .idea, etc.)
3. Run `npm install && npm run bootstrap`
## Deleting Worktrees
```bash
git worktree remove ../yaak-worktrees/<NAME>
```
## Port Assignments
- Main worktree: 1420 (Vite), 64343 (MCP)
- First worktree: 1421, 64344
- Second worktree: 1422, 64345
- etc.
Each worktree can run `npm run app-dev` simultaneously without conflicts.

View File

@@ -1,46 +0,0 @@
---
name: release-check-out-pr
description: Check out a GitHub pull request for review in this repo, either in the current directory or in a new isolated worktree at ../yaak-worktrees/pr-<PR_NUMBER>. Use when asked to run or replace the old Claude check-out-pr command.
---
# Check Out PR
Check out a PR by number and let the user choose between current-directory checkout and isolated worktree checkout.
## Workflow
1. Confirm `gh` CLI is available.
2. If no PR number is provided, list open PRs (`gh pr list`) and ask the user to choose one.
3. Read PR metadata:
- `gh pr view <PR_NUMBER> --json number,headRefName`
4. Ask the user to choose:
- Option A: check out in the current directory
- Option B: create a new worktree at `../yaak-worktrees/pr-<PR_NUMBER>`
## Option A: Current Directory
1. Run:
- `gh pr checkout <PR_NUMBER>`
2. Report the checked-out branch.
## Option B: New Worktree
1. Use path:
- `../yaak-worktrees/pr-<PR_NUMBER>`
2. Create the worktree with a timeout of at least 5 minutes because checkout hooks run bootstrap.
3. In the new worktree, run:
- `gh pr checkout <PR_NUMBER>`
4. Report:
- Worktree path
- Assigned ports from `.env.local` if present
- How to start work:
- `cd ../yaak-worktrees/pr-<PR_NUMBER>`
- `npm run app-dev`
- How to remove when done:
- `git worktree remove ../yaak-worktrees/pr-<PR_NUMBER>`
## Error Handling
- If PR does not exist, show a clear error.
- If worktree already exists, ask whether to reuse it or remove/recreate it.
- If `gh` is missing, instruct the user to install/authenticate it.

View File

@@ -32,6 +32,7 @@ Generate formatted markdown release notes for a Yaak tag.
- Keep a blank line before and after the code fence. - Keep a blank line before and after the code fence.
- Output the markdown code block last. - Output the markdown code block last.
- Do not append `by @gschier` for PRs authored by `@gschier`. - Do not append `by @gschier` for PRs authored by `@gschier`.
- These are app release notes. Exclude CLI-only changes (commits prefixed with `cli:` or only touching `crates-cli/`) since the CLI has its own release process.
## Release Creation Prompt ## Release Creation Prompt

View File

@@ -1,37 +0,0 @@
---
name: worktree-management
description: Manage Yaak git worktrees using the standard ../yaak-worktrees/<NAME> layout, including creation, removal, and expected automatic setup behavior and port assignments.
---
# Worktree Management
Use the Yaak-standard worktree path layout and lifecycle commands.
## Path Convention
Always create worktrees under:
`../yaak-worktrees/<NAME>`
Examples:
- `git worktree add ../yaak-worktrees/feature-auth`
- `git worktree add ../yaak-worktrees/bugfix-login`
- `git worktree add ../yaak-worktrees/refactor-api`
## Automatic Setup After Checkout
Project git hooks automatically:
1. Create `.env.local` with unique `YAAK_DEV_PORT` and `YAAK_PLUGIN_MCP_SERVER_PORT`
2. Copy gitignored editor config folders
3. Run `npm install && npm run bootstrap`
## Remove Worktree
`git worktree remove ../yaak-worktrees/<NAME>`
## Port Pattern
- Main worktree: Vite `1420`, MCP `64343`
- First extra worktree: `1421`, `64344`
- Second extra worktree: `1422`, `64345`
- Continue incrementally for additional worktrees

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,15 +90,15 @@ 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 run: cargo test --all --exclude yaak-cli
- name: Set version - name: Set version
run: npm run replace-version run: npm run replace-version
@@ -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,7 @@ 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" args: "${{ matrix.args }} --config ./crates-tauri/yaak-app-client/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 +171,7 @@ 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"}}}}' npx tauri bundle ${{ matrix.args }} --bundles nsis --config ./crates-tauri/yaak-app-client/tauri.release.conf.json --config '{"bundle":{"createUpdaterArtifacts":true,"windows":{"nsis":{"installMode":"perMachine"}}}}'
$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: "."

6
.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
@@ -54,3 +55,6 @@ flatpak/node-sources.json
# Local Codex desktop env state # Local Codex desktop env state
.codex/environments/environment.toml .codex/environments/environment.toml
# Claude Code local settings
.claude/settings.local.json

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

3
.oxfmtignore Normal file
View File

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

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

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

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"
}
} }

2
AGENTS.md Normal file
View File

@@ -0,0 +1,2 @@
- Tag safety: app releases use `v*` tags and CLI releases use `yaak-cli-*` tags; always confirm which one is requested before retagging.
- Do not commit, push, or tag without explicit approval

397
Cargo.lock generated
View File

@@ -173,6 +173,17 @@ version = "1.0.98"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
[[package]]
name = "apollo-parser"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "947e21ff51879f8a40d7519dfe619268de2afba4042a8a43878276de3cb910f0"
dependencies = [
"memchr",
"rowan",
"thiserror 2.0.17",
]
[[package]] [[package]]
name = "append-only-vec" name = "append-only-vec"
version = "0.1.8" version = "0.1.8"
@@ -477,6 +488,28 @@ version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
[[package]]
name = "aws-lc-rs"
version = "1.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf"
dependencies = [
"aws-lc-sys",
"zeroize",
]
[[package]]
name = "aws-lc-sys"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
]
[[package]] [[package]]
name = "axum" name = "axum"
version = "0.7.9" version = "0.7.9"
@@ -1200,7 +1233,7 @@ dependencies = [
"encode_unicode", "encode_unicode",
"libc", "libc",
"once_cell", "once_cell",
"unicode-width", "unicode-width 0.2.2",
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
@@ -1347,6 +1380,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "countme"
version = "3.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636"
[[package]] [[package]]
name = "cow-utils" name = "cow-utils"
version = "0.1.3" version = "0.1.3"
@@ -1405,6 +1444,31 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crossterm"
version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67"
dependencies = [
"bitflags 1.3.2",
"crossterm_winapi",
"libc",
"mio 0.8.11",
"parking_lot",
"signal-hook",
"signal-hook-mio",
"winapi",
]
[[package]]
name = "crossterm_winapi"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
dependencies = [
"winapi",
]
[[package]] [[package]]
name = "crunchy" name = "crunchy"
version = "0.2.3" version = "0.2.3"
@@ -2167,6 +2231,12 @@ dependencies = [
"pkg-config", "pkg-config",
] ]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]] [[package]]
name = "fsevent-sys" name = "fsevent-sys"
version = "4.1.0" version = "4.1.0"
@@ -2294,6 +2364,15 @@ dependencies = [
"slab", "slab",
] ]
[[package]]
name = "fuzzy-matcher"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94"
dependencies = [
"thread_local 1.1.9",
]
[[package]] [[package]]
name = "fxhash" name = "fxhash"
version = "0.2.1" version = "0.2.1"
@@ -3164,6 +3243,24 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "inquire"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fddf93031af70e75410a2511ec04d49e758ed2f26dad3404a934e0fb45cc12a"
dependencies = [
"bitflags 2.11.0",
"crossterm",
"dyn-clone",
"fuzzy-matcher",
"fxhash",
"newline-converter",
"once_cell",
"tempfile",
"unicode-segmentation",
"unicode-width 0.1.14",
]
[[package]] [[package]]
name = "interfaces" name = "interfaces"
version = "0.0.8" version = "0.0.8"
@@ -3756,6 +3853,18 @@ dependencies = [
"simd-adler32", "simd-adler32",
] ]
[[package]]
name = "mio"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
dependencies = [
"libc",
"log 0.4.29",
"wasi 0.11.0+wasi-snapshot-preview1",
"windows-sys 0.48.0",
]
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.0.4" version = "1.0.4"
@@ -3851,6 +3960,15 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "newline-converter"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b6b097ecb1cbfed438542d16e84fd7ad9b0c76c8a65b7f9039212a3d14dc7f"
dependencies = [
"unicode-segmentation",
]
[[package]] [[package]]
name = "nibble_vec" name = "nibble_vec"
version = "0.1.0" version = "0.1.0"
@@ -3942,7 +4060,7 @@ dependencies = [
"kqueue", "kqueue",
"libc", "libc",
"log 0.4.29", "log 0.4.29",
"mio", "mio 1.0.4",
"notify-types", "notify-types",
"walkdir", "walkdir",
"windows-sys 0.59.0", "windows-sys 0.59.0",
@@ -4483,7 +4601,7 @@ checksum = "75b1853bc34cadaa90aa09f95713d8b77ec0c0d3e2d90ccf7a74216f40d20850"
dependencies = [ dependencies = [
"flate2", "flate2",
"postcard", "postcard",
"rustc-hash", "rustc-hash 2.1.1",
"serde", "serde",
"serde_json", "serde_json",
"thiserror 2.0.17", "thiserror 2.0.17",
@@ -4501,7 +4619,7 @@ dependencies = [
"textwrap", "textwrap",
"thiserror 2.0.17", "thiserror 2.0.17",
"unicode-segmentation", "unicode-segmentation",
"unicode-width", "unicode-width 0.2.2",
] ]
[[package]] [[package]]
@@ -4526,7 +4644,7 @@ dependencies = [
"hashbrown 0.16.1", "hashbrown 0.16.1",
"oxc_data_structures", "oxc_data_structures",
"oxc_estree", "oxc_estree",
"rustc-hash", "rustc-hash 2.1.1",
"serde", "serde",
] ]
@@ -4582,7 +4700,7 @@ dependencies = [
"oxc_index", "oxc_index",
"oxc_syntax", "oxc_syntax",
"petgraph 0.8.3", "petgraph 0.8.3",
"rustc-hash", "rustc-hash 2.1.1",
] ]
[[package]] [[package]]
@@ -4603,7 +4721,7 @@ dependencies = [
"oxc_sourcemap", "oxc_sourcemap",
"oxc_span", "oxc_span",
"oxc_syntax", "oxc_syntax",
"rustc-hash", "rustc-hash 2.1.1",
] ]
[[package]] [[package]]
@@ -4615,7 +4733,7 @@ dependencies = [
"cow-utils", "cow-utils",
"oxc-browserslist", "oxc-browserslist",
"oxc_syntax", "oxc_syntax",
"rustc-hash", "rustc-hash 2.1.1",
"serde", "serde",
] ]
@@ -4690,7 +4808,7 @@ dependencies = [
"oxc_ecmascript", "oxc_ecmascript",
"oxc_span", "oxc_span",
"oxc_syntax", "oxc_syntax",
"rustc-hash", "rustc-hash 2.1.1",
] ]
[[package]] [[package]]
@@ -4707,7 +4825,7 @@ dependencies = [
"oxc_semantic", "oxc_semantic",
"oxc_span", "oxc_span",
"oxc_syntax", "oxc_syntax",
"rustc-hash", "rustc-hash 2.1.1",
] ]
[[package]] [[package]]
@@ -4732,7 +4850,7 @@ dependencies = [
"oxc_span", "oxc_span",
"oxc_syntax", "oxc_syntax",
"oxc_traverse", "oxc_traverse",
"rustc-hash", "rustc-hash 2.1.1",
] ]
[[package]] [[package]]
@@ -4754,7 +4872,7 @@ dependencies = [
"oxc_regular_expression", "oxc_regular_expression",
"oxc_span", "oxc_span",
"oxc_syntax", "oxc_syntax",
"rustc-hash", "rustc-hash 2.1.1",
"seq-macro", "seq-macro",
] ]
@@ -4770,7 +4888,7 @@ dependencies = [
"oxc_diagnostics", "oxc_diagnostics",
"oxc_span", "oxc_span",
"phf 0.13.1", "phf 0.13.1",
"rustc-hash", "rustc-hash 2.1.1",
"unicode-id-start", "unicode-id-start",
] ]
@@ -4787,7 +4905,7 @@ dependencies = [
"once_cell", "once_cell",
"papaya", "papaya",
"pnp", "pnp",
"rustc-hash", "rustc-hash 2.1.1",
"self_cell", "self_cell",
"serde", "serde",
"serde_json", "serde_json",
@@ -4817,7 +4935,7 @@ dependencies = [
"oxc_span", "oxc_span",
"oxc_syntax", "oxc_syntax",
"phf 0.13.1", "phf 0.13.1",
"rustc-hash", "rustc-hash 2.1.1",
"self_cell", "self_cell",
] ]
@@ -4829,7 +4947,7 @@ checksum = "c7f89482522f3cd820817d48ee4ade5b10822060d6e5e4d419f05f6d8bd29d70"
dependencies = [ dependencies = [
"base64-simd", "base64-simd",
"json-escape-simd", "json-escape-simd",
"rustc-hash", "rustc-hash 2.1.1",
"serde", "serde",
"serde_json", "serde_json",
] ]
@@ -4893,7 +5011,7 @@ dependencies = [
"oxc_span", "oxc_span",
"oxc_syntax", "oxc_syntax",
"oxc_traverse", "oxc_traverse",
"rustc-hash", "rustc-hash 2.1.1",
"serde", "serde",
"serde_json", "serde_json",
"sha1", "sha1",
@@ -4918,7 +5036,7 @@ dependencies = [
"oxc_syntax", "oxc_syntax",
"oxc_transformer", "oxc_transformer",
"oxc_traverse", "oxc_traverse",
"rustc-hash", "rustc-hash 2.1.1",
] ]
[[package]] [[package]]
@@ -4936,7 +5054,7 @@ dependencies = [
"oxc_semantic", "oxc_semantic",
"oxc_span", "oxc_span",
"oxc_syntax", "oxc_syntax",
"rustc-hash", "rustc-hash 2.1.1",
] ]
[[package]] [[package]]
@@ -5042,6 +5160,16 @@ dependencies = [
"hmac", "hmac",
] ]
[[package]]
name = "pem"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
dependencies = [
"base64 0.22.1",
"serde_core",
]
[[package]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.2" version = "2.3.2"
@@ -5341,7 +5469,7 @@ dependencies = [
"nodejs-built-in-modules", "nodejs-built-in-modules",
"pathdiff", "pathdiff",
"radix_trie", "radix_trie",
"rustc-hash", "rustc-hash 2.1.1",
"serde", "serde",
"serde_json", "serde_json",
"thiserror 2.0.17", "thiserror 2.0.17",
@@ -5460,6 +5588,18 @@ dependencies = [
"termtree", "termtree",
] ]
[[package]]
name = "pretty_graphql"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea8c38ecedb3d28a998ea783469a78587f5f984d61226cf071f6979861e9e6a9"
dependencies = [
"apollo-parser",
"memchr",
"rowan",
"tiny_pretty",
]
[[package]] [[package]]
name = "proc-macro-crate" name = "proc-macro-crate"
version = "1.3.1" version = "1.3.1"
@@ -5640,7 +5780,7 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
"quinn-proto", "quinn-proto",
"quinn-udp", "quinn-udp",
"rustc-hash", "rustc-hash 2.1.1",
"rustls", "rustls",
"socket2 0.5.10", "socket2 0.5.10",
"thiserror 2.0.17", "thiserror 2.0.17",
@@ -5660,7 +5800,7 @@ dependencies = [
"lru-slab", "lru-slab",
"rand 0.9.1", "rand 0.9.1",
"ring", "ring",
"rustc-hash", "rustc-hash 2.1.1",
"rustls", "rustls",
"rustls-pki-types", "rustls-pki-types",
"slab", "slab",
@@ -5882,6 +6022,19 @@ dependencies = [
"cipher", "cipher",
] ]
[[package]]
name = "rcgen"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
dependencies = [
"pem",
"ring",
"rustls-pki-types",
"time",
"yasna",
]
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.12" version = "0.5.12"
@@ -6154,7 +6307,7 @@ dependencies = [
"rolldown_tracing", "rolldown_tracing",
"rolldown_utils", "rolldown_utils",
"rolldown_watcher", "rolldown_watcher",
"rustc-hash", "rustc-hash 2.1.1",
"serde", "serde",
"serde_json", "serde_json",
"string_wizard", "string_wizard",
@@ -6171,7 +6324,7 @@ version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77dff57c9de498bb1eb5b1ce682c2e3a0ae956b266fa0933c3e151b87b078967" checksum = "77dff57c9de498bb1eb5b1ce682c2e3a0ae956b266fa0933c3e151b87b078967"
dependencies = [ dependencies = [
"unicode-width", "unicode-width 0.2.2",
"yansi", "yansi",
] ]
@@ -6196,7 +6349,7 @@ dependencies = [
"kqueue", "kqueue",
"libc", "libc",
"log 0.4.29", "log 0.4.29",
"mio", "mio 1.0.4",
"rolldown-notify-types", "rolldown-notify-types",
"walkdir", "walkdir",
"windows-sys 0.61.2", "windows-sys 0.61.2",
@@ -6244,7 +6397,7 @@ dependencies = [
"rolldown_sourcemap", "rolldown_sourcemap",
"rolldown_std_utils", "rolldown_std_utils",
"rolldown_utils", "rolldown_utils",
"rustc-hash", "rustc-hash 2.1.1",
"serde", "serde",
"serde_json", "serde_json",
"simdutf8", "simdutf8",
@@ -6262,7 +6415,7 @@ dependencies = [
"blake3", "blake3",
"dashmap", "dashmap",
"rolldown_debug_action", "rolldown_debug_action",
"rustc-hash", "rustc-hash 2.1.1",
"serde", "serde",
"serde_json", "serde_json",
"tracing", "tracing",
@@ -6319,7 +6472,7 @@ dependencies = [
"rolldown-ariadne", "rolldown-ariadne",
"rolldown_utils", "rolldown_utils",
"ropey", "ropey",
"rustc-hash", "rustc-hash 2.1.1",
"sugar_path", "sugar_path",
] ]
@@ -6353,7 +6506,7 @@ dependencies = [
"rolldown_resolver", "rolldown_resolver",
"rolldown_sourcemap", "rolldown_sourcemap",
"rolldown_utils", "rolldown_utils",
"rustc-hash", "rustc-hash 2.1.1",
"serde", "serde",
"serde_json", "serde_json",
"string_wizard", "string_wizard",
@@ -6373,7 +6526,7 @@ dependencies = [
"rolldown_common", "rolldown_common",
"rolldown_plugin", "rolldown_plugin",
"rolldown_utils", "rolldown_utils",
"rustc-hash", "rustc-hash 2.1.1",
"serde_json", "serde_json",
"xxhash-rust", "xxhash-rust",
] ]
@@ -6444,7 +6597,7 @@ dependencies = [
"oxc", "oxc",
"oxc_sourcemap", "oxc_sourcemap",
"rolldown_utils", "rolldown_utils",
"rustc-hash", "rustc-hash 2.1.1",
] ]
[[package]] [[package]]
@@ -6496,7 +6649,7 @@ dependencies = [
"regex 1.11.1", "regex 1.11.1",
"regress", "regress",
"rolldown_std_utils", "rolldown_std_utils",
"rustc-hash", "rustc-hash 2.1.1",
"serde_json", "serde_json",
"simdutf8", "simdutf8",
"sugar_path", "sugar_path",
@@ -6526,6 +6679,18 @@ dependencies = [
"str_indices", "str_indices",
] ]
[[package]]
name = "rowan"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "417a3a9f582e349834051b8a10c8d71ca88da4211e4093528e36b9845f6b5f21"
dependencies = [
"countme",
"hashbrown 0.14.5",
"rustc-hash 1.1.0",
"text-size",
]
[[package]] [[package]]
name = "rusqlite" name = "rusqlite"
version = "0.32.1" version = "0.32.1"
@@ -6568,6 +6733,12 @@ dependencies = [
"serde_json", "serde_json",
] ]
[[package]]
name = "rustc-hash"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
[[package]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.1" version = "2.1.1"
@@ -6615,6 +6786,8 @@ version = "0.23.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a9586e9ee2b4f8fab52a0048ca7334d7024eef48e2cb9407e3497bb7cab7fa7" checksum = "6a9586e9ee2b4f8fab52a0048ca7334d7024eef48e2cb9407e3497bb7cab7fa7"
dependencies = [ dependencies = [
"aws-lc-rs",
"log 0.4.29",
"once_cell", "once_cell",
"ring", "ring",
"rustls-pki-types", "rustls-pki-types",
@@ -6687,6 +6860,7 @@ version = "0.103.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10b3f4191e8a80e6b43eebabfac91e5dcecebb27a71f04e820c47ec41d314bf" checksum = "e10b3f4191e8a80e6b43eebabfac91e5dcecebb27a71f04e820c47ec41d314bf"
dependencies = [ dependencies = [
"aws-lc-rs",
"ring", "ring",
"rustls-pki-types", "rustls-pki-types",
"untrusted", "untrusted",
@@ -7173,6 +7347,27 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-mio"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
dependencies = [
"libc",
"mio 0.8.11",
"signal-hook",
]
[[package]] [[package]]
name = "signal-hook-registry" name = "signal-hook-registry"
version = "1.4.5" version = "1.4.5"
@@ -7359,7 +7554,7 @@ dependencies = [
"memchr", "memchr",
"oxc_index", "oxc_index",
"oxc_sourcemap", "oxc_sourcemap",
"rustc-hash", "rustc-hash 2.1.1",
"serde", "serde",
] ]
@@ -8060,6 +8255,12 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
[[package]]
name = "text-size"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233"
[[package]] [[package]]
name = "textwrap" name = "textwrap"
version = "0.16.2" version = "0.16.2"
@@ -8068,7 +8269,7 @@ checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
dependencies = [ dependencies = [
"smawk", "smawk",
"unicode-linebreak", "unicode-linebreak",
"unicode-width", "unicode-width 0.2.2",
] ]
[[package]] [[package]]
@@ -8182,6 +8383,12 @@ dependencies = [
"crunchy", "crunchy",
] ]
[[package]]
name = "tiny_pretty"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650d82e943da333637be9f1567d33d605e76810a26464edfd7ae74f7ef181e95"
[[package]] [[package]]
name = "tinystr" name = "tinystr"
version = "0.8.1" version = "0.8.1"
@@ -8215,7 +8422,7 @@ checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408"
dependencies = [ dependencies = [
"bytes", "bytes",
"libc", "libc",
"mio", "mio 1.0.4",
"pin-project-lite", "pin-project-lite",
"signal-hook-registry", "signal-hook-registry",
"socket2 0.6.1", "socket2 0.6.1",
@@ -8785,6 +8992,12 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]] [[package]]
name = "unicode-width" name = "unicode-width"
version = "0.2.2" version = "0.2.2"
@@ -9562,6 +9775,15 @@ dependencies = [
"windows-targets 0.42.2", "windows-targets 0.42.2",
] ]
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.5",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.52.0" version = "0.52.0"
@@ -10077,7 +10299,7 @@ dependencies = [
] ]
[[package]] [[package]]
name = "yaak-app" name = "yaak-app-client"
version = "0.0.0" version = "0.0.0"
dependencies = [ dependencies = [
"charset", "charset",
@@ -10089,6 +10311,7 @@ dependencies = [
"md5 0.8.0", "md5 0.8.0",
"mime_guess", "mime_guess",
"openssl-sys", "openssl-sys",
"pretty_graphql",
"r2d2", "r2d2",
"r2d2_sqlite", "r2d2_sqlite",
"rand 0.9.1", "rand 0.9.1",
@@ -10134,13 +10357,30 @@ dependencies = [
"yaak-tauri-utils", "yaak-tauri-utils",
"yaak-templates", "yaak-templates",
"yaak-tls", "yaak-tls",
"yaak-window",
"yaak-ws", "yaak-ws",
] ]
[[package]]
name = "yaak-app-proxy"
version = "0.0.0"
dependencies = [
"log 0.4.29",
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-os",
"yaak-mac-window",
"yaak-proxy-lib",
"yaak-rpc",
"yaak-window",
]
[[package]] [[package]]
name = "yaak-cli" name = "yaak-cli"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"arboard",
"assert_cmd", "assert_cmd",
"base64 0.22.1", "base64 0.22.1",
"clap", "clap",
@@ -10150,6 +10390,7 @@ dependencies = [
"futures", "futures",
"hex", "hex",
"include_dir", "include_dir",
"inquire",
"keyring", "keyring",
"log 0.4.29", "log 0.4.29",
"oxc_resolver", "oxc_resolver",
@@ -10166,6 +10407,7 @@ dependencies = [
"walkdir", "walkdir",
"webbrowser", "webbrowser",
"yaak", "yaak",
"yaak-api",
"yaak-crypto", "yaak-crypto",
"yaak-http", "yaak-http",
"yaak-models", "yaak-models",
@@ -10203,6 +10445,25 @@ dependencies = [
"yaak-models", "yaak-models",
] ]
[[package]]
name = "yaak-database"
version = "0.1.0"
dependencies = [
"chrono",
"include_dir",
"log 0.4.29",
"nanoid",
"r2d2",
"r2d2_sqlite",
"rusqlite",
"sea-query",
"sea-query-rusqlite",
"serde",
"serde_json",
"thiserror 2.0.17",
"ts-rs",
]
[[package]] [[package]]
name = "yaak-fonts" name = "yaak-fonts"
version = "0.1.0" version = "0.1.0"
@@ -10275,6 +10536,7 @@ dependencies = [
"hyper-util", "hyper-util",
"log 0.4.29", "log 0.4.29",
"mime_guess", "mime_guess",
"native-tls",
"regex 1.11.1", "regex 1.11.1",
"reqwest", "reqwest",
"serde", "serde",
@@ -10287,6 +10549,7 @@ dependencies = [
"urlencoding", "urlencoding",
"yaak-common", "yaak-common",
"yaak-models", "yaak-models",
"yaak-templates",
"yaak-tls", "yaak-tls",
"zstd", "zstd",
] ]
@@ -10343,6 +10606,7 @@ dependencies = [
"thiserror 2.0.17", "thiserror 2.0.17",
"ts-rs", "ts-rs",
"yaak-core", "yaak-core",
"yaak-database",
] ]
[[package]] [[package]]
@@ -10359,7 +10623,6 @@ dependencies = [
"md5 0.7.0", "md5 0.7.0",
"path-slash", "path-slash",
"rand 0.9.1", "rand 0.9.1",
"regex 1.11.1",
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
@@ -10375,6 +10638,52 @@ dependencies = [
"zip-extract", "zip-extract",
] ]
[[package]]
name = "yaak-proxy"
version = "0.1.0"
dependencies = [
"bytes",
"http",
"http-body-util",
"hyper",
"hyper-util",
"pem",
"rcgen",
"rustls",
"rustls-native-certs",
"tokio",
"tokio-rustls",
]
[[package]]
name = "yaak-proxy-lib"
version = "0.0.0"
dependencies = [
"chrono",
"include_dir",
"log 0.4.29",
"r2d2",
"r2d2_sqlite",
"rusqlite",
"sea-query",
"serde",
"serde_json",
"ts-rs",
"yaak-database",
"yaak-proxy",
"yaak-rpc",
]
[[package]]
name = "yaak-rpc"
version = "0.0.0"
dependencies = [
"log 0.4.29",
"serde",
"serde_json",
"ts-rs",
]
[[package]] [[package]]
name = "yaak-sse" name = "yaak-sse"
version = "0.1.0" version = "0.1.0"
@@ -10440,6 +10749,17 @@ dependencies = [
"yaak-models", "yaak-models",
] ]
[[package]]
name = "yaak-window"
version = "0.1.0"
dependencies = [
"log 0.4.29",
"md5 0.8.0",
"rand 0.9.1",
"tauri",
"tokio",
]
[[package]] [[package]]
name = "yaak-ws" name = "yaak-ws"
version = "0.1.0" version = "0.1.0"
@@ -10471,6 +10791,9 @@ name = "yasna"
version = "0.5.2" version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
dependencies = [
"time",
]
[[package]] [[package]]
name = "yoke" name = "yoke"

View File

@@ -2,6 +2,9 @@
resolver = "2" resolver = "2"
members = [ members = [
"crates/yaak", "crates/yaak",
# Common/foundation crates
"crates/common/yaak-database",
"crates/common/yaak-rpc",
# Shared crates (no Tauri dependency) # Shared crates (no Tauri dependency)
"crates/yaak-core", "crates/yaak-core",
"crates/yaak-common", "crates/yaak-common",
@@ -17,14 +20,19 @@ members = [
"crates/yaak-tls", "crates/yaak-tls",
"crates/yaak-ws", "crates/yaak-ws",
"crates/yaak-api", "crates/yaak-api",
"crates/yaak-proxy",
# Proxy-specific crates
"crates-proxy/yaak-proxy-lib",
# CLI crates # CLI crates
"crates-cli/yaak-cli", "crates-cli/yaak-cli",
# Tauri-specific crates # Tauri-specific crates
"crates-tauri/yaak-app", "crates-tauri/yaak-app-client",
"crates-tauri/yaak-app-proxy",
"crates-tauri/yaak-fonts", "crates-tauri/yaak-fonts",
"crates-tauri/yaak-license", "crates-tauri/yaak-license",
"crates-tauri/yaak-mac-window", "crates-tauri/yaak-mac-window",
"crates-tauri/yaak-tauri-utils", "crates-tauri/yaak-tauri-utils",
"crates-tauri/yaak-window",
] ]
[workspace.dependencies] [workspace.dependencies]
@@ -47,6 +55,10 @@ 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,46 +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 { createRequestAndNavigate } from '../lib/createRequestAndNavigate'; import { copyToClipboard } from "../lib/copy";
import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm'; import { createRequestAndNavigate } from "../lib/createRequestAndNavigate";
import { showDialog } from '../lib/dialog'; import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
import { editEnvironment } from '../lib/editEnvironment'; import { showDialog } from "../lib/dialog";
import { renameModelWithPrompt } from '../lib/renameModelWithPrompt'; import { editEnvironment } from "../lib/editEnvironment";
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;
@@ -66,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();
@@ -94,74 +94,82 @@ 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) {
commands.push({
key: "request.copy_cli_send",
searchText: `copy cli send yaak request send ${activeRequest.id}`,
label: "Copy CLI Send Command",
onSelect: () => copyToClipboard(`yaak request send ${activeRequest.id}`),
});
}
httpRequestActions.forEach((a, i) => { httpRequestActions.forEach((a, i) => {
commands.push({ commands.push({
key: `http_request_action.${i}`, key: `http_request_action.${i}`,
@@ -171,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}`,
@@ -183,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,
), ),
); );
}, [ }, [
@@ -274,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: [],
}; };
@@ -295,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 }),
}); });
@@ -311,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: [],
}; };
@@ -328,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: [],
}; };
@@ -357,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)
@@ -398,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) {
@@ -481,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

@@ -1,10 +1,9 @@
import type { Cookie } from '@yaakapp-internal/models'; import type { Cookie } from "@yaakapp-internal/models";
import { cookieJarsAtom, patchModel } from '@yaakapp-internal/models'; import { cookieJarsAtom, patchModel } from "@yaakapp-internal/models";
import { useAtomValue } from 'jotai'; import { useAtomValue } from "jotai";
import { cookieDomain } from '../lib/model_util'; import { cookieDomain } from "../lib/model_util";
import { Banner } from './core/Banner'; import { Banner, InlineCode } from "@yaakapp-internal/ui";
import { IconButton } from './core/IconButton'; import { IconButton } from "./core/IconButton";
import { InlineCode } from './core/InlineCode';
interface Props { interface Props {
cookieJarId: string | null; cookieJarId: string | null;

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/utils";
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 handleRenameSelected = useCallback(() => {
const item = items[0]; const items = getSelectedTreeModels();
if (items.length === 1 && item != null) { if (items?.length === 1 && items[0] != null) {
treeRef.current?.renameItem(item.id); treeRef.current?.renameItem(items[0].id);
} }
}, }, [getSelectedTreeModels]);
},
'sidebar.selected.delete': { const handleDeleteSelected = useCallback(
priority: 100, (items: TreeModel[]) => deleteModelWithConfirm(items),
enable, [],
cb: (items: TreeModel[]) => deleteModelWithConfirm(items), );
},
'sidebar.selected.duplicate': { const handleDuplicateSelected = useCallback(
priority: 100, async (items: TreeModel[]) => {
enable,
cb: async (items: TreeModel[]) => {
if (items.length === 1 && items[0]) { if (items.length === 1 && items[0]) {
const item = items[0]; const newId = await duplicateModel(items[0]);
const newId = await duplicateModel(item);
setSelectedEnvironmentId(newId); setSelectedEnvironmentId(newId);
} else { } else {
await Promise.all(items.map(duplicateModel)); await Promise.all(items.map(duplicateModel));
} }
}, },
}, [setSelectedEnvironmentId],
} as const; );
return actions;
}, [setSelectedEnvironmentId]);
const hotkeys = useMemo<TreeProps<TreeModel>['hotkeys']>(() => ({ actions }), [actions]); 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 { jsonLanguage } from '@codemirror/lang-json'; 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>
@@ -115,7 +113,7 @@ export function GrpcEditor({
delay: 200, delay: 200,
needsRefresh: handleRefresh, needsRefresh: handleRefresh,
}), }),
jsonLanguage.data.of({ jsoncLanguage.data.of({
autocomplete: jsonCompletion(), autocomplete: jsonCompletion(),
}), }),
stateExtensions({}), stateExtensions({}),
@@ -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,32 +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 { MarkdownEditor } from './MarkdownEditor'; import { JsonBodyEditor } from "./JsonBodyEditor";
import { RequestMethodDropdown } from './RequestMethodDropdown'; import { MarkdownEditor } from "./MarkdownEditor";
import { UrlBar } from './UrlBar'; import { RequestMethodDropdown } from "./RequestMethodDropdown";
import { UrlParametersEditor } from './UrlParameterEditor'; import { UrlBar } from "./UrlBar";
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 {
@@ -64,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);
@@ -93,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(
"request_pane.focus_tab",
() => {
tabsRef.current?.setActiveTab(TAB_PARAMS); 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(),
@@ -124,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];
@@ -133,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;
@@ -157,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;
@@ -179,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>
@@ -201,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);
} }
@@ -225,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",
}, },
], ],
[ [
@@ -252,12 +257,12 @@ 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],
); );
const handleBodyTextChange = useCallback( const handleBodyTextChange = useCallback(
(text: string) => patchModel(activeRequest, { body: { text } }), (text: string) => patchModel(activeRequest, { body: { ...activeRequest.body, text } }),
[activeRequest], [activeRequest],
); );
@@ -270,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],
@@ -279,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);
@@ -317,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 && (
<> <>
@@ -337,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}
@@ -370,16 +375,10 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
<TabContent value={TAB_BODY}> <TabContent value={TAB_BODY}>
<ConfirmLargeRequestBody request={activeRequest}> <ConfirmLargeRequestBody request={activeRequest}>
{activeRequest.bodyType === BODY_TYPE_JSON ? ( {activeRequest.bodyType === BODY_TYPE_JSON ? (
<Editor <JsonBodyEditor
forceUpdateKey={forceUpdateKey} forceUpdateKey={forceUpdateKey}
autocompleteFunctions heightMode={fullHeight ? "full" : "auto"}
autocompleteVariables request={activeRequest}
placeholder="..."
heightMode={fullHeight ? 'full' : 'auto'}
defaultValue={`${activeRequest.body?.text ?? ''}`}
language="json"
onChange={handleBodyTextChange}
stateKey={`json.${activeRequest.id}`}
/> />
) : activeRequest.bodyType === BODY_TYPE_XML ? ( ) : activeRequest.bodyType === BODY_TYPE_XML ? (
<Editor <Editor
@@ -387,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}`}
@@ -422,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}`}
/> />
@@ -470,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

@@ -0,0 +1,427 @@
import type { HttpResponse, HttpResponseEvent } from "@yaakapp-internal/models";
import { Banner, HStack, Icon, LoadingIcon, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { ComponentType, CSSProperties } from "react";
import { lazy, Suspense, useMemo } from "react";
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { useResponseBodyBytes, useResponseBodyText } from "../hooks/useResponseBodyText";
import { useResponseViewMode } from "../hooks/useResponseViewMode";
import { useTimelineViewMode } from "../hooks/useTimelineViewMode";
import { getMimeTypeFromContentType } from "../lib/contentType";
import { getContentTypeFromHeaders, getCookieCounts } from "../lib/model_util";
import { ConfirmLargeResponse } from "./ConfirmLargeResponse";
import { ConfirmLargeResponseRequest } from "./ConfirmLargeResponseRequest";
import { Button } from "./core/Button";
import { CountBadge } from "./core/CountBadge";
import { HotkeyList } from "./core/HotkeyList";
import { HttpResponseDurationTag } from "./core/HttpResponseDurationTag";
import { HttpStatusTag } from "./core/HttpStatusTag";
import { PillButton } from "./core/PillButton";
import { SizeTag } from "./core/SizeTag";
import type { TabItem } from "./core/Tabs/Tabs";
import { TabContent, Tabs } from "./core/Tabs/Tabs";
import { Tooltip } from "./core/Tooltip";
import { EmptyStateText } from "./EmptyStateText";
import { ErrorBoundary } from "./ErrorBoundary";
import { HttpResponseTimeline } from "./HttpResponseTimeline";
import { RecentHttpResponsesDropdown } from "./RecentHttpResponsesDropdown";
import { RequestBodyViewer } from "./RequestBodyViewer";
import { ResponseCookies } from "./ResponseCookies";
import { ResponseHeaders } from "./ResponseHeaders";
import { AudioViewer } from "./responseViewers/AudioViewer";
import { CsvViewer } from "./responseViewers/CsvViewer";
import { EventStreamViewer } from "./responseViewers/EventStreamViewer";
import { HTMLOrTextViewer } from "./responseViewers/HTMLOrTextViewer";
import { ImageViewer } from "./responseViewers/ImageViewer";
import { MultipartViewer } from "./responseViewers/MultipartViewer";
import { SvgViewer } from "./responseViewers/SvgViewer";
import { VideoViewer } from "./responseViewers/VideoViewer";
const PdfViewer = lazy(() =>
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
);
interface Props {
style?: CSSProperties;
className?: string;
activeRequestId: string;
}
const TAB_BODY = "body";
const TAB_REQUEST = "request";
const TAB_HEADERS = "headers";
const TAB_COOKIES = "cookies";
const TAB_TIMELINE = "timeline";
export type TimelineViewMode = "timeline" | "text";
interface RedirectDropWarning {
droppedBodyCount: number;
droppedHeaders: string[];
}
export function HttpResponsePane({ style, className, activeRequestId }: Props) {
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId);
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
const [timelineViewMode, setTimelineViewMode] = useTimelineViewMode();
const contentType = getContentTypeFromHeaders(activeResponse?.headers ?? null);
const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence;
const responseEvents = useHttpResponseEvents(activeResponse);
const redirectDropWarning = useMemo(
() => getRedirectDropWarning(responseEvents.data),
[responseEvents.data],
);
const shouldShowRedirectDropWarning =
activeResponse?.state === "closed" && redirectDropWarning != null;
const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]);
const tabs = useMemo<TabItem[]>(
() => [
{
value: TAB_BODY,
label: "Response",
options: {
value: viewMode,
onChange: setViewMode,
items: [
{ label: "Response", value: "pretty" },
...(mimeType?.startsWith("image")
? []
: [{ label: "Response (Raw)", shortLabel: "Raw", value: "raw" }]),
],
},
},
{
value: TAB_REQUEST,
label: "Request",
rightSlot:
(activeResponse?.requestContentLength ?? 0) > 0 ? <CountBadge count={true} /> : null,
},
{
value: TAB_HEADERS,
label: "Headers",
rightSlot: (
<CountBadge
count={activeResponse?.requestHeaders.length ?? 0}
count2={activeResponse?.headers.length ?? 0}
showZero
/>
),
},
{
value: TAB_COOKIES,
label: "Cookies",
rightSlot:
cookieCounts.sent > 0 || cookieCounts.received > 0 ? (
<CountBadge count={cookieCounts.sent} count2={cookieCounts.received} showZero />
) : null,
},
{
value: TAB_TIMELINE,
rightSlot: <CountBadge count={responseEvents.data?.length ?? 0} />,
options: {
value: timelineViewMode,
onChange: (v) => setTimelineViewMode((v as TimelineViewMode) ?? "timeline"),
items: [
{ label: "Timeline", value: "timeline" },
{ label: "Timeline (Text)", shortLabel: "Timeline", value: "text" },
],
},
},
],
[
activeResponse?.headers,
activeResponse?.requestContentLength,
activeResponse?.requestHeaders.length,
cookieCounts.sent,
cookieCounts.received,
mimeType,
responseEvents.data?.length,
setViewMode,
viewMode,
timelineViewMode,
setTimelineViewMode,
],
);
const cancel = useCancelHttpResponse(activeResponse?.id ?? null);
return (
<div
style={style}
className={classNames(
className,
"x-theme-responsePane",
"max-h-full h-full",
"bg-surface rounded-md border border-border-subtle overflow-hidden",
"relative",
)}
>
{activeResponse == null ? (
<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">
<HStack
className={classNames(
"text-text-subtle w-full flex-shrink-0",
// Remove a bit of space because the tabs have lots too
"-mb-1.5",
)}
>
{activeResponse && (
<div
className={classNames(
"grid grid-cols-[auto_minmax(4rem,1fr)_auto]",
"cursor-default select-none",
"whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars",
)}
>
<HStack space={2} className="w-full flex-shrink-0">
{activeResponse.state !== "closed" && <LoadingIcon size="sm" />}
<HttpStatusTag showReason response={activeResponse} />
<span>&bull;</span>
<HttpResponseDurationTag response={activeResponse} />
<span>&bull;</span>
<SizeTag
contentLength={activeResponse.contentLength ?? 0}
contentLengthCompressed={activeResponse.contentLengthCompressed}
/>
</HStack>
{shouldShowRedirectDropWarning ? (
<Tooltip
tabIndex={0}
className="my-auto pl-3 flex-shrink-0 max-w-full justify-self-end overflow-hidden"
content={
<VStack alignItems="start" space={1} className="text-xs">
<span className="font-medium text-warning">
Redirect changed this request
</span>
{redirectDropWarning.droppedBodyCount > 0 && (
<span>
Body dropped on {redirectDropWarning.droppedBodyCount}{" "}
{redirectDropWarning.droppedBodyCount === 1
? "redirect hop"
: "redirect hops"}
</span>
)}
{redirectDropWarning.droppedHeaders.length > 0 && (
<span>
Headers dropped:{" "}
<span className="font-mono">
{redirectDropWarning.droppedHeaders.join(", ")}
</span>
</span>
)}
<span className="text-text-subtle">See Timeline for details.</span>
</VStack>
}
>
<span className="inline-flex min-w-0">
<PillButton
color="warning"
className="font-sans text-sm !flex-shrink max-w-full"
innerClassName="flex items-center"
leftSlot={<Icon icon="alert_triangle" size="xs" color="warning" />}
>
<span className="truncate">
{getRedirectWarningLabel(redirectDropWarning)}
</span>
</PillButton>
</span>
</Tooltip>
) : (
<span />
)}
<div className="justify-self-end flex-shrink-0">
<RecentHttpResponsesDropdown
responses={responses}
activeResponse={activeResponse}
onPinnedResponseId={setPinnedResponseId}
/>
</div>
</div>
)}
</HStack>
<div className="overflow-hidden flex flex-col min-h-0">
{activeResponse?.error && (
<Banner color="danger" className="mx-3 mt-1 flex-shrink-0">
{activeResponse.error}
</Banner>
)}
{/* Show tabs if we have any data (headers, body, etc.) even if there's an error */}
<Tabs
tabs={tabs}
label="Response"
className="ml-3 mr-3 mb-3 min-h-0 flex-1"
tabListClassName="mt-0.5 -mb-1.5"
storageKey="http_response_tabs"
activeTabKey={activeRequestId}
>
<TabContent value={TAB_BODY}>
<ErrorBoundary name="Http Response Viewer">
<Suspense>
<ConfirmLargeResponse response={activeResponse}>
{activeResponse.state === "initialized" ? (
<EmptyStateText>
<VStack space={3}>
<HStack space={3}>
<LoadingIcon className="text-text-subtlest" />
Sending Request
</HStack>
<Button size="sm" variant="border" onClick={() => cancel.mutate()}>
Cancel
</Button>
</VStack>
</EmptyStateText>
) : activeResponse.state === "closed" &&
(activeResponse.contentLength ?? 0) === 0 ? (
<EmptyStateText>Empty</EmptyStateText>
) : mimeType?.match(/^text\/event-stream/i) && viewMode === "pretty" ? (
<EventStreamViewer response={activeResponse} />
) : mimeType?.match(/^image\/svg/) ? (
<HttpSvgViewer response={activeResponse} />
) : mimeType?.match(/^image/i) ? (
<EnsureCompleteResponse response={activeResponse} Component={ImageViewer} />
) : mimeType?.match(/^audio/i) ? (
<EnsureCompleteResponse response={activeResponse} Component={AudioViewer} />
) : mimeType?.match(/^video/i) ? (
<EnsureCompleteResponse response={activeResponse} Component={VideoViewer} />
) : mimeType?.match(/^multipart/i) && viewMode === "pretty" ? (
<HttpMultipartViewer response={activeResponse} />
) : mimeType?.match(/pdf/i) ? (
<EnsureCompleteResponse response={activeResponse} Component={PdfViewer} />
) : mimeType?.match(/csv|tab-separated/i) && viewMode === "pretty" ? (
<HttpCsvViewer className="pb-2" response={activeResponse} />
) : (
<HTMLOrTextViewer
textViewerClassName="-mr-2 bg-surface" // Pull to the right
response={activeResponse}
pretty={viewMode === "pretty"}
/>
)}
</ConfirmLargeResponse>
</Suspense>
</ErrorBoundary>
</TabContent>
<TabContent value={TAB_REQUEST}>
<ConfirmLargeResponseRequest response={activeResponse}>
<RequestBodyViewer response={activeResponse} />
</ConfirmLargeResponseRequest>
</TabContent>
<TabContent value={TAB_HEADERS}>
<ResponseHeaders response={activeResponse} />
</TabContent>
<TabContent value={TAB_COOKIES}>
<ResponseCookies response={activeResponse} />
</TabContent>
<TabContent value={TAB_TIMELINE}>
<HttpResponseTimeline response={activeResponse} viewMode={timelineViewMode} />
</TabContent>
</Tabs>
</div>
</div>
)}
</div>
);
}
function getRedirectDropWarning(
events: HttpResponseEvent[] | undefined,
): RedirectDropWarning | null {
if (events == null || events.length === 0) return null;
let droppedBodyCount = 0;
const droppedHeaders = new Set<string>();
for (const e of events) {
const event = e.event;
if (event.type !== "redirect") {
continue;
}
if (event.dropped_body) {
droppedBodyCount += 1;
}
for (const headerName of event.dropped_headers ?? []) {
pushHeaderName(droppedHeaders, headerName);
}
}
if (droppedBodyCount === 0 && droppedHeaders.size === 0) {
return null;
}
return {
droppedBodyCount,
droppedHeaders: Array.from(droppedHeaders).sort(),
};
}
function pushHeaderName(headers: Set<string>, headerName: string): void {
const existing = Array.from(headers).find((h) => h.toLowerCase() === headerName.toLowerCase());
if (existing == null) {
headers.add(headerName);
}
}
function getRedirectWarningLabel(warning: RedirectDropWarning): string {
if (warning.droppedBodyCount > 0 && warning.droppedHeaders.length > 0) {
return "Dropped body and headers";
}
if (warning.droppedBodyCount > 0) {
return "Dropped body";
}
return "Dropped headers";
}
function EnsureCompleteResponse({
response,
Component,
}: {
response: HttpResponse;
Component: ComponentType<{ bodyPath: string }>;
}) {
if (response.bodyPath === null) {
return <div>Empty response body</div>;
}
// Wait until the response has been fully-downloaded
if (response.state !== "closed") {
return (
<EmptyStateText>
<LoadingIcon />
</EmptyStateText>
);
}
return <Component bodyPath={response.bodyPath} />;
}
function HttpSvgViewer({ response }: { response: HttpResponse }) {
const body = useResponseBodyText({ response, filter: null });
if (!body.data) return null;
return <SvgViewer text={body.data} />;
}
function HttpCsvViewer({ response, className }: { response: HttpResponse; className?: string }) {
const body = useResponseBodyText({ response, filter: null });
return <CsvViewer text={body.data ?? null} className={className} />;
}
function HttpMultipartViewer({ response }: { response: HttpResponse }) {
const body = useResponseBodyBytes({ response });
if (body.data == null) return null;
const contentTypeHeader = getContentTypeFromHeaders(response.headers);
const boundary = contentTypeHeader?.split("boundary=")[1] ?? "unknown";
return <MultipartViewer data={body.data} boundary={boundary} idPrefix={response.id} />;
}

View File

@@ -2,17 +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 { HttpMethodTagRaw } from './core/HttpMethodTag'; import { HttpStatusTagRaw } from "./core/HttpStatusTag";
import { HttpStatusTagRaw } from './core/HttpStatusTag'; import { Icon, type IconProps } from "@yaakapp-internal/ui";
import { Icon, type IconProps } from './core/Icon'; 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;
@@ -29,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) {
@@ -56,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);
@@ -99,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),
}, },
]; ];
@@ -108,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;
} }
@@ -140,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>
@@ -150,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>
@@ -175,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>
@@ -187,7 +186,8 @@ 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 ?? [];
return ( return (
<KeyValueRows> <KeyValueRows>
<KeyValueRow label="Status"> <KeyValueRow label="Status">
@@ -195,14 +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 label="Body Dropped">{e.dropped_body ? "Yes" : "No"}</KeyValueRow>
<KeyValueRow label="Headers Dropped">
{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>
@@ -212,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>
@@ -251,47 +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":
return { prefix: "<", text: `${event.version} ${event.status}` };
case "header_up":
return { prefix: ">", text: `${event.name}: ${event.value}` };
case "header_down":
return { prefix: "<", text: `${event.name}: ${event.value}` };
case "redirect": {
const behavior = event.behavior === "drop_body" ? "drop body" : "preserve";
const droppedHeaders = event.dropped_headers ?? [];
const dropped = [
event.dropped_body ? "body dropped" : null,
droppedHeaders.length > 0 ? `headers dropped: ${droppedHeaders.join(", ")}` : null,
]
.filter(Boolean)
.join(", ");
return {
prefix: "*",
text: `Redirect ${event.status} -> ${event.url} (${behavior}${dropped ? `, ${dropped}` : ""})`,
}; };
case 'receive_url':
return { prefix: '<', text: `${event.version} ${event.status}` };
case 'header_up':
return { prefix: '>', text: `${event.name}: ${event.value}` };
case 'header_down':
return { prefix: '<', text: `${event.name}: ${event.value}` };
case 'redirect': {
const behavior = event.behavior === 'drop_body' ? 'drop body' : 'preserve';
return { prefix: '*', text: `Redirect ${event.status} -> ${event.url} (${behavior})` };
} }
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]" };
} }
} }
@@ -302,93 +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 dropped = [
event.dropped_body ? "drop body" : null,
droppedHeaders.length > 0
? `drop ${droppedHeaders.length} ${droppedHeaders.length === 1 ? "header" : "headers"}`
: null,
]
.filter(Boolean)
.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}${event.behavior === 'drop_body' ? ' (drop body)' : ''}`, 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

@@ -0,0 +1,122 @@
import { linter } from "@codemirror/lint";
import type { HttpRequest } from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models";
import { Banner, Icon } from "@yaakapp-internal/ui";
import { useCallback, useMemo } from "react";
import { useKeyValue } from "../hooks/useKeyValue";
import { fireAndForget } from "../lib/fireAndForget";
import { textLikelyContainsJsonComments } from "../lib/jsonComments";
import type { DropdownItem } from "./core/Dropdown";
import { Dropdown } from "./core/Dropdown";
import type { EditorProps } from "./core/Editor/Editor";
import { jsonParseLinter } from "./core/Editor/json-lint";
import { Editor } from "./core/Editor/LazyEditor";
import { IconButton } from "./core/IconButton";
import { IconTooltip } from "./core/IconTooltip";
interface Props {
forceUpdateKey: string;
heightMode: EditorProps["heightMode"];
request: HttpRequest;
}
export function JsonBodyEditor({ forceUpdateKey, heightMode, request }: Props) {
const handleChange = useCallback(
(text: string) => patchModel(request, { body: { ...request.body, text } }),
[request],
);
const autoFix = request.body?.sendJsonComments !== true;
const lintExtension = useMemo(
() =>
linter(
jsonParseLinter(
autoFix
? { allowComments: true, allowTrailingCommas: true }
: { allowComments: false, allowTrailingCommas: false },
),
),
[autoFix],
);
const hasComments = useMemo(
() => textLikelyContainsJsonComments(request.body?.text ?? ""),
[request.body?.text],
);
const { value: bannerDismissed, set: setBannerDismissed } = useKeyValue<boolean>({
namespace: "no_sync",
key: ["json-fix-3", request.workspaceId],
fallback: false,
});
const handleToggleAutoFix = useCallback(() => {
const newBody = { ...request.body };
if (autoFix) {
newBody.sendJsonComments = true;
} else {
delete newBody.sendJsonComments;
}
fireAndForget(patchModel(request, { body: newBody }));
}, [request, autoFix]);
const handleDropdownOpen = useCallback(() => {
if (!bannerDismissed) {
fireAndForget(setBannerDismissed(true));
}
}, [bannerDismissed, setBannerDismissed]);
const showBanner = hasComments && autoFix && !bannerDismissed;
const stripMessage = "Automatically strip comments and trailing commas before sending";
const actions = useMemo<EditorProps["actions"]>(
() => [
showBanner && (
<Banner color="notice" className="!opacity-100 h-sm !py-0 !px-2 flex items-center text-xs">
<p className="inline-flex items-center gap-1 min-w-0">
<span className="truncate">Auto-fix enabled</span>
<Icon icon="arrow_right" size="sm" className="opacity-disabled" />
</p>
</Banner>
),
<div key="settings" className="!opacity-100 !shadow">
<Dropdown
onOpen={handleDropdownOpen}
items={
[
{
label: "Automatically Fix JSON",
keepOpenOnSelect: true,
onSelect: handleToggleAutoFix,
rightSlot: <IconTooltip content={stripMessage} />,
leftSlot: (
<Icon icon={autoFix ? "check_square_checked" : "check_square_unchecked"} />
),
},
] satisfies DropdownItem[]
}
>
<IconButton size="sm" variant="border" icon="settings" title="JSON Settings" />
</Dropdown>
</div>,
],
[handleDropdownOpen, handleToggleAutoFix, autoFix, showBanner],
);
return (
<Editor
forceUpdateKey={forceUpdateKey}
autocompleteFunctions
autocompleteVariables
placeholder="..."
heightMode={heightMode}
defaultValue={`${request.body?.text ?? ""}`}
language="json"
onChange={handleChange}
stateKey={`json.${request.id}`}
actions={actions}
lintExtension={lintExtension}
/>
);
}

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;
}

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