Compare commits

...

53 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
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
1064 changed files with 22949 additions and 17047 deletions

View File

@@ -1,23 +1,27 @@
# Claude Context: Detaching Tauri from Yaak
## 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/`.
## Project Structure
```
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)
```
## Completed Work
### 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-cli/yaak-cli/` for the standalone CLI
### 2. Decoupled Crates (no longer depend on Tauri)
- **yaak-models**: Uses `init_standalone()` pattern for CLI database access
- **yaak-http**: Removed Tauri plugin, HttpConnectionManager initialized in yaak-app setup
- **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
### 3. CLI Implementation
- Basic CLI at `crates-cli/yaak-cli/src/main.rs`
- Commands: workspaces, requests, send (by ID), get (ad-hoc URL), create
- Uses same database as Tauri app via `yaak_models::init_standalone()`
@@ -32,31 +37,36 @@ crates-cli/ # CLI crate (yaak-cli)
## Remaining Work
### Crates Still Depending on Tauri (in `crates/`)
1. **yaak-git** (3 files) - Moderate complexity
2. **yaak-plugins** (13 files) - **Hardest** - deeply integrated with Tauri for plugin-to-window communication
3. **yaak-sync** (4 files) - Moderate complexity
4. **yaak-ws** (5 files) - Moderate complexity
### Pattern for Decoupling
1. Remove Tauri plugin `init()` function from the crate
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
4. Initialize managers in yaak-app's `.setup()` block
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()`
## 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/src/models_ext.rs` - Database plugin and extension traits
- `crates-tauri/yaak-app-client/src/lib.rs` - Main Tauri app, setup block initializes managers
- `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/yaak-models/src/lib.rs` - Contains `init_standalone()` for CLI usage
## Git Branch
Working on `detach-tauri` branch.
## Recent Commits
```
c40cff40 Remove Tauri dependencies from yaak-crypto and yaak-grpc
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
- 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

View File

@@ -8,7 +8,7 @@ Generate formatted release notes for Yaak releases by analyzing git history and
## What to do
1. Identifies the version tag and previous version
2. Retrieves all commits between versions
2. Retrieves all commits between versions
- If the version is a beta version, it retrieves commits between the beta version and previous beta version
- If the version is a stable version, it retrieves commits between the stable version and the previous stable version
3. Fetches PR descriptions for linked issues to find:

4
.gitattributes vendored
View File

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

View File

@@ -1,10 +1,9 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
title: ""
labels: ""
assignees: ""
---
**Describe the bug**
@@ -12,6 +11,7 @@ A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
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.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

View File

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

View File

@@ -14,17 +14,20 @@ jobs:
runs-on: macos-latest
steps:
- 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: Swatinem/rust-cache@v2
with:
shared-key: ci
cache-on-failure: true
- run: npm ci
- run: vp install
- run: npm run bootstrap
- run: npm run lint
- name: Run JS Tests
run: npm test
run: vp test
- name: Run Rust Tests
run: cargo test --all

View File

@@ -47,4 +47,3 @@ jobs:
# 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
# claude_args: '--allowed-tools Bash(gh pr:*)'

View File

@@ -50,8 +50,11 @@ jobs:
- name: Checkout yaakapp/app
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
- name: Setup Vite+
uses: voidzero-dev/setup-vp@v1
with:
node-version: "24"
cache: true
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
@@ -87,13 +90,13 @@ jobs:
echo $dir >> $env:GITHUB_PATH
& $exe --version
- run: npm ci
- run: vp install
- run: npm run bootstrap
env:
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
- run: npm run lint
- name: Run JS Tests
run: npm test
run: vp test
- name: Run Rust Tests
run: cargo test --all --exclude yaak-cli
@@ -122,8 +125,8 @@ jobs:
security list-keychain -d user -s $KEYCHAIN_PATH
# 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/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.yaakprotoc.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app-client/vendored/protoc/yaakprotoc || 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
env:
@@ -152,7 +155,7 @@ jobs:
releaseBody: "[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)"
releaseDraft: 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)
- name: Build and upload machine-wide installer (Windows only)
@@ -168,7 +171,7 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
run: |
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
$setupSig = "$($setup.FullName).sig"
$dest = $setup.FullName -replace '-setup\.exe$', '-setup-machine.exe'

View File

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

View File

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

3
.gitignore vendored
View File

@@ -39,7 +39,8 @@ codebook.toml
target
# 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
**/permissions/autogenerated

1
.node-version Normal file
View File

@@ -0,0 +1 @@
24.14.0

2
.npmrc Normal file
View File

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

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,
"biome.enabled": true,
"biome.lint.format.enable": true
"editor.formatOnSaveMode": "file",
"editor.codeActionsOnSave": {
"source.fixAll.oxc": "explicit"
}
}

153
Cargo.lock generated
View File

@@ -488,6 +488,28 @@ version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "axum"
version = "0.7.9"
@@ -2209,6 +2231,12 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "fsevent-sys"
version = "4.1.0"
@@ -5132,6 +5160,16 @@ dependencies = [
"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]]
name = "percent-encoding"
version = "2.3.2"
@@ -5984,6 +6022,19 @@ dependencies = [
"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]]
name = "redox_syscall"
version = "0.5.12"
@@ -6735,6 +6786,8 @@ version = "0.23.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a9586e9ee2b4f8fab52a0048ca7334d7024eef48e2cb9407e3497bb7cab7fa7"
dependencies = [
"aws-lc-rs",
"log 0.4.29",
"once_cell",
"ring",
"rustls-pki-types",
@@ -6807,6 +6860,7 @@ version = "0.103.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10b3f4191e8a80e6b43eebabfac91e5dcecebb27a71f04e820c47ec41d314bf"
dependencies = [
"aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted",
@@ -10245,7 +10299,7 @@ dependencies = [
]
[[package]]
name = "yaak-app"
name = "yaak-app-client"
version = "0.0.0"
dependencies = [
"charset",
@@ -10303,9 +10357,25 @@ dependencies = [
"yaak-tauri-utils",
"yaak-templates",
"yaak-tls",
"yaak-window",
"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]]
name = "yaak-cli"
version = "0.1.0"
@@ -10375,6 +10445,25 @@ dependencies = [
"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]]
name = "yaak-fonts"
version = "0.1.0"
@@ -10447,6 +10536,7 @@ dependencies = [
"hyper-util",
"log 0.4.29",
"mime_guess",
"native-tls",
"regex 1.11.1",
"reqwest",
"serde",
@@ -10516,6 +10606,7 @@ dependencies = [
"thiserror 2.0.17",
"ts-rs",
"yaak-core",
"yaak-database",
]
[[package]]
@@ -10547,6 +10638,52 @@ dependencies = [
"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]]
name = "yaak-sse"
version = "0.1.0"
@@ -10612,6 +10749,17 @@ dependencies = [
"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]]
name = "yaak-ws"
version = "0.1.0"
@@ -10643,6 +10791,9 @@ name = "yasna"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
dependencies = [
"time",
]
[[package]]
name = "yoke"

View File

@@ -1,30 +1,38 @@
[workspace]
resolver = "2"
members = [
"crates/yaak",
# Shared crates (no Tauri dependency)
"crates/yaak-core",
"crates/yaak-common",
"crates/yaak-crypto",
"crates/yaak-git",
"crates/yaak-grpc",
"crates/yaak-http",
"crates/yaak-models",
"crates/yaak-plugins",
"crates/yaak-sse",
"crates/yaak-sync",
"crates/yaak-templates",
"crates/yaak-tls",
"crates/yaak-ws",
"crates/yaak-api",
# CLI crates
"crates-cli/yaak-cli",
# Tauri-specific crates
"crates-tauri/yaak-app",
"crates-tauri/yaak-fonts",
"crates-tauri/yaak-license",
"crates-tauri/yaak-mac-window",
"crates-tauri/yaak-tauri-utils",
"crates/yaak",
# Common/foundation crates
"crates/common/yaak-database",
"crates/common/yaak-rpc",
# Shared crates (no Tauri dependency)
"crates/yaak-core",
"crates/yaak-common",
"crates/yaak-crypto",
"crates/yaak-git",
"crates/yaak-grpc",
"crates/yaak-http",
"crates/yaak-models",
"crates/yaak-plugins",
"crates/yaak-sse",
"crates/yaak-sync",
"crates/yaak-templates",
"crates/yaak-tls",
"crates/yaak-ws",
"crates/yaak-api",
"crates/yaak-proxy",
# Proxy-specific crates
"crates-proxy/yaak-proxy-lib",
# CLI crates
"crates-cli/yaak-cli",
# Tauri-specific crates
"crates-tauri/yaak-app-client",
"crates-tauri/yaak-app-proxy",
"crates-tauri/yaak-fonts",
"crates-tauri/yaak-license",
"crates-tauri/yaak-mac-window",
"crates-tauri/yaak-tauri-utils",
"crates-tauri/yaak-window",
]
[workspace.dependencies]
@@ -47,6 +55,10 @@ thiserror = "2.0.17"
tokio = "1.48.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
yaak-core = { path = "crates/yaak-core" }
yaak = { path = "crates/yaak" }
@@ -63,12 +75,17 @@ yaak-templates = { path = "crates/yaak-templates" }
yaak-tls = { path = "crates/yaak-tls" }
yaak-ws = { path = "crates/yaak-ws" }
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
yaak-fonts = { path = "crates-tauri/yaak-fonts" }
yaak-license = { path = "crates-tauri/yaak-license" }
yaak-mac-window = { path = "crates-tauri/yaak-mac-window" }
yaak-tauri-utils = { path = "crates-tauri/yaak-tauri-utils" }
yaak-window = { path = "crates-tauri/yaak-window" }
[profile.release]
strip = false

View File

@@ -1,24 +1,26 @@
# Developer Setup
Yaak is a combined Node.js and Rust monorepo. It is a [Tauri](https://tauri.app) project, so
Yaak is a combined Node.js and Rust monorepo. It is a [Tauri](https://tauri.app) project, so
uses Rust and HTML/CSS/JS for the main application but there is also a plugin system powered
by a Node.js sidecar that communicates to the app over gRPC.
Because of the moving parts, there are a few setup steps required before development can
Because of the moving parts, there are a few setup steps required before development can
begin.
## Prerequisites
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)
- [Vite+](https://vite.dev/guide/vite-plus) (`vp` CLI)
Check the installations with the following commands:
```shell
node -v
npm -v
vp --version
rustc --version
```
@@ -45,12 +47,12 @@ npm start
## SQLite Migrations
New migrations can be created from the `src-tauri/` directory:
```shell
npm run migration
```
Rerun the app to apply the migrations.
Rerun the app to apply the migrations.
_Note: For safety, development builds use a separate database location from production builds._
@@ -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
```
## 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:
@@ -71,12 +73,6 @@ This repo uses Biome for linting and formatting (replacing ESLint + Prettier).
npm run lint
```
- Auto-fix lint issues where possible:
```sh
npm run lint:fix
```
- Format code:
```sh
@@ -84,5 +80,7 @@ npm run format
```
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">
<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>
</p>
@@ -16,8 +16,6 @@
</p>
<br>
<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 -->
</p>
@@ -27,12 +25,10 @@
![Yaak API Client](https://yaak.app/static/screenshot.png)
## 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.
Built with [Tauri](https://tauri.app), Rust, and React, its fast, lightweight, and private. No telemetry, no VC funding, and no cloud lock-in.
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.
### 🌐 Work with any API
@@ -41,21 +37,23 @@ Built with [Tauri](https://tauri.app), Rust, and React, its fast, lightweight
- Filter and inspect responses with JSONPath or XPath.
### 🔐 Stay secure
- 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.
### ☁️ Organize & collaborate
- Group requests into workspaces and nested folders.
- Use environment variables to switch between dev, staging, and prod.
- Mirror workspaces to your filesystem for versioning in Git or syncing with Dropbox.
### 🧩 Extend & customize
- Insert dynamic values like UUIDs or timestamps with template tags.
- Pick from built-in themes or build your own.
- Create plugins to extend authentication, template tags, or the UI.
## Contribution Policy
> [!IMPORTANT]

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
import type { WebsocketRequest } from '@yaakapp-internal/models';
import { deleteWebsocketConnections as cmdDeleteWebsocketConnections } from '@yaakapp-internal/ws';
import { createFastMutation } from '../hooks/useFastMutation';
import type { WebsocketRequest } from "@yaakapp-internal/models";
import { deleteWebsocketConnections as cmdDeleteWebsocketConnections } from "@yaakapp-internal/ws";
import { createFastMutation } from "../hooks/useFastMutation";
export const deleteWebsocketConnections = createFastMutation({
mutationKey: ['delete_websocket_connections'],
mutationKey: ["delete_websocket_connections"],
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 { activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace';
import { createFastMutation } from '../hooks/useFastMutation';
import { pluralizeCount } from '../lib/pluralize';
import { showDialog } from '../lib/dialog';
import { jotaiStore } from '../lib/jotai';
import { MoveToWorkspaceDialog } from "../components/MoveToWorkspaceDialog";
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
import { createFastMutation } from "../hooks/useFastMutation";
import { pluralizeCount } from "../lib/pluralize";
import { showDialog } from "../lib/dialog";
import { jotaiStore } from "../lib/jotai";
export const moveToWorkspace = createFastMutation({
mutationKey: ['move_workspace'],
mutationKey: ["move_workspace"],
mutationFn: async (requests: (HttpRequest | GrpcRequest | WebsocketRequest)[]) => {
const activeWorkspaceId = jotaiStore.get(activeWorkspaceIdAtom);
if (activeWorkspaceId == null) return;
if (requests.length === 0) return;
const title =
requests.length === 1
? 'Move Request'
: `Move ${pluralizeCount('Request', requests.length)}`;
requests.length === 1 ? "Move Request" : `Move ${pluralizeCount("Request", requests.length)}`;
showDialog({
id: 'change-workspace',
id: "change-workspace",
title,
size: 'sm',
size: "sm",
render: ({ hide }) => (
<MoveToWorkspaceDialog
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 { activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace';
import { createFastMutation } from '../hooks/useFastMutation';
import { jotaiStore } from '../lib/jotai';
import { router } from '../lib/router';
import { invokeCmd } from '../lib/tauri';
import type { SettingsTab } from "../components/Settings/Settings";
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
import { createFastMutation } from "../hooks/useFastMutation";
import { jotaiStore } from "../lib/jotai";
import { router } from "../lib/router";
import { invokeCmd } from "../lib/tauri";
// Allow tab with optional subtab (e.g., "plugins:installed")
type SettingsTabWithSubtab = SettingsTab | `${SettingsTab}:${string}` | null;
export const openSettings = createFastMutation<void, string, SettingsTabWithSubtab>({
mutationKey: ['open_settings'],
mutationKey: ["open_settings"],
mutationFn: async (tab) => {
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
if (workspaceId == null) return;
const location = router.buildLocation({
to: '/workspaces/$workspaceId/settings',
to: "/workspaces/$workspaceId/settings",
params: { workspaceId },
search: { tab: (tab ?? undefined) as SettingsTab | undefined },
});
await invokeCmd('cmd_new_child_window', {
await invokeCmd("cmd_new_child_window", {
url: location.href,
label: 'settings',
title: 'Yaak Settings',
label: "settings",
title: "Yaak Settings",
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 { getRecentCookieJars } from '../hooks/useRecentCookieJars';
import { getRecentEnvironments } from '../hooks/useRecentEnvironments';
import { getRecentRequests } from '../hooks/useRecentRequests';
import { router } from '../lib/router';
import { invokeCmd } from '../lib/tauri';
import { createFastMutation } from "../hooks/useFastMutation";
import { getRecentCookieJars } from "../hooks/useRecentCookieJars";
import { getRecentEnvironments } from "../hooks/useRecentEnvironments";
import { getRecentRequests } from "../hooks/useRecentRequests";
import { router } from "../lib/router";
import { invokeCmd } from "../lib/tauri";
export const switchWorkspace = createFastMutation<
void,
@@ -13,7 +13,7 @@ export const switchWorkspace = createFastMutation<
inNewWindow: boolean;
}
>({
mutationKey: ['open_workspace'],
mutationKey: ["open_workspace"],
mutationFn: async ({ workspaceId, inNewWindow }) => {
const environmentId = (await getRecentEnvironments(workspaceId))[0] ?? undefined;
const requestId = (await getRecentRequests(workspaceId))[0] ?? undefined;
@@ -26,16 +26,16 @@ export const switchWorkspace = createFastMutation<
if (inNewWindow) {
const location = router.buildLocation({
to: '/workspaces/$workspaceId',
to: "/workspaces/$workspaceId",
params: { workspaceId },
search,
});
await invokeCmd<void>('cmd_new_main_window', { url: location.href });
await invokeCmd<void>("cmd_new_main_window", { url: location.href });
return;
}
await router.navigate({
to: '/workspaces/$workspaceId',
to: "/workspaces/$workspaceId",
params: { workspaceId },
search,
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,21 @@
import type { DnsOverride, Workspace } from '@yaakapp-internal/models';
import { patchModel } from '@yaakapp-internal/models';
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';
import { HStack, VStack } from './core/Stacks';
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from './core/Table';
import type { DnsOverride, Workspace } from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models";
import { fireAndForget } from "../lib/fireAndForget";
import {
HStack,
Table,
TableBody,
TableCell,
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 {
workspace: Workspace;
@@ -29,15 +38,15 @@ export function DnsOverridesEditor({ workspace }: Props) {
const handleChange = useCallback(
(overrides: DnsOverride[]) => {
patchModel(workspace, { settingDnsOverrides: overrides });
fireAndForget(patchModel(workspace, { settingDnsOverrides: overrides }));
},
[workspace],
);
const handleAdd = useCallback(() => {
const newOverride: DnsOverride = {
hostname: '',
ipv4: [''],
hostname: "",
ipv4: [""],
ipv6: [],
enabled: true,
};
@@ -65,9 +74,9 @@ export function DnsOverridesEditor({ workspace }: Props) {
return (
<VStack space={3} className="pb-3">
<div className="text-text-subtle text-sm">
Override DNS resolution for specific hostnames. This works like{' '}
<code className="text-text-subtlest bg-surface-highlight px-1 rounded">/etc/hosts</code>{' '}
but only for requests made from this workspace.
Override DNS resolution for specific hostnames. This works like{" "}
<code className="text-text-subtlest bg-surface-highlight px-1 rounded">/etc/hosts</code> but
only for requests made from this workspace.
</div>
{overridesWithIds.length > 0 && (
@@ -110,15 +119,15 @@ interface DnsOverrideRowProps {
}
function DnsOverrideRow({ override, onUpdate, onDelete }: DnsOverrideRowProps) {
const ipv4Value = override.ipv4.join(', ');
const ipv6Value = override.ipv6.join(', ');
const ipv4Value = override.ipv4.join(", ");
const ipv6Value = override.ipv6.join(", ");
return (
<TableRow>
<TableCell>
<Checkbox
hideLabel
title={override.enabled ? 'Disable override' : 'Enable override'}
title={override.enabled ? "Disable override" : "Enable override"}
checked={override.enabled ?? true}
onChange={(enabled) => onUpdate({ enabled })}
/>
@@ -143,7 +152,7 @@ function DnsOverrideRow({ override, onUpdate, onDelete }: DnsOverrideRowProps) {
onChange={(value) =>
onUpdate({
ipv4: value
.split(',')
.split(",")
.map((s) => s.trim())
.filter(Boolean),
})
@@ -160,7 +169,7 @@ function DnsOverrideRow({ override, onUpdate, onDelete }: DnsOverrideRowProps) {
onChange={(value) =>
onUpdate({
ipv6: value
.split(',')
.split(",")
.map((s) => s.trim())
.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 { foldersAtom, httpRequestsAtom } from '@yaakapp-internal/models';
import type { Folder, HttpRequest } from "@yaakapp-internal/models";
import { foldersAtom, httpRequestsAtom } from "@yaakapp-internal/models";
import type {
FormInput,
FormInputCheckbox,
@@ -10,33 +10,32 @@ import type {
FormInputSelect,
FormInputText,
JsonPrimitive,
} from '@yaakapp-internal/plugins';
import classNames from 'classnames';
import { useAtomValue } from 'jotai';
import { useCallback, useEffect, useMemo } from 'react';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useRandomKey } from '../hooks/useRandomKey';
import { capitalize } from '../lib/capitalize';
import { showDialog } from '../lib/dialog';
import { resolvedModelName } from '../lib/resolvedModelName';
import { Banner } from './core/Banner';
import { Checkbox } from './core/Checkbox';
import { DetailsBanner } from './core/DetailsBanner';
import { Editor } from './core/Editor/LazyEditor';
import { IconButton } from './core/IconButton';
import type { InputProps } from './core/Input';
import { Input } from './core/Input';
import { Label } from './core/Label';
import type { Pair } from './core/PairEditor';
import { PairEditor } from './core/PairEditor';
import { PlainInput } from './core/PlainInput';
import { Select } from './core/Select';
import { VStack } from './core/Stacks';
import { Markdown } from './Markdown';
import { SelectFile } from './SelectFile';
} from "@yaakapp-internal/plugins";
import { Banner, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo } from "react";
import { useActiveRequest } from "../hooks/useActiveRequest";
import { useRandomKey } from "../hooks/useRandomKey";
import { capitalize } from "../lib/capitalize";
import { showDialog } from "../lib/dialog";
import { resolvedModelName } from "../lib/resolvedModelName";
import { Checkbox } from "./core/Checkbox";
import { DetailsBanner } from "./core/DetailsBanner";
import { Editor } from "./core/Editor/LazyEditor";
import { IconButton } from "./core/IconButton";
import type { InputProps } from "./core/Input";
import { Input } from "./core/Input";
import { Label } from "./core/Label";
import type { Pair } from "./core/PairEditor";
import { PairEditor } from "./core/PairEditor";
import { PlainInput } from "./core/PlainInput";
import { Select } from "./core/Select";
import { Markdown } from "./Markdown";
import { SelectFile } from "./SelectFile";
export const DYNAMIC_FORM_NULL_ARG = '__NULL__';
const INPUT_SIZE = 'sm';
export const DYNAMIC_FORM_NULL_ARG = "__NULL__";
const INPUT_SIZE = "sm";
interface Props<T> {
inputs: FormInput[] | undefined | null;
@@ -75,7 +74,7 @@ export function DynamicForm<T extends Record<string, JsonPrimitive>>({
autocompleteFunctions={autocompleteFunctions}
autocompleteVariables={autocompleteVariables}
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}
className={classNames(
className,
'h-full overflow-auto',
'pr-1', // A bit of space between inputs and scrollbar
"h-full overflow-auto",
"pr-1", // A bit of space between inputs and scrollbar
)}
>
<FormInputs {...props} />
@@ -100,7 +99,7 @@ function FormInputsStack<T extends Record<string, JsonPrimitive>>({
type FormInputsProps<T> = Pick<
Props<T>,
'inputs' | 'autocompleteFunctions' | 'autocompleteVariables' | 'stateKey' | 'data'
"inputs" | "autocompleteFunctions" | "autocompleteVariables" | "stateKey" | "data"
> & {
setDataAttr: (name: string, value: JsonPrimitive) => void;
disabled?: boolean;
@@ -118,16 +117,16 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
return (
<>
{inputs?.map((input, i) => {
if ('hidden' in input && input.hidden) {
if ("hidden" in input && input.hidden) {
return null;
}
if ('disabled' in input && disabled != null) {
if ("disabled" in input && disabled != null) {
input.disabled = disabled;
}
switch (input.type) {
case 'select':
case "select":
return (
<SelectArg
key={i + stateKey}
@@ -140,7 +139,7 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
}
/>
);
case 'text':
case "text":
return (
<TextArg
key={i + stateKey}
@@ -150,11 +149,11 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
autocompleteVariables={autocompleteVariables || false}
onChange={(v) => setDataAttr(input.name, v)}
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 (
<EditorArg
key={i + stateKey}
@@ -164,11 +163,11 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
autocompleteVariables={autocompleteVariables || false}
onChange={(v) => setDataAttr(input.name, v)}
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 (
<CheckboxArg
key={i + stateKey}
@@ -177,7 +176,7 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
value={data[input.name] != null ? data[input.name] === true : false}
/>
);
case 'http_request':
case "http_request":
return (
<HttpRequestArg
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}
/>
);
case 'file':
case "file":
return (
<FileArg
key={i + stateKey}
@@ -197,7 +196,7 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
}
/>
);
case 'accordion':
case "accordion":
if (!hasVisibleInputs(input.inputs)) {
return null;
}
@@ -205,7 +204,7 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
<div key={i + stateKey}>
<DetailsBanner
summary={input.label}
className={classNames('!mb-auto', disabled && 'opacity-disabled')}
className={classNames("!mb-auto", disabled && "opacity-disabled")}
>
<div className="mt-3">
<FormInputsStack
@@ -221,7 +220,7 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
</DetailsBanner>
</div>
);
case 'h_stack':
case "h_stack":
if (!hasVisibleInputs(input.inputs)) {
return null;
}
@@ -238,7 +237,7 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
/>
</div>
);
case 'banner':
case "banner":
if (!hasVisibleInputs(input.inputs)) {
return null;
}
@@ -246,7 +245,7 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
<Banner
key={i + stateKey}
color={input.color}
className={classNames(disabled && 'opacity-disabled')}
className={classNames(disabled && "opacity-disabled")}
>
<FormInputsStack
data={data}
@@ -259,9 +258,9 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
/>
</Banner>
);
case 'markdown':
case "markdown":
return <Markdown key={i + stateKey}>{input.content}</Markdown>;
case 'key_value':
case "key_value":
return (
<KeyValueArg
key={i + stateKey}
@@ -269,7 +268,7 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
stateKey={stateKey}
onChange={(v) => setDataAttr(input.name, v)}
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,
name: arg.name,
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,
required: !arg.optional,
disabled: arg.disabled,
help: arg.description,
type: arg.password ? 'password' : 'text',
type: arg.password ? "password" : "text",
label: arg.label ?? arg.name,
size: INPUT_SIZE,
hideLabel: arg.hideLabel ?? arg.label == null,
@@ -358,9 +357,9 @@ function EditorArg({
</Label>
<div
className={classNames(
'border border-border rounded-md overflow-hidden px-2 py-1',
'focus-within:border-border-focus',
!arg.rows && 'max-h-[10rem]', // So it doesn't take up too much space
"border border-border rounded-md overflow-hidden px-2 py-1",
"focus-within:border-border-focus",
!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}
>
@@ -390,10 +389,10 @@ function EditorArg({
title="Pop out to large editor"
onClick={() => {
showDialog({
id: 'id',
size: 'full',
title: arg.readOnly ? 'View Value' : 'Edit Value',
className: '!max-w-[50rem] !max-h-[60rem]',
id: "id",
size: "full",
title: arg.readOnly ? "View Value" : "Edit Value",
className: "!max-w-[50rem] !max-h-[60rem]",
description: arg.label && (
<Label
htmlFor={id}
@@ -496,7 +495,7 @@ function HttpRequestArg({
}) {
const folders = useAtomValue(foldersAtom);
const httpRequests = useAtomValue(httpRequestsAtom);
const activeHttpRequest = useActiveRequest('http_request');
const activeHttpRequest = useActiveRequest("http_request");
useEffect(() => {
if (value === DYNAMIC_FORM_NULL_ARG && activeHttpRequest) {
@@ -512,16 +511,14 @@ function HttpRequestArg({
help={arg.description}
value={value}
disabled={arg.disabled}
options={[
...httpRequests.map((r) => {
return {
label:
buildRequestBreadcrumbs(r, folders).join(' / ') +
(r.id === activeHttpRequest?.id ? ' (current)' : ''),
value: r.id,
};
}),
]}
options={httpRequests.map((r) => {
return {
label:
buildRequestBreadcrumbs(r, folders).join(" / ") +
(r.id === activeHttpRequest?.id ? " (current)" : ""),
value: r.id,
};
})}
/>
);
}
@@ -541,7 +538,7 @@ function buildRequestBreadcrumbs(request: HttpRequest, folders: Folder[]): strin
};
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({
@@ -618,7 +615,7 @@ function hasVisibleInputs(inputs: FormInput[] | undefined): boolean {
if (!inputs) return false;
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
return false;
}

View File

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

View File

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

View File

@@ -1,19 +1,19 @@
import classNames from 'classnames';
import { memo, useMemo } from 'react';
import { useActiveEnvironment } from '../hooks/useActiveEnvironment';
import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown';
import { editEnvironment } from '../lib/editEnvironment';
import { setWorkspaceSearchParams } from '../lib/setWorkspaceSearchParams';
import type { ButtonProps } from './core/Button';
import { Button } from './core/Button';
import type { DropdownItem } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon';
import { EnvironmentColorIndicator } from './EnvironmentColorIndicator';
import classNames from "classnames";
import { memo, useMemo } from "react";
import { useActiveEnvironment } from "../hooks/useActiveEnvironment";
import { useEnvironmentsBreakdown } from "../hooks/useEnvironmentsBreakdown";
import { editEnvironment } from "../lib/editEnvironment";
import { setWorkspaceSearchParams } from "../lib/setWorkspaceSearchParams";
import type { ButtonProps } from "./core/Button";
import { Button } from "./core/Button";
import type { DropdownItem } from "./core/Dropdown";
import { Dropdown } from "./core/Dropdown";
import { Icon } from "@yaakapp-internal/ui";
import { EnvironmentColorIndicator } from "./EnvironmentColorIndicator";
type Props = {
className?: string;
} & Pick<ButtonProps, 'forDropdown' | 'leftSlot'>;
} & Pick<ButtonProps, "forDropdown" | "leftSlot">;
export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdown({
className,
@@ -41,11 +41,11 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
[activeEnvironment?.id],
),
...((subEnvironments.length > 0
? [{ type: 'separator', label: 'Environments' }]
? [{ type: "separator", label: "Environments" }]
: []) as DropdownItem[]),
{
label: 'Manage Environments',
hotKeyAction: 'environment_editor.toggle',
label: "Manage Environments",
hotKeyAction: "environment_editor.toggle",
leftSlot: <Icon icon="box" />,
onSelect: () => editEnvironment(activeEnvironment),
},
@@ -62,8 +62,8 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
size="sm"
className={classNames(
className,
'text !px-2 truncate',
!activeEnvironment && !hasBaseVars && 'text-text-subtlest italic',
"text !px-2 truncate",
!activeEnvironment && !hasBaseVars && "text-text-subtlest italic",
)}
// 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
@@ -71,7 +71,7 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
{...buttonProps}
>
<EnvironmentColorIndicator environment={activeEnvironment ?? null} />
{activeEnvironment?.name ?? (hasBaseVars ? 'Environment' : 'No Environment')}
{activeEnvironment?.name ?? (hasBaseVars ? "Environment" : "No Environment")}
</Button>
</Dropdown>
);

View File

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

View File

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

View File

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

View File

@@ -1,27 +1,27 @@
import type { Environment } from '@yaakapp-internal/models';
import { patchModel } from '@yaakapp-internal/models';
import type { GenericCompletionOption } from '@yaakapp-internal/plugins';
import classNames from 'classnames';
import { useCallback, useMemo } from 'react';
import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown';
import { useIsEncryptionEnabled } from '../hooks/useIsEncryptionEnabled';
import { useKeyValue } from '../hooks/useKeyValue';
import { useRandomKey } from '../hooks/useRandomKey';
import { analyzeTemplate, convertTemplateToSecure } from '../lib/encryption';
import { isBaseEnvironment } from '../lib/model_util';
import type { Environment } from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models";
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
import { Heading } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useCallback, useMemo } from "react";
import { useEnvironmentsBreakdown } from "../hooks/useEnvironmentsBreakdown";
import { useIsEncryptionEnabled } from "../hooks/useIsEncryptionEnabled";
import { useKeyValue } from "../hooks/useKeyValue";
import { useRandomKey } from "../hooks/useRandomKey";
import { analyzeTemplate, convertTemplateToSecure } from "../lib/encryption";
import { isBaseEnvironment } from "../lib/model_util";
import {
setupOrConfigureEncryption,
withEncryptionEnabled,
} from '../lib/setupOrConfigureEncryption';
import { DismissibleBanner } from './core/DismissibleBanner';
import type { GenericCompletionConfig } from './core/Editor/genericCompletion';
import { Heading } from './core/Heading';
import type { PairEditorHandle, PairWithId } from './core/PairEditor';
import { ensurePairId } from './core/PairEditor.util';
import { PairOrBulkEditor } from './core/PairOrBulkEditor';
import { PillButton } from './core/PillButton';
import { EnvironmentColorIndicator } from './EnvironmentColorIndicator';
import { EnvironmentSharableTooltip } from './EnvironmentSharableTooltip';
} from "../lib/setupOrConfigureEncryption";
import { DismissibleBanner } from "./core/DismissibleBanner";
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
import type { PairEditorHandle, PairWithId } from "./core/PairEditor";
import { ensurePairId } from "./core/PairEditor.util";
import { PairOrBulkEditor } from "./core/PairOrBulkEditor";
import { PillButton } from "./core/PillButton";
import { EnvironmentColorIndicator } from "./EnvironmentColorIndicator";
import { EnvironmentSharableTooltip } from "./EnvironmentSharableTooltip";
interface Props {
environment: Environment;
@@ -34,8 +34,8 @@ export function EnvironmentEditor({ environment, hideName, className, setRef }:
const workspaceId = environment.workspaceId;
const isEncryptionEnabled = useIsEncryptionEnabled();
const valueVisibility = useKeyValue<boolean>({
namespace: 'global',
key: ['environmentValueVisibility', workspaceId],
namespace: "global",
key: ["environmentValueVisibility", workspaceId],
fallback: false,
});
const { allEnvironments } = useEnvironmentsBreakdown();
@@ -64,8 +64,8 @@ export function EnvironmentEditor({ environment, hideName, className, setRef }:
}
options.push({
label: name,
type: 'constant',
detail: containingEnvs.map((e) => e.name).join(', '),
type: "constant",
detail: containingEnvs.map((e) => e.name).join(", "),
});
}
return { options };
@@ -73,14 +73,14 @@ export function EnvironmentEditor({ environment, hideName, className, setRef }:
const validateName = useCallback((name: string) => {
// 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;
}, []);
const valueType = !isEncryptionEnabled && valueVisibility.value ? 'text' : 'password';
const valueType = !isEncryptionEnabled && valueVisibility.value ? "text" : "password";
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],
);
@@ -88,7 +88,7 @@ export function EnvironmentEditor({ environment, hideName, className, setRef }:
withEncryptionEnabled(async () => {
const encryptedVariables: PairWithId[] = [];
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 }));
}
await handleChange(encryptedVariables);
@@ -100,7 +100,7 @@ export function EnvironmentEditor({ environment, hideName, className, setRef }:
<div
className={classNames(
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">
@@ -123,7 +123,7 @@ export function EnvironmentEditor({ environment, hideName, className, setRef }:
)
) : (
<PillButton color="secondary" onClick={() => valueVisibility.set((v) => !v)}>
{valueVisibility.value ? 'Hide Values' : 'Show Values'}
{valueVisibility.value ? "Hide Values" : "Show Values"}
</PillButton>
)}
<PillButton
@@ -133,7 +133,7 @@ export function EnvironmentEditor({ environment, hideName, className, setRef }:
await patchModel(environment, { public: !environment.public });
}}
>
{environment.public ? 'Sharable' : 'Private'}
{environment.public ? "Sharable" : "Private"}
</PillButton>
</Heading>
{environment.public && (!isEncryptionEnabled || !allVariableAreEncrypted) && (
@@ -143,9 +143,9 @@ export function EnvironmentEditor({ environment, hideName, className, setRef }:
className="mr-3"
actions={[
{
label: 'Encrypt Variables',
label: "Encrypt Variables",
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() {
return (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,38 +1,36 @@
import type { GrpcEvent, GrpcRequest } from '@yaakapp-internal/models';
import { useAtomValue, useSetAtom } from 'jotai';
import type { CSSProperties } from 'react';
import { useEffect, useMemo, useState } from 'react';
import type { GrpcEvent, GrpcRequest } from "@yaakapp-internal/models";
import { HStack, Icon, type IconProps, LoadingIcon, VStack } from "@yaakapp-internal/ui";
import { useAtomValue, useSetAtom } from "jotai";
import type { CSSProperties } from "react";
import { useEffect, useMemo, useState } from "react";
import {
activeGrpcConnectionAtom,
activeGrpcConnections,
pinnedGrpcConnectionIdAtom,
useGrpcEvents,
} from '../hooks/usePinnedGrpcConnection';
import { useStateWithDeps } from '../hooks/useStateWithDeps';
import { Button } from './core/Button';
import { Editor } from './core/Editor/LazyEditor';
import { EventDetailHeader, EventViewer } from './core/EventViewer';
import { EventViewerRow } from './core/EventViewerRow';
import { HotkeyList } from './core/HotkeyList';
import { Icon, type IconProps } from './core/Icon';
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
import { LoadingIcon } from './core/LoadingIcon';
import { HStack, VStack } from './core/Stacks';
import { EmptyStateText } from './EmptyStateText';
import { ErrorBoundary } from './ErrorBoundary';
import { RecentGrpcConnectionsDropdown } from './RecentGrpcConnectionsDropdown';
} from "../hooks/usePinnedGrpcConnection";
import { useStateWithDeps } from "../hooks/useStateWithDeps";
import { Button } from "./core/Button";
import { Editor } from "./core/Editor/LazyEditor";
import { EventDetailHeader, EventViewer } from "./core/EventViewer";
import { EventViewerRow } from "./core/EventViewerRow";
import { HotkeyList } from "./core/HotkeyList";
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
import { EmptyStateText } from "./EmptyStateText";
import { ErrorBoundary } from "./ErrorBoundary";
import { RecentGrpcConnectionsDropdown } from "./RecentGrpcConnectionsDropdown";
interface Props {
style?: CSSProperties;
className?: string;
activeRequest: GrpcRequest;
methodType:
| 'unary'
| 'client_streaming'
| 'server_streaming'
| 'streaming'
| 'no-schema'
| 'no-method';
| "unary"
| "client_streaming"
| "server_streaming"
| "streaming"
| "no-schema"
| "no-method";
}
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
// biome-ignore lint/correctness/useExhaustiveDependencies: none
// oxlint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => {
if (events.length === 0 || activeEvent != null || methodType !== 'unary') {
if (events.length === 0 || activeEvent != null || methodType !== "unary") {
return;
}
const firstServerMessageIndex = events.findIndex((m) => m.eventType === 'server_message');
const firstServerMessageIndex = events.findIndex((m) => m.eventType === "server_message");
if (firstServerMessageIndex !== -1) {
setActiveEventIndex(firstServerMessageIndex);
}
@@ -63,7 +61,7 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
if (activeConnection == null) {
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 space={2}>
<span className="whitespace-nowrap">{events.length} Messages</span>
{activeConnection.state !== 'closed' && (
{activeConnection.state !== "closed" && (
<LoadingIcon size="sm" className="text-text-subtlest" />
)}
</HStack>
@@ -93,7 +91,7 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
getEventKey={(event) => event.id}
error={activeConnection.error}
header={header}
splitLayoutName="grpc_events"
splitLayoutStorageKey="grpc_events"
defaultRatio={0.4}
renderRow={({ event, isActive, onClick }) => (
<GrpcEventRow event={event} isActive={isActive} onClick={onClick} />
@@ -157,8 +155,8 @@ function GrpcEventDetail({
setShowingLarge: (v: boolean) => void;
onClose: () => void;
}) {
if (event.eventType === 'client_message' || event.eventType === 'server_message') {
const title = `Message ${event.eventType === 'client_message' ? 'Sent' : 'Received'}`;
if (event.eventType === "client_message" || event.eventType === "server_message") {
const title = `Message ${event.eventType === "client_message" ? "Sent" : "Received"}`;
return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
@@ -192,7 +190,7 @@ function GrpcEventDetail({
) : (
<Editor
language="json"
defaultValue={event.content ?? ''}
defaultValue={event.content ?? ""}
wrapLines={false}
readOnly={true}
stateKey={null}
@@ -214,7 +212,7 @@ function GrpcEventDetail({
<div className="py-2 h-full">
{Object.keys(event.metadata).length === 0 ? (
<EmptyStateText>
No {event.eventType === 'connection_end' ? 'trailers' : 'metadata'}
No {event.eventType === "connection_end" ? "trailers" : "metadata"}
</EmptyStateText>
) : (
<KeyValueRows>
@@ -231,20 +229,20 @@ function GrpcEventDetail({
}
function getEventDisplay(
eventType: GrpcEvent['eventType'],
status: GrpcEvent['status'],
): { icon: IconProps['icon']; color: IconProps['color']; title: string } {
if (eventType === 'server_message') {
return { icon: 'arrow_big_down_dash', color: 'info', title: 'Server message' };
eventType: GrpcEvent["eventType"],
status: GrpcEvent["status"],
): { icon: IconProps["icon"]; color: IconProps["color"]; title: string } {
if (eventType === "server_message") {
return { icon: "arrow_big_down_dash", color: "info", title: "Server message" };
}
if (eventType === 'client_message') {
return { icon: 'arrow_big_up_dash', color: 'primary', title: 'Client message' };
if (eventType === "client_message") {
return { icon: "arrow_big_up_dash", color: "primary", title: "Client message" };
}
if (eventType === 'error' || (status != null && status > 0)) {
return { icon: 'alert_triangle', color: 'danger', title: 'Error' };
if (eventType === "error" || (status != null && status > 0)) {
return { icon: "alert_triangle", color: "danger", title: "Error" };
}
if (eventType === 'connection_end') {
return { icon: 'check', color: 'success', title: 'Connection response' };
if (eventType === "connection_end") {
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 { GenericCompletionOption } from '@yaakapp-internal/plugins';
import { charsets } from '../lib/data/charsets';
import { connections } from '../lib/data/connections';
import { encodings } from '../lib/data/encodings';
import { headerNames } from '../lib/data/headerNames';
import { mimeTypes } from '../lib/data/mimetypes';
import { CountBadge } from './core/CountBadge';
import { DetailsBanner } from './core/DetailsBanner';
import type { GenericCompletionConfig } from './core/Editor/genericCompletion';
import type { InputProps } from './core/Input';
import type { Pair, PairEditorProps } from './core/PairEditor';
import { PairEditorRow } from './core/PairEditor';
import { ensurePairId } from './core/PairEditor.util';
import { PairOrBulkEditor } from './core/PairOrBulkEditor';
import { HStack } from './core/Stacks';
import type { HttpRequestHeader } from "@yaakapp-internal/models";
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
import { HStack } from "@yaakapp-internal/ui";
import { charsets } from "../lib/data/charsets";
import { connections } from "../lib/data/connections";
import { encodings } from "../lib/data/encodings";
import { headerNames } from "../lib/data/headerNames";
import { mimeTypes } from "../lib/data/mimetypes";
import { CountBadge } from "./core/CountBadge";
import { DetailsBanner } from "./core/DetailsBanner";
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
import type { InputProps } from "./core/Input";
import type { Pair, PairEditorProps } from "./core/PairEditor";
import { PairEditorRow } from "./core/PairEditor";
import { ensurePairId } from "./core/PairEditor.util";
import { PairOrBulkEditor } from "./core/PairOrBulkEditor";
type Props = {
forceUpdateKey: string;
@@ -29,7 +29,7 @@ export function HeadersEditor({
stateKey,
headers,
inheritedHeaders,
inheritedHeadersLabel = 'Inherited',
inheritedHeadersLabel = "Inherited",
onChange,
forceUpdateKey,
}: Props) {
@@ -41,15 +41,17 @@ export function HeadersEditor({
const validInheritedHeaders =
inheritedHeaders?.filter(
(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;
return (
<div
className={
hasInheritedHeaders
? '@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 grid grid-rows-[auto_minmax(0,1fr)] gap-y-1.5"
: "@container w-full h-full"
}
>
{hasInheritedHeaders && (
@@ -104,28 +106,28 @@ export function HeadersEditor({
const MIN_MATCH = 3;
const headerOptionsMap: Record<string, string[]> = {
'content-type': mimeTypes,
accept: ['*/*', ...mimeTypes],
'accept-encoding': encodings,
"content-type": mimeTypes,
accept: ["*/*", ...mimeTypes],
"accept-encoding": encodings,
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();
if (
name.includes('authorization') ||
name.includes('api-key') ||
name.includes('access-token') ||
name.includes('auth') ||
name.includes('secret') ||
name.includes('token') ||
name === 'cookie' ||
name === 'set-cookie'
name.includes("authorization") ||
name.includes("api-key") ||
name.includes("access-token") ||
name.includes("auth") ||
name.includes("secret") ||
name.includes("token") ||
name === "cookie" ||
name === "set-cookie"
) {
return 'password';
return "password";
}
return 'text';
return "text";
};
const valueAutocomplete = (headerName: string): GenericCompletionConfig | undefined => {
@@ -133,19 +135,19 @@ const valueAutocomplete = (headerName: string): GenericCompletionConfig | undefi
const options: GenericCompletionOption[] =
headerOptionsMap[name]?.map((o) => ({
label: o,
type: 'constant',
type: "constant",
boost: 1, // Put above other completions
})) ?? [];
return { minMatch: MIN_MATCH, options };
};
const nameAutocomplete: PairEditorProps['nameAutocomplete'] = {
const nameAutocomplete: PairEditorProps["nameAutocomplete"] = {
minMatch: MIN_MATCH,
options: headerNames.map((t) =>
typeof t === 'string'
typeof t === "string"
? {
label: t,
type: 'constant',
type: "constant",
boost: 1, // Put above other completions
}
: {
@@ -156,11 +158,11 @@ const nameAutocomplete: PairEditorProps['nameAutocomplete'] = {
};
const validateHttpHeader = (v: string) => {
if (v === '') {
if (v === "") {
return true;
}
// 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;
};

View File

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

View File

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

View File

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

View File

@@ -1,49 +1,46 @@
import type { HttpResponse, HttpResponseEvent } from '@yaakapp-internal/models';
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 { Banner } from './core/Banner';
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 { Icon } from './core/Icon';
import { LoadingIcon } from './core/LoadingIcon';
import { PillButton } from './core/PillButton';
import { SizeTag } from './core/SizeTag';
import { HStack, VStack } from './core/Stacks';
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';
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 })),
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
);
interface Props {
@@ -52,13 +49,13 @@ interface Props {
activeRequestId: string;
}
const TAB_BODY = 'body';
const TAB_REQUEST = 'request';
const TAB_HEADERS = 'headers';
const TAB_COOKIES = 'cookies';
const TAB_TIMELINE = 'timeline';
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';
export type TimelineViewMode = "timeline" | "text";
interface RedirectDropWarning {
droppedBodyCount: number;
@@ -78,7 +75,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
[responseEvents.data],
);
const shouldShowRedirectDropWarning =
activeResponse?.state === 'closed' && redirectDropWarning != null;
activeResponse?.state === "closed" && redirectDropWarning != null;
const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]);
@@ -86,27 +83,27 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
() => [
{
value: TAB_BODY,
label: 'Response',
label: "Response",
options: {
value: viewMode,
onChange: setViewMode,
items: [
{ label: 'Response', value: 'pretty' },
...(mimeType?.startsWith('image')
{ label: "Response", value: "pretty" },
...(mimeType?.startsWith("image")
? []
: [{ label: 'Response (Raw)', shortLabel: 'Raw', value: 'raw' }]),
: [{ label: "Response (Raw)", shortLabel: "Raw", value: "raw" }]),
],
},
},
{
value: TAB_REQUEST,
label: 'Request',
label: "Request",
rightSlot:
(activeResponse?.requestContentLength ?? 0) > 0 ? <CountBadge count={true} /> : null,
},
{
value: TAB_HEADERS,
label: 'Headers',
label: "Headers",
rightSlot: (
<CountBadge
count={activeResponse?.requestHeaders.length ?? 0}
@@ -117,7 +114,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
},
{
value: TAB_COOKIES,
label: 'Cookies',
label: "Cookies",
rightSlot:
cookieCounts.sent > 0 || cookieCounts.received > 0 ? (
<CountBadge count={cookieCounts.sent} count2={cookieCounts.received} showZero />
@@ -128,10 +125,10 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
rightSlot: <CountBadge count={responseEvents.data?.length ?? 0} />,
options: {
value: timelineViewMode,
onChange: (v) => setTimelineViewMode((v as TimelineViewMode) ?? 'timeline'),
onChange: (v) => setTimelineViewMode((v as TimelineViewMode) ?? "timeline"),
items: [
{ label: 'Timeline', value: 'timeline' },
{ label: 'Timeline (Text)', shortLabel: 'Timeline', value: 'text' },
{ label: "Timeline", value: "timeline" },
{ label: "Timeline (Text)", shortLabel: "Timeline", value: "text" },
],
},
},
@@ -158,33 +155,33 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
style={style}
className={classNames(
className,
'x-theme-responsePane',
'max-h-full h-full',
'bg-surface rounded-md border border-border-subtle overflow-hidden',
'relative',
"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']} />
<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',
"text-text-subtle w-full flex-shrink-0",
// Remove a bit of space because the tabs have lots too
'-mb-1.5',
"-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',
"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" />}
{activeResponse.state !== "closed" && <LoadingIcon size="sm" />}
<HttpStatusTag showReason response={activeResponse} />
<span>&bull;</span>
<HttpResponseDurationTag response={activeResponse} />
@@ -205,17 +202,17 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
</span>
{redirectDropWarning.droppedBodyCount > 0 && (
<span>
Body dropped on {redirectDropWarning.droppedBodyCount}{' '}
Body dropped on {redirectDropWarning.droppedBodyCount}{" "}
{redirectDropWarning.droppedBodyCount === 1
? 'redirect hop'
: 'redirect hops'}
? "redirect hop"
: "redirect hops"}
</span>
)}
{redirectDropWarning.droppedHeaders.length > 0 && (
<span>
Headers dropped:{' '}
Headers dropped:{" "}
<span className="font-mono">
{redirectDropWarning.droppedHeaders.join(', ')}
{redirectDropWarning.droppedHeaders.join(", ")}
</span>
</span>
)}
@@ -269,7 +266,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
<ErrorBoundary name="Http Response Viewer">
<Suspense>
<ConfirmLargeResponse response={activeResponse}>
{activeResponse.state === 'initialized' ? (
{activeResponse.state === "initialized" ? (
<EmptyStateText>
<VStack space={3}>
<HStack space={3}>
@@ -281,10 +278,10 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
</Button>
</VStack>
</EmptyStateText>
) : activeResponse.state === 'closed' &&
) : activeResponse.state === "closed" &&
(activeResponse.contentLength ?? 0) === 0 ? (
<EmptyStateText>Empty</EmptyStateText>
) : mimeType?.match(/^text\/event-stream/i) && viewMode === 'pretty' ? (
) : mimeType?.match(/^text\/event-stream/i) && viewMode === "pretty" ? (
<EventStreamViewer response={activeResponse} />
) : mimeType?.match(/^image\/svg/) ? (
<HttpSvgViewer response={activeResponse} />
@@ -294,17 +291,17 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
<EnsureCompleteResponse response={activeResponse} Component={AudioViewer} />
) : mimeType?.match(/^video/i) ? (
<EnsureCompleteResponse response={activeResponse} Component={VideoViewer} />
) : mimeType?.match(/^multipart/i) && viewMode === 'pretty' ? (
) : 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' ? (
) : 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'}
pretty={viewMode === "pretty"}
/>
)}
</ConfirmLargeResponse>
@@ -342,7 +339,7 @@ function getRedirectDropWarning(
const droppedHeaders = new Set<string>();
for (const e of events) {
const event = e.event;
if (event.type !== 'redirect') {
if (event.type !== "redirect") {
continue;
}
@@ -373,12 +370,12 @@ function pushHeaderName(headers: Set<string>, headerName: string): void {
function getRedirectWarningLabel(warning: RedirectDropWarning): string {
if (warning.droppedBodyCount > 0 && warning.droppedHeaders.length > 0) {
return 'Dropped body and headers';
return "Dropped body and headers";
}
if (warning.droppedBodyCount > 0) {
return 'Dropped body';
return "Dropped body";
}
return 'Dropped headers';
return "Dropped headers";
}
function EnsureCompleteResponse({
@@ -393,7 +390,7 @@ function EnsureCompleteResponse({
}
// Wait until the response has been fully-downloaded
if (response.state !== 'closed') {
if (response.state !== "closed") {
return (
<EmptyStateText>
<LoadingIcon />
@@ -424,7 +421,7 @@ function HttpMultipartViewer({ response }: { response: HttpResponse }) {
if (body.data == null) return null;
const contentTypeHeader = getContentTypeFromHeaders(response.headers);
const boundary = contentTypeHeader?.split('boundary=')[1] ?? 'unknown';
const boundary = contentTypeHeader?.split("boundary=")[1] ?? "unknown";
return <MultipartViewer data={body.data} boundary={boundary} idPrefix={response.id} />;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,22 +1,22 @@
import { linter } from '@codemirror/lint';
import type { HttpRequest } from '@yaakapp-internal/models';
import { patchModel } from '@yaakapp-internal/models';
import { useCallback, useMemo } from 'react';
import { useKeyValue } from '../hooks/useKeyValue';
import { textLikelyContainsJsonComments } from '../lib/jsonComments';
import { Banner } from './core/Banner';
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 { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { IconTooltip } from './core/IconTooltip';
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'];
heightMode: EditorProps["heightMode"];
request: HttpRequest;
}
@@ -41,13 +41,13 @@ export function JsonBodyEditor({ forceUpdateKey, heightMode, request }: Props) {
);
const hasComments = useMemo(
() => textLikelyContainsJsonComments(request.body?.text ?? ''),
() => textLikelyContainsJsonComments(request.body?.text ?? ""),
[request.body?.text],
);
const { value: bannerDismissed, set: setBannerDismissed } = useKeyValue<boolean>({
namespace: 'no_sync',
key: ['json-fix-3', request.workspaceId],
namespace: "no_sync",
key: ["json-fix-3", request.workspaceId],
fallback: false,
});
@@ -58,19 +58,19 @@ export function JsonBodyEditor({ forceUpdateKey, heightMode, request }: Props) {
} else {
delete newBody.sendJsonComments;
}
patchModel(request, { body: newBody });
fireAndForget(patchModel(request, { body: newBody }));
}, [request, autoFix]);
const handleDropdownOpen = useCallback(() => {
if (!bannerDismissed) {
setBannerDismissed(true);
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']>(
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">
@@ -86,12 +86,12 @@ export function JsonBodyEditor({ forceUpdateKey, heightMode, request }: Props) {
items={
[
{
label: 'Automatically Fix JSON',
label: "Automatically Fix JSON",
keepOpenOnSelect: true,
onSelect: handleToggleAutoFix,
rightSlot: <IconTooltip content={stripMessage} />,
leftSlot: (
<Icon icon={autoFix ? 'check_square_checked' : 'check_square_unchecked'} />
<Icon icon={autoFix ? "check_square_checked" : "check_square_unchecked"} />
),
},
] satisfies DropdownItem[]
@@ -111,7 +111,7 @@ export function JsonBodyEditor({ forceUpdateKey, heightMode, request }: Props) {
autocompleteVariables
placeholder="..."
heightMode={heightMode}
defaultValue={`${request.body?.text ?? ''}`}
defaultValue={`${request.body?.text ?? ""}`}
language="json"
onChange={handleChange}
stateKey={`json.${request.id}`}

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import { convertFileSrc } from '@tauri-apps/api/core';
import { resolveResource } from '@tauri-apps/api/path';
import classNames from 'classnames';
import { useQuery } from "@tanstack/react-query";
import { convertFileSrc } from "@tauri-apps/api/core";
import { resolveResource } from "@tauri-apps/api/path";
import classNames from "classnames";
interface Props {
src: string;
@@ -10,7 +10,7 @@ interface Props {
export function LocalImage({ src: srcPath, className }: Props) {
const src = useQuery({
queryKey: ['local-image', srcPath],
queryKey: ["local-image", srcPath],
queryFn: async () => {
const p = await resolveResource(srcPath);
return convertFileSrc(p);
@@ -23,8 +23,8 @@ export function LocalImage({ src: srcPath, className }: Props) {
alt="Response preview"
className={classNames(
className,
'transition-opacity',
src.data == null ? 'opacity-0' : 'opacity-100',
"transition-opacity",
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 { useRef, useState } from 'react';
import type { EditorProps } from './core/Editor/Editor';
import { Editor } from './core/Editor/LazyEditor';
import { SegmentedControl } from './core/SegmentedControl';
import { Markdown } from './Markdown';
import classNames from "classnames";
import { useRef, useState } from "react";
import type { EditorProps } from "./core/Editor/Editor";
import { Editor } from "./core/Editor/LazyEditor";
import { SegmentedControl } from "./core/SegmentedControl";
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;
className?: string;
editorClassName?: string;
@@ -25,7 +25,7 @@ export function MarkdownEditor({
forceUpdateKey,
...editorProps
}: Props) {
const [viewMode, setViewMode] = useState<ViewMode>(defaultValue ? 'preview' : 'edit');
const [viewMode, setViewMode] = useState<ViewMode>(defaultValue ? "preview" : "edit");
const containerRef = useRef<HTMLDivElement>(null);
@@ -33,7 +33,7 @@ export function MarkdownEditor({
<Editor
hideGutter
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"
defaultValue={defaultValue}
onChange={onChange}
@@ -51,15 +51,15 @@ export function MarkdownEditor({
</div>
);
const contents = viewMode === 'preview' ? preview : editor;
const contents = viewMode === "preview" ? preview : editor;
return (
<div
ref={containerRef}
className={classNames(
'group/markdown',
'relative w-full h-full pt-1.5 rounded-md gap-x-1.5',
'min-w-0', // Not sure why this is needed
"group/markdown",
"relative w-full h-full pt-1.5 rounded-md gap-x-1.5",
"min-w-0", // Not sure why this is needed
className,
)}
>
@@ -73,8 +73,8 @@ export function MarkdownEditor({
value={viewMode}
className="opacity-0 group-focus-within/markdown:opacity-100 group-hover/markdown:opacity-100"
options={[
{ icon: 'eye', label: 'Preview mode', value: 'preview' },
{ icon: 'pencil', label: 'Edit mode', value: 'edit' },
{ icon: "eye", label: "Preview mode", value: "preview" },
{ icon: "pencil", label: "Edit mode", value: "edit" },
]}
/>
</div>

View File

@@ -1,15 +1,14 @@
import type { GrpcRequest, HttpRequest, WebsocketRequest } from '@yaakapp-internal/models';
import { patchModel, workspacesAtom } from '@yaakapp-internal/models';
import { useAtomValue } from 'jotai';
import { useState } from 'react';
import { pluralizeCount } from '../lib/pluralize';
import { resolvedModelName } from '../lib/resolvedModelName';
import { router } from '../lib/router';
import { showToast } from '../lib/toast';
import { Button } from './core/Button';
import { InlineCode } from './core/InlineCode';
import { Select } from './core/Select';
import { VStack } from './core/Stacks';
import type { GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-internal/models";
import { patchModel, workspacesAtom } from "@yaakapp-internal/models";
import { InlineCode, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useState } from "react";
import { pluralizeCount } from "../lib/pluralize";
import { resolvedModelName } from "../lib/resolvedModelName";
import { router } from "../lib/router";
import { showToast } from "../lib/toast";
import { Button } from "./core/Button";
import { Select } from "./core/Select";
interface Props {
activeWorkspaceId: string;
@@ -50,17 +49,17 @@ export function MoveToWorkspaceDialog({ onDone, requests, activeWorkspaceId }: P
// Hide after a moment, to give time for requests to disappear
setTimeout(onDone, 100);
showToast({
id: 'workspace-moved',
id: "workspace-moved",
message:
requests.length === 1 && requests[0] != null ? (
<>
<InlineCode>{resolvedModelName(requests[0])}</InlineCode> moved to{' '}
<InlineCode>{targetWorkspace?.name ?? 'unknown'}</InlineCode>
<InlineCode>{resolvedModelName(requests[0])}</InlineCode> moved to{" "}
<InlineCode>{targetWorkspace?.name ?? "unknown"}</InlineCode>
</>
) : (
<>
{pluralizeCount('request', requests.length)} moved to{' '}
<InlineCode>{targetWorkspace?.name ?? 'unknown'}</InlineCode>
{pluralizeCount("request", requests.length)} moved to{" "}
<InlineCode>{targetWorkspace?.name ?? "unknown"}</InlineCode>
</>
),
action: ({ hide }) => (
@@ -70,7 +69,7 @@ export function MoveToWorkspaceDialog({ onDone, requests, activeWorkspaceId }: P
className="mr-auto min-w-[5rem]"
onClick={async () => {
await router.navigate({
to: '/workspaces/$workspaceId',
to: "/workspaces/$workspaceId",
params: { workspaceId: selectedWorkspaceId },
});
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>
</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 { deleteModel } from '@yaakapp-internal/models';
import { formatDistanceToNowStrict } from 'date-fns';
import { useDeleteGrpcConnections } from '../hooks/useDeleteGrpcConnections';
import { pluralizeCount } from '../lib/pluralize';
import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { HStack } from './core/Stacks';
import type { GrpcConnection } from "@yaakapp-internal/models";
import { deleteModel } from "@yaakapp-internal/models";
import { HStack, Icon } from "@yaakapp-internal/ui";
import { formatDistanceToNowStrict } from "date-fns";
import { useDeleteGrpcConnections } from "../hooks/useDeleteGrpcConnections";
import { pluralizeCount } from "../lib/pluralize";
import { Dropdown } from "./core/Dropdown";
import { IconButton } from "./core/IconButton";
interface Props {
connections: GrpcConnection[];
@@ -20,27 +19,27 @@ export function RecentGrpcConnectionsDropdown({
onPinnedConnectionId,
}: Props) {
const deleteAllConnections = useDeleteGrpcConnections(activeConnection?.requestId);
const latestConnectionId = connections[0]?.id ?? 'n/a';
const latestConnectionId = connections[0]?.id ?? "n/a";
return (
<Dropdown
items={[
{
label: 'Clear Connection',
label: "Clear Connection",
onSelect: () => deleteModel(activeConnection),
disabled: connections.length === 0,
},
{
label: `Clear ${pluralizeCount('Connection', connections.length)}`,
label: `Clear ${pluralizeCount("Connection", connections.length)}`,
onSelect: deleteAllConnections.mutate,
hidden: connections.length <= 1,
disabled: connections.length === 0,
},
{ type: 'separator', label: 'History' },
{ type: "separator", label: "History" },
...connections.map((c) => ({
label: (
<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>
</HStack>
),
@@ -51,7 +50,7 @@ export function RecentGrpcConnectionsDropdown({
>
<IconButton
title="Show connection history"
icon={activeConnection?.id === latestConnectionId ? 'history' : 'pin'}
icon={activeConnection?.id === latestConnectionId ? "history" : "pin"}
className="m-0.5 text-text-subtle"
size="sm"
iconSize="md"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,16 +1,13 @@
import { Button } from './core/Button';
import { DetailsBanner } from './core/DetailsBanner';
import { FormattedError } from './core/FormattedError';
import { Heading } from './core/Heading';
import { VStack } from './core/Stacks';
import { Button, FormattedError, Heading, VStack } from "@yaakapp-internal/ui";
import { DetailsBanner } from "./core/DetailsBanner";
export default function RouteError({ error }: { error: unknown }) {
console.log('Error', error);
console.log("Error", error);
const stringified = JSON.stringify(error);
// biome-ignore lint/suspicious/noExplicitAny: none
// oxlint-disable-next-line no-explicit-any -- none
const message = (error as any).message ?? stringified;
const stack =
typeof error === 'object' && error != null && 'stack' in error ? String(error.stack) : null;
typeof error === "object" && error != null && "stack" in error ? String(error.stack) : null;
return (
<div className="flex items-center justify-center h-full">
<VStack space={5} className="w-[50rem] !h-auto">
@@ -31,7 +28,7 @@ export default function RouteError({ error }: { error: unknown }) {
<Button
color="primary"
onClick={async () => {
window.location.assign('/');
window.location.assign("/");
}}
>
Go Home

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