Compare commits

..

10 Commits

Author SHA1 Message Date
Gregory Schier b9b90188a0 Revert "plugin-events: move model/find handlers to shared path"
This reverts commit 072b486857.
2026-02-26 10:13:36 -08:00
Gregory Schier e64404d7a5 plugins: keep dev workspace plugins in watch mode 2026-02-26 09:53:58 -08:00
Gregory Schier 072b486857 plugin-events: move model/find handlers to shared path 2026-02-26 09:51:17 -08:00
Gregory Schier 23e9cbb376 cli: inline cwd plugins resolver 2026-02-26 09:22:54 -08:00
Gregory Schier 9c09e32a56 cli: only use cwd plugins in debug builds 2026-02-26 09:19:59 -08:00
Gregory Schier be26cc4db4 cli: prefer cwd plugins dir when present 2026-02-26 09:19:18 -08:00
Gregory Schier a2f12aef35 cli: use explicit flag for workspace plugin dir 2026-02-26 09:17:11 -08:00
Gregory Schier e34301ccab plugins: have callers provide bundled plugin dir 2026-02-26 08:36:42 -08:00
Gregory Schier 8f0062f917 cli: avoid tauri dev path logic in plugin manager 2026-02-26 08:30:35 -08:00
Gregory Schier 68d68035a1 cli: use workspace plugins dir in dev mode 2026-02-26 08:22:44 -08:00
1161 changed files with 22756 additions and 36731 deletions
+7 -18
View File
@@ -1,27 +1,23 @@
# 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-client, yaak-tauri-utils, etc.)
crates-tauri/ # Tauri-specific crates (yaak-app, 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-client/`
- Moved Tauri-dependent app code to `crates-tauri/yaak-app/`
- 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)
@@ -29,7 +25,6 @@ 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()`
@@ -37,36 +32,31 @@ 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-client/capabilities/default.json` to remove the plugin permission
6. Update `crates-tauri/yaak-app/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-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-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-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
@@ -77,7 +67,6 @@ 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 client:dev` to test the Tauri app still works
- Run `npm run app-dev` to test the Tauri app still works
- Run `cargo run -p yaak-cli -- --help` to test the CLI
+62
View File
@@ -0,0 +1,62 @@
---
description: Review a PR in a new worktree
allowed-tools: Bash(git worktree:*), Bash(gh pr:*), Bash(git branch:*)
---
Check out a GitHub pull request for review.
## Usage
```
/check-out-pr <PR_NUMBER>
```
## What to do
1. If no PR number is provided, list all open pull requests and ask the user to select one
2. Get PR information using `gh pr view <PR_NUMBER> --json number,headRefName`
3. **Ask the user** whether they want to:
- **A) Check out in current directory** — simple `gh pr checkout <PR_NUMBER>`
- **B) Create a new worktree** — isolated copy at `../yaak-worktrees/pr-<PR_NUMBER>`
4. Follow the appropriate path below
## Option A: Check out in current directory
1. Run `gh pr checkout <PR_NUMBER>`
2. Inform the user which branch they're now on
## Option B: Create a new worktree
1. Create a new worktree at `../yaak-worktrees/pr-<PR_NUMBER>` using `git worktree add` with a timeout of at least 300000ms (5 minutes) since the post-checkout hook runs a bootstrap script
2. Checkout the PR branch in the new worktree using `gh pr checkout <PR_NUMBER>`
3. The post-checkout hook will automatically:
- Create `.env.local` with unique ports
- Copy editor config folders
- Run `npm install && npm run bootstrap`
4. Inform the user:
- Where the worktree was created
- What ports were assigned
- How to access it (cd command)
- How to run the dev server
- How to remove the worktree when done
### Example worktree output
```
Created worktree for PR #123 at ../yaak-worktrees/pr-123
Branch: feature-auth
Ports: Vite (1421), MCP (64344)
To start working:
cd ../yaak-worktrees/pr-123
npm run app-dev
To remove when done:
git worktree remove ../yaak-worktrees/pr-123
```
## Error Handling
- If the PR doesn't exist, show a helpful error
- If the worktree already exists, inform the user and ask if they want to remove and recreate it
- If `gh` CLI is not available, inform the user to install it
@@ -37,7 +37,6 @@ The skill generates markdown-formatted release notes following this structure:
**IMPORTANT**: Always add a blank lines around the markdown code fence and output the markdown code block last
**IMPORTANT**: PRs by `@gschier` should not mention the @username
**IMPORTANT**: These are app release notes. Exclude CLI-only changes (commits prefixed with `cli:` or only touching `crates-cli/`) since the CLI has its own release process.
## After Generating Release Notes
+35
View File
@@ -0,0 +1,35 @@
# Worktree Management Skill
## Creating Worktrees
When creating git worktrees for this project, ALWAYS use the path format:
```
../yaak-worktrees/<NAME>
```
For example:
- `git worktree add ../yaak-worktrees/feature-auth`
- `git worktree add ../yaak-worktrees/bugfix-login`
- `git worktree add ../yaak-worktrees/refactor-api`
## What Happens Automatically
The post-checkout hook will automatically:
1. Create `.env.local` with unique ports (YAAK_DEV_PORT and YAAK_PLUGIN_MCP_SERVER_PORT)
2. Copy gitignored editor config folders (.zed, .idea, etc.)
3. Run `npm install && npm run bootstrap`
## Deleting Worktrees
```bash
git worktree remove ../yaak-worktrees/<NAME>
```
## Port Assignments
- Main worktree: 1420 (Vite), 64343 (MCP)
- First worktree: 1421, 64344
- Second worktree: 1422, 64345
- etc.
Each worktree can run `npm run app-dev` simultaneously without conflicts.
@@ -0,0 +1,46 @@
---
name: release-check-out-pr
description: Check out a GitHub pull request for review in this repo, either in the current directory or in a new isolated worktree at ../yaak-worktrees/pr-<PR_NUMBER>. Use when asked to run or replace the old Claude check-out-pr command.
---
# Check Out PR
Check out a PR by number and let the user choose between current-directory checkout and isolated worktree checkout.
## Workflow
1. Confirm `gh` CLI is available.
2. If no PR number is provided, list open PRs (`gh pr list`) and ask the user to choose one.
3. Read PR metadata:
- `gh pr view <PR_NUMBER> --json number,headRefName`
4. Ask the user to choose:
- Option A: check out in the current directory
- Option B: create a new worktree at `../yaak-worktrees/pr-<PR_NUMBER>`
## Option A: Current Directory
1. Run:
- `gh pr checkout <PR_NUMBER>`
2. Report the checked-out branch.
## Option B: New Worktree
1. Use path:
- `../yaak-worktrees/pr-<PR_NUMBER>`
2. Create the worktree with a timeout of at least 5 minutes because checkout hooks run bootstrap.
3. In the new worktree, run:
- `gh pr checkout <PR_NUMBER>`
4. Report:
- Worktree path
- Assigned ports from `.env.local` if present
- How to start work:
- `cd ../yaak-worktrees/pr-<PR_NUMBER>`
- `npm run app-dev`
- How to remove when done:
- `git worktree remove ../yaak-worktrees/pr-<PR_NUMBER>`
## Error Handling
- If PR does not exist, show a clear error.
- If worktree already exists, ask whether to reuse it or remove/recreate it.
- If `gh` is missing, instruct the user to install/authenticate it.
@@ -32,7 +32,6 @@ Generate formatted markdown release notes for a Yaak tag.
- Keep a blank line before and after the code fence.
- Output the markdown code block last.
- Do not append `by @gschier` for PRs authored by `@gschier`.
- These are app release notes. Exclude CLI-only changes (commits prefixed with `cli:` or only touching `crates-cli/`) since the CLI has its own release process.
## Release Creation Prompt
@@ -0,0 +1,37 @@
---
name: worktree-management
description: Manage Yaak git worktrees using the standard ../yaak-worktrees/<NAME> layout, including creation, removal, and expected automatic setup behavior and port assignments.
---
# Worktree Management
Use the Yaak-standard worktree path layout and lifecycle commands.
## Path Convention
Always create worktrees under:
`../yaak-worktrees/<NAME>`
Examples:
- `git worktree add ../yaak-worktrees/feature-auth`
- `git worktree add ../yaak-worktrees/bugfix-login`
- `git worktree add ../yaak-worktrees/refactor-api`
## Automatic Setup After Checkout
Project git hooks automatically:
1. Create `.env.local` with unique `YAAK_DEV_PORT` and `YAAK_PLUGIN_MCP_SERVER_PORT`
2. Copy gitignored editor config folders
3. Run `npm install && npm run bootstrap`
## Remove Worktree
`git worktree remove ../yaak-worktrees/<NAME>`
## Port Pattern
- Main worktree: Vite `1420`, MCP `64343`
- First extra worktree: `1421`, `64344`
- Second extra worktree: `1422`, `64345`
- Continue incrementally for additional worktrees
+2 -2
View File
@@ -1,5 +1,5 @@
crates-tauri/yaak-app-client/vendored/**/* linguist-generated=true
crates-tauri/yaak-app-client/gen/schemas/**/* linguist-generated=true
crates-tauri/yaak-app/vendored/**/* linguist-generated=true
crates-tauri/yaak-app/gen/schemas/**/* linguist-generated=true
**/bindings/* linguist-generated=true
crates/yaak-templates/pkg/* linguist-generated=true
+11 -13
View File
@@ -1,9 +1,10 @@
---
name: Bug report
about: Create a report to help us improve
title: ""
labels: ""
assignees: ""
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
@@ -11,7 +12,6 @@ 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,17 +24,15 @@ 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.
-1
View File
@@ -11,7 +11,6 @@
- [ ] 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
+3 -6
View File
@@ -14,20 +14,17 @@ jobs:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: voidzero-dev/setup-vp@v1
with:
node-version: "24"
cache: true
- uses: actions/setup-node@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
shared-key: ci
cache-on-failure: true
- run: vp install
- run: npm ci
- run: npm run bootstrap
- run: npm run lint
- name: Run JS Tests
run: vp test
run: npm test
- name: Run Rust Tests
run: cargo test --all
+1
View File
@@ -47,3 +47,4 @@ 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:*)'
+9 -15
View File
@@ -50,11 +50,8 @@ jobs:
- name: Checkout yaakapp/app
uses: actions/checkout@v4
- name: Setup Vite+
uses: voidzero-dev/setup-vp@v1
with:
node-version: "24"
cache: true
- name: Setup Node
uses: actions/setup-node@v4
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
@@ -90,15 +87,15 @@ jobs:
echo $dir >> $env:GITHUB_PATH
& $exe --version
- run: vp install
- run: npm ci
- run: npm run bootstrap
env:
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
- run: npm run lint
- name: Run JS Tests
run: vp test
run: npm test
- name: Run Rust Tests
run: cargo test --all --exclude yaak-cli
run: cargo test --all
- name: Set version
run: npm run replace-version
@@ -125,8 +122,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-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
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
- uses: tauri-apps/tauri-action@v0
env:
@@ -155,8 +152,7 @@ jobs:
releaseBody: "[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)"
releaseDraft: true
prerelease: true
projectPath: ./crates-tauri/yaak-app-client
args: "${{ matrix.args }} --config ./tauri.release.conf.json"
args: "${{ matrix.args }} --config ./crates-tauri/yaak-app/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)
@@ -172,9 +168,7 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
run: |
Get-ChildItem -Recurse -Path target -File -Filter "*.exe.sig" | Remove-Item -Force
Push-Location crates-tauri/yaak-app-client
npx tauri bundle ${{ matrix.args }} --bundles nsis --config ./tauri.release.conf.json --config '{"bundle":{"createUpdaterArtifacts":true,"windows":{"nsis":{"installMode":"perMachine"}}}}'
Pop-Location
npx tauri bundle ${{ matrix.args }} --bundles nsis --config ./crates-tauri/yaak-app/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'
+3 -3
View File
@@ -45,8 +45,8 @@ jobs:
with:
name: vendored-assets
path: |
crates-tauri/yaak-app-client/vendored/plugin-runtime/index.cjs
crates-tauri/yaak-app-client/vendored/plugins
crates-tauri/yaak-app/vendored/plugin-runtime/index.cjs
crates-tauri/yaak-app/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-client/vendored
path: crates-tauri/yaak-app/vendored
- name: Set CLI build version
shell: bash
+5 -5
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: '.'
+1 -5
View File
@@ -39,8 +39,7 @@ codebook.toml
target
# Per-worktree Tauri config (generated by post-checkout hook)
crates-tauri/yaak-app-client/tauri.worktree.conf.json
crates-tauri/yaak-app-proxy/tauri.worktree.conf.json
crates-tauri/yaak-app/tauri.worktree.conf.json
# Tauri auto-generated permission files
**/permissions/autogenerated
@@ -55,6 +54,3 @@ flatpak/node-sources.json
# Local Codex desktop env state
.codex/environments/environment.toml
# Claude Code local settings
.claude/settings.local.json
-1
View File
@@ -1 +0,0 @@
24.14.0
-2
View File
@@ -1,2 +0,0 @@
# vite-plugin-wasm has not yet declared Vite 8 in its peerDependencies
legacy-peer-deps=true
+1
View File
@@ -0,0 +1 @@
20
-3
View File
@@ -1,3 +0,0 @@
**/bindings/**
**/routeTree.gen.ts
crates/yaak-templates/pkg/**
-8
View File
@@ -1,8 +0,0 @@
{
"printWidth": 100,
"ignorePatterns": [
"**/bindings/**",
"crates/yaak-templates/pkg/**",
"apps/yaak-client/routeTree.gen.ts"
]
}
-2
View File
@@ -1,2 +0,0 @@
vp lint
vp staged
+1 -5
View File
@@ -1,7 +1,3 @@
{
"recommendations": [
"rust-lang.rust-analyzer",
"bradlc.vscode-tailwindcss",
"VoidZero.vite-plus-extension-pack"
]
"recommendations": ["biomejs.biome", "rust-lang.rust-analyzer", "bradlc.vscode-tailwindcss"]
}
+3 -5
View File
@@ -1,8 +1,6 @@
{
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true,
"editor.formatOnSaveMode": "file",
"editor.codeActionsOnSave": {
"source.fixAll.oxc": "explicit"
}
"biome.enabled": true,
"biome.lint.format.enable": true
}
-2
View File
@@ -1,2 +0,0 @@
- Tag safety: app releases use `v*` tags and CLI releases use `yaak-cli-*` tags; always confirm which one is requested before retagging.
- Do not commit, push, or tag without explicit approval
Generated
+677 -772
View File
File diff suppressed because it is too large Load Diff
+5 -22
View File
@@ -2,9 +2,6 @@
resolver = "2"
members = [
"crates/yaak",
# Common/foundation crates
"crates/common/yaak-database",
"crates/common/yaak-rpc",
# Shared crates (no Tauri dependency)
"crates/yaak-core",
"crates/yaak-common",
@@ -20,19 +17,14 @@ members = [
"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-app",
"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,18 +39,14 @@ schemars = { version = "0.8.22", features = ["chrono"] }
serde = "1.0.228"
serde_json = "1.0.145"
sha2 = "0.10.9"
tauri = "2.11.1"
tauri-plugin = "2.6.1"
tauri-plugin-dialog = "2.7.1"
tauri-plugin-shell = "2.3.5"
tauri = "2.9.5"
tauri-plugin = "2.5.2"
tauri-plugin-dialog = "2.4.2"
tauri-plugin-shell = "2.3.3"
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" }
@@ -75,17 +63,12 @@ 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
+11 -9
View File
@@ -11,16 +11,14 @@ begin.
Make sure you have the following tools installed:
- [Node.js](https://nodejs.org/en/download/package-manager) (v24+)
- [Node.js](https://nodejs.org/en/download/package-manager)
- [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
```
@@ -63,9 +61,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 and Formatting
## Linting & Formatting
This repo uses [Vite+](https://vite.dev/guide/vite-plus) for linting (oxlint) and formatting (oxfmt).
This repo uses Biome for linting and formatting (replacing ESLint + Prettier).
- Lint the entire repo:
@@ -73,6 +71,12 @@ This repo uses [Vite+](https://vite.dev/guide/vite-plus) for linting (oxlint) an
npm run lint
```
- Auto-fix lint issues where possible:
```sh
npm run lint:fix
```
- Format code:
```sh
@@ -80,7 +84,5 @@ npm run format
```
Notes:
- 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.
- 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.
+6 -4
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-client/icons/icon.png">
<img width="200px" src="https://github.com/mountain-loop/yaak/raw/main/crates-tauri/yaak-app/icons/icon.png">
</a>
</p>
@@ -16,6 +16,8 @@
</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>
@@ -25,11 +27,13 @@
![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.
### 🌐 Work with any API
- Import collections from Postman, Insomnia, OpenAPI, Swagger, or Curl.
@@ -37,23 +41,21 @@ 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.
- 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]
@@ -1,17 +0,0 @@
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} />,
});
}
@@ -1,27 +0,0 @@
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 },
});
},
});
@@ -1,10 +0,0 @@
import type { WorkspaceSettingsTab } from "../components/WorkspaceSettingsDialog";
import { WorkspaceSettingsDialog } from "../components/WorkspaceSettingsDialog";
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
import { jotaiStore } from "../lib/jotai";
export function openWorkspaceSettings(tab?: WorkspaceSettingsTab) {
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
if (workspaceId == null) return;
WorkspaceSettingsDialog.show(workspaceId, tab);
}
@@ -1,727 +0,0 @@
import type { Cookie } from "@yaakapp-internal/models";
import { cookieJarsAtom, patchModel } from "@yaakapp-internal/models";
import { formatDate } from "date-fns/format";
import { useAtomValue } from "jotai";
import {
type ComponentProps,
type CSSProperties,
type FormEvent,
type ReactNode,
type RefObject,
useMemo,
useRef,
useState,
} from "react";
import { showDialog } from "../lib/dialog";
import { jotaiStore } from "../lib/jotai";
import { cookieDomain } from "../lib/model_util";
import {
Icon,
SplitLayout,
Table,
TableBody,
TableCell,
TableHead,
TableHeaderCell,
TableRow,
TruncatedWideTableCell,
} from "@yaakapp-internal/ui";
import { IconButton } from "./core/IconButton";
import { Checkbox } from "./core/Checkbox";
import classNames from "classnames";
import { EventDetailHeader } from "./core/EventViewer";
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
import { EmptyStateText } from "./EmptyStateText";
import { PlainInput } from "./core/PlainInput";
import { Select } from "./core/Select";
import { showAlert } from "../lib/alert";
interface Props {
cookieJarId: string | null;
}
export const CookieDialog = ({ cookieJarId }: Props) => {
const cookieJars = useAtomValue(cookieJarsAtom);
const cookieJar = cookieJars?.find((c) => c.id === cookieJarId);
const [filter, setFilter] = useState("");
const [filterUpdateKey, setFilterUpdateKey] = useState(0);
const [selectedCookieKey, setSelectedCookieKey] = useState<string | null>(null);
const [editingCookieKey, setEditingCookieKey] = useState<string | null>(null);
const [draftCookie, setDraftCookie] = useState<Cookie | null>(null);
const [draftExpiresInput, setDraftExpiresInput] = useState("");
const editorFormRef = useRef<HTMLFormElement>(null);
const filteredCookies = useMemo(() => {
return cookieJar?.cookies.filter((cookie) => cookieMatchesFilter(cookie, filter)) ?? [];
}, [cookieJar?.cookies, filter]);
const selectedCookie = useMemo(
() =>
selectedCookieKey == null
? null
: (filteredCookies.find((cookie) => cookieKey(cookie) === selectedCookieKey) ?? null),
[filteredCookies, selectedCookieKey],
);
const detailCookie = draftCookie ?? selectedCookie;
const isCreatingCookie = editingCookieKey === NEW_COOKIE_KEY;
const isEditingCookie = draftCookie != null;
const handleAddCookie = () => {
setSelectedCookieKey(null);
setEditingCookieKey(NEW_COOKIE_KEY);
setDraftCookie(newCookieDraft());
setDraftExpiresInput("");
};
const handleEditCookie = () => {
if (selectedCookie == null) {
return;
}
setEditingCookieKey(cookieKey(selectedCookie));
setDraftCookie(selectedCookie);
setDraftExpiresInput(cookieExpiresInputValue(selectedCookie));
};
const handleCancelEdit = () => {
if (isCreatingCookie) {
setSelectedCookieKey(null);
}
setEditingCookieKey(null);
setDraftCookie(null);
setDraftExpiresInput("");
};
const handleCloseDetails = () => {
if (isEditingCookie) {
handleCancelEdit();
return;
}
setSelectedCookieKey(null);
};
const handleSaveCookie = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (cookieJar == null || draftCookie == null) {
return;
}
let nextCookie = normalizeCookie(draftCookie);
if (nextCookie.expires !== "SessionEnd") {
const expires = cookieExpiresFromInput(draftExpiresInput);
if (expires == null) {
showAlert({
id: "invalid-cookie-expires",
title: "Invalid Cookie",
body: "Cookie expiration must be a valid date.",
});
return;
}
nextCookie = { ...nextCookie, expires };
}
const nextCookieKey = cookieKey(nextCookie);
const nextCookies = cookieJar.cookies.filter((cookie) => {
const key = cookieKey(cookie);
if (editingCookieKey != null && key === editingCookieKey) {
return false;
}
return key !== nextCookieKey;
});
patchModel(cookieJar, { cookies: [...nextCookies, nextCookie] });
setSelectedCookieKey(nextCookieKey);
setEditingCookieKey(null);
setDraftCookie(null);
setDraftExpiresInput("");
};
if (cookieJar == null) {
return <div>No cookie jar selected</div>;
}
return (
<div className="pb-2 grid grid-rows-[auto_minmax(0,1fr)] space-y-2">
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
<PlainInput
name="cookie-filter"
label="Filter cookies"
hideLabel
placeholder="Filter cookies"
defaultValue={filter}
forceUpdateKey={filterUpdateKey}
onChange={setFilter}
rightSlot={
filter.length > 0 && (
<IconButton
className="!bg-transparent !h-auto min-h-full opacity-50 hover:opacity-100 -mr-1"
icon="x"
title="Clear filter"
onClick={() => {
setFilter("");
setFilterUpdateKey((key) => key + 1);
}}
/>
)
}
/>
<IconButton icon="plus" size="sm" title="Add cookie" onClick={handleAddCookie} />
</div>
{cookieJar.cookies.length === 0 && detailCookie == null ? (
<EmptyStateText>
Cookies will appear when a response includes a Set-Cookie header.
</EmptyStateText>
) : filteredCookies.length === 0 && detailCookie == null ? (
<EmptyStateText>No cookies match the current filter.</EmptyStateText>
) : (
<SplitLayout
layout="vertical"
storageKey="cookie-dialog-details"
defaultRatio={0.5}
className="-mx-2"
minHeightPx={10}
firstSlot={({ style }) =>
filteredCookies.length === 0 ? (
<div style={style}>
<EmptyStateText>No cookies match the current filter.</EmptyStateText>
</div>
) : (
<Table scrollable style={style} className="pr-0.5">
<TableHead>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Value</TableHeaderCell>
<TableHeaderCell>Domain</TableHeaderCell>
<TableHeaderCell>Path</TableHeaderCell>
<TableHeaderCell>Expires</TableHeaderCell>
<TableHeaderCell>Size</TableHeaderCell>
<TableHeaderCell>HTTP Only</TableHeaderCell>
<TableHeaderCell>Secure</TableHeaderCell>
<TableHeaderCell>Same Site</TableHeaderCell>
<TableHeaderCell>
<IconButton
icon="list_x"
size="sm"
className="text-text-subtle"
title="Clear all cookies"
onClick={() => {
setSelectedCookieKey(null);
setEditingCookieKey(null);
setDraftCookie(null);
setDraftExpiresInput("");
patchModel(cookieJar, { cookies: [] });
}}
/>
</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody className="[&_td]:select-auto [&_td]:cursor-auto">
{filteredCookies.map((c: Cookie) => {
const key = cookieKey(c);
const isSelected = key === selectedCookieKey;
return (
<TableRow
key={key}
className={classNames(
"group/tr cursor-default",
isSelected && "[&_td]:bg-surface-highlight",
!isSelected && "hover:[&_td]:bg-surface-hover",
)}
onClick={() => {
setSelectedCookieKey(key);
setEditingCookieKey(null);
setDraftCookie(null);
setDraftExpiresInput("");
}}
>
<TableCell className={classNames("pl-2", isSelected && "rounded-l")}>
{c.name}
</TableCell>
<TruncatedWideTableCell className="min-w-[10rem]">
{c.value}
</TruncatedWideTableCell>
<TableCell>{cookieDomain(c)}</TableCell>
<TableCell>{c.path}</TableCell>
<TableCell>{cookieExpires(c)}</TableCell>
<TableCell>{cookieSize(c)}</TableCell>
<TableCell>
<Icon
icon={c.httpOnly ? "check" : "x"}
className={classNames(!c.httpOnly && "opacity-10")}
/>
</TableCell>
<TableCell>
<Icon
icon={c.secure ? "check" : "x"}
className={classNames(!c.secure && "opacity-10")}
/>
</TableCell>
<TableCell>{c.sameSite}</TableCell>
<TableCell className="rounded-r pr-2">
<IconButton
icon="trash"
size="xs"
iconSize="sm"
title="Delete"
className="text-text-subtlest ml-auto group-hover/tr:text-text transition-colors"
onClick={(event) => {
event.stopPropagation();
if (isSelected) {
setSelectedCookieKey(null);
}
if (editingCookieKey === key) {
setEditingCookieKey(null);
setDraftCookie(null);
setDraftExpiresInput("");
}
patchModel(cookieJar, {
cookies: cookieJar.cookies.filter(
(c2: Cookie) => cookieKey(c2) !== key,
),
});
}}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)
}
secondSlot={
detailCookie == null
? null
: ({ style }) => (
<CookieDetailsPane
formRef={editorFormRef}
isEditing={isEditingCookie}
onSubmit={handleSaveCookie}
style={style}
>
<EventDetailHeader
title={isCreatingCookie ? "New Cookie" : detailCookie.name || "Cookie"}
copyText={isEditingCookie ? undefined : detailCookie.value}
actions={
isEditingCookie
? [
{
key: "save",
label: isCreatingCookie ? "Create" : "Save",
onClick: () => editorFormRef.current?.requestSubmit(),
},
{
key: "cancel",
label: "Cancel",
onClick: handleCancelEdit,
},
]
: [
{
key: "edit",
label: "Edit",
onClick: handleEditCookie,
},
]
}
onClose={handleCloseDetails}
/>
{isEditingCookie ? (
<CookieEditor
cookie={detailCookie}
expiresInputValue={draftExpiresInput}
onChange={setDraftCookie}
onExpiresInputChange={setDraftExpiresInput}
/>
) : (
<CookieDetails cookie={detailCookie} />
)}
</CookieDetailsPane>
)
}
/>
)}
</div>
);
};
function CookieDetailsPane({
children,
formRef,
isEditing,
onSubmit,
style,
}: {
children: ReactNode;
formRef: RefObject<HTMLFormElement | null>;
isEditing: boolean;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
style: CSSProperties;
}) {
const className = "grid grid-rows-[auto_minmax(0,1fr)] bg-surface border-t border-border pt-2";
if (isEditing) {
return (
<form ref={formRef} style={style} className={className} onSubmit={onSubmit}>
{children}
</form>
);
}
return (
<div style={style} className={className}>
{children}
</div>
);
}
CookieDialog.show = (cookieJarId: string | null) => {
const cookieJar = jotaiStore.get(cookieJarsAtom)?.find((jar) => jar.id === cookieJarId);
if (cookieJar == null) {
showAlert({
id: "invalid-jar",
body: `Failed to find cookie jar for ID: ${cookieJarId}`,
title: "Invalid Cookie Jar",
});
return;
}
showDialog({
id: "cookies",
title: `${cookieJar.name} Cookies`,
size: "full",
render: () => <CookieDialog cookieJarId={cookieJarId} />,
});
};
function CookieDetails({ cookie }: { cookie: Cookie }) {
return (
<div className="overflow-y-auto">
<KeyValueRows selectable>
<CookieKeyValueRow label="Name">{cookie.name}</CookieKeyValueRow>
<CookieKeyValueRow label="Value" enableCopy copyText={cookie.value}>
<pre className="whitespace-pre-wrap break-all">{cookie.value}</pre>
</CookieKeyValueRow>
<CookieKeyValueRow label="Domain">{cookieDomain(cookie)}</CookieKeyValueRow>
<CookieKeyValueRow label="Path">{cookie.path}</CookieKeyValueRow>
<CookieKeyValueRow label="Expires">{cookieExpires(cookie)}</CookieKeyValueRow>
<CookieKeyValueRow label="Size">{cookieSize(cookie)}</CookieKeyValueRow>
<CookieKeyValueRow label="HTTP Only">{cookie.httpOnly ? "Yes" : "No"}</CookieKeyValueRow>
<CookieKeyValueRow label="Secure">{cookie.secure ? "Yes" : "No"}</CookieKeyValueRow>
{cookie.sameSite && (
<CookieKeyValueRow label="Same Site">{cookie.sameSite}</CookieKeyValueRow>
)}
</KeyValueRows>
</div>
);
}
function CookieEditor({
cookie,
expiresInputValue,
onChange,
onExpiresInputChange,
}: {
cookie: Cookie;
expiresInputValue: string;
onChange: (cookie: Cookie) => void;
onExpiresInputChange: (value: string) => void;
}) {
const sessionCookie = cookie.expires === "SessionEnd";
return (
<div className="overflow-y-auto">
<KeyValueRows>
<CookieKeyValueRow align="middle" label="Name">
<CookieTextInput
required
autoFocus
pattern={NON_EMPTY_INPUT_PATTERN}
value={cookie.name}
onChange={(name) => onChange({ ...cookie, name })}
/>
</CookieKeyValueRow>
<CookieKeyValueRow label="Value">
<CookieTextarea
value={cookie.value}
onChange={(value) => onChange({ ...cookie, value })}
/>
</CookieKeyValueRow>
<CookieKeyValueRow align="middle" label="Domain">
<CookieTextInput
required
pattern={NON_EMPTY_INPUT_PATTERN}
value={cookieDomainInputValue(cookie)}
placeholder="example.com"
onChange={(domain) => onChange(cookieWithDomain(cookie, domain))}
/>
</CookieKeyValueRow>
<CookieKeyValueRow align="middle" label="Path">
<CookieTextInput
value={cookie.path}
placeholder="/"
onChange={(path) => onChange({ ...cookie, path })}
/>
</CookieKeyValueRow>
<CookieKeyValueRow label="Expires">
<div className="grid gap-1">
<Checkbox
checked={sessionCookie}
title="Session cookie"
onChange={(checked) => {
if (checked) {
onChange({ ...cookie, expires: "SessionEnd" });
return;
}
const expiresInput =
cookieExpiresFromInput(expiresInputValue) == null
? defaultCookieExpiresInputValue()
: expiresInputValue;
onExpiresInputChange(expiresInput);
onChange({
...cookie,
expires: cookieExpiresFromInput(expiresInput)!,
});
}}
/>
<CookieTextInput
value={sessionCookie ? "" : expiresInputValue}
disabled={sessionCookie}
onChange={(value) => {
onExpiresInputChange(value);
const expires = cookieExpiresFromInput(value);
if (expires != null) {
onChange({ ...cookie, expires });
}
}}
/>
</div>
</CookieKeyValueRow>
<CookieKeyValueRow label="Size">{cookieSize(cookie)}</CookieKeyValueRow>
<CookieKeyValueRow align="middle" label="HTTP Only">
<Checkbox
hideLabel
title="HTTP Only"
checked={cookie.httpOnly}
onChange={(httpOnly) => onChange({ ...cookie, httpOnly })}
/>
</CookieKeyValueRow>
<CookieKeyValueRow align="middle" label="Secure">
<Checkbox
hideLabel
title="Secure"
checked={cookie.secure}
onChange={(secure) => onChange({ ...cookie, secure })}
/>
</CookieKeyValueRow>
<CookieKeyValueRow align="middle" label="Same Site">
<Select
hideLabel
name="cookie-same-site"
label="Same Site"
value={cookie.sameSite ?? ""}
size="xs"
className="w-full"
options={[
{ label: "n/a", value: "" },
{ label: "Lax", value: "Lax" },
{ label: "Strict", value: "Strict" },
{ label: "None", value: "None" },
]}
onChange={(sameSite) =>
onChange({
...cookie,
sameSite: sameSite === "" ? null : (sameSite as Cookie["sameSite"]),
})
}
/>
</CookieKeyValueRow>
</KeyValueRows>
</div>
);
}
function CookieKeyValueRow({ labelClassName, ...props }: ComponentProps<typeof KeyValueRow>) {
return <KeyValueRow labelClassName={classNames("w-[7rem]", labelClassName)} {...props} />;
}
function CookieTextInput({
autoFocus,
disabled,
onChange,
pattern,
placeholder,
required,
value,
}: {
autoFocus?: boolean;
disabled?: boolean;
onChange: (value: string) => void;
pattern?: string;
placeholder?: string;
required?: boolean;
value: string;
}) {
return (
<input
autoFocus={autoFocus}
className={cookieInputClassName}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
pattern={pattern}
placeholder={placeholder}
required={required}
type="text"
value={value}
/>
);
}
function CookieTextarea({ onChange, value }: { onChange: (value: string) => void; value: string }) {
return (
<textarea
className={classNames(cookieInputClassName, "min-h-[5rem] resize-y")}
onChange={(event) => onChange(event.target.value)}
value={value}
/>
);
}
const NEW_COOKIE_KEY = "__new-cookie__";
const NON_EMPTY_INPUT_PATTERN = ".*\\S.*";
const cookieInputClassName = classNames(
"x-theme-input w-full min-w-0 min-h-sm rounded-md bg-transparent",
"border border-border-subtle outline-none",
"px-2 text-xs font-mono cursor-text placeholder:text-placeholder",
"focus:border-border-focus invalid:border-danger",
"disabled:opacity-disabled disabled:border-dotted",
);
function cookieSize(cookie: Cookie) {
const encoder = new TextEncoder();
return encoder.encode(cookie.name).length + encoder.encode(cookie.value).length;
}
function newCookieDraft(): Cookie {
return {
name: "",
value: "",
domain: "NotPresent",
expires: "SessionEnd",
path: "/",
secure: false,
httpOnly: false,
sameSite: null,
};
}
function normalizeCookie(cookie: Cookie): Cookie {
return {
...cookie,
domain: normalizeCookieDomain(cookie.domain),
name: cookie.name.trim(),
path: cookie.path.trim() || "/",
};
}
function normalizeCookieDomain(domain: Cookie["domain"]): Cookie["domain"] {
if (domain === "NotPresent" || domain === "Empty") {
return domain;
}
if ("Suffix" in domain) {
return { Suffix: domain.Suffix.trim() };
}
return { HostOnly: domain.HostOnly.trim() };
}
function cookieDomainInputValue(cookie: Cookie) {
const domain = cookieDomain(cookie);
return domain === "n/a" ? "" : domain;
}
function cookieWithDomain(cookie: Cookie, domain: string): Cookie {
const trimmedDomain = domain.trim();
if (trimmedDomain.length === 0) {
return { ...cookie, domain: "NotPresent" };
}
if (cookie.domain !== "NotPresent" && cookie.domain !== "Empty" && "Suffix" in cookie.domain) {
return { ...cookie, domain: { Suffix: trimmedDomain } };
}
return { ...cookie, domain: { HostOnly: trimmedDomain } };
}
function cookieExpires(cookie: Cookie) {
if (cookie.expires === "SessionEnd") {
return "Session";
}
const expiresSeconds = Number(cookie.expires.AtUtc);
if (!Number.isFinite(expiresSeconds)) {
return cookie.expires.AtUtc;
}
const date = new Date(expiresSeconds * 1000);
return formatDate(date, "MMM d, yyyy, h:mm:ss a");
}
function cookieExpiresInputValue(cookie: Cookie) {
if (cookie.expires === "SessionEnd") {
return "";
}
const expiresSeconds = Number(cookie.expires.AtUtc);
if (!Number.isFinite(expiresSeconds)) {
return "";
}
return new Date(expiresSeconds * 1000).toISOString();
}
function defaultCookieExpiresInputValue() {
return new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
}
function cookieExpiresFromInput(value: string): Cookie["expires"] | null {
const time = new Date(value).getTime();
if (!Number.isFinite(time)) {
return null;
}
return { AtUtc: `${Math.floor(time / 1000)}` };
}
function cookieMatchesFilter(cookie: Cookie, filter: string) {
const query = filter.trim().toLowerCase();
if (query.length === 0) {
return true;
}
return [cookie.name, cookie.value, cookieDomain(cookie)].some((value) =>
value.toLowerCase().includes(query),
);
}
function cookieKey(cookie: Cookie) {
return [cookie.name, cookieDomainKey(cookie.domain), cookie.path].join("|");
}
function cookieDomainKey(domain: Cookie["domain"]) {
if (typeof domain !== "string" && "HostOnly" in domain) {
return `HostOnly:${domain.HostOnly}`;
}
if (typeof domain !== "string" && "Suffix" in domain) {
return `Suffix:${domain.Suffix}`;
}
return domain;
}
@@ -1,33 +0,0 @@
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>
);
}
@@ -1,31 +0,0 @@
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();
}
}}
/>
);
}
@@ -1,34 +0,0 @@
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,
);
@@ -1,38 +0,0 @@
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;
}
@@ -1,427 +0,0 @@
import type { HttpResponse, HttpResponseEvent } from "@yaakapp-internal/models";
import { Banner, HStack, Icon, LoadingIcon, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { ComponentType, CSSProperties } from "react";
import { lazy, Suspense, useMemo } from "react";
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { useResponseBodyBytes, useResponseBodyText } from "../hooks/useResponseBodyText";
import { useResponseViewMode } from "../hooks/useResponseViewMode";
import { useTimelineViewMode } from "../hooks/useTimelineViewMode";
import { getMimeTypeFromContentType } from "../lib/contentType";
import { getContentTypeFromHeaders, getCookieCounts } from "../lib/model_util";
import { ConfirmLargeResponse } from "./ConfirmLargeResponse";
import { ConfirmLargeResponseRequest } from "./ConfirmLargeResponseRequest";
import { Button } from "./core/Button";
import { CountBadge } from "./core/CountBadge";
import { HotkeyList } from "./core/HotkeyList";
import { HttpResponseDurationTag } from "./core/HttpResponseDurationTag";
import { HttpStatusTag } from "./core/HttpStatusTag";
import { PillButton } from "./core/PillButton";
import { SizeTag } from "./core/SizeTag";
import type { TabItem } from "./core/Tabs/Tabs";
import { TabContent, Tabs } from "./core/Tabs/Tabs";
import { Tooltip } from "./core/Tooltip";
import { EmptyStateText } from "./EmptyStateText";
import { ErrorBoundary } from "./ErrorBoundary";
import { HttpResponseTimeline } from "./HttpResponseTimeline";
import { RecentHttpResponsesDropdown } from "./RecentHttpResponsesDropdown";
import { RequestBodyViewer } from "./RequestBodyViewer";
import { ResponseCookies } from "./ResponseCookies";
import { ResponseHeaders } from "./ResponseHeaders";
import { AudioViewer } from "./responseViewers/AudioViewer";
import { CsvViewer } from "./responseViewers/CsvViewer";
import { EventStreamViewer } from "./responseViewers/EventStreamViewer";
import { HTMLOrTextViewer } from "./responseViewers/HTMLOrTextViewer";
import { ImageViewer } from "./responseViewers/ImageViewer";
import { MultipartViewer } from "./responseViewers/MultipartViewer";
import { SvgViewer } from "./responseViewers/SvgViewer";
import { VideoViewer } from "./responseViewers/VideoViewer";
const PdfViewer = lazy(() =>
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
);
interface Props {
style?: CSSProperties;
className?: string;
activeRequestId: string;
}
const TAB_BODY = "body";
const TAB_REQUEST = "request";
const TAB_HEADERS = "headers";
const TAB_COOKIES = "cookies";
const TAB_TIMELINE = "timeline";
export type TimelineViewMode = "timeline" | "text";
interface RedirectDropWarning {
droppedBodyCount: number;
droppedHeaders: string[];
}
export function HttpResponsePane({ style, className, activeRequestId }: Props) {
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId);
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
const [timelineViewMode, setTimelineViewMode] = useTimelineViewMode();
const contentType = getContentTypeFromHeaders(activeResponse?.headers ?? null);
const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence;
const responseEvents = useHttpResponseEvents(activeResponse);
const redirectDropWarning = useMemo(
() => getRedirectDropWarning(responseEvents.data),
[responseEvents.data],
);
const shouldShowRedirectDropWarning =
activeResponse?.state === "closed" && redirectDropWarning != null;
const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]);
const tabs = useMemo<TabItem[]>(
() => [
{
value: TAB_BODY,
label: "Response",
options: {
value: viewMode,
onChange: setViewMode,
items: [
{ label: "Response", value: "pretty" },
...(mimeType?.startsWith("image")
? []
: [{ label: "Response (Raw)", shortLabel: "Raw", value: "raw" }]),
],
},
},
{
value: TAB_REQUEST,
label: "Request",
rightSlot:
(activeResponse?.requestContentLength ?? 0) > 0 ? <CountBadge count={true} /> : null,
},
{
value: TAB_HEADERS,
label: "Headers",
rightSlot: (
<CountBadge
count={activeResponse?.requestHeaders.length ?? 0}
count2={activeResponse?.headers.length ?? 0}
showZero
/>
),
},
{
value: TAB_COOKIES,
label: "Cookies",
rightSlot:
cookieCounts.sent > 0 || cookieCounts.received > 0 ? (
<CountBadge count={cookieCounts.sent} count2={cookieCounts.received} showZero />
) : null,
},
{
value: TAB_TIMELINE,
rightSlot: <CountBadge count={responseEvents.data?.length ?? 0} />,
options: {
value: timelineViewMode,
onChange: (v) => setTimelineViewMode((v as TimelineViewMode) ?? "timeline"),
items: [
{ label: "Timeline", value: "timeline" },
{ label: "Timeline (Text)", shortLabel: "Timeline", value: "text" },
],
},
},
],
[
activeResponse?.headers,
activeResponse?.requestContentLength,
activeResponse?.requestHeaders.length,
cookieCounts.sent,
cookieCounts.received,
mimeType,
responseEvents.data?.length,
setViewMode,
viewMode,
timelineViewMode,
setTimelineViewMode,
],
);
const cancel = useCancelHttpResponse(activeResponse?.id ?? null);
return (
<div
style={style}
className={classNames(
className,
"x-theme-responsePane",
"max-h-full h-full",
"bg-surface rounded-md border border-border-subtle overflow-hidden",
"relative",
)}
>
{activeResponse == null ? (
<HotkeyList hotkeys={["request.send", "model.create", "sidebar.focus", "url_bar.focus"]} />
) : (
<div className="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1">
<HStack
className={classNames(
"text-text-subtle w-full flex-shrink-0",
// Remove a bit of space because the tabs have lots too
"-mb-1.5",
)}
>
{activeResponse && (
<div
className={classNames(
"grid grid-cols-[auto_minmax(4rem,1fr)_auto]",
"cursor-default select-none",
"whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars",
)}
>
<HStack space={2} className="w-full flex-shrink-0">
{activeResponse.state !== "closed" && <LoadingIcon size="sm" />}
<HttpStatusTag showReason response={activeResponse} />
<span>&bull;</span>
<HttpResponseDurationTag response={activeResponse} />
<span>&bull;</span>
<SizeTag
contentLength={activeResponse.contentLength ?? 0}
contentLengthCompressed={activeResponse.contentLengthCompressed}
/>
</HStack>
{shouldShowRedirectDropWarning ? (
<Tooltip
tabIndex={0}
className="my-auto pl-3 flex-shrink-0 max-w-full justify-self-end overflow-hidden"
content={
<VStack alignItems="start" space={1} className="text-xs">
<span className="font-medium text-warning">
Redirect changed this request
</span>
{redirectDropWarning.droppedBodyCount > 0 && (
<span>
Body dropped on {redirectDropWarning.droppedBodyCount}{" "}
{redirectDropWarning.droppedBodyCount === 1
? "redirect hop"
: "redirect hops"}
</span>
)}
{redirectDropWarning.droppedHeaders.length > 0 && (
<span>
Headers dropped:{" "}
<span className="font-mono">
{redirectDropWarning.droppedHeaders.join(", ")}
</span>
</span>
)}
<span className="text-text-subtle">See Timeline for details.</span>
</VStack>
}
>
<span className="inline-flex min-w-0">
<PillButton
color="warning"
className="font-sans text-sm !flex-shrink max-w-full"
innerClassName="flex items-center"
leftSlot={<Icon icon="alert_triangle" size="xs" color="warning" />}
>
<span className="truncate">
{getRedirectWarningLabel(redirectDropWarning)}
</span>
</PillButton>
</span>
</Tooltip>
) : (
<span />
)}
<div className="justify-self-end flex-shrink-0">
<RecentHttpResponsesDropdown
responses={responses}
activeResponse={activeResponse}
onPinnedResponseId={setPinnedResponseId}
/>
</div>
</div>
)}
</HStack>
<div className="overflow-hidden flex flex-col min-h-0">
{activeResponse?.error && (
<Banner color="danger" className="mx-3 mt-1 flex-shrink-0">
{activeResponse.error}
</Banner>
)}
{/* Show tabs if we have any data (headers, body, etc.) even if there's an error */}
<Tabs
tabs={tabs}
label="Response"
className="ml-3 mr-3 mb-3 min-h-0 flex-1"
tabListClassName="mt-0.5 -mb-1.5"
storageKey="http_response_tabs"
activeTabKey={activeRequestId}
>
<TabContent value={TAB_BODY}>
<ErrorBoundary name="Http Response Viewer">
<Suspense>
<ConfirmLargeResponse response={activeResponse}>
{activeResponse.state === "initialized" ? (
<EmptyStateText>
<VStack space={3}>
<HStack space={3}>
<LoadingIcon className="text-text-subtlest" />
Sending Request
</HStack>
<Button size="sm" variant="border" onClick={() => cancel.mutate()}>
Cancel
</Button>
</VStack>
</EmptyStateText>
) : activeResponse.state === "closed" &&
(activeResponse.contentLength ?? 0) === 0 ? (
<EmptyStateText>Empty</EmptyStateText>
) : mimeType?.match(/^text\/event-stream/i) && viewMode === "pretty" ? (
<EventStreamViewer response={activeResponse} />
) : mimeType?.match(/^image\/svg/) ? (
<HttpSvgViewer response={activeResponse} />
) : mimeType?.match(/^image/i) ? (
<EnsureCompleteResponse response={activeResponse} Component={ImageViewer} />
) : mimeType?.match(/^audio/i) ? (
<EnsureCompleteResponse response={activeResponse} Component={AudioViewer} />
) : mimeType?.match(/^video/i) ? (
<EnsureCompleteResponse response={activeResponse} Component={VideoViewer} />
) : mimeType?.match(/^multipart/i) && viewMode === "pretty" ? (
<HttpMultipartViewer response={activeResponse} />
) : mimeType?.match(/pdf/i) ? (
<EnsureCompleteResponse response={activeResponse} Component={PdfViewer} />
) : mimeType?.match(/csv|tab-separated/i) && viewMode === "pretty" ? (
<HttpCsvViewer className="pb-2" response={activeResponse} />
) : (
<HTMLOrTextViewer
textViewerClassName="-mr-2 bg-surface" // Pull to the right
response={activeResponse}
pretty={viewMode === "pretty"}
/>
)}
</ConfirmLargeResponse>
</Suspense>
</ErrorBoundary>
</TabContent>
<TabContent value={TAB_REQUEST}>
<ConfirmLargeResponseRequest response={activeResponse}>
<RequestBodyViewer response={activeResponse} />
</ConfirmLargeResponseRequest>
</TabContent>
<TabContent value={TAB_HEADERS}>
<ResponseHeaders response={activeResponse} />
</TabContent>
<TabContent value={TAB_COOKIES}>
<ResponseCookies response={activeResponse} />
</TabContent>
<TabContent value={TAB_TIMELINE}>
<HttpResponseTimeline response={activeResponse} viewMode={timelineViewMode} />
</TabContent>
</Tabs>
</div>
</div>
)}
</div>
);
}
function getRedirectDropWarning(
events: HttpResponseEvent[] | undefined,
): RedirectDropWarning | null {
if (events == null || events.length === 0) return null;
let droppedBodyCount = 0;
const droppedHeaders = new Set<string>();
for (const e of events) {
const event = e.event;
if (event.type !== "redirect") {
continue;
}
if (event.dropped_body) {
droppedBodyCount += 1;
}
for (const headerName of event.dropped_headers ?? []) {
pushHeaderName(droppedHeaders, headerName);
}
}
if (droppedBodyCount === 0 && droppedHeaders.size === 0) {
return null;
}
return {
droppedBodyCount,
droppedHeaders: Array.from(droppedHeaders).sort(),
};
}
function pushHeaderName(headers: Set<string>, headerName: string): void {
const existing = Array.from(headers).find((h) => h.toLowerCase() === headerName.toLowerCase());
if (existing == null) {
headers.add(headerName);
}
}
function getRedirectWarningLabel(warning: RedirectDropWarning): string {
if (warning.droppedBodyCount > 0 && warning.droppedHeaders.length > 0) {
return "Dropped body and headers";
}
if (warning.droppedBodyCount > 0) {
return "Dropped body";
}
return "Dropped headers";
}
function EnsureCompleteResponse({
response,
Component,
}: {
response: HttpResponse;
Component: ComponentType<{ bodyPath: string }>;
}) {
if (response.bodyPath === null) {
return <div>Empty response body</div>;
}
// Wait until the response has been fully-downloaded
if (response.state !== "closed") {
return (
<EmptyStateText>
<LoadingIcon />
</EmptyStateText>
);
}
return <Component bodyPath={response.bodyPath} />;
}
function HttpSvgViewer({ response }: { response: HttpResponse }) {
const body = useResponseBodyText({ response, filter: null });
if (!body.data) return null;
return <SvgViewer text={body.data} />;
}
function HttpCsvViewer({ response, className }: { response: HttpResponse; className?: string }) {
const body = useResponseBodyText({ response, filter: null });
return <CsvViewer text={body.data ?? null} className={className} />;
}
function HttpMultipartViewer({ response }: { response: HttpResponse }) {
const body = useResponseBodyBytes({ response });
if (body.data == null) return null;
const contentTypeHeader = getContentTypeFromHeaders(response.headers);
const boundary = contentTypeHeader?.split("boundary=")[1] ?? "unknown";
return <MultipartViewer data={body.data} boundary={boundary} idPrefix={response.id} />;
}
@@ -1,122 +0,0 @@
import { linter } from "@codemirror/lint";
import type { HttpRequest } from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models";
import { Banner, Icon } from "@yaakapp-internal/ui";
import { useCallback, useMemo } from "react";
import { useKeyValue } from "../hooks/useKeyValue";
import { fireAndForget } from "../lib/fireAndForget";
import { textLikelyContainsJsonComments } from "../lib/jsonComments";
import type { DropdownItem } from "./core/Dropdown";
import { Dropdown } from "./core/Dropdown";
import type { EditorProps } from "./core/Editor/Editor";
import { jsonParseLinter } from "./core/Editor/json-lint";
import { Editor } from "./core/Editor/LazyEditor";
import { IconButton } from "./core/IconButton";
import { IconTooltip } from "./core/IconTooltip";
interface Props {
forceUpdateKey: string;
heightMode: EditorProps["heightMode"];
request: HttpRequest;
}
export function JsonBodyEditor({ forceUpdateKey, heightMode, request }: Props) {
const handleChange = useCallback(
(text: string) => patchModel(request, { body: { ...request.body, text } }),
[request],
);
const autoFix = request.body?.sendJsonComments !== true;
const lintExtension = useMemo(
() =>
linter(
jsonParseLinter(
autoFix
? { allowComments: true, allowTrailingCommas: true }
: { allowComments: false, allowTrailingCommas: false },
),
),
[autoFix],
);
const hasComments = useMemo(
() => textLikelyContainsJsonComments(request.body?.text ?? ""),
[request.body?.text],
);
const { value: bannerDismissed, set: setBannerDismissed } = useKeyValue<boolean>({
namespace: "no_sync",
key: ["json-fix-3", request.workspaceId],
fallback: false,
});
const handleToggleAutoFix = useCallback(() => {
const newBody = { ...request.body };
if (autoFix) {
newBody.sendJsonComments = true;
} else {
delete newBody.sendJsonComments;
}
fireAndForget(patchModel(request, { body: newBody }));
}, [request, autoFix]);
const handleDropdownOpen = useCallback(() => {
if (!bannerDismissed) {
fireAndForget(setBannerDismissed(true));
}
}, [bannerDismissed, setBannerDismissed]);
const showBanner = hasComments && autoFix && !bannerDismissed;
const stripMessage = "Automatically strip comments and trailing commas before sending";
const actions = useMemo<EditorProps["actions"]>(
() => [
showBanner && (
<Banner color="notice" className="!opacity-100 h-sm !py-0 !px-2 flex items-center text-xs">
<p className="inline-flex items-center gap-1 min-w-0">
<span className="truncate">Auto-fix enabled</span>
<Icon icon="arrow_right" size="sm" className="opacity-disabled" />
</p>
</Banner>
),
<div key="settings" className="!opacity-100 !shadow">
<Dropdown
onOpen={handleDropdownOpen}
items={
[
{
label: "Automatically Fix JSON",
keepOpenOnSelect: true,
onSelect: handleToggleAutoFix,
rightSlot: <IconTooltip content={stripMessage} />,
leftSlot: (
<Icon icon={autoFix ? "check_square_checked" : "check_square_unchecked"} />
),
},
] satisfies DropdownItem[]
}
>
<IconButton size="sm" variant="border" icon="settings" title="JSON Settings" />
</Dropdown>
</div>,
],
[handleDropdownOpen, handleToggleAutoFix, autoFix, showBanner],
);
return (
<Editor
forceUpdateKey={forceUpdateKey}
autocompleteFunctions
autocompleteVariables
placeholder="..."
heightMode={heightMode}
defaultValue={`${request.body?.text ?? ""}`}
language="json"
onChange={handleChange}
stateKey={`json.${request.id}`}
actions={actions}
lintExtension={lintExtension}
/>
);
}
-113
View File
@@ -1,113 +0,0 @@
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>
);
},
};
@@ -1,347 +0,0 @@
import type {
Folder,
GrpcRequest,
HttpRequest,
InheritedBoolSetting,
InheritedIntSetting,
WebsocketRequest,
Workspace,
} from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models";
import { useModelAncestors } from "../hooks/useModelAncestors";
import {
modelSupportsSetting,
type RequestSettingDefinition,
SETTING_FOLLOW_REDIRECTS,
SETTING_REQUEST_TIMEOUT,
SETTING_SEND_COOKIES,
SETTING_STORE_COOKIES,
SETTING_VALIDATE_CERTIFICATES,
} from "../lib/requestSettings";
import { Checkbox } from "./core/Checkbox";
import { PlainInput } from "./core/PlainInput";
import {
SettingOverrideRow,
SettingRowBoolean,
SettingRowNumber,
SettingsList,
SettingsSection,
} from "./core/SettingRow";
interface Props {
showSectionTitles?: boolean;
model: ModelWithSettings;
}
type ModelWithSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest;
type ModelWithHttpSettings = Workspace | Folder | HttpRequest;
type ModelWithTlsSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest;
type ModelWithCookieSettings = Workspace | Folder | HttpRequest | WebsocketRequest;
type BooleanSetting = boolean | InheritedBoolSetting;
type IntegerSetting = number | InheritedIntSetting;
type CookieSettingsPatch = {
settingSendCookies?: ModelWithCookieSettings["settingSendCookies"];
settingStoreCookies?: ModelWithCookieSettings["settingStoreCookies"];
};
type HttpSettingsPatch = {
settingFollowRedirects?: ModelWithHttpSettings["settingFollowRedirects"];
settingRequestTimeout?: ModelWithHttpSettings["settingRequestTimeout"];
};
type TlsSettingsPatch = {
settingValidateCertificates?: ModelWithTlsSettings["settingValidateCertificates"];
};
export function ModelSettingsEditor({ model, showSectionTitles = false }: Props) {
const ancestors = useModelAncestors(model);
const supportsHttpSettings = modelSupportsHttpSettings(model);
const supportsCookieSettings = modelSupportsCookieSettings(model);
const supportsTlsSettings = modelSupportsTlsSettings(model);
return (
<SettingsList className="space-y-8">
{supportsTlsSettings && (
<SettingsSection title={showSectionTitles ? "Requests" : null}>
{supportsHttpSettings && (
<IntegerSettingRow
settingDefinition={SETTING_REQUEST_TIMEOUT}
setting={model.settingRequestTimeout}
inheritedValue={resolveInheritedValue(
ancestors,
SETTING_REQUEST_TIMEOUT.modelKey,
model.settingRequestTimeout,
)}
onChange={(settingRequestTimeout) =>
patchHttpSettings(model, {
settingRequestTimeout,
})
}
/>
)}
<BooleanSettingRow
settingDefinition={SETTING_VALIDATE_CERTIFICATES}
setting={model.settingValidateCertificates}
inheritedValue={resolveInheritedValue(
ancestors,
SETTING_VALIDATE_CERTIFICATES.modelKey,
model.settingValidateCertificates,
)}
onChange={(settingValidateCertificates) =>
patchTlsSettings(model, {
settingValidateCertificates,
})
}
/>
{supportsHttpSettings && (
<BooleanSettingRow
settingDefinition={SETTING_FOLLOW_REDIRECTS}
setting={model.settingFollowRedirects}
inheritedValue={resolveInheritedValue(
ancestors,
SETTING_FOLLOW_REDIRECTS.modelKey,
model.settingFollowRedirects,
)}
onChange={(settingFollowRedirects) =>
patchHttpSettings(model, {
settingFollowRedirects,
})
}
/>
)}
</SettingsSection>
)}
{supportsCookieSettings && (
<SettingsSection title={supportsTlsSettings || showSectionTitles ? "Cookies" : null}>
<BooleanSettingRow
settingDefinition={SETTING_SEND_COOKIES}
setting={model.settingSendCookies}
inheritedValue={resolveInheritedValue(
ancestors,
SETTING_SEND_COOKIES.modelKey,
model.settingSendCookies,
)}
onChange={(settingSendCookies) =>
patchCookieSettings(model, {
settingSendCookies,
})
}
/>
<BooleanSettingRow
settingDefinition={SETTING_STORE_COOKIES}
setting={model.settingStoreCookies}
inheritedValue={resolveInheritedValue(
ancestors,
SETTING_STORE_COOKIES.modelKey,
model.settingStoreCookies,
)}
onChange={(settingStoreCookies) =>
patchCookieSettings(model, {
settingStoreCookies,
})
}
/>
</SettingsSection>
)}
</SettingsList>
);
}
export function countOverriddenSettings(model: ModelWithSettings) {
const settings: (BooleanSetting | IntegerSetting)[] = [];
if (modelSupportsCookieSettings(model)) {
settings.push(model.settingSendCookies, model.settingStoreCookies);
}
settings.push(model.settingValidateCertificates);
if (modelSupportsHttpSettings(model)) {
settings.push(model.settingFollowRedirects, model.settingRequestTimeout);
}
return settings.filter((setting) => isInheritedSetting(setting) && setting.enabled === true)
.length;
}
function patchCookieSettings(model: ModelWithCookieSettings, patch: Partial<CookieSettingsPatch>) {
if (model.model === "workspace") return patchModel(model, patch as Partial<Workspace>);
if (model.model === "folder") return patchModel(model, patch as Partial<Folder>);
if (model.model === "http_request") return patchModel(model, patch as Partial<HttpRequest>);
if (model.model === "websocket_request")
return patchModel(model, patch as Partial<WebsocketRequest>);
throw new Error("Unsupported cookie settings model");
}
function patchHttpSettings(model: ModelWithHttpSettings, patch: Partial<HttpSettingsPatch>) {
if (model.model === "workspace") return patchModel(model, patch as Partial<Workspace>);
if (model.model === "folder") return patchModel(model, patch as Partial<Folder>);
return patchModel(model, patch as Partial<HttpRequest>);
}
function patchTlsSettings(model: ModelWithTlsSettings, patch: Partial<TlsSettingsPatch>) {
if (model.model === "workspace") return patchModel(model, patch as Partial<Workspace>);
if (model.model === "folder") return patchModel(model, patch as Partial<Folder>);
if (model.model === "http_request") return patchModel(model, patch as Partial<HttpRequest>);
if (model.model === "websocket_request")
return patchModel(model, patch as Partial<WebsocketRequest>);
return patchModel(model, patch as Partial<GrpcRequest>);
}
function modelSupportsHttpSettings(model: ModelWithSettings): model is ModelWithHttpSettings {
return modelSupportsSetting(model, SETTING_REQUEST_TIMEOUT);
}
function modelSupportsCookieSettings(model: ModelWithSettings): model is ModelWithCookieSettings {
return modelSupportsSetting(model, SETTING_SEND_COOKIES);
}
function modelSupportsTlsSettings(model: ModelWithSettings): model is ModelWithTlsSettings {
return modelSupportsSetting(model, SETTING_VALIDATE_CERTIFICATES);
}
function BooleanSettingRow({
inheritedValue,
setting,
settingDefinition,
onChange,
}: {
inheritedValue: boolean;
setting: BooleanSetting;
settingDefinition: RequestSettingDefinition;
onChange: (setting: BooleanSetting) => void;
}) {
const inherited = isInheritedSetting(setting);
const overridden = inherited ? setting.enabled === true : false;
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
if (!inherited) {
return (
<SettingRowBoolean
checked={value}
title={settingDefinition.title}
description={settingDefinition.description}
onChange={(value) => onChange(value)}
/>
);
}
return (
<SettingOverrideRow
title={settingDefinition.title}
description={settingDefinition.description}
overridden={overridden}
onResetOverride={() => onChange({ ...setting, enabled: false })}
>
<Checkbox
hideLabel
size="md"
title={settingDefinition.title}
checked={value}
onChange={(value) => onChange({ ...setting, enabled: true, value })}
/>
</SettingOverrideRow>
);
}
function IntegerSettingRow({
inheritedValue,
setting,
settingDefinition,
onChange,
}: {
inheritedValue: number;
setting: IntegerSetting;
settingDefinition: RequestSettingDefinition<"settingRequestTimeout">;
onChange: (setting: IntegerSetting) => void;
}) {
const inherited = isInheritedSetting(setting);
const overridden = inherited ? setting.enabled === true : false;
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
if (!inherited) {
return (
<SettingRowNumber
name={settingDefinition.modelKey}
title={settingDefinition.title}
description={settingDefinition.description}
value={value}
placeholder={`${settingDefinition.defaultValue}`}
validate={(value) => value === "" || Number.parseInt(value, 10) >= 0}
onChange={(value) => onChange(value)}
/>
);
}
return (
<SettingOverrideRow
title={settingDefinition.title}
description={settingDefinition.description}
overridden={overridden}
onResetOverride={() => onChange({ ...setting, enabled: false })}
>
<PlainInput
hideLabel
name={settingDefinition.modelKey}
label={settingDefinition.title}
size="sm"
type="number"
placeholder={`${settingDefinition.defaultValue}`}
defaultValue={`${value}`}
containerClassName="!w-48"
validate={(value) => value === "" || Number.parseInt(value, 10) >= 0}
onChange={(value) =>
onChange({
...setting,
enabled: true,
value: Number.parseInt(value, 10) || 0,
})
}
/>
</SettingOverrideRow>
);
}
function isInheritedSetting<T>(
setting: T | { enabled?: boolean; value: T },
): setting is { enabled?: boolean; value: T } {
return typeof setting === "object" && setting != null && "value" in setting;
}
function resolveInheritedValue(
ancestors: (Folder | Workspace)[],
key: "settingRequestTimeout",
fallback: IntegerSetting,
): number;
function resolveInheritedValue(
ancestors: (Folder | Workspace)[],
key: BooleanWorkspaceSettingKey,
fallback: BooleanSetting,
): boolean;
function resolveInheritedValue(
ancestors: (Folder | Workspace)[],
key: keyof WorkspaceSettings,
fallback: BooleanSetting | IntegerSetting,
) {
for (const ancestor of ancestors) {
const setting = ancestor[key] as BooleanSetting | IntegerSetting;
if (isInheritedSetting(setting)) {
if (setting.enabled === true) {
return setting.value;
}
continue;
}
return setting;
}
return isInheritedSetting(fallback) ? fallback.value : fallback;
}
type WorkspaceSettings = Pick<
Workspace,
| "settingFollowRedirects"
| "settingRequestTimeout"
| "settingSendCookies"
| "settingStoreCookies"
| "settingValidateCertificates"
>;
type BooleanWorkspaceSettingKey = Exclude<keyof WorkspaceSettings, "settingRequestTimeout">;
-12
View File
@@ -1,12 +0,0 @@
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} />;
}
@@ -1,44 +0,0 @@
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;
}
@@ -1,200 +0,0 @@
import { revealItemInDir } from "@tauri-apps/plugin-opener";
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { Heading, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
import { useCheckForUpdates } from "../../hooks/useCheckForUpdates";
import { appInfo } from "../../lib/appInfo";
import {
SETTING_FOLLOW_REDIRECTS,
SETTING_REQUEST_TIMEOUT,
SETTING_SEND_COOKIES,
SETTING_STORE_COOKIES,
SETTING_VALIDATE_CERTIFICATES,
} from "../../lib/requestSettings";
import { revealInFinderText } from "../../lib/reveal";
import { CargoFeature } from "../CargoFeature";
import { IconButton } from "../core/IconButton";
import {
ModelSettingRowBoolean,
ModelSettingRowNumber,
ModelSettingSelectControl,
SettingValue,
SettingRow,
SettingRowBoolean,
SettingRowSelect,
SettingsList,
SettingsSection,
} from "../core/SettingRow";
export function SettingsGeneral() {
const workspace = useAtomValue(activeWorkspaceAtom);
const settings = useAtomValue(settingsAtom);
const checkForUpdates = useCheckForUpdates();
if (settings == null || workspace == null) {
return null;
}
return (
<VStack space={1.5} className="mb-4">
<div className="mb-4">
<Heading>General</Heading>
<p className="text-text-subtle">Configure general settings for update behavior and more.</p>
</div>
<SettingsList className="space-y-8">
<CargoFeature feature="updater">
<SettingsSection title="Updates">
<SettingRow
title="Update Channel"
description="Choose whether Yaak should use stable releases or beta releases."
>
<div className="grid grid-cols-[12rem_auto] gap-1">
<ModelSettingSelectControl
model={settings}
modelKey="updateChannel"
label="Update Channel"
selectClassName="!w-full"
options={[
{ label: "Stable", value: "stable" },
{ label: "Beta", value: "beta" },
]}
/>
<IconButton
variant="border"
size="sm"
title="Check for updates"
icon="refresh"
spin={checkForUpdates.isPending}
onClick={() => checkForUpdates.mutateAsync()}
/>
</div>
</SettingRow>
<SettingRowSelect
title="Update Behavior"
description="Choose whether updates are installed automatically or manually."
name="autoupdate"
value={settings.autoupdate ? "auto" : "manual"}
onChange={(v) => patchModel(settings, { autoupdate: v === "auto" })}
options={[
{ label: "Automatic", value: "auto" },
{ label: "Manual", value: "manual" },
]}
/>
<ModelSettingRowBoolean
model={settings}
modelKey="autoDownloadUpdates"
title="Automatically download updates"
description="Download Yaak updates in the background so they are ready to install."
disabled={!settings.autoupdate}
/>
<ModelSettingRowBoolean
model={settings}
modelKey="checkNotifications"
title="Check for notifications"
description="Periodically ping Yaak servers to check for relevant notifications."
/>
<SettingRowBoolean
title="Send anonymous usage statistics"
description="Yaak is local-first and does not collect analytics or usage data."
disabled
checked={false}
onChange={() => {}}
/>
</SettingsSection>
</CargoFeature>
<SettingsSection
title={
<>
Workspace{" "}
<span className="inline-block bg-surface-highlight px-2 py-0.5 rounded text">
{workspace.name}
</span>
</>
}
>
<ModelSettingRowNumber
model={workspace}
modelKey={SETTING_REQUEST_TIMEOUT.modelKey}
title={SETTING_REQUEST_TIMEOUT.title}
description={SETTING_REQUEST_TIMEOUT.description}
placeholder={`${SETTING_REQUEST_TIMEOUT.defaultValue}`}
required
validate={(value) => Number.parseInt(value, 10) >= 0}
/>
<ModelSettingRowBoolean
model={workspace}
modelKey={SETTING_VALIDATE_CERTIFICATES.modelKey}
title={SETTING_VALIDATE_CERTIFICATES.title}
description={SETTING_VALIDATE_CERTIFICATES.description}
/>
<ModelSettingRowBoolean
model={workspace}
modelKey={SETTING_FOLLOW_REDIRECTS.modelKey}
title={SETTING_FOLLOW_REDIRECTS.title}
description={SETTING_FOLLOW_REDIRECTS.description}
/>
<ModelSettingRowBoolean
model={workspace}
modelKey={SETTING_SEND_COOKIES.modelKey}
title={SETTING_SEND_COOKIES.title}
description={SETTING_SEND_COOKIES.description}
/>
<ModelSettingRowBoolean
model={workspace}
modelKey={SETTING_STORE_COOKIES.modelKey}
title={SETTING_STORE_COOKIES.title}
description={SETTING_STORE_COOKIES.description}
/>
</SettingsSection>
<SettingsSection title="App Info">
<SettingRow title="Version" description="Current Yaak version.">
<SettingValue value={appInfo.version} />
</SettingRow>
<SettingRow
title="Data Directory"
description="Where Yaak stores application data."
controlClassName="min-w-0 max-w-[min(42rem,55vw)] gap-2"
>
<SettingValue
value={appInfo.appDataDir}
actions={[
{
title: revealInFinderText,
icon: "folder_open",
onClick: () => revealItemInDir(appInfo.appDataDir),
},
]}
/>
</SettingRow>
<SettingRow
title="Logs Directory"
description="Where Yaak writes application logs."
controlClassName="min-w-0 max-w-[min(42rem,55vw)] gap-2"
>
<SettingValue
value={appInfo.appLogDir}
actions={[
{
title: revealInFinderText,
icon: "folder_open",
onClick: () => revealItemInDir(appInfo.appLogDir),
},
]}
/>
</SettingRow>
</SettingsSection>
</SettingsList>
</VStack>
);
}
@@ -1,271 +0,0 @@
import { type } from "@tauri-apps/plugin-os";
import { useFonts } from "@yaakapp-internal/fonts";
import { useLicense } from "@yaakapp-internal/license";
import type { EditorKeymap, Settings } from "@yaakapp-internal/models";
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { clamp, Heading, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useState } from "react";
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
import { showConfirm } from "../../lib/confirm";
import { invokeCmd } from "../../lib/tauri";
import { CargoFeature } from "../CargoFeature";
import { Button } from "../core/Button";
import { Checkbox } from "../core/Checkbox";
import { Link } from "../core/Link";
import {
ModelSettingRowBoolean,
ModelSettingRowSelect,
SettingRow,
SettingRowBoolean,
SettingRowSelect,
SettingSelectControl,
SettingsList,
SettingsSection,
} from "../core/SettingRow";
const NULL_FONT_VALUE = "__NULL_FONT__";
const fontSizeOptions = [
8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30,
].map((n) => ({ label: `${n}`, value: `${n}` }));
const keymaps: { value: EditorKeymap; label: string }[] = [
{ value: "default", label: "Default" },
{ value: "vim", label: "Vim" },
{ value: "vscode", label: "VSCode" },
{ value: "emacs", label: "Emacs" },
];
export function SettingsInterface() {
const workspace = useAtomValue(activeWorkspaceAtom);
const settings = useAtomValue(settingsAtom);
const fonts = useFonts();
if (settings == null || workspace == null) {
return null;
}
return (
<VStack space={1.5} className="mb-4">
<div className="mb-3">
<Heading>Interface</Heading>
<p className="text-text-subtle">Tweak settings related to the user interface.</p>
</div>
<SettingsList className="space-y-8">
<SettingsSection title="Workspaces">
<SettingRowSelect
title="Open workspace behavior"
description="Choose what happens when opening another workspace."
name="switchWorkspaceBehavior"
value={
settings.openWorkspaceNewWindow === true
? "new"
: settings.openWorkspaceNewWindow === false
? "current"
: "ask"
}
onChange={async (v) => {
if (v === "current") await patchModel(settings, { openWorkspaceNewWindow: false });
else if (v === "new") await patchModel(settings, { openWorkspaceNewWindow: true });
else await patchModel(settings, { openWorkspaceNewWindow: null });
}}
options={[
{ label: "Always ask", value: "ask" },
{ label: "Open in current window", value: "current" },
{ label: "Open in new window", value: "new" },
]}
/>
</SettingsSection>
<SettingsSection title="Fonts">
<SettingRow
title="Interface font"
description="Font used for Yaak interface controls."
controlClassName="gap-1"
>
{fonts.data && (
<SettingSelectControl
name="uiFont"
label="Interface font"
selectClassName="!w-72"
value={settings.interfaceFont ?? NULL_FONT_VALUE}
defaultValue={NULL_FONT_VALUE}
options={[
{ label: "System default", value: NULL_FONT_VALUE },
...fonts.data.uiFonts.map((f) => ({ label: f, value: f })),
...fonts.data.editorFonts.map((f) => ({ label: f, value: f })),
]}
onChange={async (v) => {
const interfaceFont = v === NULL_FONT_VALUE ? null : v;
await patchModel(settings, { interfaceFont });
}}
/>
)}
<SettingSelectControl
name="interfaceFontSize"
label="Interface Font Size"
selectClassName="!w-20"
value={`${settings.interfaceFontSize}`}
defaultValue="14"
options={fontSizeOptions}
onChange={(v) => patchModel(settings, { interfaceFontSize: Number.parseInt(v, 10) })}
/>
</SettingRow>
<SettingRow
title="Editor font"
description="Font used in request and response editors."
controlClassName="gap-1"
>
{fonts.data && (
<SettingSelectControl
name="editorFont"
label="Editor font"
selectClassName="!w-72"
value={settings.editorFont ?? NULL_FONT_VALUE}
defaultValue={NULL_FONT_VALUE}
options={[
{ label: "System default", value: NULL_FONT_VALUE },
...fonts.data.editorFonts.map((f) => ({ label: f, value: f })),
]}
onChange={async (v) => {
const editorFont = v === NULL_FONT_VALUE ? null : v;
await patchModel(settings, { editorFont });
}}
/>
)}
<SettingSelectControl
name="editorFontSize"
label="Editor Font Size"
selectClassName="!w-20"
value={`${settings.editorFontSize}`}
defaultValue="12"
options={fontSizeOptions}
onChange={(v) =>
patchModel(settings, {
editorFontSize: clamp(Number.parseInt(v, 10) || 14, 8, 30),
})
}
/>
</SettingRow>
</SettingsSection>
<SettingsSection title="Editor">
<ModelSettingRowSelect
model={settings}
modelKey="editorKeymap"
title="Editor keymap"
description="Keyboard shortcut preset used by text editors."
options={keymaps}
/>
<ModelSettingRowBoolean
model={settings}
modelKey="editorSoftWrap"
title="Wrap editor lines"
description="Wrap long lines in request and response editors."
/>
<ModelSettingRowBoolean
model={settings}
modelKey="coloredMethods"
title="Colorize request methods"
description="Use method-specific colors for HTTP request methods."
/>
</SettingsSection>
<SettingsSection title="Window">
<NativeTitlebarSetting settings={settings} />
{type() !== "macos" && (
<ModelSettingRowBoolean
model={settings}
modelKey="hideWindowControls"
title="Hide window controls"
description="Hide the close, maximize, and minimize controls on Windows or Linux."
/>
)}
</SettingsSection>
<CargoFeature feature="license">
<LicenseSettings settings={settings} />
</CargoFeature>
</SettingsList>
</VStack>
);
}
function NativeTitlebarSetting({ settings }: { settings: Settings }) {
const [nativeTitlebar, setNativeTitlebar] = useState(settings.useNativeTitlebar);
return (
<SettingRow
title="Native title bar"
description="Use the operating system's standard title bar and window controls."
controlClassName="gap-2"
>
<Checkbox
hideLabel
size="md"
checked={nativeTitlebar}
title="Native title bar"
onChange={setNativeTitlebar}
/>
{settings.useNativeTitlebar !== nativeTitlebar && (
<Button
color="primary"
size="xs"
onClick={async () => {
await patchModel(settings, { useNativeTitlebar: nativeTitlebar });
await invokeCmd("cmd_restart");
}}
>
Apply and Restart
</Button>
)}
</SettingRow>
);
}
function LicenseSettings({ settings }: { settings: Settings }) {
const license = useLicense();
if (license.check.data?.status !== "personal_use") {
return null;
}
return (
<SettingsSection title="License">
<SettingRowBoolean
checked={settings.hideLicenseBadge}
title="Hide personal use badge"
description="Hide the personal-use badge from the interface."
onChange={async (hideLicenseBadge) => {
if (hideLicenseBadge) {
const confirmed = await showConfirm({
id: "hide-license-badge",
title: "Confirm Personal Use",
confirmText: "Confirm",
description: (
<VStack space={3}>
<p>Hey there 👋🏼</p>
<p>
Yaak is free for personal projects and learning.{" "}
<strong>If youre using Yaak at work, a license is required.</strong>
</p>
<p>
Licenses help keep Yaak independent and sustainable.{" "}
<Link href="https://yaak.app/pricing?s=badge">Purchase a License </Link>
</p>
</VStack>
),
requireTyping: "Personal Use",
color: "info",
});
if (!confirmed) {
return;
}
}
await patchModel(settings, { hideLicenseBadge });
}}
/>
</SettingsSection>
);
}
@@ -1,178 +0,0 @@
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import type { ProxySetting } from "@yaakapp-internal/models";
import { Heading, InlineCode, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import {
SettingRowBoolean,
SettingRowSelect,
SettingRowText,
SettingsList,
SettingsSection,
} from "../core/SettingRow";
export function SettingsProxy() {
const settings = useAtomValue(settingsAtom);
const proxy = enabledProxyOrDefault(settings.proxy);
const patchProxy = async (patch: Partial<EnabledProxySetting>) => {
await patchModel(settings, {
proxy: {
...proxy,
...patch,
auth: Object.hasOwn(patch, "auth") ? (patch.auth ?? null) : proxy.auth,
},
});
};
return (
<VStack space={1.5} className="mb-4">
<div className="mb-3">
<Heading>Proxy</Heading>
<p className="text-text-subtle">
Configure a proxy server for HTTP requests. Useful for corporate firewalls, debugging
traffic, or routing through specific infrastructure.
</p>
</div>
<SettingsList className="space-y-8">
<SettingsSection title="Proxy">
<SettingRowSelect
title="Proxy"
description="Choose how Yaak should discover or use proxy settings."
name="proxy"
value={settings.proxy?.type ?? "automatic"}
onChange={async (v) => {
if (v === "automatic") {
await patchModel(settings, { proxy: undefined });
} else if (v === "enabled") {
await patchModel(settings, { proxy });
} else {
await patchModel(settings, { proxy: { type: "disabled" } });
}
}}
options={[
{ label: "Automatic proxy detection", value: "automatic" },
{ label: "Custom proxy configuration", value: "enabled" },
{ label: "No proxy", value: "disabled" },
]}
selectClassName="!w-64"
/>
</SettingsSection>
{settings.proxy?.type === "enabled" && (
<>
<SettingsSection title="Custom Proxy">
<SettingRowBoolean
checked={!settings.proxy.disabled}
title="Enable proxy"
description="Temporarily disable the proxy without losing the configuration."
onChange={(enabled) => patchProxy({ disabled: !enabled })}
/>
<SettingRowText
name="proxyHttp"
title={
<>
Proxy for <InlineCode>http://</InlineCode> traffic
</>
}
description="Proxy host used for unencrypted HTTP traffic."
value={settings.proxy.http}
placeholder="localhost:9090"
onChange={(http) => patchProxy({ http })}
/>
<SettingRowText
name="proxyHttps"
title={
<>
Proxy for <InlineCode>https://</InlineCode> traffic
</>
}
description="Proxy host used for HTTPS traffic."
value={settings.proxy.https}
placeholder="localhost:9090"
onChange={(https) => patchProxy({ https })}
/>
<SettingRowText
name="proxyBypass"
title="Proxy Bypass"
description="Comma-separated list of hosts that should bypass the proxy."
value={settings.proxy.bypass}
placeholder="127.0.0.1, *.example.com, localhost:3000"
inputWidthClassName="!w-96"
onChange={(bypass) => patchProxy({ bypass })}
/>
</SettingsSection>
<SettingsSection title="Authentication">
<SettingRowBoolean
checked={settings.proxy.auth != null}
title="Enable authentication"
description="Send proxy credentials with proxied requests."
onChange={(enabled) =>
patchProxy({ auth: enabled ? { user: "", password: "" } : null })
}
/>
{settings.proxy.auth != null && (
<>
<SettingRowText
required
name="proxyUser"
title="User"
description="Username for proxy authentication."
value={settings.proxy.auth.user}
placeholder="myUser"
onChange={(user) =>
patchProxy({
auth: {
user,
password:
settings.proxy?.type === "enabled"
? (settings.proxy.auth?.password ?? "")
: "",
},
})
}
/>
<SettingRowText
name="proxyPassword"
title="Password"
description="Password for proxy authentication."
value={settings.proxy.auth.password}
placeholder="s3cretPassw0rd"
type="password"
onChange={(password) =>
patchProxy({
auth: {
user:
settings.proxy?.type === "enabled"
? (settings.proxy.auth?.user ?? "")
: "",
password,
},
})
}
/>
</>
)}
</SettingsSection>
</>
)}
</SettingsList>
</VStack>
);
}
type EnabledProxySetting = Extract<ProxySetting, { type: "enabled" }>;
function enabledProxyOrDefault(proxy: ProxySetting | null): EnabledProxySetting {
if (proxy?.type === "enabled") return proxy;
return {
disabled: false,
type: "enabled",
http: "",
https: "",
auth: { user: "", password: "" },
bypass: "",
};
}
@@ -1,173 +0,0 @@
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { Heading, HStack, Icon, type IconProps, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { lazy, Suspense } from "react";
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
import { useResolvedAppearance } from "../../hooks/useResolvedAppearance";
import { useResolvedTheme } from "../../hooks/useResolvedTheme";
import type { ButtonProps } from "../core/Button";
import { IconButton } from "../core/IconButton";
import { Link } from "../core/Link";
import type { SelectProps } from "../core/Select";
import {
ModelSettingRowSelect,
SettingRowSelect,
SettingsList,
SettingsSection,
} from "../core/SettingRow";
const Editor = lazy(() => import("../core/Editor/Editor").then((m) => ({ default: m.Editor })));
const buttonColors: ButtonProps["color"][] = [
"primary",
"info",
"success",
"notice",
"warning",
"danger",
"secondary",
"default",
];
const icons: IconProps["icon"][] = [
"info",
"box",
"update",
"alert_triangle",
"arrow_big_right_dash",
"download",
"copy",
"magic_wand",
"settings",
"trash",
"sparkles",
"pencil",
"paste",
"search",
"send_horizontal",
];
export function SettingsTheme() {
const workspace = useAtomValue(activeWorkspaceAtom);
const settings = useAtomValue(settingsAtom);
const appearance = useResolvedAppearance();
const activeTheme = useResolvedTheme();
if (settings == null || workspace == null || activeTheme.data == null) {
return null;
}
const lightThemes: SelectProps<string>["options"] = activeTheme.data.themes
.filter((theme) => !theme.dark)
.map((theme) => ({
label: theme.label,
value: theme.id,
}));
const darkThemes: SelectProps<string>["options"] = activeTheme.data.themes
.filter((theme) => theme.dark)
.map((theme) => ({
label: theme.label,
value: theme.id,
}));
return (
<VStack space={1.5} className="mb-4">
<div className="mb-3">
<Heading>Theme</Heading>
<p className="text-text-subtle">
Make Yaak your own by selecting a theme, or{" "}
<Link href="https://yaak.app/docs/plugin-development/plugins-quick-start">
Create Your Own
</Link>
</p>
</div>
<SettingsList className="space-y-8">
<SettingsSection title="Theme">
<ModelSettingRowSelect
model={settings}
modelKey="appearance"
title="Appearance"
description="Choose whether Yaak follows your system appearance or uses a fixed mode."
options={[
{ label: "Automatic", value: "system" },
{ label: "Light", value: "light" },
{ label: "Dark", value: "dark" },
]}
/>
{(settings.appearance === "system" || settings.appearance === "light") && (
<SettingRowSelect
name="lightTheme"
title="Light theme"
description="Theme used when Yaak is in light mode."
value={activeTheme.data.light.id}
options={lightThemes}
onChange={(themeLight) => patchModel(settings, { themeLight })}
/>
)}
{(settings.appearance === "system" || settings.appearance === "dark") && (
<SettingRowSelect
name="darkTheme"
title="Dark theme"
description="Theme used when Yaak is in dark mode."
value={activeTheme.data.dark.id}
options={darkThemes}
onChange={(themeDark) => patchModel(settings, { themeDark })}
/>
)}
</SettingsSection>
<SettingsSection title="Preview">
<VStack
space={3}
className="mt-4 w-full bg-surface p-3 border border-dashed border-border-subtle rounded overflow-x-auto"
>
<HStack className="text" space={1.5}>
<Icon icon={appearance === "dark" ? "moon" : "sun"} />
<strong>{activeTheme.data.active.label}</strong>
<em>(preview)</em>
</HStack>
<HStack space={1.5} className="w-full">
{buttonColors.map((c, i) => (
<IconButton
key={c}
color={c}
size="2xs"
iconSize="xs"
icon={icons[i % icons.length] ?? "info"}
iconClassName="text"
title={`${c}`}
/>
))}
{buttonColors.map((c, i) => (
<IconButton
key={c}
color={c}
variant="border"
size="2xs"
iconSize="xs"
icon={icons[i % icons.length] ?? "info"}
iconClassName="text"
title={`${c}`}
/>
))}
</HStack>
<Suspense>
<Editor
defaultValue={[
"let foo = { // Demo code editor",
' foo: ("bar" || "baz" ?? \'qux\'),',
" baz: [1, 10.2, null, false, true],",
"};",
].join("\n")}
heightMode="auto"
language="javascript"
stateKey={null}
/>
</Suspense>
</VStack>
</SettingsSection>
</SettingsList>
</VStack>
);
}
@@ -1,111 +0,0 @@
import { openUrl } from "@tauri-apps/plugin-opener";
import { useLicense } from "@yaakapp-internal/license";
import { useRef } from "react";
import { openSettings } from "../commands/openSettings";
import { useCheckForUpdates } from "../hooks/useCheckForUpdates";
import { useExportData } from "../hooks/useExportData";
import { appInfo } from "../lib/appInfo";
import { showDialog } from "../lib/dialog";
import { importData } from "../lib/importData";
import type { DropdownRef } from "./core/Dropdown";
import { Dropdown } from "./core/Dropdown";
import { Icon } from "@yaakapp-internal/ui";
import { IconButton } from "./core/IconButton";
import { KeyboardShortcutsDialog } from "./KeyboardShortcutsDialog";
export function SettingsDropdown() {
const exportData = useExportData();
const dropdownRef = useRef<DropdownRef>(null);
const checkForUpdates = useCheckForUpdates();
const { check } = useLicense();
return (
<Dropdown
ref={dropdownRef}
items={[
{
label: "Settings",
hotKeyAction: "settings.show",
leftSlot: <Icon icon="settings" />,
onSelect: () => openSettings.mutate(null),
},
{
label: "Keyboard shortcuts",
hotKeyAction: "hotkeys.showHelp",
leftSlot: <Icon icon="keyboard" />,
onSelect: () => {
showDialog({
id: "hotkey",
title: "Keyboard Shortcuts",
size: "dynamic",
render: () => <KeyboardShortcutsDialog />,
});
},
},
{
label: "Plugins",
leftSlot: <Icon icon="puzzle" />,
onSelect: () => openSettings.mutate("plugins"),
},
{ type: "separator", label: "Share Workspace(s)" },
{
label: "Import Data",
leftSlot: <Icon icon="folder_input" />,
onSelect: () => importData.mutate(),
},
{
label: "Export Data",
leftSlot: <Icon icon="folder_output" />,
onSelect: () => exportData.mutate(),
},
{
label: "Create Run Button",
leftSlot: <Icon icon="rocket" />,
onSelect: () => openUrl("https://yaak.app/button/new"),
},
{ type: "separator", label: `Yaak v${appInfo.version}` },
{
label: "Check for Updates",
leftSlot: <Icon icon="update" />,
hidden: !appInfo.featureUpdater,
onSelect: () => checkForUpdates.mutate(),
},
{
label: "Purchase License",
color: "success",
hidden: check.data == null || check.data.status === "active",
leftSlot: <Icon icon="circle_dollar_sign" />,
rightSlot: <Icon icon="external_link" color="success" className="opacity-60" />,
onSelect: () => openUrl("https://yaak.app/pricing"),
},
{
label: "Install CLI",
hidden: appInfo.cliVersion != null,
leftSlot: <Icon icon="square_terminal" />,
rightSlot: <Icon icon="external_link" color="secondary" />,
onSelect: () => openUrl("https://yaak.app/docs/cli"),
},
{
label: "Feedback",
leftSlot: <Icon icon="chat" />,
rightSlot: <Icon icon="external_link" color="secondary" />,
onSelect: () => openUrl("https://yaak.app/feedback"),
},
{
label: "Changelog",
leftSlot: <Icon icon="cake" />,
rightSlot: <Icon icon="external_link" color="secondary" />,
onSelect: () => openUrl(`https://yaak.app/changelog/${appInfo.version}`),
},
]}
>
<IconButton
size="sm"
title="Main Menu"
icon="settings"
iconColor="secondary"
className="pointer-events-auto"
/>
</Dropdown>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,39 +0,0 @@
import { HStack } from "@yaakapp-internal/ui";
import { useMemo } from "react";
import { useFloatingSidebarHidden } from "../hooks/useFloatingSidebarHidden";
import { useSidebarHidden } from "../hooks/useSidebarHidden";
import { CreateDropdown } from "./CreateDropdown";
import { IconButton } from "./core/IconButton";
interface Props {
floating?: boolean;
}
export function SidebarActions({ floating = false }: Props) {
const [sidebarHidden, setSidebarHidden] = useSidebarHidden();
const [floatingHidden, setFloatingHidden] = useFloatingSidebarHidden();
const hidden = floating ? floatingHidden : sidebarHidden;
const setHidden = useMemo(
() => (floating ? setFloatingHidden : setSidebarHidden),
[floating, setFloatingHidden, setSidebarHidden],
);
return (
<HStack className="h-full">
<IconButton
onClick={async () => {
await setHidden(!hidden);
}}
className="pointer-events-auto"
size="sm"
title="Toggle sidebar"
icon={hidden ? "left_panel_hidden" : "left_panel_visible"}
iconColor="secondary"
/>
<CreateDropdown hotKeyAction="model.create">
<IconButton size="sm" icon="plus_circle" iconColor="secondary" title="Add Resource" />
</CreateDropdown>
</HStack>
);
}
@@ -1,121 +0,0 @@
import { readDir } from "@tauri-apps/plugin-fs";
import { Banner, VStack } from "@yaakapp-internal/ui";
import { useState } from "react";
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { SettingRowBoolean, SettingRowDirectory } from "./core/SettingRow";
import { SelectFile } from "./SelectFile";
export interface SyncToFilesystemSettingProps {
layout?: "form" | "settings";
onChange: (args: { filePath: string | null; initGit?: boolean }) => void;
onCreateNewWorkspace: () => void;
value: { filePath: string | null; initGit?: boolean };
}
export function SyncToFilesystemSetting({
layout = "form",
onChange,
onCreateNewWorkspace,
value,
}: SyncToFilesystemSettingProps) {
const [syncDir, setSyncDir] = useState<string | null>(null);
const handleFilePathChange = async (filePath: string | null) => {
if (filePath != null) {
const files = await readDir(filePath);
if (files.length > 0) {
setSyncDir(filePath);
return;
}
}
setSyncDir(null);
onChange({ ...value, filePath });
};
if (layout === "settings") {
return (
<VStack className="w-full" space={0}>
{syncDir && (
<Banner color="notice" className="mb-3 flex flex-col gap-1.5">
<p>Directory is not empty. Do you want to open it instead?</p>
<div>
<Button
variant="border"
color="notice"
size="xs"
type="button"
onClick={() => {
openWorkspaceFromSyncDir.mutate(syncDir);
onCreateNewWorkspace();
}}
>
Open Workspace
</Button>
</div>
</Banner>
)}
<SettingRowDirectory
title="Local directory sync"
description="Sync data to a folder for backup and Git integration."
filePath={value.filePath}
onChange={handleFilePathChange}
/>
{value.filePath && typeof value.initGit === "boolean" && (
<SettingRowBoolean
checked={value.initGit}
title="Initialize Git Repo"
description="Create a Git repository in the selected sync directory."
onChange={(initGit) => onChange({ ...value, initGit })}
/>
)}
</VStack>
);
}
return (
<VStack className="w-full my-2" space={3}>
{syncDir && (
<Banner color="notice" className="flex flex-col gap-1.5">
<p>Directory is not empty. Do you want to open it instead?</p>
<div>
<Button
variant="border"
color="notice"
size="xs"
type="button"
onClick={() => {
openWorkspaceFromSyncDir.mutate(syncDir);
onCreateNewWorkspace();
}}
>
Open Workspace
</Button>
</div>
</Banner>
)}
<SelectFile
directory
label="Local directory sync"
size="xs"
noun="Directory"
help="Sync data to a folder for backup and Git integration."
filePath={value.filePath}
onChange={async ({ filePath }) => handleFilePathChange(filePath)}
/>
{value.filePath && typeof value.initGit === "boolean" && (
<Checkbox
checked={value.initGit}
onChange={(initGit) => onChange({ ...value, initGit })}
title="Initialize Git Repo"
/>
)}
</VStack>
);
}
@@ -1,49 +0,0 @@
import type { WebsocketRequest } from "@yaakapp-internal/models";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import type { CSSProperties } from "react";
import { SplitLayout } from "@yaakapp-internal/ui";
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
import { workspaceLayoutAtom } from "../lib/atoms";
import { WebsocketRequestPane } from "./WebsocketRequestPane";
import { WebsocketResponsePane } from "./WebsocketResponsePane";
interface Props {
activeRequest: WebsocketRequest;
style: CSSProperties;
}
export function WebsocketRequestLayout({ activeRequest, style }: Props) {
const workspaceLayout = useAtomValue(workspaceLayoutAtom);
const activeWorkspace = useAtomValue(activeWorkspaceAtom);
const wsId = activeWorkspace?.id ?? "n/a";
return (
<SplitLayout
storageKey={`websocket_layout::${wsId}`}
className="p-3 gap-1.5"
layout={workspaceLayout}
style={style}
firstSlot={({ orientation, style }) => (
<WebsocketRequestPane
style={style}
activeRequest={activeRequest}
fullHeight={orientation === "horizontal"}
/>
)}
secondSlot={({ style }) => (
<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",
)}
>
<WebsocketResponsePane activeRequest={activeRequest} />
</div>
)}
/>
);
}
-227
View File
@@ -1,227 +0,0 @@
import { type } from "@tauri-apps/plugin-os";
import { settingsAtom, workspacesAtom } from "@yaakapp-internal/models";
import { Banner, HeaderSize, HStack, SidebarLayout } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import * as m from "motion/react-m";
import { useMemo, useState } from "react";
import {
getActiveCookieJar,
useEnsureActiveCookieJar,
useSubscribeActiveCookieJarId,
} from "../hooks/useActiveCookieJar";
import {
activeEnvironmentAtom,
useSubscribeActiveEnvironmentId,
} from "../hooks/useActiveEnvironment";
import { activeFolderAtom } from "../hooks/useActiveFolder";
import { useSubscribeActiveFolderId } from "../hooks/useActiveFolderId";
import { activeRequestAtom } from "../hooks/useActiveRequest";
import { useSubscribeActiveRequestId } from "../hooks/useActiveRequestId";
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
import { useFloatingSidebarHidden } from "../hooks/useFloatingSidebarHidden";
import { useHotKey } from "../hooks/useHotKey";
import { useSubscribeRecentCookieJars } from "../hooks/useRecentCookieJars";
import { useSubscribeRecentEnvironments } from "../hooks/useRecentEnvironments";
import { useSubscribeRecentRequests } from "../hooks/useRecentRequests";
import { useSubscribeRecentWorkspaces } from "../hooks/useRecentWorkspaces";
import { useSidebarHidden } from "../hooks/useSidebarHidden";
import { useSidebarWidth } from "../hooks/useSidebarWidth";
import { useSyncWorkspaceRequestTitle } from "../hooks/useSyncWorkspaceRequestTitle";
import { duplicateRequestOrFolderAndNavigate } from "../lib/duplicateRequestOrFolderAndNavigate";
import { importData } from "../lib/importData";
import { jotaiStore } from "../lib/jotai";
import { CreateDropdown } from "./CreateDropdown";
import { Button } from "./core/Button";
import { HotkeyList } from "./core/HotkeyList";
import { CookieDialog } from "./CookieDialog";
import { FeedbackLink } from "./core/Link";
import { ErrorBoundary } from "./ErrorBoundary";
import { FolderLayout } from "./FolderLayout";
import { GrpcConnectionLayout } from "./GrpcConnectionLayout";
import { HttpRequestLayout } from "./HttpRequestLayout";
import Sidebar from "./Sidebar";
import { SidebarActions } from "./SidebarActions";
import { WebsocketRequestLayout } from "./WebsocketRequestLayout";
import { WorkspaceHeader } from "./WorkspaceHeader";
const body = { gridArea: "body" };
export function Workspace() {
// First, subscribe to some things applicable to workspaces
useGlobalWorkspaceHooks();
const workspaces = useAtomValue(workspacesAtom);
const settings = useAtomValue(settingsAtom);
const osType = type();
const [width, setWidth] = useSidebarWidth();
const [sidebarHidden, setSidebarHidden] = useSidebarHidden();
const [floatingSidebarHidden, setFloatingSidebarHidden] = useFloatingSidebarHidden();
const activeEnvironment = useAtomValue(activeEnvironmentAtom);
const [floating, setFloating] = useState(false);
const environmentBgStyle = useMemo(() => {
if (activeEnvironment?.color == null) return undefined;
const background = `linear-gradient(to right, ${activeEnvironment.color} 15%, transparent 40%)`;
return { background };
}, [activeEnvironment?.color]);
// We're loading still
if (workspaces.length === 0) {
return null;
}
const header = (
<HeaderSize
data-tauri-drag-region
size="lg"
className="relative x-theme-appHeader bg-surface"
osType={osType}
hideWindowControls={settings.hideWindowControls}
useNativeTitlebar={settings.useNativeTitlebar}
interfaceScale={settings.interfaceScale}
>
<div className="absolute inset-0 pointer-events-none">
<div style={environmentBgStyle} className="absolute inset-0 opacity-[0.07]" />
<div
style={environmentBgStyle}
className="absolute left-0 right-0 -bottom-[1px] h-[1px] opacity-20"
/>
</div>
<WorkspaceHeader className="pointer-events-none" floatingSidebar={floating} />
</HeaderSize>
);
const workspaceBody = (
<ErrorBoundary name="Workspace Body">
<WorkspaceBody />
</ErrorBoundary>
);
const sidebarContent = floating ? (
<div
className={classNames(
"x-theme-sidebar",
"h-full bg-surface border-r border-border-subtle",
"grid grid-rows-[auto_1fr]",
)}
>
<HeaderSize
hideControls
size="lg"
className="border-transparent flex items-center"
osType={osType}
hideWindowControls={settings.hideWindowControls}
useNativeTitlebar={settings.useNativeTitlebar}
interfaceScale={settings.interfaceScale}
>
<SidebarActions floating />
</HeaderSize>
<ErrorBoundary name="Sidebar (Floating)">
<Sidebar />
</ErrorBoundary>
</div>
) : (
<div className="x-theme-sidebar overflow-hidden bg-surface h-full">
<ErrorBoundary name="Sidebar">
<Sidebar className="border-r border-border-subtle" />
</ErrorBoundary>
</div>
);
return (
<div className="grid w-full h-full grid-rows-[auto_minmax(0,1fr)]">
{header}
<SidebarLayout
width={width ?? 250}
onWidthChange={setWidth}
hidden={sidebarHidden ?? false}
onHiddenChange={(hidden) => setSidebarHidden(hidden)}
floatingHidden={floatingSidebarHidden ?? true}
onFloatingHiddenChange={(hidden) => setFloatingSidebarHidden(hidden)}
onFloatingChange={setFloating}
sidebar={sidebarContent}
>
{workspaceBody}
</SidebarLayout>
</div>
);
}
function WorkspaceBody() {
const activeRequest = useAtomValue(activeRequestAtom);
const activeFolder = useAtomValue(activeFolderAtom);
const activeWorkspace = useAtomValue(activeWorkspaceAtom);
if (activeWorkspace == null) {
return (
<m.div
className="m-auto"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
// Delay the entering because the workspaces might load after a slight delay
transition={{ delay: 0.5 }}
>
<Banner color="warning" className="max-w-[30rem]">
The active workspace was not found. Select a workspace from the header menu or report this
bug to <FeedbackLink />
</Banner>
</m.div>
);
}
if (activeRequest?.model === "grpc_request") {
return <GrpcConnectionLayout style={body} />;
}
if (activeRequest?.model === "websocket_request") {
return <WebsocketRequestLayout style={body} activeRequest={activeRequest} />;
}
if (activeRequest?.model === "http_request") {
return <HttpRequestLayout activeRequest={activeRequest} style={body} />;
}
if (activeFolder != null) {
return <FolderLayout folder={activeFolder} style={body} />;
}
return (
<HotkeyList
hotkeys={["model.create", "sidebar.focus", "settings.show"]}
bottomSlot={
<HStack space={1} justifyContent="center" className="mt-3">
<Button variant="border" size="sm" onClick={() => importData.mutate()}>
Import
</Button>
<CreateDropdown hideFolder>
<Button variant="border" forDropdown size="sm">
New Request
</Button>
</CreateDropdown>
</HStack>
}
/>
);
}
function useGlobalWorkspaceHooks() {
useEnsureActiveCookieJar();
useSubscribeActiveRequestId();
useSubscribeActiveFolderId();
useSubscribeActiveEnvironmentId();
useSubscribeActiveCookieJarId();
useSubscribeRecentRequests();
useSubscribeRecentWorkspaces();
useSubscribeRecentEnvironments();
useSubscribeRecentCookieJars();
useSyncWorkspaceRequestTitle();
useHotKey("model.duplicate", () =>
duplicateRequestOrFolderAndNavigate(jotaiStore.get(activeRequestAtom)),
);
useHotKey("cookies_editor.show", () => CookieDialog.show(getActiveCookieJar()?.id ?? null), {
enable: () => getActiveCookieJar() != null,
});
}
@@ -1,94 +0,0 @@
import { HStack, Icon } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtom, useAtomValue } from "jotai";
import { memo } from "react";
import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from "../hooks/useActiveWorkspace";
import { useToggleCommandPalette } from "../hooks/useToggleCommandPalette";
import { workspaceLayoutAtom } from "../lib/atoms";
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
import { CookieDropdown } from "./CookieDropdown";
import { IconButton } from "./core/IconButton";
import { PillButton } from "./core/PillButton";
import { EnvironmentActionsDropdown } from "./EnvironmentActionsDropdown";
import { ImportCurlButton } from "./ImportCurlButton";
import { LicenseBadge } from "./LicenseBadge";
import { RecentRequestsDropdown } from "./RecentRequestsDropdown";
import { SettingsDropdown } from "./SettingsDropdown";
import { SidebarActions } from "./SidebarActions";
import { WorkspaceActionsDropdown } from "./WorkspaceActionsDropdown";
interface Props {
className?: string;
floatingSidebar?: boolean;
}
export const WorkspaceHeader = memo(function WorkspaceHeader({
className,
floatingSidebar,
}: Props) {
const togglePalette = useToggleCommandPalette();
const [workspaceLayout, setWorkspaceLayout] = useAtom(workspaceLayoutAtom);
const workspace = useAtomValue(activeWorkspaceAtom);
const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);
const showEncryptionSetup =
workspace != null &&
workspaceMeta != null &&
workspace.encryptionKeyChallenge != null &&
workspaceMeta.encryptionKey == null;
return (
<div
className={classNames(
className,
"grid grid-cols-[auto_minmax(0,1fr)_auto] items-center w-full h-full",
)}
>
<HStack space={0.5} className={classNames("flex-1 pointer-events-none")}>
<SidebarActions floating={floatingSidebar} />
<CookieDropdown />
<HStack className="min-w-0">
<WorkspaceActionsDropdown />
<Icon icon="chevron_right" color="secondary" />
<EnvironmentActionsDropdown className="w-auto pointer-events-auto" />
</HStack>
</HStack>
<div className="pointer-events-none w-full max-w-[30vw] mx-auto flex justify-center">
<RecentRequestsDropdown />
</div>
<div className="flex-1 flex gap-1 items-center h-full justify-end pointer-events-none pr-1">
<ImportCurlButton />
{showEncryptionSetup ? (
<PillButton color="danger" onClick={setupOrConfigureEncryption}>
Enter Encryption Key
</PillButton>
) : (
<LicenseBadge />
)}
<IconButton
icon={
workspaceLayout === "responsive"
? "magic_wand"
: workspaceLayout === "horizontal"
? "columns_2"
: "rows_2"
}
title={`Change to ${workspaceLayout === "horizontal" ? "vertical" : "horizontal"} layout`}
size="sm"
iconColor="secondary"
onClick={() =>
setWorkspaceLayout((prev) => (prev === "horizontal" ? "vertical" : "horizontal"))
}
/>
<IconButton
icon="search"
title="Search or execute a command"
size="sm"
hotkeyAction="command_palette.toggle"
iconColor="secondary"
onClick={togglePalette}
/>
<SettingsDropdown />
</div>
</div>
);
});
@@ -1,34 +0,0 @@
import { Button as BaseButton, type ButtonProps as BaseButtonProps } from "@yaakapp-internal/ui";
import { forwardRef, useImperativeHandle, useRef } from "react";
import type { HotkeyAction } from "../../hooks/useHotKey";
import { useFormattedHotkey, useHotKey } from "../../hooks/useHotKey";
export type ButtonProps = BaseButtonProps & {
hotkeyAction?: HotkeyAction;
hotkeyLabelOnly?: boolean;
hotkeyPriority?: number;
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{ hotkeyAction, hotkeyPriority, hotkeyLabelOnly, title, ...props }: ButtonProps,
ref,
) {
const hotkeyTrigger = useFormattedHotkey(hotkeyAction ?? null)?.join("");
const fullTitle = hotkeyTrigger ? `${title ?? ""} ${hotkeyTrigger}`.trim() : title;
const buttonRef = useRef<HTMLButtonElement>(null);
useImperativeHandle<HTMLButtonElement | null, HTMLButtonElement | null>(
ref,
() => buttonRef.current,
);
useHotKey(
hotkeyAction ?? null,
() => {
buttonRef.current?.click();
},
{ priority: hotkeyPriority, enable: !hotkeyLabelOnly },
);
return <BaseButton ref={buttonRef} title={fullTitle} {...props} />;
});
@@ -1,73 +0,0 @@
import { HStack, Icon } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { ReactNode } from "react";
import { IconTooltip } from "./IconTooltip";
export interface CheckboxProps {
checked: boolean | "indeterminate";
title: ReactNode;
onChange: (checked: boolean) => void;
className?: string;
disabled?: boolean;
inputWrapperClassName?: string;
hideLabel?: boolean;
fullWidth?: boolean;
help?: ReactNode;
size?: "sm" | "md";
}
export function Checkbox({
checked,
onChange,
className,
inputWrapperClassName,
disabled,
title,
hideLabel,
fullWidth,
help,
size = "sm",
}: CheckboxProps) {
return (
<HStack
as="label"
alignItems="center"
space={2}
className={classNames(className, "text-text mr-auto")}
>
<div className={classNames(inputWrapperClassName, "x-theme-input", "relative flex mr-0.5")}>
<input
aria-hidden
className={classNames(
"appearance-none flex-shrink-0 border border-border",
size === "sm" && "w-4 h-4",
size === "md" && "w-5 h-5",
"rounded outline-none ring-0",
!disabled && "hocus:border-border-focus hocus:bg-focus/[5%]",
disabled && "border-dotted",
)}
type="checkbox"
disabled={disabled}
onChange={() => {
onChange(checked === "indeterminate" ? true : !checked);
}}
/>
<div className="absolute inset-0 flex items-center justify-center">
<Icon
size={size}
className={classNames(disabled && "opacity-disabled")}
icon={checked === "indeterminate" ? "minus" : checked ? "check" : "empty"}
/>
</div>
</div>
{!hideLabel && (
<div
className={classNames("text-sm", fullWidth && "w-full", disabled && "opacity-disabled")}
>
{title}
</div>
)}
{help && <IconTooltip content={help} />}
</HStack>
);
}
@@ -1,12 +0,0 @@
import { lazy, Suspense } from "react";
import type { EditorProps } from "./Editor";
const Editor_ = lazy(() => import("./Editor").then((m) => ({ default: m.Editor })));
export function Editor(props: EditorProps) {
return (
<Suspense>
<Editor_ {...props} />
</Suspense>
);
}
@@ -1,21 +0,0 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
import { LRParser } from "@lezer/lr";
import { highlight } from "./highlight";
export const parser = LRParser.deserialize({
version: 14,
states:
"!pQQOPOOO`OPO'#C^OeOPO'#CaOjOPO'#CcOoOPO'#CeOOOO'#Ci'#CiOOOO'#Cg'#CgQQOPOOOOOO,58x,58xOOOO,58{,58{OOOO,58},58}OOOO,59P,59POOOO-E6e-E6e",
stateData: "z~ORPOUQOWROYSO~OSWO~OSXO~OSYO~OSZO~ORUWYW~",
goto: "m^PP_PP_P_P_PcPiTTOVQVOR[VTUOV",
nodeNames:
"⚠ Timeline OutgoingLine OutgoingText Newline IncomingLine IncomingText InfoLine InfoText PlainLine PlainText",
maxTerm: 13,
propSources: [highlight],
skippedNodes: [0],
repeatNodeCount: 1,
tokenData:
"%h~RZOYtYZ!]Zztz{!b{!^t!^!_#d!_!`t!`!a$f!a;'St;'S;=`!V<%lOt~ySY~OYtZ;'St;'S;=`!V<%lOt~!YP;=`<%lt~!bOS~~!gUY~OYtZptpq!yq;'St;'S;=`!V<%lOt~#QSW~Y~OY!yZ;'S!y;'S;=`#^<%lO!y~#aP;=`<%l!y~#iUY~OYtZptpq#{q;'St;'S;=`!V<%lOt~$SSU~Y~OY#{Z;'S#{;'S;=`$`<%lO#{~$cP;=`<%l#{~$kUY~OYtZptpq$}q;'St;'S;=`!V<%lOt~%USR~Y~OY$}Z;'S$};'S;=`%b<%lO$}~%eP;=`<%l$}",
tokenizers: [0],
topRules: { Timeline: [0, 1] },
tokenPrec: 36,
});
@@ -1,108 +0,0 @@
/* oxlint-disable no-template-curly-in-string */
import { describe, expect, test } from "vite-plus/test";
import { parser } from "./twig";
function getNodeNames(input: string): string[] {
const tree = parser.parse(input);
const nodes: string[] = [];
const cursor = tree.cursor();
do {
if (cursor.name !== "Template") {
nodes.push(cursor.name);
}
} while (cursor.next());
return nodes;
}
function hasTag(input: string): boolean {
return getNodeNames(input).includes("Tag");
}
function hasError(input: string): boolean {
return getNodeNames(input).includes("⚠");
}
describe("twig grammar", () => {
describe("${[var]} format (valid template tags)", () => {
test("parses simple variable as Tag", () => {
expect(hasTag("${[var]}")).toBe(true);
expect(hasError("${[var]}")).toBe(false);
});
test("parses variable with whitespace as Tag", () => {
expect(hasTag("${[ var ]}")).toBe(true);
expect(hasError("${[ var ]}")).toBe(false);
});
test("parses embedded variable as Tag", () => {
expect(hasTag("hello ${[name]} world")).toBe(true);
expect(hasError("hello ${[name]} world")).toBe(false);
});
test("parses function call as Tag", () => {
expect(hasTag("${[fn()]}")).toBe(true);
expect(hasError("${[fn()]}")).toBe(false);
});
});
describe("${var} format (should be plain text, not tags)", () => {
test("parses ${var} as plain Text without errors", () => {
expect(hasTag("${var}")).toBe(false);
expect(hasError("${var}")).toBe(false);
});
test("parses embedded ${var} as plain Text", () => {
expect(hasTag("hello ${name} world")).toBe(false);
expect(hasError("hello ${name} world")).toBe(false);
});
test("parses JSON with ${var} as plain Text", () => {
const json = '{"key": "${value}"}';
expect(hasTag(json)).toBe(false);
expect(hasError(json)).toBe(false);
});
test("parses multiple ${var} as plain Text", () => {
expect(hasTag("${a} and ${b}")).toBe(false);
expect(hasError("${a} and ${b}")).toBe(false);
});
});
describe("mixed content", () => {
test("distinguishes ${var} from ${[var]} in same string", () => {
const input = "${plain} and ${[tag]}";
expect(hasTag(input)).toBe(true);
expect(hasError(input)).toBe(false);
});
test("parses JSON with ${[var]} as having Tag", () => {
const json = '{"key": "${[value]}"}';
expect(hasTag(json)).toBe(true);
expect(hasError(json)).toBe(false);
});
});
describe("edge cases", () => {
test("handles $ at end of string", () => {
expect(hasError("hello$")).toBe(false);
expect(hasTag("hello$")).toBe(false);
});
test("handles ${ at end of string without crash", () => {
// Incomplete syntax may produce errors, but should not crash
expect(() => parser.parse("hello${")).not.toThrow();
});
test("handles ${[ without closing without crash", () => {
// Unclosed tag may produce partial match, but should not crash
expect(() => parser.parse("${[unclosed")).not.toThrow();
});
test("handles empty ${[]}", () => {
// Empty tags may or may not be valid depending on grammar
// Just ensure no crash
expect(() => parser.parse("${[]}")).not.toThrow();
});
});
});
@@ -1,9 +0,0 @@
import { genericCompletion } from "../genericCompletion";
export const completions = genericCompletion({
options: [
{ label: "http://", type: "constant" },
{ label: "https://", type: "constant" },
],
minMatch: 1,
});
@@ -1,15 +0,0 @@
import classNames from "classnames";
import type { HotkeyAction } from "../../hooks/useHotKey";
import { useHotkeyLabel } from "../../hooks/useHotKey";
interface Props {
action: HotkeyAction;
className?: string;
}
export function HotkeyLabel({ action, className }: Props) {
const label = useHotkeyLabel(action);
return (
<span className={classNames(className, "text-text-subtle whitespace-nowrap")}>{label}</span>
);
}
@@ -1,91 +0,0 @@
import type { GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-internal/models";
import { settingsAtom } from "@yaakapp-internal/models";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import { memo } from "react";
interface Props {
request: HttpRequest | GrpcRequest | WebsocketRequest;
className?: string;
short?: boolean;
noAlias?: boolean;
}
const methodNames: Record<string, string> = {
get: "GET",
put: "PUT",
post: "POST",
patch: "PTCH",
delete: "DELE",
options: "OPTN",
head: "HEAD",
query: "QURY",
graphql: "GQL",
grpc: "GRPC",
websocket: "WS",
};
export const HttpMethodTag = memo(function HttpMethodTag({
request,
className,
short,
noAlias,
}: Props) {
const method =
request.model === "http_request" && request.bodyType === "graphql" && !noAlias
? "graphql"
: request.model === "grpc_request"
? "grpc"
: request.model === "websocket_request"
? "websocket"
: request.method;
return <HttpMethodTagRaw method={method} className={className} short={short} />;
});
export function HttpMethodTagRaw({
className,
method,
short,
forceColor,
}: {
method: string;
className?: string;
short?: boolean;
forceColor?: boolean;
}) {
let label = method.toUpperCase();
if (short) {
label = methodNames[method.toLowerCase()] ?? method.slice(0, 4);
label = label.padEnd(4, " ");
}
const m = method.toUpperCase();
const settings = useAtomValue(settingsAtom);
const colored = forceColor || settings.coloredMethods;
return (
<span
className={classNames(
className,
!colored && "text-text-subtle",
colored && m === "GRAPHQL" && "text-info",
colored && m === "WEBSOCKET" && "text-info",
colored && m === "GRPC" && "text-info",
colored && m === "QUERY" && "text-text-subtle",
colored && m === "OPTIONS" && "text-info",
colored && m === "HEAD" && "text-text-subtle",
colored && m === "GET" && "text-primary",
colored && m === "PUT" && "text-warning",
colored && m === "PATCH" && "text-notice",
colored && m === "POST" && "text-success",
colored && m === "DELETE" && "text-danger",
"font-mono flex-shrink-0 whitespace-pre",
"pt-[0.15em]", // Fix for monospace font not vertically centering
)}
>
{label}
</span>
);
}
@@ -1,37 +0,0 @@
import {
IconButton as BaseIconButton,
type IconButtonProps as BaseIconButtonProps,
} from "@yaakapp-internal/ui";
import { forwardRef, useImperativeHandle, useRef } from "react";
import type { HotkeyAction } from "../../hooks/useHotKey";
import { useFormattedHotkey, useHotKey } from "../../hooks/useHotKey";
export type IconButtonProps = BaseIconButtonProps & {
hotkeyAction?: HotkeyAction;
hotkeyLabelOnly?: boolean;
hotkeyPriority?: number;
};
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(
{ hotkeyAction, hotkeyPriority, hotkeyLabelOnly, title, ...props }: IconButtonProps,
ref,
) {
const hotkeyTrigger = useFormattedHotkey(hotkeyAction ?? null)?.join("");
const fullTitle = hotkeyTrigger ? `${title ?? ""} ${hotkeyTrigger}`.trim() : title;
const buttonRef = useRef<HTMLButtonElement>(null);
useImperativeHandle<HTMLButtonElement | null, HTMLButtonElement | null>(
ref,
() => buttonRef.current,
);
useHotKey(
hotkeyAction ?? null,
() => {
buttonRef.current?.click();
},
{ priority: hotkeyPriority, enable: !hotkeyLabelOnly },
);
return <BaseIconButton ref={buttonRef} title={fullTitle} {...props} />;
});
@@ -1,104 +0,0 @@
import classNames from "classnames";
import type { HTMLAttributes, ReactElement, ReactNode } from "react";
import { CopyIconButton } from "../CopyIconButton";
interface Props {
children:
| ReactElement<HTMLAttributes<HTMLTableColElement>>
| (ReactElement<HTMLAttributes<HTMLTableColElement>> | null)[];
selectable?: boolean;
}
export function KeyValueRows({ children, selectable }: Props) {
const childArray = Array.isArray(children) ? children.filter(Boolean) : [children];
return (
<table
className={classNames(
"text-editor font-mono min-w-0 w-full mb-auto",
selectable &&
"[&_td]:select-auto [&_td]:cursor-auto [&_td_*]:select-auto [&_td_*]:cursor-auto",
)}
>
<tbody className="divide-y divide-surface-highlight">
{childArray.map((child, i) => (
// oxlint-disable-next-line react/no-array-index-key
<tr key={i}>{child}</tr>
))}
</tbody>
</table>
);
}
interface KeyValueRowProps {
label: ReactNode;
children: ReactNode;
rightSlot?: ReactNode;
leftSlot?: ReactNode;
align?: "top" | "middle";
labelClassName?: string;
labelColor?: "secondary" | "primary" | "info";
enableCopy?: boolean;
copyText?: string;
}
export function KeyValueRow({
label,
children,
rightSlot,
leftSlot,
align = "top",
labelColor = "secondary",
labelClassName,
enableCopy,
copyText,
}: KeyValueRowProps) {
const textToCopy =
copyText ??
(typeof children === "string" || typeof children === "number" ? `${children}` : null);
const resolvedRightSlot =
rightSlot ??
(enableCopy && textToCopy != null ? (
<CopyIconButton
text={textToCopy}
className="text-text-subtle"
size="2xs"
title={`Copy ${label}`}
iconSize="sm"
/>
) : null);
return (
<>
<td
className={classNames(
"select-none py-0.5 pr-2 h-full max-w-[10rem]",
align === "top" && "align-top",
align === "middle" && "align-middle",
labelClassName,
labelColor === "primary" && "text-primary",
labelColor === "secondary" && "text-text-subtle",
labelColor === "info" && "text-info",
)}
>
<span className="select-text cursor-text">{label}</span>
</td>
<td
className={classNames(
"select-none py-0.5 break-all max-w-[15rem]",
align === "top" && "align-top",
align === "middle" && "align-middle",
)}
>
<div className="select-text cursor-text max-h-[12rem] overflow-y-auto grid grid-cols-[auto_minmax(0,1fr)_auto]">
{leftSlot ?? <span aria-hidden />}
{children}
{resolvedRightSlot ? (
<div className="ml-1.5">{resolvedRightSlot}</div>
) : (
<span aria-hidden />
)}
</div>
</td>
</>
);
}
@@ -1,9 +0,0 @@
import { generateId } from "../../lib/generateId";
import type { Pair, PairWithId } from "./PairEditor";
export function ensurePairId(p: Pair): PairWithId {
if (typeof p.id === "string") {
return p as PairWithId;
}
return { ...p, id: p.id ?? generateId() };
}
@@ -1,14 +0,0 @@
import classNames from "classnames";
import type { ButtonProps } from "./Button";
import { Button } from "./Button";
export function PillButton({ className, ...props }: ButtonProps) {
return (
<Button
size="2xs"
variant="border"
className={classNames(className, "!rounded-full mx-1 !px-3")}
{...props}
/>
);
}
@@ -1,43 +0,0 @@
import type { Color } from "@yaakapp-internal/plugins";
import classNames from "classnames";
import type { ReactNode } from "react";
interface Props {
orientation?: "horizontal" | "vertical";
dashed?: boolean;
className?: string;
children?: ReactNode;
color?: Color;
}
export function Separator({
color,
className,
dashed,
orientation = "horizontal",
children,
}: Props) {
return (
<div role="presentation" className={classNames(className, "flex items-center w-full")}>
{children && (
<div className="text-sm text-text-subtlest mr-2 whitespace-nowrap">{children}</div>
)}
<div
className={classNames(
"opacity-60",
color == null && "border-border",
color === "primary" && "border-primary",
color === "secondary" && "border-secondary",
color === "success" && "border-success",
color === "notice" && "border-notice",
color === "warning" && "border-warning",
color === "danger" && "border-danger",
color === "info" && "border-info",
dashed && "border-dashed",
orientation === "horizontal" && "w-full h-0 border-t",
orientation === "vertical" && "h-full w-0 border-l",
)}
/>
</div>
);
}
@@ -1,514 +0,0 @@
import type { AnyModel } from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models";
import classNames from "classnames";
import type { ReactNode } from "react";
import { CopyIconButton } from "../CopyIconButton";
import { Checkbox } from "./Checkbox";
import { IconButton, type IconButtonProps } from "./IconButton";
import { PlainInput } from "./PlainInput";
import type { RadioDropdownItem } from "./RadioDropdown";
import { Select } from "./Select";
import { SelectFile } from "../SelectFile";
type ModelKeyOfValue<T, V> = {
[K in keyof T]-?: T[K] extends V ? K : never;
}[keyof T];
type SettingRowBaseProps = {
className?: string;
controlClassName?: string;
description?: ReactNode;
disabled?: boolean;
title: ReactNode;
};
export function SettingsList({ children, className }: { children: ReactNode; className?: string }) {
return <div className={classNames("w-full", className)}>{children}</div>;
}
export function SettingsSection({
children,
className,
description,
title,
}: {
children: ReactNode;
className?: string;
description?: ReactNode;
title: ReactNode | null;
}) {
const showHeader = title != null || description != null;
return (
<section className={classNames(className, "w-full")}>
{showHeader && (
<div className="border-b border-border-subtle pb-2">
{title != null && <div className="text-text-subtle">{title}</div>}
{description != null && <p className="mt-1 text-sm text-text-subtlest">{description}</p>}
</div>
)}
<div className="[&>*:last-child]:border-b-0">{children}</div>
</section>
);
}
export function SettingRow({
children,
className,
controlClassName,
description,
disabled,
title,
}: {
children: ReactNode;
} & SettingRowBaseProps) {
return (
<div
aria-disabled={disabled || undefined}
className={classNames(
className,
"@container border-b border-border-subtle py-4",
disabled && "opacity-disabled",
)}
>
<div
className={classNames(
"grid grid-cols-1 gap-2",
"@[30rem]:grid-cols-[minmax(0,1fr)_auto] items-center",
)}
>
<div className="min-w-0">
<div className="text text-text">{title}</div>
{description != null && (
<div className="mt-1 max-w-2xl text-sm text-text-subtle">{description}</div>
)}
</div>
<div
className={classNames(
"flex min-w-0 items-center justify-start @[40rem]:justify-end",
controlClassName,
)}
>
{children}
</div>
</div>
</div>
);
}
export function SettingValue({
actions,
className,
copyText,
enableCopy = true,
value,
}: {
actions?: SettingValueAction[];
className?: string;
copyText?: string;
enableCopy?: boolean;
value: ReactNode;
}) {
const textValue = typeof value === "string" || typeof value === "number" ? `${value}` : null;
const textToCopy = copyText ?? textValue;
return (
<>
<span
className={classNames(
className,
"cursor-text select-text truncate font-mono text-editor text-text-subtle pr-1.5",
)}
>
{value}
</span>
{actions?.map((action) => (
<IconButton
key={action.title}
icon={action.icon}
title={action.title}
size="2xs"
iconSize="sm"
onClick={action.onClick}
/>
))}
{enableCopy && textToCopy != null && (
<CopyIconButton size="2xs" text={textToCopy} title="Copy value" />
)}
</>
);
}
type SettingValueAction = {
icon: IconButtonProps["icon"];
onClick: () => void;
title: string;
};
export function SettingRowBoolean({
checked,
checkboxSize = "md",
onChange,
title,
...props
}: {
checked: boolean;
checkboxSize?: "sm" | "md";
onChange: (checked: boolean) => void;
} & SettingRowBaseProps) {
return (
<SettingRow title={title} {...props}>
<Checkbox
hideLabel
size={checkboxSize}
checked={checked}
disabled={props.disabled}
title={title}
onChange={onChange}
/>
</SettingRow>
);
}
export function ModelSettingRowBoolean<M extends AnyModel, K extends ModelKeyOfValue<M, boolean>>({
model,
modelKey,
...props
}: {
model: M;
modelKey: K;
} & Omit<Parameters<typeof SettingRowBoolean>[0], "checked" | "onChange">) {
return (
<SettingRowBoolean
checked={model[modelKey] as boolean}
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
{...props}
/>
);
}
export function SettingRowNumber({
inputClassName,
inputWidthClassName = "!w-48",
name,
onChange,
placeholder,
required,
title,
type = "number",
validate,
value,
...props
}: {
inputClassName?: string;
inputWidthClassName?: string;
name: string;
onChange: (value: number) => void;
placeholder?: string;
required?: boolean;
type?: "number";
validate?: (value: string) => boolean;
value: number;
} & SettingRowBaseProps) {
return (
<SettingRow title={title} {...props}>
<PlainInput
required={required}
hideLabel
size="sm"
name={name}
label={typeof title === "string" ? title : name}
placeholder={placeholder}
defaultValue={`${value}`}
validate={validate}
onChange={(value) => onChange(Number.parseInt(value, 10) || 0)}
type={type}
className={inputClassName}
containerClassName={inputWidthClassName}
disabled={props.disabled}
/>
</SettingRow>
);
}
export function ModelSettingRowNumber<M extends AnyModel, K extends ModelKeyOfValue<M, number>>({
model,
modelKey,
...props
}: {
model: M;
modelKey: K;
} & Omit<Parameters<typeof SettingRowNumber>[0], "name" | "onChange" | "value">) {
return (
<SettingRowNumber
name={String(modelKey)}
value={model[modelKey] as number}
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
{...props}
/>
);
}
export function SettingRowText({
inputClassName,
inputWidthClassName = "!w-80",
name,
onChange,
placeholder,
required,
title,
type = "text",
value,
...props
}: {
inputClassName?: string;
inputWidthClassName?: string;
name: string;
onChange: (value: string) => void;
placeholder?: string;
required?: boolean;
type?: "text" | "password";
value: string;
} & SettingRowBaseProps) {
return (
<SettingRow title={title} {...props}>
<PlainInput
required={required}
hideLabel
size="sm"
name={name}
label={typeof title === "string" ? title : name}
placeholder={placeholder}
defaultValue={value}
onChange={onChange}
type={type}
className={inputClassName}
containerClassName={inputWidthClassName}
disabled={props.disabled}
/>
</SettingRow>
);
}
export function ModelSettingRowText<M extends AnyModel, K extends ModelKeyOfValue<M, string>>({
model,
modelKey,
...props
}: {
model: M;
modelKey: K;
} & Omit<Parameters<typeof SettingRowText>[0], "name" | "onChange" | "value">) {
return (
<SettingRowText
name={String(modelKey)}
value={model[modelKey] as string}
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
{...props}
/>
);
}
export function SettingRowFile({
buttonClassName,
controlClassName = "min-w-0 max-w-[min(32rem,45vw)]",
directory,
filePath,
nameOverride,
noun,
onChange,
size = "xs",
title,
...props
}: {
buttonClassName?: string;
directory?: boolean;
filePath: string | null;
nameOverride?: string | null;
noun?: string;
onChange: (filePath: string | null) => void | Promise<void>;
size?: Parameters<typeof SelectFile>[0]["size"];
} & SettingRowBaseProps) {
return (
<SettingRow title={title} controlClassName={controlClassName} {...props}>
<SelectFile
directory={directory}
inline
hideLabel
label={typeof title === "string" ? title : noun}
size={size}
noun={noun}
nameOverride={nameOverride}
filePath={filePath}
className={buttonClassName}
onChange={({ filePath }) => onChange(filePath)}
/>
</SettingRow>
);
}
export function SettingRowDirectory({
noun = "Directory",
...props
}: Omit<Parameters<typeof SettingRowFile>[0], "directory">) {
return <SettingRowFile directory noun={noun} {...props} />;
}
export function SettingRowSelect<T extends string>({
defaultValue,
name,
onChange,
options,
selectClassName = "!w-48",
title,
value,
...props
}: {
defaultValue?: T;
name: string;
onChange: (value: T) => void;
options: RadioDropdownItem<T>[];
selectClassName?: string;
value: T;
} & SettingRowBaseProps) {
return (
<SettingRow title={title} {...props}>
<SettingSelectControl
name={name}
label={typeof title === "string" ? title : name}
value={value}
defaultValue={defaultValue}
selectClassName={selectClassName}
disabled={props.disabled}
onChange={onChange}
options={options}
/>
</SettingRow>
);
}
export function SettingSelectControl<T extends string>({
defaultValue,
disabled,
label,
name,
onChange,
options,
selectClassName = "!w-48",
value,
}: {
defaultValue?: T;
disabled?: boolean;
label: string;
name: string;
onChange: (value: T) => void;
options: RadioDropdownItem<T>[];
selectClassName?: string;
value: T;
}) {
return (
<Select
hideLabel
name={name}
value={value}
defaultValue={defaultValue}
label={label}
size="sm"
className={selectClassName}
disabled={disabled}
onChange={onChange}
options={options}
/>
);
}
export function ModelSettingSelectControl<
M extends AnyModel,
K extends ModelKeyOfValue<M, string>,
V extends M[K] & string,
>({
model,
modelKey,
...props
}: {
model: M;
modelKey: K;
} & Omit<Parameters<typeof SettingSelectControl<V>>[0], "name" | "onChange" | "value">) {
return (
<SettingSelectControl
name={String(modelKey)}
value={model[modelKey] as V}
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
{...props}
/>
);
}
export function ModelSettingRowSelect<
M extends AnyModel,
K extends ModelKeyOfValue<M, string>,
V extends M[K] & string,
>({
model,
modelKey,
...props
}: {
model: M;
modelKey: K;
} & Omit<Parameters<typeof SettingRowSelect<V>>[0], "name" | "onChange" | "value">) {
return (
<SettingRowSelect
name={String(modelKey)}
value={model[modelKey] as V}
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
{...props}
/>
);
}
export function SettingOverrideRow({
children,
className,
controlClassName,
description,
disabled,
onResetOverride,
overridden,
resetTitle = "Reset override",
title,
}: {
children: ReactNode;
className?: string;
controlClassName?: string;
description?: ReactNode;
disabled?: boolean;
onResetOverride: () => void;
overridden: boolean;
resetTitle?: string;
title: ReactNode;
}) {
return (
<SettingRow
className={className}
controlClassName={controlClassName}
description={description}
disabled={disabled}
title={
<span className="inline-flex items-center gap-1.5">
{title}
{overridden && (
<IconButton
icon="undo_2"
size="2xs"
iconSize="sm"
title={resetTitle}
className="text-text-subtle"
onClick={onResetOverride}
/>
)}
</span>
}
>
{children}
</SettingRow>
);
}
@@ -1,165 +0,0 @@
import classNames from "classnames";
import type { CSSProperties, KeyboardEvent, ReactNode } from "react";
import { useRef, useState } from "react";
import { generateId } from "../../lib/generateId";
import { Portal } from "@yaakapp-internal/ui";
export interface TooltipProps {
children: ReactNode;
content: ReactNode;
tabIndex?: number;
size?: "md" | "lg";
className?: string;
}
const hiddenStyles: CSSProperties = {
left: -99999,
top: -99999,
visibility: "hidden",
pointerEvents: "none",
opacity: 0,
};
type TooltipPosition = "top" | "bottom";
interface TooltipOpenState {
styles: CSSProperties;
position: TooltipPosition;
}
export function Tooltip({ children, className, content, tabIndex, size = "md" }: TooltipProps) {
const [openState, setOpenState] = useState<TooltipOpenState | null>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
const showTimeout = useRef<ReturnType<typeof setTimeout>>(undefined);
const handleOpenImmediate = () => {
if (triggerRef.current == null || tooltipRef.current == null) return;
clearTimeout(showTimeout.current);
const triggerRect = triggerRef.current.getBoundingClientRect();
const tooltipRect = tooltipRef.current.getBoundingClientRect();
const viewportHeight = document.documentElement.clientHeight;
const margin = 8;
const spaceAbove = Math.max(0, triggerRect.top - margin);
const spaceBelow = Math.max(0, viewportHeight - triggerRect.bottom - margin);
const preferBottom = spaceAbove < tooltipRect.height + margin && spaceBelow > spaceAbove;
const position: TooltipPosition = preferBottom ? "bottom" : "top";
const styles: CSSProperties = {
left: Math.max(0, triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2),
maxHeight: position === "top" ? spaceAbove : spaceBelow,
...(position === "top"
? { bottom: viewportHeight - triggerRect.top }
: { top: triggerRect.bottom }),
};
setOpenState({ styles, position });
};
const handleOpen = () => {
clearTimeout(showTimeout.current);
showTimeout.current = setTimeout(handleOpenImmediate, 500);
};
const handleClose = () => {
clearTimeout(showTimeout.current);
setOpenState(null);
};
const handleToggleImmediate = () => {
if (openState) handleClose();
else handleOpenImmediate();
};
const handleKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
if (openState && e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
handleClose();
}
};
const id = useRef(`tooltip-${generateId()}`);
return (
<>
<Portal name="tooltip">
<div
ref={tooltipRef}
style={openState?.styles ?? hiddenStyles}
id={id.current}
role="tooltip"
aria-hidden={openState == null}
onMouseEnter={handleOpenImmediate}
onMouseLeave={handleClose}
className="p-2 fixed z-50 text-sm transition-opacity grid grid-rows-[minmax(0,1fr)]"
>
<div
className={classNames(
"bg-surface-highlight rounded-md px-3 py-2 z-50 border border-border overflow-auto",
size === "md" && "max-w-sm",
size === "lg" && "max-w-md",
)}
>
{content}
</div>
<Triangle
className="text-border"
position={openState?.position === "bottom" ? "top" : "bottom"}
/>
</div>
</Portal>
{/* oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- Needs to be usable in other buttons */}
<span
ref={triggerRef}
role="button"
aria-describedby={openState ? id.current : undefined}
tabIndex={tabIndex ?? -1}
className={classNames(className, "flex-grow-0 flex items-center")}
onClick={handleToggleImmediate}
onMouseEnter={handleOpen}
onMouseLeave={handleClose}
onFocus={handleOpenImmediate}
onBlur={handleClose}
onKeyDown={handleKeyDown}
>
{children}
</span>
</>
);
}
function Triangle({ className, position }: { className?: string; position: "top" | "bottom" }) {
const isBottom = position === "bottom";
return (
<svg
aria-hidden
viewBox="0 0 30 10"
preserveAspectRatio="none"
shapeRendering="crispEdges"
className={classNames(
className,
"absolute z-50 left-[calc(50%-0.4rem)] h-[0.5rem] w-[0.8rem]",
isBottom
? "border-t-[2px] border-surface-highlight -bottom-[calc(0.5rem-3px)] mb-2"
: "border-b-[2px] border-surface-highlight -top-[calc(0.5rem-3px)] mt-2",
)}
>
<title>Triangle</title>
<polygon
className="fill-surface-highlight"
points={isBottom ? "0,0 30,0 15,10" : "0,10 30,10 15,0"}
/>
<path
d={isBottom ? "M0 0 L15 9 L30 0" : "M0 10 L15 1 L30 10"}
fill="none"
stroke="currentColor"
strokeWidth="1"
strokeLinejoin="miter"
vectorEffect="non-scaling-stroke"
/>
</svg>
);
}
@@ -1,31 +0,0 @@
import type { WebsocketConnection } from "@yaakapp-internal/models";
import classNames from "classnames";
interface Props {
connection: WebsocketConnection;
className?: string;
}
export function WebsocketStatusTag({ connection, className }: Props) {
const { state, error } = connection;
let label: string;
let colorClass = "text-text-subtle";
if (error) {
label = "ERROR";
colorClass = "text-danger";
} else if (state === "connected") {
label = "CONNECTED";
colorClass = "text-success";
} else if (state === "closing") {
label = "CLOSING";
} else if (state === "closed") {
label = "CLOSED";
colorClass = "text-warning";
} else {
label = "CONNECTING";
}
return <span className={classNames(className, "font-mono", colorClass)}>{label}</span>;
}
@@ -1,131 +0,0 @@
import { useGitFileDiffForCommit, useGitLog, useGitMutations } from "@yaakapp-internal/git";
import type { GitCommit } from "@yaakapp-internal/git";
import { InlineCode, SplitLayout } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { formatDistanceToNowStrict } from "date-fns";
import { useCallback, useEffect, useMemo, useState } from "react";
import { sync } from "../../init/sync";
import { showConfirm } from "../../lib/confirm";
import { EmptyStateText } from "../EmptyStateText";
import { Button } from "../core/Button";
import { DiffViewer } from "../core/Editor/DiffViewer";
import { useGitCallbacks } from "./callbacks";
export function FileHistoryDialog({ dir, relaPath }: { dir: string; relaPath: string }) {
const callbacks = useGitCallbacks(dir);
const { restoreFileFromCommit } = useGitMutations(dir, callbacks);
const log = useGitLog(dir, undefined, relaPath);
const commits = log.data ?? [];
const [selectedOid, setSelectedOid] = useState<string | null>(null);
const selectedCommit = useMemo(
() => commits.find((commit) => commit.oid === selectedOid) ?? null,
[commits, selectedOid],
);
const diff = useGitFileDiffForCommit(dir, relaPath, selectedCommit?.oid);
useEffect(() => {
if (commits.length === 0) {
setSelectedOid(null);
} else if (selectedOid == null || !commits.some((commit) => commit.oid === selectedOid)) {
setSelectedOid(commits[0]?.oid ?? null);
}
}, [commits, selectedOid]);
const handleRestoreCommit = useCallback(
async (commit: GitCommit) => {
const confirmed = await showConfirm({
id: "git-restore-file-history-entry",
title: "Restore File",
description: "This will restore the file to the selected commit.",
confirmText: "Restore",
color: "warning",
});
if (!confirmed) return;
await restoreFileFromCommit.mutateAsync({ commitOid: commit.oid, relaPath });
await sync({ force: true });
},
[relaPath, restoreFileFromCommit],
);
if (commits.length === 0 && !log.isLoading) {
return <EmptyStateText>No history for this file</EmptyStateText>;
}
return (
<div className="h-full px-2 pb-4">
<SplitLayout
storageKey="git-file-history-horizontal"
layout="horizontal"
defaultRatio={0.6}
firstSlot={({ style }) => (
<div style={style} className="h-full overflow-y-auto px-4 pb-2 transform-cpu">
<div className="flex flex-col pt-1.5">
{commits.map((commit) => (
<CommitListItem
key={commit.oid}
commit={commit}
selected={commit.oid === selectedCommit?.oid}
onSelect={() => setSelectedOid(commit.oid)}
/>
))}
</div>
</div>
)}
secondSlot={({ style }) => (
<div style={style} className="h-full min-w-0 border-l border-l-border-subtle px-4">
{selectedCommit == null ? (
<EmptyStateText>Select a commit to view diff</EmptyStateText>
) : (
<div className="h-full flex flex-col">
<div className="mb-2 min-w-0 text-text-subtle grid items-center gap-2 grid-cols-[minmax(0,1fr)_auto]">
<div className="min-w-0 truncate">{selectedCommit.message || "No message"}</div>
<Button
className="ml-auto"
color="warning"
size="2xs"
variant="border"
onClick={() => handleRestoreCommit(selectedCommit)}
>
Restore File
</Button>
</div>
<DiffViewer
original={diff.data?.original ?? ""}
modified={diff.data?.modified ?? ""}
className="flex-1 min-h-0"
/>
</div>
)}
</div>
)}
/>
</div>
);
}
function CommitListItem({
commit,
selected,
onSelect,
}: {
commit: GitCommit;
selected: boolean;
onSelect: () => void;
}) {
return (
<button
type="button"
className={classNames(
"w-full min-w-0 text-left rounded px-2 py-1.5",
selected && "bg-surface-active",
)}
onClick={onSelect}
>
<div className="truncate flex-1">{commit.message || "No message"}</div>
<div className="text-text-subtle text-sm truncate">
{commit.author.name || "Unknown"} - {formatDistanceToNowStrict(commit.when)} ago - <span className="shrink-0 text-2xs text-text-subtle font-mono">{commit.oid.slice(0, 7)}</span>
</div>
</button>
);
}
@@ -1,681 +0,0 @@
import { useGitBranchInfo, useGitMutations } from "@yaakapp-internal/git";
import type { WorkspaceMeta } from "@yaakapp-internal/models";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import type { HTMLAttributes } from "react";
import { forwardRef, useCallback, useMemo } from "react";
import { openWorkspaceSettings } from "../../commands/openWorkspaceSettings";
import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from "../../hooks/useActiveWorkspace";
import { useKeyValue } from "../../hooks/useKeyValue";
import { useRandomKey } from "../../hooks/useRandomKey";
import { sync } from "../../init/sync";
import { showConfirm, showConfirmDelete } from "../../lib/confirm";
import { fireAndForget } from "../../lib/fireAndForget";
import { showDialog } from "../../lib/dialog";
import { gitWorktreeStatusAtom } from "../../lib/gitWorktreeStatus";
import { showPrompt } from "../../lib/prompt";
import { showErrorToast, showToast } from "../../lib/toast";
import type { DropdownItem } from "../core/Dropdown";
import { Dropdown } from "../core/Dropdown";
import { Banner, Icon, InlineCode } from "@yaakapp-internal/ui";
import { useGitCallbacks } from "./callbacks";
import { GitCommitDialog } from "./GitCommitDialog";
import { GitRemotesDialog } from "./GitRemotesDialog";
import { handlePullResult, handlePushResult } from "./git-util";
import { HistoryDialog } from "./HistoryDialog";
const EMPTY_BRANCHES: string[] = [];
export function GitDropdown() {
const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);
if (workspaceMeta == null) return null;
if (workspaceMeta.settingSyncDir == null) {
return <SetupSyncDropdown workspaceMeta={workspaceMeta} />;
}
return <SyncDropdownWithSyncDir syncDir={workspaceMeta.settingSyncDir} />;
}
function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
const workspace = useAtomValue(activeWorkspaceAtom);
const worktreeStatus = useAtomValue(gitWorktreeStatusAtom);
const [refreshKey, regenerateKey] = useRandomKey();
const branchInfo = useGitBranchInfo(syncDir, refreshKey);
const callbacks = useGitCallbacks(syncDir);
const {
createBranch,
deleteBranch,
deleteRemoteBranch,
renameBranch,
mergeBranch,
push,
pull,
checkout,
resetChanges,
init,
} = useGitMutations(syncDir, callbacks);
const localBranches = branchInfo.data?.localBranches ?? EMPTY_BRANCHES;
const remoteBranches = branchInfo.data?.remoteBranches ?? EMPTY_BRANCHES;
const remoteOnlyBranches = useMemo(
() => remoteBranches.filter((b) => !localBranches.includes(b.replace(/^origin\//, ""))),
[localBranches, remoteBranches],
);
const currentBranch = branchInfo.data?.headRefShorthand;
const hasChanges = worktreeStatus?.entries.some((e) => e.status !== "current") ?? false;
const ahead = branchInfo.data?.ahead ?? 0;
const behind = branchInfo.data?.behind ?? 0;
const initRepo = useCallback(() => {
init.mutate();
}, [init]);
const items: DropdownItem[] = useMemo(() => {
if (workspace == null || branchInfo.data == null) return [];
const tryCheckout = (branch: string, force: boolean) => {
checkout.mutate(
{ branch, force },
{
disableToastError: true,
async onError(err) {
if (!force) {
// Checkout failed so ask user if they want to force it
const forceCheckout = await showConfirm({
id: "git-force-checkout",
title: "Conflicts Detected",
description:
"Your branch has conflicts. Either make a commit or force checkout to discard changes.",
confirmText: "Force Checkout",
color: "warning",
});
if (forceCheckout) {
tryCheckout(branch, true);
}
} else {
// Checkout failed
showErrorToast({
id: "git-checkout-error",
title: "Error checking out branch",
message: String(err),
});
}
},
async onSuccess(branchName) {
showToast({
id: "git-checkout-success",
message: (
<>
Switched branch <InlineCode>{branchName}</InlineCode>
</>
),
color: "success",
});
await sync({ force: true });
},
},
);
};
return [
{
label: "View History...",
leftSlot: <Icon icon="history" />,
onSelect: async () => {
showDialog({
id: "git-history",
size: "md",
title: "Commit History",
noPadding: true,
render: () => <HistoryDialog dir={syncDir} />,
});
},
},
{
label: "Manage Remotes...",
leftSlot: <Icon icon="hard_drive_download" />,
onSelect: () => GitRemotesDialog.show(syncDir),
},
{ type: "separator" },
{
label: "New Branch...",
leftSlot: <Icon icon="git_branch_plus" />,
async onSelect() {
const name = await showPrompt({
id: "git-branch-name",
title: "Create Branch",
label: "Branch Name",
});
if (!name) return;
await createBranch.mutateAsync(
{ branch: name },
{
disableToastError: true,
onError: (err) => {
showErrorToast({
id: "git-branch-error",
title: "Error creating branch",
message: String(err),
});
},
},
);
tryCheckout(name, false);
},
},
{ type: "separator" },
{
label: "Push",
leftSlot: <Icon icon="arrow_up_from_line" />,
waitForOnSelect: true,
async onSelect() {
await push.mutateAsync(undefined, {
disableToastError: true,
onSuccess: handlePushResult,
onError(err) {
showErrorToast({
id: "git-push-error",
title: "Error pushing changes",
message: String(err),
});
},
});
},
},
{
label: "Pull",
leftSlot: <Icon icon="arrow_down_to_line" />,
waitForOnSelect: true,
async onSelect() {
await pull.mutateAsync(undefined, {
disableToastError: true,
onSuccess: handlePullResult,
onError(err) {
showErrorToast({
id: "git-pull-error",
title: "Error pulling changes",
message: String(err),
});
},
});
},
},
{
label: "Commit...",
leftSlot: <Icon icon="git_commit_vertical" />,
onSelect() {
showDialog({
id: "commit",
title: "Commit Changes",
size: "full",
noPadding: true,
render: ({ hide }) => (
<GitCommitDialog syncDir={syncDir} onDone={hide} workspace={workspace} />
),
});
},
},
{
label: "Reset Changes",
hidden: !hasChanges,
leftSlot: <Icon icon="rotate_ccw" />,
color: "danger",
async onSelect() {
const confirmed = await showConfirm({
id: "git-reset-changes",
title: "Reset Changes",
description: "This will discard all uncommitted changes. This cannot be undone.",
confirmText: "Reset",
color: "danger",
});
if (!confirmed) return;
await resetChanges.mutateAsync(undefined, {
disableToastError: true,
onSuccess() {
showToast({
id: "git-reset-success",
message: "Changes have been reset",
color: "success",
});
fireAndForget(sync({ force: true }));
},
onError(err) {
showErrorToast({
id: "git-reset-error",
title: "Error resetting changes",
message: String(err),
});
},
});
},
},
{ type: "separator", label: "Branches", hidden: localBranches.length < 1 },
...localBranches.map((branch) => {
const isCurrent = currentBranch === branch;
return {
label: branch,
leftSlot: <Icon icon={isCurrent ? "check" : "empty"} />,
submenuOpenOnClick: true,
submenu: [
{
label: "Checkout",
hidden: isCurrent,
onSelect: () => tryCheckout(branch, false),
},
{
label: (
<>
Merge into <InlineCode>{currentBranch}</InlineCode>
</>
),
hidden: isCurrent,
async onSelect() {
await mergeBranch.mutateAsync(
{ branch },
{
disableToastError: true,
onSuccess() {
showToast({
id: "git-merged-branch",
message: (
<>
Merged <InlineCode>{branch}</InlineCode> into{" "}
<InlineCode>{currentBranch}</InlineCode>
</>
),
});
fireAndForget(sync({ force: true }));
},
onError(err) {
showErrorToast({
id: "git-merged-branch-error",
title: "Error merging branch",
message: String(err),
});
},
},
);
},
},
{
label: "New Branch...",
async onSelect() {
const name = await showPrompt({
id: "git-new-branch-from",
title: "New Branch",
description: (
<>
Create a new branch from <InlineCode>{branch}</InlineCode>
</>
),
label: "Branch Name",
});
if (!name) return;
await createBranch.mutateAsync(
{ branch: name, base: branch },
{
disableToastError: true,
onError: (err) => {
showErrorToast({
id: "git-branch-error",
title: "Error creating branch",
message: String(err),
});
},
},
);
tryCheckout(name, false);
},
},
{
label: "Rename...",
async onSelect() {
const newName = await showPrompt({
id: "git-rename-branch",
title: "Rename Branch",
label: "New Branch Name",
defaultValue: branch,
});
if (!newName || newName === branch) return;
await renameBranch.mutateAsync(
{ oldName: branch, newName },
{
disableToastError: true,
onSuccess() {
showToast({
id: "git-rename-branch-success",
message: (
<>
Renamed <InlineCode>{branch}</InlineCode> to{" "}
<InlineCode>{newName}</InlineCode>
</>
),
color: "success",
});
},
onError(err) {
showErrorToast({
id: "git-rename-branch-error",
title: "Error renaming branch",
message: String(err),
});
},
},
);
},
},
{ type: "separator", hidden: isCurrent },
{
label: "Delete",
color: "danger",
hidden: isCurrent,
onSelect: async () => {
const confirmed = await showConfirmDelete({
id: "git-delete-branch",
title: "Delete Branch",
description: (
<>
Permanently delete <InlineCode>{branch}</InlineCode>?
</>
),
});
if (!confirmed) {
return;
}
const result = await deleteBranch.mutateAsync(
{ branch },
{
disableToastError: true,
onError(err) {
showErrorToast({
id: "git-delete-branch-error",
title: "Error deleting branch",
message: String(err),
});
},
},
);
if (result.type === "not_fully_merged") {
const confirmed = await showConfirm({
id: "force-branch-delete",
title: "Branch not fully merged",
description: (
<>
<p>
Branch <InlineCode>{branch}</InlineCode> is not fully merged.
</p>
<p>Do you want to delete it anyway?</p>
</>
),
});
if (confirmed) {
await deleteBranch.mutateAsync(
{ branch, force: true },
{
disableToastError: true,
onError(err) {
showErrorToast({
id: "git-force-delete-branch-error",
title: "Error force deleting branch",
message: String(err),
});
},
},
);
}
}
},
},
],
} satisfies DropdownItem;
}),
...remoteOnlyBranches.map((branch) => {
const isCurrent = currentBranch === branch;
return {
label: branch,
leftSlot: <Icon icon={isCurrent ? "check" : "empty"} />,
submenuOpenOnClick: true,
submenu: [
{
label: "Checkout",
hidden: isCurrent,
onSelect: () => tryCheckout(branch, false),
},
{
label: "Delete",
color: "danger",
async onSelect() {
const confirmed = await showConfirmDelete({
id: "git-delete-remote-branch",
title: "Delete Remote Branch",
description: (
<>
Permanently delete <InlineCode>{branch}</InlineCode> from the remote?
</>
),
});
if (!confirmed) return;
await deleteRemoteBranch.mutateAsync(
{ branch },
{
disableToastError: true,
onSuccess() {
showToast({
id: "git-delete-remote-branch-success",
message: (
<>
Deleted remote branch <InlineCode>{branch}</InlineCode>
</>
),
color: "success",
});
},
onError(err) {
showErrorToast({
id: "git-delete-remote-branch-error",
title: "Error deleting remote branch",
message: String(err),
});
},
},
);
},
},
],
} satisfies DropdownItem;
}),
];
}, [
branchInfo.data,
checkout,
createBranch,
currentBranch,
deleteBranch,
deleteRemoteBranch,
hasChanges,
localBranches,
mergeBranch,
pull,
push,
remoteOnlyBranches,
renameBranch,
resetChanges,
syncDir,
workspace,
]);
if (workspace == null) {
return null;
}
const noRepo = branchInfo.error?.includes("not found");
if (noRepo) {
return <SetupGitDropdown workspaceId={workspace.id} initRepo={initRepo} />;
}
// Still loading
if (branchInfo.data == null) {
return null;
}
return (
<Dropdown fullWidth items={items} onOpen={regenerateKey}>
<GitMenuButton>
<InlineCode className="flex items-center gap-1">
<Icon icon="git_branch" size="xs" className="opacity-50" />
{currentBranch}
</InlineCode>
<div className="flex items-center gap-1.5">
{ahead > 0 && (
<span className="text-xs flex items-center gap-0.5">
<span className="text-primary"></span>
{ahead}
</span>
)}
{behind > 0 && (
<span className="text-xs flex items-center gap-0.5">
<span className="text-info"></span>
{behind}
</span>
)}
</div>
</GitMenuButton>
</Dropdown>
);
}
const GitMenuButton = forwardRef<HTMLButtonElement, HTMLAttributes<HTMLButtonElement>>(
function GitMenuButton({ className, ...props }: HTMLAttributes<HTMLButtonElement>, ref) {
return (
<button
ref={ref}
className={classNames(
className,
"px-3 h-md border-t border-border flex items-center justify-between text-text-subtle outline-none focus-visible:bg-surface-highlight",
)}
{...props}
/>
);
},
);
function SetupSyncDropdown({ workspaceMeta }: { workspaceMeta: WorkspaceMeta }) {
const { value: hidden, set: setHidden } = useKeyValue<Record<string, boolean>>({
key: "setup_sync",
fallback: {},
});
if (hidden == null || hidden[workspaceMeta.workspaceId]) {
return null;
}
const banner = (
<Banner color="info">
When enabled, workspace data syncs to the chosen folder as text files, ideal for backup and
Git collaboration.
</Banner>
);
return (
<Dropdown
fullWidth
items={[
{
type: "content",
label: banner,
},
{
color: "success",
label: "Open Workspace Settings",
leftSlot: <Icon icon="settings" />,
onSelect: () => openWorkspaceSettings("settings"),
},
{ type: "separator" },
{
label: "Hide This Message",
leftSlot: <Icon icon="eye_closed" />,
async onSelect() {
const confirmed = await showConfirm({
id: "hide-sync-menu-prompt",
title: "Hide Setup Message",
description: "You can configure filesystem sync or Git it in the workspace settings",
});
if (confirmed) {
await setHidden((prev) => ({ ...prev, [workspaceMeta.workspaceId]: true }));
}
},
},
]}
>
<GitMenuButton>
<div className="text-sm text-text-subtle grid grid-cols-[auto_minmax(0,1fr)] items-center gap-2">
<Icon icon="wrench" />
<div className="truncate">Setup FS Sync or Git</div>
</div>
</GitMenuButton>
</Dropdown>
);
}
function SetupGitDropdown({
workspaceId,
initRepo,
}: {
workspaceId: string;
initRepo: () => void;
}) {
const { value: hidden, set: setHidden } = useKeyValue<Record<string, boolean>>({
key: "setup_git_repo",
fallback: {},
});
if (hidden == null || hidden[workspaceId]) {
return null;
}
const banner = <Banner color="info">Initialize local repo to start versioning with Git</Banner>;
return (
<Dropdown
fullWidth
items={[
{ type: "content", label: banner },
{
label: "Initialize Git Repo",
leftSlot: <Icon icon="magic_wand" />,
onSelect: initRepo,
},
{ type: "separator" },
{
label: "Hide This Message",
leftSlot: <Icon icon="eye_closed" />,
async onSelect() {
const confirmed = await showConfirm({
id: "hide-git-init-prompt",
title: "Hide Git Setup",
description: "You can initialize a git repo outside of Yaak to bring this back",
});
if (confirmed) {
await setHidden((prev) => ({ ...prev, [workspaceId]: true }));
}
},
},
]}
>
<GitMenuButton>
<div className="text-sm text-text-subtle grid grid-cols-[auto_minmax(0,1fr)] items-center gap-2">
<Icon icon="folder_git" />
<div className="truncate">Setup Git</div>
</div>
</GitMenuButton>
</Dropdown>
);
}
@@ -1,31 +0,0 @@
import type { GitCallbacks } from "@yaakapp-internal/git";
import { useMemo } from "react";
import { sync } from "../../init/sync";
import { promptCredentials } from "./credentials";
import { promptDivergedStrategy } from "./diverged";
import { addGitRemote } from "./showAddRemoteDialog";
import { promptUncommittedChangesStrategy } from "./uncommitted";
export function gitCallbacks(dir: string): GitCallbacks {
return {
addRemote: async () => {
return addGitRemote(dir, "origin");
},
promptCredentials: async ({ url, error }) => {
const creds = await promptCredentials({ url, error });
if (creds == null) throw new Error("Cancelled credentials prompt");
return creds;
},
promptDiverged: async ({ remote, branch }) => {
return promptDivergedStrategy({ remote, branch });
},
promptUncommittedChanges: async () => {
return promptUncommittedChangesStrategy();
},
forceSync: () => sync({ force: true }),
};
}
export function useGitCallbacks(dir: string): GitCallbacks {
return useMemo(() => gitCallbacks(dir), [dir]);
}
@@ -1,49 +0,0 @@
import { showPromptForm } from "../../lib/prompt-form";
import { Banner, InlineCode } from "@yaakapp-internal/ui";
export interface GitCredentials {
username: string;
password: string;
}
export async function promptCredentials({
url: remoteUrl,
error,
}: {
url: string;
error: string | null;
}): Promise<GitCredentials | null> {
const isGitHub = /github\.com/i.test(remoteUrl);
const userLabel = isGitHub ? "GitHub Username" : "Username";
const passLabel = isGitHub ? "GitHub Personal Access Token" : "Password / Token";
const userDescription = isGitHub ? "Use your GitHub username (not your email)." : undefined;
const passDescription = isGitHub
? "GitHub requires a Personal Access Token (PAT) for write operations over HTTPS. Passwords are not supported."
: "Enter your password or access token for this Git server.";
const r = await showPromptForm({
id: "git-credentials",
title: "Credentials Required",
description: error ? (
<Banner color="danger">{error}</Banner>
) : (
<>
Enter credentials for <InlineCode>{remoteUrl}</InlineCode>
</>
),
inputs: [
{ type: "text", name: "username", label: userLabel, description: userDescription },
{
type: "text",
name: "password",
label: passLabel,
description: passDescription,
password: true,
},
],
});
if (r == null) return null;
const username = String(r.username || "");
const password = String(r.password || "");
return { username, password };
}
@@ -1,36 +0,0 @@
import type { PullResult, PushResult } from "@yaakapp-internal/git";
import { showToast } from "../../lib/toast";
export function handlePushResult(r: PushResult) {
switch (r.type) {
case "needs_credentials":
showToast({ id: "push-error", message: "Credentials not found", color: "danger" });
break;
case "success":
showToast({ id: "push-success", message: r.message, color: "success" });
break;
case "up_to_date":
showToast({ id: "push-nothing", message: "Already up-to-date", color: "info" });
break;
}
}
export function handlePullResult(r: PullResult) {
switch (r.type) {
case "needs_credentials":
showToast({ id: "pull-error", message: "Credentials not found", color: "danger" });
break;
case "success":
showToast({ id: "pull-success", message: r.message, color: "success" });
break;
case "up_to_date":
showToast({ id: "pull-nothing", message: "Already up-to-date", color: "info" });
break;
case "diverged":
// Handled by mutation callback before reaching here
break;
case "uncommitted_changes":
// Handled by mutation callback before reaching here
break;
}
}
@@ -1,20 +0,0 @@
import type { GitRemote } from "@yaakapp-internal/git";
import { gitMutations } from "@yaakapp-internal/git";
import { showPromptForm } from "../../lib/prompt-form";
import { gitCallbacks } from "./callbacks";
export async function addGitRemote(dir: string, defaultName?: string): Promise<GitRemote> {
const r = await showPromptForm({
id: "add-remote",
title: "Add Remote",
inputs: [
{ type: "text", label: "Name", name: "name", defaultValue: defaultName },
{ type: "text", label: "URL", name: "url" },
],
});
if (r == null) throw new Error("Cancelled remote prompt");
const name = String(r.name ?? "");
const url = String(r.url ?? "");
return gitMutations(dir, gitCallbacks(dir)).addRemote.mutateAsync({ name, url });
}
@@ -1,13 +0,0 @@
import type { UncommittedChangesStrategy } from "@yaakapp-internal/git";
import { showConfirm } from "../../lib/confirm";
export async function promptUncommittedChangesStrategy(): Promise<UncommittedChangesStrategy> {
const confirmed = await showConfirm({
id: "git-uncommitted-changes",
title: "Uncommitted Changes",
description: "You have uncommitted changes. Commit or reset your changes before pulling.",
confirmText: "Reset and Pull",
color: "danger",
});
return confirmed ? "reset" : "cancel";
}
-17
View File
@@ -1,17 +0,0 @@
// Listen for settings changes, the re-compute theme
import { listen } from "@tauri-apps/api/event";
import type { ModelPayload } from "@yaakapp-internal/models";
import { fireAndForget } from "./lib/fireAndForget";
import { getSettings } from "./lib/settings";
function setFontSizeOnDocument(fontSize: number) {
document.documentElement.style.fontSize = `${fontSize}px`;
}
listen<ModelPayload>("model_write", async (event) => {
if (event.payload.change.type !== "upsert") return;
if (event.payload.model.model !== "settings") return;
setFontSizeOnDocument(event.payload.model.interfaceFontSize);
}).catch(console.error);
fireAndForget(getSettings().then((settings) => setFontSizeOnDocument(settings.interfaceFontSize)));
-21
View File
@@ -1,21 +0,0 @@
// Listen for settings changes, the re-compute theme
import { listen } from "@tauri-apps/api/event";
import type { ModelPayload, Settings } from "@yaakapp-internal/models";
import { fireAndForget } from "./lib/fireAndForget";
import { getSettings } from "./lib/settings";
function setFonts(settings: Settings) {
document.documentElement.style.setProperty("--font-family-editor", settings.editorFont ?? "");
document.documentElement.style.setProperty(
"--font-family-interface",
settings.interfaceFont ?? "",
);
}
listen<ModelPayload>("model_write", async (event) => {
if (event.payload.change.type !== "upsert") return;
if (event.payload.model.model !== "settings") return;
setFonts(event.payload.model);
}).catch(console.error);
fireAndForget(getSettings().then((settings) => setFonts(settings)));
@@ -1,26 +0,0 @@
import { useMutation } from "@tanstack/react-query";
import { InlineCode } from "@yaakapp-internal/ui";
import { showAlert } from "../lib/alert";
import { appInfo } from "../lib/appInfo";
import { minPromiseMillis } from "../lib/minPromiseMillis";
import { invokeCmd } from "../lib/tauri";
export function useCheckForUpdates() {
return useMutation({
mutationKey: ["check_for_updates"],
mutationFn: async () => {
const hasUpdate: boolean = await minPromiseMillis(invokeCmd("cmd_check_for_updates"), 500);
if (!hasUpdate) {
showAlert({
id: "no-updates",
title: "No Update Available",
body: (
<>
You are currently on the latest version <InlineCode>{appInfo.version}</InlineCode>
</>
),
});
}
},
});
}
@@ -1,14 +0,0 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { copyToClipboard } from "../lib/copy";
import { getResponseBodyText } from "../lib/responseBody";
import { useFastMutation } from "./useFastMutation";
export function useCopyHttpResponse(response: HttpResponse) {
return useFastMutation({
mutationKey: ["copy_http_response", response.id],
async mutationFn() {
const body = await getResponseBodyText({ response, filter: null });
copyToClipboard(body);
},
});
}
@@ -1,33 +0,0 @@
import { createWorkspaceModel } from "@yaakapp-internal/models";
import { jotaiStore } from "../lib/jotai";
import { showPrompt } from "../lib/prompt";
import { setWorkspaceSearchParams } from "../lib/setWorkspaceSearchParams";
import { activeWorkspaceIdAtom } from "./useActiveWorkspace";
import { useFastMutation } from "./useFastMutation";
export function useCreateCookieJar() {
return useFastMutation({
mutationKey: ["create_cookie_jar"],
mutationFn: async () => {
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
if (workspaceId == null) {
throw new Error("Cannot create cookie jar when there's no active workspace");
}
const name = await showPrompt({
id: "new-cookie-jar",
title: "New CookieJar",
placeholder: "My Jar",
confirmText: "Create",
label: "Name",
defaultValue: "My Jar",
});
if (name == null) return null;
return createWorkspaceModel({ model: "cookie_jar", workspaceId, name });
},
onSuccess: async (cookieJarId) => {
setWorkspaceSearchParams({ cookie_jar_id: cookieJarId });
},
});
}
@@ -1,14 +0,0 @@
import { useCallback } from "react";
import { CreateWorkspaceDialog } from "../components/CreateWorkspaceDialog";
import { showDialog } from "../lib/dialog";
export function useCreateWorkspace() {
return useCallback(() => {
showDialog({
id: "create-workspace",
title: "Create Workspace",
size: "sm",
render: ({ hide }) => <CreateWorkspaceDialog hide={hide} />,
});
}, []);
}
@@ -1,12 +0,0 @@
import { invokeCmd } from "../lib/tauri";
import { useFastMutation } from "./useFastMutation";
export function useDeleteGrpcConnections(requestId?: string) {
return useFastMutation({
mutationKey: ["delete_grpc_connections", requestId],
mutationFn: async () => {
if (requestId === undefined) return;
await invokeCmd("cmd_delete_all_grpc_connections", { requestId });
},
});
}
@@ -1,12 +0,0 @@
import { invokeCmd } from "../lib/tauri";
import { useFastMutation } from "./useFastMutation";
export function useDeleteHttpResponses(requestId?: string) {
return useFastMutation({
mutationKey: ["delete_http_responses", requestId],
mutationFn: async () => {
if (requestId === undefined) return;
await invokeCmd("cmd_delete_all_http_responses", { requestId });
},
});
}
@@ -1,52 +0,0 @@
import {
grpcConnectionsAtom,
httpResponsesAtom,
websocketConnectionsAtom,
} from "@yaakapp-internal/models";
import { useAtomValue } from "jotai";
import { showAlert } from "../lib/alert";
import { showConfirmDelete } from "../lib/confirm";
import { jotaiStore } from "../lib/jotai";
import { pluralizeCount } from "../lib/pluralize";
import { invokeCmd } from "../lib/tauri";
import { activeWorkspaceIdAtom } from "./useActiveWorkspace";
import { useFastMutation } from "./useFastMutation";
export function useDeleteSendHistory() {
const httpResponses = useAtomValue(httpResponsesAtom);
const grpcConnections = useAtomValue(grpcConnectionsAtom);
const websocketConnections = useAtomValue(websocketConnectionsAtom);
const labels = [
httpResponses.length > 0 ? pluralizeCount("Http Response", httpResponses.length) : null,
grpcConnections.length > 0 ? pluralizeCount("Grpc Connection", grpcConnections.length) : null,
websocketConnections.length > 0
? pluralizeCount("WebSocket Connection", websocketConnections.length)
: null,
].filter((l) => l != null);
return useFastMutation({
mutationKey: ["delete_send_history", labels],
mutationFn: async () => {
if (labels.length === 0) {
showAlert({
id: "no-responses",
title: "Nothing to Delete",
body: "There is no Http, Grpc, or Websocket history",
});
return;
}
const confirmed = await showConfirmDelete({
id: "delete-send-history",
title: "Clear Send History",
description: <>Delete {labels.join(" and ")}?</>,
});
if (!confirmed) return false;
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
await invokeCmd("cmd_delete_send_history", { workspaceId });
return true;
},
});
}
@@ -1,10 +0,0 @@
import type { Environment } from "@yaakapp-internal/models";
import { useKeyValue } from "./useKeyValue";
export function useEnvironmentValueVisibility(environment: Environment) {
return useKeyValue<boolean>({
namespace: "global",
key: ["environmentValueVisibility", environment.workspaceId],
fallback: false,
});
}
-41
View File
@@ -1,41 +0,0 @@
import { workspacesAtom } from "@yaakapp-internal/models";
import { ExportDataDialog } from "../components/ExportDataDialog";
import { showAlert } from "../lib/alert";
import { showDialog } from "../lib/dialog";
import { jotaiStore } from "../lib/jotai";
import { showToast } from "../lib/toast";
import { activeWorkspaceAtom } from "./useActiveWorkspace";
import { useFastMutation } from "./useFastMutation";
export function useExportData() {
return useFastMutation({
mutationKey: ["export_data"],
onError: (err: string) => {
showAlert({ id: "export-failed", title: "Export Failed", body: err });
},
mutationFn: async () => {
const activeWorkspace = jotaiStore.get(activeWorkspaceAtom);
const workspaces = jotaiStore.get(workspacesAtom);
if (activeWorkspace == null || workspaces.length === 0) return;
showDialog({
id: "export-data",
title: "Export Data",
size: "md",
noPadding: true,
render: ({ hide }) => (
<ExportDataDialog
onHide={hide}
onSuccess={() => {
showToast({
color: "success",
message: "Data export successful",
});
}}
/>
),
});
},
});
}
@@ -1,14 +0,0 @@
import { useAtomValue } from "jotai";
import { activeWorkspaceAtom } from "./useActiveWorkspace";
import { useKeyValue } from "./useKeyValue";
export function useFloatingSidebarHidden() {
const activeWorkspace = useAtomValue(activeWorkspaceAtom);
const { set, value } = useKeyValue<boolean>({
namespace: "no_sync",
key: ["floating_sidebar_hidden", activeWorkspace?.id ?? "n/a"],
fallback: false,
});
return [value, set] as const;
}
-71
View File
@@ -1,71 +0,0 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { emit } from "@tauri-apps/api/event";
import type { GrpcConnection, GrpcRequest } from "@yaakapp-internal/models";
import { jotaiStore } from "../lib/jotai";
import { minPromiseMillis } from "../lib/minPromiseMillis";
import { invokeCmd } from "../lib/tauri";
import { activeEnvironmentIdAtom, useActiveEnvironment } from "./useActiveEnvironment";
import { useDebouncedValue } from "@yaakapp-internal/ui";
export interface ReflectResponseService {
name: string;
methods: { name: string; schema: string; serverStreaming: boolean; clientStreaming: boolean }[];
}
export function useGrpc(
req: GrpcRequest | null,
conn: GrpcConnection | null,
protoFiles: string[],
) {
const requestId = req?.id ?? "n/a";
const environment = useActiveEnvironment();
const go = useMutation<void, string>({
mutationKey: ["grpc_go", conn?.id],
mutationFn: () =>
invokeCmd<void>("cmd_grpc_go", { requestId, environmentId: environment?.id, protoFiles }),
});
const send = useMutation({
mutationKey: ["grpc_send", conn?.id],
mutationFn: ({ message }: { message: string }) =>
emit(`grpc_client_msg_${conn?.id ?? "none"}`, { Message: message }),
});
const cancel = useMutation({
mutationKey: ["grpc_cancel", conn?.id ?? "n/a"],
mutationFn: () => emit(`grpc_client_msg_${conn?.id ?? "none"}`, "Cancel"),
});
const commit = useMutation({
mutationKey: ["grpc_commit", conn?.id ?? "n/a"],
mutationFn: () => emit(`grpc_client_msg_${conn?.id ?? "none"}`, "Commit"),
});
const debouncedUrl = useDebouncedValue<string>(req?.url ?? "", 1000);
const reflect = useQuery<ReflectResponseService[], string>({
enabled: req != null,
queryKey: ["grpc_reflect", req?.id ?? "n/a", debouncedUrl, protoFiles],
staleTime: Infinity,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
queryFn: () => {
const environmentId = jotaiStore.get(activeEnvironmentIdAtom);
return minPromiseMillis<ReflectResponseService[]>(
invokeCmd("cmd_grpc_reflect", { requestId, protoFiles, environmentId }),
300,
);
},
});
return {
go,
reflect,
cancel,
commit,
isStreaming: conn != null && conn.state !== "closed",
send,
};
}
@@ -1,32 +0,0 @@
import { invoke } from "@tauri-apps/api/core";
import type { HttpResponse, HttpResponseEvent } from "@yaakapp-internal/models";
import {
httpResponseEventsAtom,
mergeModelsInStore,
replaceModelsInStore,
} from "@yaakapp-internal/models";
import { useAtomValue } from "jotai";
import { useEffect } from "react";
import { fireAndForget } from "../lib/fireAndForget";
export function useHttpResponseEvents(response: HttpResponse | null) {
const allEvents = useAtomValue(httpResponseEventsAtom);
useEffect(() => {
if (response?.id == null) {
replaceModelsInStore("http_response_event", []);
return;
}
// Fetch events from database, filtering out events from other responses and merging atomically
fireAndForget(
invoke<HttpResponseEvent[]>("cmd_get_http_response_events", { responseId: response.id }).then(
(events) =>
mergeModelsInStore("http_response_event", events, (e) => e.responseId === response.id),
),
);
}, [response?.id]);
const events = allEvents.filter((e) => e.responseId === response?.id);
return { data: events, error: null, isLoading: false };
}
@@ -1,11 +0,0 @@
import { installPluginFromDirectory } from "@yaakapp-internal/plugins";
import { useFastMutation } from "./useFastMutation";
export function useInstallPlugin() {
return useFastMutation<void, unknown, string>({
mutationKey: ["install_plugin"],
mutationFn: async (directory: string) => {
await installPluginFromDirectory(directory);
},
});
}
@@ -1,34 +0,0 @@
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import type { ModelPayload } from "@yaakapp-internal/models";
import { atom, useAtomValue } from "jotai";
import { generateId } from "../lib/generateId";
import { jotaiStore } from "../lib/jotai";
const requestUpdateKeyAtom = atom<Record<string, string>>({});
getCurrentWebviewWindow()
.listen<ModelPayload>("model_write", ({ payload }) => {
if (payload.change.type !== "upsert") return;
if (
(payload.model.model === "http_request" ||
payload.model.model === "grpc_request" ||
payload.model.model === "websocket_request") &&
((payload.updateSource.type === "window" &&
payload.updateSource.label !== getCurrentWebviewWindow().label) ||
payload.updateSource.type !== "window")
) {
wasUpdatedExternally(payload.model.id);
}
})
.catch(console.error);
export function wasUpdatedExternally(changedRequestId: string) {
jotaiStore.set(requestUpdateKeyAtom, (m) => ({ ...m, [changedRequestId]: generateId() }));
}
export function useRequestUpdateKey(requestId: string | null) {
const keys = useAtomValue(requestUpdateKeyAtom);
const key = keys[requestId ?? "n/a"];
return `${requestId}::${key ?? "default"}`;
}
@@ -1,10 +0,0 @@
import { settingsAtom } from "@yaakapp-internal/models";
import { useAtomValue } from "jotai";
import { resolveAppearance } from "../lib/theme/appearance";
import { usePreferredAppearance } from "./usePreferredAppearance";
export function useResolvedAppearance() {
const preferredAppearance = usePreferredAppearance();
const settings = useAtomValue(settingsAtom);
return resolveAppearance(preferredAppearance, settings.appearance);
}
@@ -1,12 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import type { HttpResponse } from "@yaakapp-internal/models";
import type { ServerSentEvent } from "@yaakapp-internal/sse";
import { getResponseBodyEventSource } from "../lib/responseBody";
export function useResponseBodyEventSource(response: HttpResponse) {
return useQuery<ServerSentEvent[]>({
placeholderData: (prev) => prev, // Keep previous data on refetch
queryKey: ["response-body-event-source", response.id, response.contentLength],
queryFn: () => getResponseBodyEventSource(response),
});
}
@@ -1,8 +0,0 @@
import { useLocalStorage } from "react-use";
const DEFAULT_VIEW_MODE = "pretty";
export function useResponseViewMode(requestId?: string): [string, (m: "pretty" | "raw") => void] {
const [value, setValue] = useLocalStorage<"pretty" | "raw">(`response_view_mode::${requestId}`);
return [value ?? DEFAULT_VIEW_MODE, setValue];
}
@@ -1,36 +0,0 @@
import { save } from "@tauri-apps/plugin-dialog";
import type { HttpResponse } from "@yaakapp-internal/models";
import { getModel } from "@yaakapp-internal/models";
import mime from "mime";
import slugify from "slugify";
import { InlineCode } from "@yaakapp-internal/ui";
import { getContentTypeFromHeaders } from "../lib/model_util";
import { invokeCmd } from "../lib/tauri";
import { showToast } from "../lib/toast";
import { useFastMutation } from "./useFastMutation";
export function useSaveResponse(response: HttpResponse) {
return useFastMutation({
mutationKey: ["save_response", response.id],
mutationFn: async () => {
const request = getModel("http_request", response.requestId);
if (request == null) return null;
const contentType = getContentTypeFromHeaders(response.headers) ?? "unknown";
const ext = mime.getExtension(contentType);
const slug = slugify(request.name || "response", { lower: true });
const filepath = await save({
defaultPath: ext ? `${slug}.${ext}` : slug,
title: "Save Response",
});
await invokeCmd("cmd_save_response", { responseId: response.id, filepath });
showToast({
message: (
<>
Response saved to <InlineCode>{filepath}</InlineCode>
</>
),
});
},
});
}
@@ -1,14 +0,0 @@
import { useAtomValue } from "jotai";
import { activeWorkspaceIdAtom } from "./useActiveWorkspace";
import { useKeyValue } from "./useKeyValue";
export function useSidebarHidden() {
const activeWorkspaceId = useAtomValue(activeWorkspaceIdAtom);
const { set, value } = useKeyValue<boolean>({
namespace: "no_sync",
key: ["sidebar_hidden", activeWorkspaceId ?? "n/a"],
fallback: false,
});
return [value, set] as const;
}
@@ -1,8 +0,0 @@
import { type } from "@tauri-apps/plugin-os";
import { useIsFullscreen } from "@yaakapp-internal/ui";
export function useStoplightsVisible() {
const fullscreen = useIsFullscreen();
const stoplightsVisible = type() === "macos" && !fullscreen;
return stoplightsVisible;
}

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