Compare commits

..

2 Commits

Author SHA1 Message Date
Gregory Schier
9e6523f477 Update entitlements.plist for 1Password shared lib 2025-12-11 09:23:08 -08:00
Gregory Schier
0c8d180928 Don't strip symbols hotfix 2025-12-11 06:49:48 -08:00
1234 changed files with 18855 additions and 49416 deletions

View File

@@ -1,72 +0,0 @@
# 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-cli/ # CLI crate (yaak-cli)
```
## Completed Work
### 1. Folder Restructure
- Moved Tauri-dependent app code to `crates-tauri/yaak-app-client/`
- Created `crates-tauri/yaak-tauri-utils/` for shared Tauri utilities (window traits, api_client, error handling)
- Created `crates-cli/yaak-cli/` for the standalone CLI
### 2. Decoupled Crates (no longer depend on Tauri)
- **yaak-models**: Uses `init_standalone()` pattern for CLI database access
- **yaak-http**: Removed Tauri plugin, HttpConnectionManager initialized in yaak-app setup
- **yaak-common**: Only contains Tauri-free utilities (serde, platform)
- **yaak-crypto**: Removed Tauri plugin, EncryptionManager initialized in yaak-app setup, commands moved to yaak-app
- **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()`
## 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
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-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
481e0273 Remove Tauri dependencies from yaak-http and yaak-common
10568ac3 Add HTTP request sending to yaak-cli
bcb7d600 Add yaak-cli stub with basic database access
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 `cargo run -p yaak-cli -- --help` to test the CLI

View File

@@ -1,49 +0,0 @@
---
description: Generate formatted release notes for Yaak releases
allowed-tools: Bash(git tag:*)
---
Generate formatted release notes for Yaak releases by analyzing git history and pull request descriptions.
## What to do
1. Identifies the version tag and previous version
2. Retrieves all commits between versions
- If the version is a beta version, it retrieves commits between the beta version and previous beta version
- If the version is a stable version, it retrieves commits between the stable version and the previous stable version
3. Fetches PR descriptions for linked issues to find:
- Feedback URLs (feedback.yaak.app)
- Additional context and descriptions
- Installation links for plugins
4. Formats the release notes using the standard Yaak format:
- Changelog badge at the top
- Bulleted list of changes with PR links
- Feedback links where available
- Full changelog comparison link at the bottom
## Output Format
The skill generates markdown-formatted release notes following this structure:
```markdown
[![Changelog](https://img.shields.io/badge/Changelog-VERSION-blue)](https://yaak.app/changelog/VERSION)
- Feature/fix description in by @username [#123](https://github.com/mountain-loop/yaak/pull/123)
- [Linked feedback item](https://feedback.yaak.app/p/item) by @username in [#456](https://github.com/mountain-loop/yaak/pull/456)
- A simple item that doesn't have a feedback or PR link
**Full Changelog**: https://github.com/mountain-loop/yaak/compare/vPREV...vCURRENT
```
**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
## After Generating Release Notes
After outputting the release notes, ask the user if they would like to create a draft GitHub release with these notes. If they confirm, create the release using:
```bash
gh release create <tag> --draft --prerelease --title "Release <version>" --notes '<release notes>'
```
**IMPORTANT**: The release title format is "Release XXXX" where XXXX is the version WITHOUT the `v` prefix. For example, tag `v2026.2.1-beta.1` gets title "Release 2026.2.1-beta.1".

View File

@@ -1,27 +0,0 @@
# Project Rules
## General Development
- **NEVER** commit or push without explicit confirmation
## Build and Lint
- **ALWAYS** run `npm run lint` after modifying TypeScript or JavaScript files
- Run `npm run bootstrap` after changing plugin runtime or MCP server code
## Plugin System
### Backend Constraints
- Always use `UpdateSource::Plugin` when calling database methods from plugin events
- Never send timestamps (`createdAt`, `updatedAt`) from TypeScript - Rust backend controls these
- Backend uses `NaiveDateTime` (no timezone) so avoid sending ISO timestamp strings
### MCP Server
- MCP server has **no active window context** - cannot call `window.workspaceId()`
- Get workspace ID from `workspaceCtx.yaak.workspace.list()` instead
## Rust Type Generation
- Run `cargo test --package yaak-plugins` (and for other crates) to regenerate TypeScript bindings after modifying Rust event types

View File

@@ -1,48 +0,0 @@
---
name: release-generate-release-notes
description: Generate Yaak release notes from git history and PR metadata, including feedback links and full changelog compare links. Use when asked to run or replace the old Claude generate-release-notes command.
---
# Generate Release Notes
Generate formatted markdown release notes for a Yaak tag.
## Workflow
1. Determine target tag.
2. Determine previous comparable tag:
- Beta tag: compare against previous beta (if the root version is the same) or stable tag.
- Stable tag: compare against previous stable tag.
3. Collect commits in range:
- `git log --oneline <prev_tag>..<target_tag>`
4. For linked PRs, fetch metadata:
- `gh pr view <PR_NUMBER> --json number,title,body,author,url`
5. Extract useful details:
- Feedback URLs (`feedback.yaak.app`)
- Plugin install links or other notable context
6. Format notes using Yaak style:
- Changelog badge at top
- Bulleted items with PR links where available
- Feedback links where available
- Full changelog compare link at bottom
## Formatting Rules
- Wrap final notes in a markdown code fence.
- 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`.
## Release Creation Prompt
After producing notes, ask whether to create a draft GitHub release.
If confirmed and release does not yet exist, run:
`gh release create <tag> --draft --prerelease --title "Release <version_without_v>" --notes '<release notes>'`
If a draft release for the tag already exists, update it instead:
`gh release edit <tag> --title "Release <version_without_v>" --notes-file <path_to_notes>`
Use title format `Release <version_without_v>`, e.g. `v2026.2.1-beta.1` -> `Release 2026.2.1-beta.1`.

9
.gitattributes vendored
View File

@@ -1,7 +1,2 @@
crates-tauri/yaak-app-client/vendored/**/* linguist-generated=true
crates-tauri/yaak-app-client/gen/schemas/**/* linguist-generated=true
**/bindings/* linguist-generated=true
crates/yaak-templates/pkg/* linguist-generated=true
# Ensure consistent line endings for test files that check exact content
crates/yaak-http/tests/test.txt text eol=lf
src-tauri/vendored/**/* linguist-generated=true
src-tauri/gen/schemas/**/* linguist-generated=true

View File

@@ -1,18 +0,0 @@
## Summary
<!-- Describe the bug and the fix in 1-3 sentences. -->
## Submission
- [ ] This PR is a bug fix or small-scope improvement.
- [ ] If this PR is not a bug fix or small-scope improvement, I linked an approved feedback item below.
- [ ] I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).
- [ ] I tested this change locally.
- [ ] 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
<!-- Link related issues, discussions, or feedback items. -->

View File

@@ -18,13 +18,14 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: 'src-tauri'
shared-key: ci
cache-on-failure: true
- run: npm ci
- run: npm run bootstrap
- run: npm run lint
- name: Run JS Tests
run: npm test
- name: Run Rust Tests
run: cargo test --all
working-directory: src-tauri

View File

@@ -1,50 +0,0 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'

View File

@@ -1,52 +0,0 @@
name: Update Flathub
on:
release:
types: [published]
permissions:
contents: read
jobs:
update-flathub:
name: Update Flathub manifest
runs-on: ubuntu-latest
# Only run for stable releases (skip betas/pre-releases)
if: ${{ !github.event.release.prerelease }}
steps:
- name: Checkout app repo
uses: actions/checkout@v4
- name: Checkout Flathub repo
uses: actions/checkout@v4
with:
repository: flathub/app.yaak.Yaak
token: ${{ secrets.FLATHUB_TOKEN }}
path: flathub-repo
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install source generators
run: |
pip install flatpak-node-generator tomlkit aiohttp
git clone --depth 1 https://github.com/flatpak/flatpak-builder-tools flatpak/flatpak-builder-tools
- name: Run update-manifest.sh
run: bash flatpak/update-manifest.sh "${{ github.event.release.tag_name }}" flathub-repo
- name: Commit and push to Flathub
working-directory: flathub-repo
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add -A
git diff --cached --quiet && echo "No changes to commit" && exit 0
git commit -m "Update to ${{ github.event.release.tag_name }}"
git push

View File

@@ -1,59 +0,0 @@
name: Release API to NPM
on:
push:
tags: [yaak-api-*]
workflow_dispatch:
inputs:
version:
description: API version to publish (for example 0.9.0 or v0.9.0)
required: true
type: string
permissions:
contents: read
jobs:
publish-npm:
name: Publish @yaakapp/api
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: lts/*
registry-url: https://registry.npmjs.org
- name: Install dependencies
run: npm ci
- name: Set @yaakapp/api version
shell: bash
env:
WORKFLOW_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="$WORKFLOW_VERSION"
else
VERSION="${GITHUB_REF_NAME#yaak-api-}"
fi
VERSION="${VERSION#v}"
echo "Preparing @yaakapp/api version: $VERSION"
cd packages/plugin-runtime-types
npm version "$VERSION" --no-git-tag-version --allow-same-version
- name: Build @yaakapp/api
working-directory: packages/plugin-runtime-types
run: npm run build
- name: Publish @yaakapp/api
working-directory: packages/plugin-runtime-types
run: npm publish --provenance --access public

View File

@@ -1,179 +0,0 @@
name: Release App Artifacts
on:
push:
tags: [v*]
jobs:
build-artifacts:
permissions:
contents: write
name: Build
strategy:
fail-fast: false
matrix:
include:
- platform: "macos-latest" # for Arm-based Macs (M1 and above).
args: "--target aarch64-apple-darwin"
yaak_arch: "arm64"
os: "macos"
targets: "aarch64-apple-darwin"
- platform: "macos-latest" # for Intel-based Macs.
args: "--target x86_64-apple-darwin"
yaak_arch: "x64"
os: "macos"
targets: "x86_64-apple-darwin"
- platform: "ubuntu-22.04"
args: ""
yaak_arch: "x64"
os: "ubuntu"
targets: ""
- platform: "ubuntu-22.04-arm"
args: ""
yaak_arch: "arm64"
os: "ubuntu"
targets: ""
- platform: "windows-latest"
args: ""
yaak_arch: "x64"
os: "windows"
targets: ""
# Windows ARM64
- platform: "windows-latest"
args: "--target aarch64-pc-windows-msvc"
yaak_arch: "arm64"
os: "windows"
targets: "aarch64-pc-windows-msvc"
runs-on: ${{ matrix.platform }}
timeout-minutes: 40
steps:
- name: Checkout yaakapp/app
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.targets }}
- uses: Swatinem/rust-cache@v2
with:
shared-key: ci
cache-on-failure: true
- name: install dependencies (Linux only)
if: matrix.os == 'ubuntu'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
- name: Install Protoc for plugin-runtime
uses: arduino/setup-protoc@v3
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Install trusted-signing-cli (Windows only)
if: matrix.os == 'windows'
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$dir = "$env:USERPROFILE\trusted-signing"
New-Item -ItemType Directory -Force -Path $dir | Out-Null
$url = "https://github.com/Levminer/trusted-signing-cli/releases/download/0.8.0/trusted-signing-cli.exe"
$exe = Join-Path $dir "trusted-signing-cli.exe"
Invoke-WebRequest -Uri $url -OutFile $exe
echo $dir >> $env:GITHUB_PATH
& $exe --version
- run: npm ci
- run: npm run bootstrap
env:
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
- run: npm run lint
- name: Run JS Tests
run: npm test
- name: Run Rust Tests
run: cargo test --all
- name: Set version
run: npm run replace-version
env:
YAAK_VERSION: ${{ github.ref_name }}
- name: Sign vendored binaries (macOS only)
if: matrix.os == 'macos'
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
# Create keychain
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# Import certificate
echo "$APPLE_CERTIFICATE" | base64 --decode > certificate.p12
security import certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
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
- uses: tauri-apps/tauri-action@v0
env:
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
# Apple signing stuff
APPLE_CERTIFICATE: ${{ matrix.os == 'macos' && secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ matrix.os == 'macos' && secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_ID: ${{ matrix.os == 'macos' && secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ matrix.os == 'macos' && secrets.APPLE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ matrix.os == 'macos' && secrets.APPLE_SIGNING_IDENTITY }}
APPLE_TEAM_ID: ${{ matrix.os == 'macos' && secrets.APPLE_TEAM_ID }}
# Windows signing stuff
AZURE_CLIENT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_SECRET }}
AZURE_TENANT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_TENANT_ID }}
with:
tagName: "v__VERSION__"
releaseName: "Release __VERSION__"
releaseBody: "[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)"
releaseDraft: true
prerelease: true
args: "${{ matrix.args }} --config ./crates-tauri/yaak-app-client/tauri.release.conf.json"
# Build a per-machine NSIS installer for enterprise deployment (PDQ, SCCM, Intune)
- name: Build and upload machine-wide installer (Windows only)
if: matrix.os == 'windows'
shell: pwsh
env:
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
run: |
Get-ChildItem -Recurse -Path target -File -Filter "*.exe.sig" | Remove-Item -Force
npx tauri bundle ${{ matrix.args }} --bundles nsis --config ./crates-tauri/yaak-app-client/tauri.release.conf.json --config '{"bundle":{"createUpdaterArtifacts":true,"windows":{"nsis":{"installMode":"perMachine"}}}}'
$setup = Get-ChildItem -Recurse -Path target -Filter "*setup*.exe" | Select-Object -First 1
$setupSig = "$($setup.FullName).sig"
$dest = $setup.FullName -replace '-setup\.exe$', '-setup-machine.exe'
$destSig = "$dest.sig"
Copy-Item $setup.FullName $dest
Copy-Item $setupSig $destSig
gh release upload "${{ github.ref_name }}" "$dest" --clobber
gh release upload "${{ github.ref_name }}" "$destSig" --clobber

View File

@@ -1,218 +0,0 @@
name: Release CLI to NPM
on:
push:
tags: [yaak-cli-*]
workflow_dispatch:
inputs:
version:
description: CLI version to publish (for example 0.4.0 or v0.4.0)
required: true
type: string
permissions:
contents: read
jobs:
prepare-vendored-assets:
name: Prepare vendored plugin assets
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Install dependencies
run: npm ci
- name: Build plugin assets
env:
SKIP_WASM_BUILD: "1"
run: |
npm run build
npm run vendor:vendor-plugins
- name: Upload vendored assets
uses: actions/upload-artifact@v4
with:
name: vendored-assets
path: |
crates-tauri/yaak-app-client/vendored/plugin-runtime/index.cjs
crates-tauri/yaak-app-client/vendored/plugins
if-no-files-found: error
build-binaries:
name: Build ${{ matrix.pkg }}
needs: prepare-vendored-assets
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- pkg: cli-darwin-arm64
runner: macos-latest
target: aarch64-apple-darwin
binary: yaak
- pkg: cli-darwin-x64
runner: macos-latest
target: x86_64-apple-darwin
binary: yaak
- pkg: cli-linux-arm64
runner: ubuntu-22.04-arm
target: aarch64-unknown-linux-gnu
binary: yaak
- pkg: cli-linux-x64
runner: ubuntu-22.04
target: x86_64-unknown-linux-gnu
binary: yaak
- pkg: cli-win32-arm64
runner: windows-latest
target: aarch64-pc-windows-msvc
binary: yaak.exe
- pkg: cli-win32-x64
runner: windows-latest
target: x86_64-pc-windows-msvc
binary: yaak.exe
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Restore Rust cache
uses: Swatinem/rust-cache@v2
with:
shared-key: release-cli-npm
cache-on-failure: true
- name: Install Linux build dependencies
if: startsWith(matrix.runner, 'ubuntu')
run: |
sudo apt-get update
sudo apt-get install -y pkg-config libdbus-1-dev
- name: Download vendored assets
uses: actions/download-artifact@v4
with:
name: vendored-assets
path: crates-tauri/yaak-app-client/vendored
- name: Set CLI build version
shell: bash
env:
WORKFLOW_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="$WORKFLOW_VERSION"
else
VERSION="${GITHUB_REF_NAME#yaak-cli-}"
fi
VERSION="${VERSION#v}"
echo "Building yaak version: $VERSION"
echo "YAAK_CLI_VERSION=$VERSION" >> "$GITHUB_ENV"
- name: Build yaak
run: cargo build --locked --release -p yaak-cli --bin yaak --target ${{ matrix.target }}
- name: Stage binary artifact
shell: bash
run: |
set -euo pipefail
mkdir -p "npm/dist/${{ matrix.pkg }}"
cp "target/${{ matrix.target }}/release/${{ matrix.binary }}" "npm/dist/${{ matrix.pkg }}/${{ matrix.binary }}"
- name: Upload binary artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.pkg }}
path: npm/dist/${{ matrix.pkg }}/${{ matrix.binary }}
if-no-files-found: error
publish-npm:
name: Publish @yaakapp/cli packages
needs: build-binaries
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: lts/*
registry-url: https://registry.npmjs.org
- name: Download binary artifacts
uses: actions/download-artifact@v4
with:
pattern: cli-*
path: npm/dist
merge-multiple: false
- name: Prepare npm packages
shell: bash
env:
WORKFLOW_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="$WORKFLOW_VERSION"
else
VERSION="${GITHUB_REF_NAME#yaak-cli-}"
fi
VERSION="${VERSION#v}"
if [[ "$VERSION" == *-* ]]; then
PRERELEASE="${VERSION#*-}"
NPM_TAG="${PRERELEASE%%.*}"
else
NPM_TAG="latest"
fi
echo "Preparing CLI npm packages for version: $VERSION"
echo "Publishing with npm dist-tag: $NPM_TAG"
echo "NPM_TAG=$NPM_TAG" >> "$GITHUB_ENV"
YAAK_CLI_VERSION="$VERSION" node npm/prepare-publish.js
- name: Publish @yaakapp/cli-darwin-arm64
run: npm publish --provenance --access public --tag "$NPM_TAG"
working-directory: npm/cli-darwin-arm64
- name: Publish @yaakapp/cli-darwin-x64
run: npm publish --provenance --access public --tag "$NPM_TAG"
working-directory: npm/cli-darwin-x64
- name: Publish @yaakapp/cli-linux-arm64
run: npm publish --provenance --access public --tag "$NPM_TAG"
working-directory: npm/cli-linux-arm64
- name: Publish @yaakapp/cli-linux-x64
run: npm publish --provenance --access public --tag "$NPM_TAG"
working-directory: npm/cli-linux-x64
- name: Publish @yaakapp/cli-win32-arm64
run: npm publish --provenance --access public --tag "$NPM_TAG"
working-directory: npm/cli-win32-arm64
- name: Publish @yaakapp/cli-win32-x64
run: npm publish --provenance --access public --tag "$NPM_TAG"
working-directory: npm/cli-win32-x64
- name: Publish @yaakapp/cli
run: npm publish --provenance --access public --tag "$NPM_TAG"
working-directory: npm/cli

131
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,131 @@
name: Generate Artifacts
on:
push:
tags: [ v* ]
jobs:
build-artifacts:
permissions:
contents: write
name: Build
strategy:
fail-fast: false
matrix:
include:
- platform: 'macos-latest' # for Arm-based Macs (M1 and above).
args: '--target aarch64-apple-darwin'
yaak_arch: 'arm64'
os: 'macos'
targets: 'aarch64-apple-darwin'
- platform: 'macos-latest' # for Intel-based Macs.
args: '--target x86_64-apple-darwin'
yaak_arch: 'x64'
os: 'macos'
targets: 'x86_64-apple-darwin'
- platform: 'ubuntu-22.04'
args: ''
yaak_arch: 'x64'
os: 'ubuntu'
targets: ''
- platform: 'ubuntu-22.04-arm'
args: ''
yaak_arch: 'arm64'
os: 'ubuntu'
targets: ''
- platform: 'windows-latest'
args: ''
yaak_arch: 'x64'
os: 'windows'
targets: ''
# Windows ARM64
- platform: 'windows-latest'
args: '--target aarch64-pc-windows-msvc'
yaak_arch: 'arm64'
os: 'windows'
targets: 'aarch64-pc-windows-msvc'
runs-on: ${{ matrix.platform }}
timeout-minutes: 40
steps:
- name: Checkout yaakapp/app
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.targets }}
- uses: Swatinem/rust-cache@v2
with:
workspaces: 'src-tauri'
shared-key: ci
cache-on-failure: true
- name: install dependencies (Linux only)
if: matrix.os == 'ubuntu'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
- name: Install Protoc for plugin-runtime
uses: arduino/setup-protoc@v3
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Install trusted-signing-cli (Windows only)
if: matrix.os == 'windows'
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$dir = "$env:USERPROFILE\trusted-signing"
New-Item -ItemType Directory -Force -Path $dir | Out-Null
$url = "https://github.com/Levminer/trusted-signing-cli/releases/download/0.8.0/trusted-signing-cli.exe"
$exe = Join-Path $dir "trusted-signing-cli.exe"
Invoke-WebRequest -Uri $url -OutFile $exe
echo $dir >> $env:GITHUB_PATH
& $exe --version
- run: npm ci
- run: npm run lint
- name: Run JS Tests
run: npm test
- name: Run Rust Tests
run: cargo test --all
working-directory: src-tauri
- name: Set version
run: npm run replace-version
env:
YAAK_VERSION: ${{ github.ref_name }}
- uses: tauri-apps/tauri-action@v0
env:
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
# Apple signing stuff
APPLE_CERTIFICATE: ${{ matrix.os == 'macos' && secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ matrix.os == 'macos' && secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_ID: ${{ matrix.os == 'macos' && secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ matrix.os == 'macos' && secrets.APPLE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ matrix.os == 'macos' && secrets.APPLE_SIGNING_IDENTITY }}
APPLE_TEAM_ID: ${{ matrix.os == 'macos' && secrets.APPLE_TEAM_ID }}
# Windows signing stuff
AZURE_CLIENT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_SECRET }}
AZURE_TENANT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_TENANT_ID }}
with:
tagName: 'v__VERSION__'
releaseName: 'Release __VERSION__'
releaseBody: '[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)'
releaseDraft: true
prerelease: true
args: '${{ matrix.args }} --config ./src-tauri/tauri.release.conf.json'

25
.gitignore vendored
View File

@@ -25,7 +25,6 @@ dist-ssr
*.sln
*.sw?
.eslintcache
out
*.sqlite
*.sqlite-*
@@ -34,27 +33,3 @@ out
.tmp
tmp
.zed
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
# Tauri auto-generated permission files
**/permissions/autogenerated
**/permissions/schemas
# Flatpak build artifacts
flatpak-repo/
.flatpak-builder/
flatpak/flatpak-builder-tools/
flatpak/cargo-sources.json
flatpak/node-sources.json
# Local Codex desktop env state
.codex/environments/environment.toml
# Claude Code local settings
.claude/settings.local.json

View File

@@ -1 +0,0 @@
node scripts/git-hooks/post-checkout.mjs "$@"

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

View File

@@ -1,16 +0,0 @@
# Contributing to Yaak
Yaak accepts community pull requests for:
- Bug fixes
- Small-scope improvements directly tied to existing behavior
Pull requests that introduce broad new features, major redesigns, or large refactors are out of scope unless explicitly approved first.
## Approval for Non-Bugfix Changes
If your PR is not a bug fix or small-scope improvement, include a link to the approved [feedback item](https://yaak.app/feedback) where contribution approval was explicitly stated.
## Development Setup
For local setup and development workflows, see [`DEVELOPMENT.md`](DEVELOPMENT.md).

View File

@@ -1,91 +0,0 @@
[workspace]
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",
"crates/yaak-crypto",
"crates/yaak-git",
"crates/yaak-grpc",
"crates/yaak-http",
"crates/yaak-models",
"crates/yaak-plugins",
"crates/yaak-sse",
"crates/yaak-sync",
"crates/yaak-templates",
"crates/yaak-tls",
"crates/yaak-ws",
"crates/yaak-api",
"crates/yaak-proxy",
# Proxy-specific crates
"crates-proxy/yaak-proxy-lib",
# CLI crates
"crates-cli/yaak-cli",
# Tauri-specific crates
"crates-tauri/yaak-app-client",
"crates-tauri/yaak-app-proxy",
"crates-tauri/yaak-fonts",
"crates-tauri/yaak-license",
"crates-tauri/yaak-mac-window",
"crates-tauri/yaak-tauri-utils",
"crates-tauri/yaak-window",
]
[workspace.dependencies]
chrono = "0.4.42"
hex = "0.4.3"
keyring = "3.6.3"
log = "0.4.29"
reqwest = "0.12.20"
rustls = { version = "0.23.34", default-features = false }
rustls-platform-verifier = "0.6.2"
schemars = { version = "0.8.22", features = ["chrono"] }
serde = "1.0.228"
serde_json = "1.0.145"
sha2 = "0.10.9"
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" }
yaak-common = { path = "crates/yaak-common" }
yaak-crypto = { path = "crates/yaak-crypto" }
yaak-git = { path = "crates/yaak-git" }
yaak-grpc = { path = "crates/yaak-grpc" }
yaak-http = { path = "crates/yaak-http" }
yaak-models = { path = "crates/yaak-models" }
yaak-plugins = { path = "crates/yaak-plugins" }
yaak-sse = { path = "crates/yaak-sse" }
yaak-sync = { path = "crates/yaak-sync" }
yaak-templates = { path = "crates/yaak-templates" }
yaak-tls = { path = "crates/yaak-tls" }
yaak-ws = { path = "crates/yaak-ws" }
yaak-api = { path = "crates/yaak-api" }
yaak-proxy = { path = "crates/yaak-proxy" }
# Internal crates - proxy
yaak-proxy-lib = { path = "crates-proxy/yaak-proxy-lib" }
# Internal crates - Tauri-specific
yaak-fonts = { path = "crates-tauri/yaak-fonts" }
yaak-license = { path = "crates-tauri/yaak-license" }
yaak-mac-window = { path = "crates-tauri/yaak-mac-window" }
yaak-tauri-utils = { path = "crates-tauri/yaak-tauri-utils" }
yaak-window = { path = "crates-tauri/yaak-window" }
[profile.release]
strip = false

View File

@@ -1,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/src-tauri/icons/icon.png">
</a>
</p>
@@ -19,10 +19,10 @@
<p align="center">
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https:&#x2F;&#x2F;github.com&#x2F;MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a>&nbsp;&nbsp;<a href="https://github.com/dharsanb"><img src="https:&#x2F;&#x2F;github.com&#x2F;dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a>&nbsp;&nbsp;<a href="https://github.com/railwayapp"><img src="https:&#x2F;&#x2F;github.com&#x2F;railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a>&nbsp;&nbsp;<a href="https://github.com/caseyamcl"><img src="https:&#x2F;&#x2F;github.com&#x2F;caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a>&nbsp;&nbsp;<a href="https://github.com/bytebase"><img src="https:&#x2F;&#x2F;github.com&#x2F;bytebase.png" width="80px" alt="User avatar: bytebase" /></a>&nbsp;&nbsp;<a href="https://github.com/"><img src="https:&#x2F;&#x2F;raw.githubusercontent.com&#x2F;JamesIves&#x2F;github-sponsors-readme-action&#x2F;dev&#x2F;.github&#x2F;assets&#x2F;placeholder.png" width="80px" alt="User avatar: " /></a>&nbsp;&nbsp;<!-- sponsors-premium -->
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https:&#x2F;&#x2F;github.com&#x2F;MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a>&nbsp;&nbsp;<a href="https://github.com/dharsanb"><img src="https:&#x2F;&#x2F;github.com&#x2F;dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a>&nbsp;&nbsp;<a href="https://github.com/railwayapp"><img src="https:&#x2F;&#x2F;github.com&#x2F;railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a>&nbsp;&nbsp;<a href="https://github.com/caseyamcl"><img src="https:&#x2F;&#x2F;github.com&#x2F;caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a>&nbsp;&nbsp;<a href="https://github.com/"><img src="https:&#x2F;&#x2F;raw.githubusercontent.com&#x2F;JamesIves&#x2F;github-sponsors-readme-action&#x2F;dev&#x2F;.github&#x2F;assets&#x2F;placeholder.png" width="80px" alt="User avatar: " /></a>&nbsp;&nbsp;<!-- sponsors-premium -->
</p>
<p align="center">
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https:&#x2F;&#x2F;github.com&#x2F;seanwash.png" width="50px" alt="User avatar: seanwash" /></a>&nbsp;&nbsp;<a href="https://github.com/jerath"><img src="https:&#x2F;&#x2F;github.com&#x2F;jerath.png" width="50px" alt="User avatar: jerath" /></a>&nbsp;&nbsp;<a href="https://github.com/itsa-sh"><img src="https:&#x2F;&#x2F;github.com&#x2F;itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a>&nbsp;&nbsp;<a href="https://github.com/dmmulroy"><img src="https:&#x2F;&#x2F;github.com&#x2F;dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a>&nbsp;&nbsp;<a href="https://github.com/timcole"><img src="https:&#x2F;&#x2F;github.com&#x2F;timcole.png" width="50px" alt="User avatar: timcole" /></a>&nbsp;&nbsp;<a href="https://github.com/VLZH"><img src="https:&#x2F;&#x2F;github.com&#x2F;VLZH.png" width="50px" alt="User avatar: VLZH" /></a>&nbsp;&nbsp;<a href="https://github.com/terasaka2k"><img src="https:&#x2F;&#x2F;github.com&#x2F;terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a>&nbsp;&nbsp;<a href="https://github.com/andriyor"><img src="https:&#x2F;&#x2F;github.com&#x2F;andriyor.png" width="50px" alt="User avatar: andriyor" /></a>&nbsp;&nbsp;<a href="https://github.com/majudhu"><img src="https:&#x2F;&#x2F;github.com&#x2F;majudhu.png" width="50px" alt="User avatar: majudhu" /></a>&nbsp;&nbsp;<a href="https://github.com/axelrindle"><img src="https:&#x2F;&#x2F;github.com&#x2F;axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a>&nbsp;&nbsp;<a href="https://github.com/jirizverina"><img src="https:&#x2F;&#x2F;github.com&#x2F;jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a>&nbsp;&nbsp;<a href="https://github.com/chip-well"><img src="https:&#x2F;&#x2F;github.com&#x2F;chip-well.png" width="50px" alt="User avatar: chip-well" /></a>&nbsp;&nbsp;<a href="https://github.com/GRAYAH"><img src="https:&#x2F;&#x2F;github.com&#x2F;GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a>&nbsp;&nbsp;<a href="https://github.com/flashblaze"><img src="https:&#x2F;&#x2F;github.com&#x2F;flashblaze.png" width="50px" alt="User avatar: flashblaze" /></a>&nbsp;&nbsp;<a href="https://github.com/Frostist"><img src="https:&#x2F;&#x2F;github.com&#x2F;Frostist.png" width="50px" alt="User avatar: Frostist" /></a>&nbsp;&nbsp;<!-- sponsors-base -->
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https:&#x2F;&#x2F;github.com&#x2F;seanwash.png" width="50px" alt="User avatar: seanwash" /></a>&nbsp;&nbsp;<a href="https://github.com/jerath"><img src="https:&#x2F;&#x2F;github.com&#x2F;jerath.png" width="50px" alt="User avatar: jerath" /></a>&nbsp;&nbsp;<a href="https://github.com/itsa-sh"><img src="https:&#x2F;&#x2F;github.com&#x2F;itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a>&nbsp;&nbsp;<a href="https://github.com/dmmulroy"><img src="https:&#x2F;&#x2F;github.com&#x2F;dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a>&nbsp;&nbsp;<a href="https://github.com/timcole"><img src="https:&#x2F;&#x2F;github.com&#x2F;timcole.png" width="50px" alt="User avatar: timcole" /></a>&nbsp;&nbsp;<a href="https://github.com/VLZH"><img src="https:&#x2F;&#x2F;github.com&#x2F;VLZH.png" width="50px" alt="User avatar: VLZH" /></a>&nbsp;&nbsp;<a href="https://github.com/terasaka2k"><img src="https:&#x2F;&#x2F;github.com&#x2F;terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a>&nbsp;&nbsp;<a href="https://github.com/andriyor"><img src="https:&#x2F;&#x2F;github.com&#x2F;andriyor.png" width="50px" alt="User avatar: andriyor" /></a>&nbsp;&nbsp;<a href="https://github.com/majudhu"><img src="https:&#x2F;&#x2F;github.com&#x2F;majudhu.png" width="50px" alt="User avatar: majudhu" /></a>&nbsp;&nbsp;<a href="https://github.com/axelrindle"><img src="https:&#x2F;&#x2F;github.com&#x2F;axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a>&nbsp;&nbsp;<a href="https://github.com/jirizverina"><img src="https:&#x2F;&#x2F;github.com&#x2F;jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a>&nbsp;&nbsp;<a href="https://github.com/chip-well"><img src="https:&#x2F;&#x2F;github.com&#x2F;chip-well.png" width="50px" alt="User avatar: chip-well" /></a>&nbsp;&nbsp;<a href="https://github.com/GRAYAH"><img src="https:&#x2F;&#x2F;github.com&#x2F;GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a>&nbsp;&nbsp;<!-- sponsors-base -->
</p>
![Yaak API Client](https://yaak.app/static/screenshot.png)
@@ -58,15 +58,13 @@ Built with [Tauri](https://tauri.app), Rust, and React, its fast, lightweight
## Contribution Policy
> [!IMPORTANT]
> Community PRs are currently limited to bug fixes and small-scope improvements.
> If your PR is out of scope, link an approved feedback item from [yaak.app/feedback](https://yaak.app/feedback).
> See [`CONTRIBUTING.md`](CONTRIBUTING.md) for policy details and [`DEVELOPMENT.md`](DEVELOPMENT.md) for local setup.
Yaak is open source but only accepting contributions for bug fixes. To get started,
visit [`DEVELOPMENT.md`](DEVELOPMENT.md) for tips on setting up your environment.
## Useful Resources
- [Feedback and Bug Reports](https://feedback.yaak.app)
- [Documentation](https://yaak.app/docs)
- [Documentation](https://feedback.yaak.app/help)
- [Yaak vs Postman](https://yaak.app/alternatives/postman)
- [Yaak vs Bruno](https://yaak.app/alternatives/bruno)
- [Yaak vs Insomnia](https://yaak.app/alternatives/insomnia)

View File

@@ -1,161 +0,0 @@
import { open } from '@tauri-apps/plugin-dialog';
import { gitClone } from '@yaakapp-internal/git';
import { useState } from 'react';
import { openWorkspaceFromSyncDir } from '../commands/openWorkspaceFromSyncDir';
import { appInfo } from '../lib/appInfo';
import { showErrorToast } from '../lib/toast';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import { Checkbox } from './core/Checkbox';
import { IconButton } from './core/IconButton';
import { PlainInput } from './core/PlainInput';
import { VStack } from './core/Stacks';
import { promptCredentials } from './git/credentials';
interface Props {
hide: () => void;
}
// Detect path separator from an existing path (defaults to /)
function getPathSeparator(path: string): string {
return path.includes('\\') ? '\\' : '/';
}
export function CloneGitRepositoryDialog({ hide }: Props) {
const [url, setUrl] = useState<string>('');
const [baseDirectory, setBaseDirectory] = useState<string>(appInfo.defaultProjectDir);
const [directoryOverride, setDirectoryOverride] = useState<string | null>(null);
const [hasSubdirectory, setHasSubdirectory] = useState(false);
const [subdirectory, setSubdirectory] = useState<string>('');
const [isCloning, setIsCloning] = useState(false);
const [error, setError] = useState<string | null>(null);
const repoName = extractRepoName(url);
const sep = getPathSeparator(baseDirectory);
const computedDirectory = repoName ? `${baseDirectory}${sep}${repoName}` : baseDirectory;
const directory = directoryOverride ?? computedDirectory;
const workspaceDirectory =
hasSubdirectory && subdirectory ? `${directory}${sep}${subdirectory}` : directory;
const handleSelectDirectory = async () => {
const dir = await open({
title: 'Select Directory',
directory: true,
multiple: false,
});
if (dir != null) {
setBaseDirectory(dir);
setDirectoryOverride(null);
}
};
const handleClone = async (e: React.FormEvent) => {
e.preventDefault();
if (!url || !directory) return;
setIsCloning(true);
setError(null);
try {
const result = await gitClone(url, directory, promptCredentials);
if (result.type === 'needs_credentials') {
setError(
result.error ?? 'Authentication failed. Please check your credentials and try again.',
);
return;
}
// Open the workspace from the cloned directory (or subdirectory)
await openWorkspaceFromSyncDir.mutateAsync(workspaceDirectory);
hide();
} catch (err) {
setError(String(err));
showErrorToast({
id: 'git-clone-error',
title: 'Clone Failed',
message: String(err),
});
} finally {
setIsCloning(false);
}
};
return (
<VStack as="form" space={3} alignItems="start" className="pb-3" onSubmit={handleClone}>
{error && (
<Banner color="danger" className="w-full">
{error}
</Banner>
)}
<PlainInput
required
label="Repository URL"
placeholder="https://github.com/user/repo.git"
defaultValue={url}
onChange={setUrl}
/>
<PlainInput
label="Directory"
placeholder={appInfo.defaultProjectDir}
defaultValue={directory}
onChange={setDirectoryOverride}
rightSlot={
<IconButton
size="xs"
className="mr-0.5 !h-auto my-0.5"
icon="folder"
title="Browse"
onClick={handleSelectDirectory}
/>
}
/>
<Checkbox
checked={hasSubdirectory}
onChange={setHasSubdirectory}
title="Workspace is in a subdirectory"
help="Enable if the Yaak workspace files are not at the root of the repository"
/>
{hasSubdirectory && (
<PlainInput
label="Subdirectory"
placeholder="path/to/workspace"
defaultValue={subdirectory}
onChange={setSubdirectory}
/>
)}
<Button
type="submit"
color="primary"
className="w-full mt-3"
disabled={!url || !directory || isCloning}
isLoading={isCloning}
>
{isCloning ? 'Cloning...' : 'Clone Repository'}
</Button>
</VStack>
);
}
function extractRepoName(url: string): string {
// Handle various Git URL formats:
// https://github.com/user/repo.git
// git@github.com:user/repo.git
// https://github.com/user/repo
const match = url.match(/\/([^/]+?)(\.git)?$/);
if (match?.[1]) {
return match[1];
}
// Fallback for SSH-style URLs
const sshMatch = url.match(/:([^/]+?)(\.git)?$/);
if (sshMatch?.[1]) {
return sshMatch[1];
}
return '';
}

View File

@@ -1,58 +0,0 @@
import type { HttpResponse } from '@yaakapp-internal/models';
import { type ReactNode, useMemo } from 'react';
import { getRequestBodyText as getHttpResponseRequestBodyText } from '../hooks/useHttpRequestBody';
import { useToggle } from '../hooks/useToggle';
import { isProbablyTextContentType } from '../lib/contentType';
import { getContentTypeFromHeaders } from '../lib/model_util';
import { CopyButton } from './CopyButton';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import { InlineCode } from './core/InlineCode';
import { SizeTag } from './core/SizeTag';
import { HStack } from './core/Stacks';
interface Props {
children: ReactNode;
response: HttpResponse;
}
const LARGE_BYTES = 2 * 1000 * 1000;
export function ConfirmLargeResponseRequest({ children, response }: Props) {
const [showLargeResponse, toggleShowLargeResponse] = useToggle();
const isProbablyText = useMemo(() => {
const contentType = getContentTypeFromHeaders(response.headers);
return isProbablyTextContentType(contentType);
}, [response.headers]);
const contentLength = response.requestContentLength ?? 0;
const isLarge = contentLength > LARGE_BYTES;
if (!showLargeResponse && isLarge) {
return (
<Banner color="primary" className="flex flex-col gap-3">
<p>
Showing content over{' '}
<InlineCode>
<SizeTag contentLength={LARGE_BYTES} />
</InlineCode>{' '}
may impact performance
</p>
<HStack wrap space={2}>
<Button color="primary" size="xs" onClick={toggleShowLargeResponse}>
Reveal Request Body
</Button>
{isProbablyText && (
<CopyButton
color="secondary"
variant="border"
size="xs"
text={() => getHttpResponseRequestBodyText(response).then((d) => d?.bodyText ?? '')}
/>
)}
</HStack>
</Banner>
);
}
return <>{children}</>;
}

View File

@@ -1,181 +0,0 @@
import type { DnsOverride, Workspace } from '@yaakapp-internal/models';
import { patchModel } from '@yaakapp-internal/models';
import { useCallback, useId, useMemo } from 'react';
import { Button } from './core/Button';
import { Checkbox } from './core/Checkbox';
import { IconButton } from './core/IconButton';
import { PlainInput } from './core/PlainInput';
import { HStack, VStack } from './core/Stacks';
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from './core/Table';
interface Props {
workspace: Workspace;
}
interface DnsOverrideWithId extends DnsOverride {
_id: string;
}
export function DnsOverridesEditor({ workspace }: Props) {
const reactId = useId();
// Ensure each override has an internal ID for React keys
const overridesWithIds = useMemo<DnsOverrideWithId[]>(() => {
return workspace.settingDnsOverrides.map((override, index) => ({
...override,
_id: `${reactId}-${index}`,
}));
}, [workspace.settingDnsOverrides, reactId]);
const handleChange = useCallback(
(overrides: DnsOverride[]) => {
patchModel(workspace, { settingDnsOverrides: overrides });
},
[workspace],
);
const handleAdd = useCallback(() => {
const newOverride: DnsOverride = {
hostname: '',
ipv4: [''],
ipv6: [],
enabled: true,
};
handleChange([...workspace.settingDnsOverrides, newOverride]);
}, [workspace.settingDnsOverrides, handleChange]);
const handleUpdate = useCallback(
(index: number, update: Partial<DnsOverride>) => {
const updated = workspace.settingDnsOverrides.map((o, i) =>
i === index ? { ...o, ...update } : o,
);
handleChange(updated);
},
[workspace.settingDnsOverrides, handleChange],
);
const handleDelete = useCallback(
(index: number) => {
const updated = workspace.settingDnsOverrides.filter((_, i) => i !== index);
handleChange(updated);
},
[workspace.settingDnsOverrides, handleChange],
);
return (
<VStack space={3} className="pb-3">
<div className="text-text-subtle text-sm">
Override DNS resolution for specific hostnames. This works like{' '}
<code className="text-text-subtlest bg-surface-highlight px-1 rounded">/etc/hosts</code>{' '}
but only for requests made from this workspace.
</div>
{overridesWithIds.length > 0 && (
<Table>
<TableHead>
<TableRow>
<TableHeaderCell className="w-8" />
<TableHeaderCell>Hostname</TableHeaderCell>
<TableHeaderCell>IPv4 Address</TableHeaderCell>
<TableHeaderCell>IPv6 Address</TableHeaderCell>
<TableHeaderCell className="w-10" />
</TableRow>
</TableHead>
<TableBody>
{overridesWithIds.map((override, index) => (
<DnsOverrideRow
key={override._id}
override={override}
onUpdate={(update) => handleUpdate(index, update)}
onDelete={() => handleDelete(index)}
/>
))}
</TableBody>
</Table>
)}
<HStack>
<Button size="xs" color="secondary" variant="border" onClick={handleAdd}>
Add DNS Override
</Button>
</HStack>
</VStack>
);
}
interface DnsOverrideRowProps {
override: DnsOverride;
onUpdate: (update: Partial<DnsOverride>) => void;
onDelete: () => void;
}
function DnsOverrideRow({ override, onUpdate, onDelete }: DnsOverrideRowProps) {
const ipv4Value = override.ipv4.join(', ');
const ipv6Value = override.ipv6.join(', ');
return (
<TableRow>
<TableCell>
<Checkbox
hideLabel
title={override.enabled ? 'Disable override' : 'Enable override'}
checked={override.enabled ?? true}
onChange={(enabled) => onUpdate({ enabled })}
/>
</TableCell>
<TableCell>
<PlainInput
size="sm"
hideLabel
label="Hostname"
placeholder="api.example.com"
defaultValue={override.hostname}
onChange={(hostname) => onUpdate({ hostname })}
/>
</TableCell>
<TableCell>
<PlainInput
size="sm"
hideLabel
label="IPv4 addresses"
placeholder="127.0.0.1"
defaultValue={ipv4Value}
onChange={(value) =>
onUpdate({
ipv4: value
.split(',')
.map((s) => s.trim())
.filter(Boolean),
})
}
/>
</TableCell>
<TableCell>
<PlainInput
size="sm"
hideLabel
label="IPv6 addresses"
placeholder="::1"
defaultValue={ipv6Value}
onChange={(value) =>
onUpdate({
ipv6: value
.split(',')
.map((s) => s.trim())
.filter(Boolean),
})
}
/>
</TableCell>
<TableCell>
<IconButton
size="xs"
iconSize="sm"
icon="trash"
title="Delete override"
onClick={onDelete}
/>
</TableCell>
</TableRow>
);
}

View File

@@ -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,
);

View File

@@ -1,210 +0,0 @@
import {
createWorkspaceModel,
foldersAtom,
patchModel,
} from '@yaakapp-internal/models';
import { useAtomValue } from 'jotai';
import { Fragment, useMemo } from 'react';
import { useAuthTab } from '../hooks/useAuthTab';
import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown';
import { useHeadersTab } from '../hooks/useHeadersTab';
import { useInheritedHeaders } from '../hooks/useInheritedHeaders';
import { useModelAncestors } from '../hooks/useModelAncestors';
import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm';
import { hideDialog } from '../lib/dialog';
import { CopyIconButton } from './CopyIconButton';
import { Button } from './core/Button';
import { CountBadge } from './core/CountBadge';
import { Icon } from '@yaakapp-internal/ui';
import { InlineCode } from './core/InlineCode';
import { Input } from './core/Input';
import { Link } from './core/Link';
import { HStack, VStack } from './core/Stacks';
import type { TabItem } from './core/Tabs/Tabs';
import { TabContent, Tabs } from './core/Tabs/Tabs';
import { EmptyStateText } from './EmptyStateText';
import { EnvironmentEditor } from './EnvironmentEditor';
import { HeadersEditor } from './HeadersEditor';
import { HttpAuthenticationEditor } from './HttpAuthenticationEditor';
import { MarkdownEditor } from './MarkdownEditor';
interface Props {
folderId: string | null;
tab?: FolderSettingsTab;
}
const TAB_AUTH = 'auth';
const TAB_HEADERS = 'headers';
const TAB_VARIABLES = 'variables';
const TAB_GENERAL = 'general';
export type FolderSettingsTab =
| typeof TAB_AUTH
| typeof TAB_HEADERS
| typeof TAB_GENERAL
| typeof TAB_VARIABLES;
export function FolderSettingsDialog({ folderId, tab }: Props) {
const folders = useAtomValue(foldersAtom);
const folder = folders.find((f) => f.id === folderId) ?? null;
const ancestors = useModelAncestors(folder);
const breadcrumbs = useMemo(() => ancestors.toReversed(), [ancestors]);
const authTab = useAuthTab(TAB_AUTH, folder);
const headersTab = useHeadersTab(TAB_HEADERS, folder);
const inheritedHeaders = useInheritedHeaders(folder);
const environments = useEnvironmentsBreakdown();
const folderEnvironment = environments.allEnvironments.find(
(e) => e.parentModel === 'folder' && e.parentId === folderId,
);
const numVars = (folderEnvironment?.variables ?? []).filter((v) => v.name).length;
const tabs = useMemo<TabItem[]>(() => {
if (folder == null) return [];
return [
{
value: TAB_GENERAL,
label: 'General',
},
...headersTab,
...authTab,
{
value: TAB_VARIABLES,
label: 'Variables',
rightSlot: numVars > 0 ? <CountBadge count={numVars} /> : null,
},
];
}, [authTab, folder, headersTab, numVars]);
if (folder == null) return null;
return (
<div className="h-full flex flex-col">
<div className="flex items-center gap-3 px-6 pr-10 mt-4 mb-2 min-w-0 text-xl">
<Icon icon="folder_cog" size="lg" color="secondary" className="flex-shrink-0" />
<div className="flex items-center gap-1.5 font-semibold text-text min-w-0 overflow-hidden flex-1">
{breadcrumbs.map((item, index) => (
<Fragment key={item.id}>
{index > 0 && (
<Icon
icon="chevron_right"
size="lg"
className="opacity-50 flex-shrink-0"
/>
)}
<span className="text-text-subtle truncate min-w-0" title={item.name}>
{item.name}
</span>
</Fragment>
))}
{breadcrumbs.length > 0 && (
<Icon icon="chevron_right" size="lg" className="opacity-50 flex-shrink-0" />
)}
<span
className="whitespace-nowrap"
title={folder.name}
>
{folder.name}
</span>
</div>
</div>
<Tabs
defaultValue={tab ?? TAB_GENERAL}
label="Folder Settings"
className="pt-2 pb-2 pl-3 pr-1 flex-1"
layout="horizontal"
addBorders
tabs={tabs}
>
<TabContent value={TAB_AUTH} className="overflow-y-auto h-full px-4">
<HttpAuthenticationEditor model={folder} />
</TabContent>
<TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-4">
<div className="grid grid-rows-[auto_minmax(0,1fr)_auto] gap-3 pb-3 h-full">
<Input
label="Folder Name"
defaultValue={folder.name}
onChange={(name) => patchModel(folder, { name })}
stateKey={`name.${folder.id}`}
/>
<MarkdownEditor
name="folder-description"
placeholder="Folder description"
className="border border-border px-2"
defaultValue={folder.description}
stateKey={`description.${folder.id}`}
onChange={(description) => patchModel(folder, { description })}
/>
<HStack alignItems="center" justifyContent="between" className="w-full">
<Button
onClick={async () => {
const didDelete = await deleteModelWithConfirm(folder);
if (didDelete) {
hideDialog('folder-settings');
}
}}
color="danger"
variant="border"
size="xs"
>
Delete Folder
</Button>
<InlineCode className="flex gap-1 items-center text-primary pl-2.5">
{folder.id}
<CopyIconButton
className="opacity-70 !text-primary"
size="2xs"
iconSize="sm"
title="Copy folder ID"
text={folder.id}
/>
</InlineCode>
</HStack>
</div>
</TabContent>
<TabContent value={TAB_HEADERS} className="overflow-y-auto h-full px-4">
<HeadersEditor
inheritedHeaders={inheritedHeaders}
forceUpdateKey={folder.id}
headers={folder.headers}
onChange={(headers) => patchModel(folder, { headers })}
stateKey={`headers.${folder.id}`}
/>
</TabContent>
<TabContent value={TAB_VARIABLES} className="overflow-y-auto h-full px-4">
{folderEnvironment == null ? (
<EmptyStateText>
<VStack alignItems="center" space={1.5}>
<p>
Override{' '}
<Link href="https://yaak.app/docs/using-yaak/environments-and-variables">
Variables
</Link>{' '}
for requests within this folder.
</p>
<Button
variant="border"
size="sm"
onClick={async () => {
await createWorkspaceModel({
workspaceId: folder.workspaceId,
parentModel: 'folder',
parentId: folder.id,
model: 'environment',
name: 'Folder Environment',
});
}}
>
Create Folder Environment
</Button>
</VStack>
</EmptyStateText>
) : (
<EnvironmentEditor hideName environment={folderEnvironment} />
)}
</TabContent>
</Tabs>
</div>
);
}

View File

@@ -1,249 +0,0 @@
import type { GrpcEvent, GrpcRequest } from '@yaakapp-internal/models';
import { useAtomValue, useSetAtom } from 'jotai';
import type { CSSProperties } from 'react';
import { useEffect, useMemo, useState } from 'react';
import {
activeGrpcConnectionAtom,
activeGrpcConnections,
pinnedGrpcConnectionIdAtom,
useGrpcEvents,
} from '../hooks/usePinnedGrpcConnection';
import { useStateWithDeps } from '../hooks/useStateWithDeps';
import { Button } from './core/Button';
import { Editor } from './core/Editor/LazyEditor';
import { EventDetailHeader, EventViewer } from './core/EventViewer';
import { EventViewerRow } from './core/EventViewerRow';
import { HotkeyList } from './core/HotkeyList';
import { Icon, LoadingIcon, type IconProps } from '@yaakapp-internal/ui';
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
import { HStack, VStack } from './core/Stacks';
import { EmptyStateText } from './EmptyStateText';
import { ErrorBoundary } from './ErrorBoundary';
import { RecentGrpcConnectionsDropdown } from './RecentGrpcConnectionsDropdown';
interface Props {
style?: CSSProperties;
className?: string;
activeRequest: GrpcRequest;
methodType:
| 'unary'
| 'client_streaming'
| 'server_streaming'
| 'streaming'
| 'no-schema'
| 'no-method';
}
export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
const [activeEventIndex, setActiveEventIndex] = useState<number | null>(null);
const [showLarge, setShowLarge] = useStateWithDeps<boolean>(false, [activeRequest.id]);
const [showingLarge, setShowingLarge] = useState<boolean>(false);
const connections = useAtomValue(activeGrpcConnections);
const activeConnection = useAtomValue(activeGrpcConnectionAtom);
const events = useGrpcEvents(activeConnection?.id ?? null);
const setPinnedGrpcConnectionId = useSetAtom(pinnedGrpcConnectionIdAtom);
const activeEvent = useMemo(
() => (activeEventIndex != null ? events[activeEventIndex] : null),
[activeEventIndex, events],
);
// Set the active message to the first message received if unary
// biome-ignore lint/correctness/useExhaustiveDependencies: none
useEffect(() => {
if (events.length === 0 || activeEvent != null || methodType !== 'unary') {
return;
}
const firstServerMessageIndex = events.findIndex((m) => m.eventType === 'server_message');
if (firstServerMessageIndex !== -1) {
setActiveEventIndex(firstServerMessageIndex);
}
}, [events.length]);
if (activeConnection == null) {
return (
<HotkeyList hotkeys={['request.send', 'model.create', 'sidebar.focus', 'url_bar.focus']} />
);
}
const header = (
<HStack className="pl-3 mb-1 font-mono text-sm text-text-subtle overflow-x-auto hide-scrollbars">
<HStack space={2}>
<span className="whitespace-nowrap">{events.length} Messages</span>
{activeConnection.state !== 'closed' && (
<LoadingIcon size="sm" className="text-text-subtlest" />
)}
</HStack>
<div className="ml-auto">
<RecentGrpcConnectionsDropdown
connections={connections}
activeConnection={activeConnection}
onPinnedConnectionId={setPinnedGrpcConnectionId}
/>
</div>
</HStack>
);
return (
<div style={style} className="h-full">
<ErrorBoundary name="GRPC Events">
<EventViewer
events={events}
getEventKey={(event) => event.id}
error={activeConnection.error}
header={header}
splitLayoutName="grpc_events"
defaultRatio={0.4}
renderRow={({ event, isActive, onClick }) => (
<GrpcEventRow event={event} isActive={isActive} onClick={onClick} />
)}
renderDetail={({ event, onClose }) => (
<GrpcEventDetail
event={event}
showLarge={showLarge}
showingLarge={showingLarge}
setShowLarge={setShowLarge}
setShowingLarge={setShowingLarge}
onClose={onClose}
/>
)}
/>
</ErrorBoundary>
</div>
);
}
function GrpcEventRow({
event,
isActive,
onClick,
}: {
event: GrpcEvent;
isActive: boolean;
onClick: () => void;
}) {
const { eventType, status, content, error } = event;
const display = getEventDisplay(eventType, status);
return (
<EventViewerRow
isActive={isActive}
onClick={onClick}
icon={<Icon color={display.color} title={display.title} icon={display.icon} />}
content={
<span className="text-xs">
{content.slice(0, 1000)}
{error && <span className="text-warning"> ({error})</span>}
</span>
}
timestamp={event.createdAt}
/>
);
}
function GrpcEventDetail({
event,
showLarge,
showingLarge,
setShowLarge,
setShowingLarge,
onClose,
}: {
event: GrpcEvent;
showLarge: boolean;
showingLarge: boolean;
setShowLarge: (v: boolean) => void;
setShowingLarge: (v: boolean) => void;
onClose: () => void;
}) {
if (event.eventType === 'client_message' || event.eventType === 'server_message') {
const title = `Message ${event.eventType === 'client_message' ? 'Sent' : 'Received'}`;
return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
<EventDetailHeader
title={title}
timestamp={event.createdAt}
copyText={event.content}
onClose={onClose}
/>
{!showLarge && event.content.length > 1000 * 1000 ? (
<VStack space={2} className="italic text-text-subtlest">
Message previews larger than 1MB are hidden
<div>
<Button
onClick={() => {
setShowingLarge(true);
setTimeout(() => {
setShowLarge(true);
setShowingLarge(false);
}, 500);
}}
isLoading={showingLarge}
color="secondary"
variant="border"
size="xs"
>
Try Showing
</Button>
</div>
</VStack>
) : (
<Editor
language="json"
defaultValue={event.content ?? ''}
wrapLines={false}
readOnly={true}
stateKey={null}
/>
)}
</div>
);
}
// Error or connection_end - show metadata/trailers
return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
<EventDetailHeader title={event.content} timestamp={event.createdAt} onClose={onClose} />
{event.error && (
<div className="select-text cursor-text text-sm font-mono py-1 text-warning">
{event.error}
</div>
)}
<div className="py-2 h-full">
{Object.keys(event.metadata).length === 0 ? (
<EmptyStateText>
No {event.eventType === 'connection_end' ? 'trailers' : 'metadata'}
</EmptyStateText>
) : (
<KeyValueRows>
{Object.entries(event.metadata).map(([key, value]) => (
<KeyValueRow key={key} label={key}>
{value}
</KeyValueRow>
))}
</KeyValueRows>
)}
</div>
</div>
);
}
function getEventDisplay(
eventType: GrpcEvent['eventType'],
status: GrpcEvent['status'],
): { icon: IconProps['icon']; color: IconProps['color']; title: string } {
if (eventType === 'server_message') {
return { icon: 'arrow_big_down_dash', color: 'info', title: 'Server message' };
}
if (eventType === 'client_message') {
return { icon: 'arrow_big_up_dash', color: 'primary', title: 'Client message' };
}
if (eventType === 'error' || (status != null && status > 0)) {
return { icon: 'alert_triangle', color: 'danger', title: 'Error' };
}
if (eventType === 'connection_end') {
return { icon: 'check', color: 'success', title: 'Connection response' };
}
return { icon: 'info', color: undefined, title: 'Event' };
}

View File

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

View File

@@ -1,418 +0,0 @@
import type {
HttpResponse,
HttpResponseEvent,
HttpResponseEventData,
} from '@yaakapp-internal/models';
import { type ReactNode, useMemo, useState } from 'react';
import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents';
import { Editor } from './core/Editor/LazyEditor';
import { type EventDetailAction, EventDetailHeader, EventViewer } from './core/EventViewer';
import { EventViewerRow } from './core/EventViewerRow';
import { HttpStatusTagRaw } from './core/HttpStatusTag';
import { Icon, type IconProps } from '@yaakapp-internal/ui';
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
import type { TimelineViewMode } from './HttpResponsePane';
interface Props {
response: HttpResponse;
viewMode: TimelineViewMode;
}
export function HttpResponseTimeline({ response, viewMode }: Props) {
return <Inner key={response.id} response={response} viewMode={viewMode} />;
}
function Inner({ response, viewMode }: Props) {
const [showRaw, setShowRaw] = useState(false);
const { data: events, error, isLoading } = useHttpResponseEvents(response);
// Generate plain text representation of all events (with prefixes for timeline view)
const plainText = useMemo(() => {
if (!events || events.length === 0) return '';
return events.map((event) => formatEventText(event.event, true)).join('\n');
}, [events]);
// Plain text view - show all events as text in an editor
if (viewMode === 'text') {
if (isLoading) {
return <div className="p-4 text-text-subtlest">Loading events...</div>;
} else if (error) {
return <div className="p-4 text-danger">{String(error)}</div>;
} else if (!events || events.length === 0) {
return <div className="p-4 text-text-subtlest">No events recorded</div>;
} else {
return (
<Editor language="timeline" defaultValue={plainText} readOnly stateKey={null} hideGutter />
);
}
}
return (
<EventViewer
events={events ?? []}
getEventKey={(event) => event.id}
error={error ? String(error) : null}
isLoading={isLoading}
loadingMessage="Loading events..."
emptyMessage="No events recorded"
splitLayoutName="http_response_events"
defaultRatio={0.25}
renderRow={({ event, isActive, onClick }) => {
const display = getEventDisplay(event.event);
return (
<EventViewerRow
isActive={isActive}
onClick={onClick}
icon={<Icon color={display.color} icon={display.icon} size="sm" />}
content={display.summary}
timestamp={event.createdAt}
/>
);
}}
renderDetail={({ event, onClose }) => (
<EventDetails event={event} showRaw={showRaw} setShowRaw={setShowRaw} onClose={onClose} />
)}
/>
);
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function EventDetails({
event,
showRaw,
setShowRaw,
onClose,
}: {
event: HttpResponseEvent;
showRaw: boolean;
setShowRaw: (v: boolean) => void;
onClose: () => void;
}) {
const { label } = getEventDisplay(event.event);
const e = event.event;
const actions: EventDetailAction[] = [
{
key: 'toggle-raw',
label: showRaw ? 'Formatted' : 'Text',
onClick: () => setShowRaw(!showRaw),
},
];
// Determine the title based on event type
const title = (() => {
switch (e.type) {
case 'header_up':
return 'Header Sent';
case 'header_down':
return 'Header Received';
case 'send_url':
return 'Request';
case 'receive_url':
return 'Response';
case 'redirect':
return 'Redirect';
case 'setting':
return 'Apply Setting';
case 'chunk_sent':
return 'Data Sent';
case 'chunk_received':
return 'Data Received';
case 'dns_resolved':
return e.overridden ? 'DNS Override' : 'DNS Resolution';
default:
return label;
}
})();
// Render content based on view mode and event type
const renderContent = () => {
// Raw view - show plaintext representation (without prefix)
if (showRaw) {
const rawText = formatEventText(event.event, false);
return <Editor language="text" defaultValue={rawText} readOnly stateKey={null} hideGutter />;
}
// Headers - show name and value
if (e.type === 'header_up' || e.type === 'header_down') {
return (
<KeyValueRows>
<KeyValueRow label="Header">{e.name}</KeyValueRow>
<KeyValueRow label="Value">{e.value}</KeyValueRow>
</KeyValueRows>
);
}
// Request URL - show all URL parts separately
if (e.type === 'send_url') {
const auth = e.username || e.password ? `${e.username}:${e.password}@` : '';
const isDefaultPort =
(e.scheme === 'http' && e.port === 80) || (e.scheme === 'https' && e.port === 443);
const portStr = isDefaultPort ? '' : `:${e.port}`;
const query = e.query ? `?${e.query}` : '';
const fragment = e.fragment ? `#${e.fragment}` : '';
const fullUrl = `${e.scheme}://${auth}${e.host}${portStr}${e.path}${query}${fragment}`;
return (
<KeyValueRows>
<KeyValueRow label="URL">{fullUrl}</KeyValueRow>
<KeyValueRow label="Method">{e.method}</KeyValueRow>
<KeyValueRow label="Scheme">{e.scheme}</KeyValueRow>
{e.username ? <KeyValueRow label="Username">{e.username}</KeyValueRow> : null}
{e.password ? <KeyValueRow label="Password">{e.password}</KeyValueRow> : null}
<KeyValueRow label="Host">{e.host}</KeyValueRow>
{!isDefaultPort ? <KeyValueRow label="Port">{e.port}</KeyValueRow> : null}
<KeyValueRow label="Path">{e.path}</KeyValueRow>
{e.query ? <KeyValueRow label="Query">{e.query}</KeyValueRow> : null}
{e.fragment ? <KeyValueRow label="Fragment">{e.fragment}</KeyValueRow> : null}
</KeyValueRows>
);
}
// Response status - show version and status separately
if (e.type === 'receive_url') {
return (
<KeyValueRows>
<KeyValueRow label="HTTP Version">{e.version}</KeyValueRow>
<KeyValueRow label="Status">
<HttpStatusTagRaw status={e.status} />
</KeyValueRow>
</KeyValueRows>
);
}
// Redirect - show status, URL, and behavior
if (e.type === 'redirect') {
const droppedHeaders = e.dropped_headers ?? [];
return (
<KeyValueRows>
<KeyValueRow label="Status">
<HttpStatusTagRaw status={e.status} />
</KeyValueRow>
<KeyValueRow label="Location">{e.url}</KeyValueRow>
<KeyValueRow label="Behavior">
{e.behavior === 'drop_body' ? 'Drop body, change to GET' : 'Preserve method and body'}
</KeyValueRow>
<KeyValueRow label="Body Dropped">{e.dropped_body ? 'Yes' : 'No'}</KeyValueRow>
<KeyValueRow label="Headers Dropped">
{droppedHeaders.length > 0 ? droppedHeaders.join(', ') : '--'}
</KeyValueRow>
</KeyValueRows>
);
}
// Settings - show as key/value
if (e.type === 'setting') {
return (
<KeyValueRows>
<KeyValueRow label="Setting">{e.name}</KeyValueRow>
<KeyValueRow label="Value">{e.value}</KeyValueRow>
</KeyValueRows>
);
}
// Chunks - show formatted bytes
if (e.type === 'chunk_sent' || e.type === 'chunk_received') {
return <div className="font-mono text-editor">{formatBytes(e.bytes)}</div>;
}
// DNS Resolution - show hostname, addresses, and timing
if (e.type === 'dns_resolved') {
return (
<KeyValueRows>
<KeyValueRow label="Hostname">{e.hostname}</KeyValueRow>
<KeyValueRow label="Addresses">{e.addresses.join(', ')}</KeyValueRow>
<KeyValueRow label="Duration">
{e.overridden ? (
<span className="text-text-subtlest">--</span>
) : (
`${String(e.duration)}ms`
)}
</KeyValueRow>
{e.overridden ? <KeyValueRow label="Source">Workspace Override</KeyValueRow> : null}
</KeyValueRows>
);
}
// Default - use summary
const { summary } = getEventDisplay(event.event);
return <div className="font-mono text-editor">{summary}</div>;
};
return (
<div className="flex flex-col gap-2 h-full">
<EventDetailHeader
title={title}
timestamp={event.createdAt}
actions={actions}
onClose={onClose}
/>
{renderContent()}
</div>
);
}
type EventTextParts = { prefix: '>' | '<' | '*'; text: string };
/** Get the prefix and text for an event */
function getEventTextParts(event: HttpResponseEventData): EventTextParts {
switch (event.type) {
case 'send_url':
return {
prefix: '>',
text: `${event.method} ${event.path}${event.query ? `?${event.query}` : ''}${event.fragment ? `#${event.fragment}` : ''}`,
};
case 'receive_url':
return { prefix: '<', text: `${event.version} ${event.status}` };
case 'header_up':
return { prefix: '>', text: `${event.name}: ${event.value}` };
case 'header_down':
return { prefix: '<', text: `${event.name}: ${event.value}` };
case 'redirect': {
const behavior = event.behavior === 'drop_body' ? 'drop body' : 'preserve';
const droppedHeaders = event.dropped_headers ?? [];
const dropped = [
event.dropped_body ? 'body dropped' : null,
droppedHeaders.length > 0 ? `headers dropped: ${droppedHeaders.join(', ')}` : null,
]
.filter(Boolean)
.join(', ');
return {
prefix: '*',
text: `Redirect ${event.status} -> ${event.url} (${behavior}${dropped ? `, ${dropped}` : ''})`,
};
}
case 'setting':
return { prefix: '*', text: `Setting ${event.name}=${event.value}` };
case 'info':
return { prefix: '*', text: event.message };
case 'chunk_sent':
return { prefix: '*', text: `[${formatBytes(event.bytes)} sent]` };
case 'chunk_received':
return { prefix: '*', text: `[${formatBytes(event.bytes)} received]` };
case 'dns_resolved':
if (event.overridden) {
return {
prefix: '*',
text: `DNS override ${event.hostname} -> ${event.addresses.join(', ')}`,
};
}
return {
prefix: '*',
text: `DNS resolved ${event.hostname} to ${event.addresses.join(', ')} (${event.duration}ms)`,
};
default:
return { prefix: '*', text: '[unknown event]' };
}
}
/** Format event as plaintext, optionally with curl-style prefix (> outgoing, < incoming, * info) */
function formatEventText(event: HttpResponseEventData, includePrefix: boolean): string {
const { prefix, text } = getEventTextParts(event);
return includePrefix ? `${prefix} ${text}` : text;
}
type EventDisplay = {
icon: IconProps['icon'];
color: IconProps['color'];
label: string;
summary: ReactNode;
};
function getEventDisplay(event: HttpResponseEventData): EventDisplay {
switch (event.type) {
case 'setting':
return {
icon: 'settings',
color: 'secondary',
label: 'Setting',
summary: `${event.name} = ${event.value}`,
};
case 'info':
return {
icon: 'info',
color: 'secondary',
label: 'Info',
summary: event.message,
};
case 'redirect': {
const droppedHeaders = event.dropped_headers ?? [];
const dropped = [
event.dropped_body ? 'drop body' : null,
droppedHeaders.length > 0
? `drop ${droppedHeaders.length} ${droppedHeaders.length === 1 ? 'header' : 'headers'}`
: null,
]
.filter(Boolean)
.join(', ');
return {
icon: 'arrow_big_right_dash',
color: 'success',
label: 'Redirect',
summary: `Redirecting ${event.status} ${event.url}${dropped ? ` (${dropped})` : ''}`,
};
}
case 'send_url':
return {
icon: 'arrow_big_up_dash',
color: 'primary',
label: 'Request',
summary: `${event.method} ${event.path}${event.query ? `?${event.query}` : ''}${event.fragment ? `#${event.fragment}` : ''}`,
};
case 'receive_url':
return {
icon: 'arrow_big_down_dash',
color: 'info',
label: 'Response',
summary: `${event.version} ${event.status}`,
};
case 'header_up':
return {
icon: 'arrow_big_up_dash',
color: 'primary',
label: 'Header',
summary: `${event.name}: ${event.value}`,
};
case 'header_down':
return {
icon: 'arrow_big_down_dash',
color: 'info',
label: 'Header',
summary: `${event.name}: ${event.value}`,
};
case 'chunk_sent':
return {
icon: 'info',
color: 'secondary',
label: 'Chunk',
summary: `${formatBytes(event.bytes)} chunk sent`,
};
case 'chunk_received':
return {
icon: 'info',
color: 'secondary',
label: 'Chunk',
summary: `${formatBytes(event.bytes)} chunk received`,
};
case 'dns_resolved':
return {
icon: 'globe',
color: event.overridden ? 'success' : 'secondary',
label: event.overridden ? 'DNS Override' : 'DNS',
summary: event.overridden
? `${event.hostname}${event.addresses.join(', ')} (overridden)`
: `${event.hostname}${event.addresses.join(', ')} (${event.duration}ms)`,
};
default:
return {
icon: 'info',
color: 'secondary',
label: 'Unknown',
summary: 'Unknown event',
};
}
}

View File

@@ -1,102 +0,0 @@
import type { HttpResponse } from '@yaakapp-internal/models';
import { lazy, Suspense } from 'react';
import { useHttpRequestBody } from '../hooks/useHttpRequestBody';
import { getMimeTypeFromContentType, languageFromContentType } from '../lib/contentType';
import { LoadingIcon } from '@yaakapp-internal/ui';
import { EmptyStateText } from './EmptyStateText';
import { AudioViewer } from './responseViewers/AudioViewer';
import { CsvViewer } from './responseViewers/CsvViewer';
import { ImageViewer } from './responseViewers/ImageViewer';
import { MultipartViewer } from './responseViewers/MultipartViewer';
import { SvgViewer } from './responseViewers/SvgViewer';
import { TextViewer } from './responseViewers/TextViewer';
import { VideoViewer } from './responseViewers/VideoViewer';
import { WebPageViewer } from './responseViewers/WebPageViewer';
const PdfViewer = lazy(() =>
import('./responseViewers/PdfViewer').then((m) => ({ default: m.PdfViewer })),
);
interface Props {
response: HttpResponse;
}
export function RequestBodyViewer({ response }: Props) {
return <RequestBodyViewerInner key={response.id} response={response} />;
}
function RequestBodyViewerInner({ response }: Props) {
const { data, isLoading, error } = useHttpRequestBody(response);
if (isLoading) {
return (
<EmptyStateText>
<LoadingIcon />
</EmptyStateText>
);
}
if (error) {
return <EmptyStateText>Error loading request body: {error.message}</EmptyStateText>;
}
if (data?.bodyText == null || data.bodyText.length === 0) {
return <EmptyStateText>No request body</EmptyStateText>;
}
const { bodyText, body } = data;
// Try to detect language from content-type header that was sent
const contentTypeHeader = response.requestHeaders.find(
(h) => h.name.toLowerCase() === 'content-type',
);
const contentType = contentTypeHeader?.value ?? null;
const mimeType = contentType ? getMimeTypeFromContentType(contentType).essence : null;
const language = languageFromContentType(contentType, bodyText);
// Route to appropriate viewer based on content type
if (mimeType?.match(/^multipart/i)) {
const boundary = contentType?.split('boundary=')[1] ?? 'unknown';
// Create a copy because parseMultipart may detach the buffer
const bodyCopy = new Uint8Array(body);
return (
<MultipartViewer data={bodyCopy} boundary={boundary} idPrefix={`request.${response.id}`} />
);
}
if (mimeType?.match(/^image\/svg/i)) {
return <SvgViewer text={bodyText} />;
}
if (mimeType?.match(/^image/i)) {
return <ImageViewer data={body.buffer} />;
}
if (mimeType?.match(/^audio/i)) {
return <AudioViewer data={body} />;
}
if (mimeType?.match(/^video/i)) {
return <VideoViewer data={body} />;
}
if (mimeType?.match(/csv|tab-separated/i)) {
return <CsvViewer text={bodyText} />;
}
if (mimeType?.match(/^text\/html/i)) {
return <WebPageViewer html={bodyText} />;
}
if (mimeType?.match(/pdf/i)) {
return (
<Suspense fallback={<LoadingIcon />}>
<PdfViewer data={body} />
</Suspense>
);
}
return (
<TextViewer text={bodyText} language={language} stateKey={`request.body.${response.id}`} />
);
}

View File

@@ -1,225 +0,0 @@
import type { HttpResponse } from '@yaakapp-internal/models';
import classNames from 'classnames';
import { useMemo } from 'react';
import type { JSX } from 'react/jsx-runtime';
import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents';
import { CountBadge } from './core/CountBadge';
import { DetailsBanner } from './core/DetailsBanner';
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
interface Props {
response: HttpResponse;
}
interface ParsedCookie {
name: string;
value: string;
domain?: string;
path?: string;
expires?: string;
maxAge?: string;
secure?: boolean;
httpOnly?: boolean;
sameSite?: string;
isDeleted?: boolean;
}
function parseCookieHeader(cookieHeader: string): Array<{ name: string; value: string }> {
// Parse "Cookie: name=value; name2=value2" format
return cookieHeader.split(';').map((pair) => {
const [name = '', ...valueParts] = pair.split('=');
return {
name: name.trim(),
value: valueParts.join('=').trim(),
};
});
}
function parseSetCookieHeader(setCookieHeader: string): ParsedCookie {
// Parse "Set-Cookie: name=value; Domain=...; Path=..." format
const parts = setCookieHeader.split(';').map((p) => p.trim());
const [nameValue = '', ...attributes] = parts;
const [name = '', ...valueParts] = nameValue.split('=');
const cookie: ParsedCookie = {
name: name.trim(),
value: valueParts.join('=').trim(),
};
for (const attr of attributes) {
const [key = '', val] = attr.split('=').map((s) => s.trim());
const lowerKey = key.toLowerCase();
if (lowerKey === 'domain') cookie.domain = val;
else if (lowerKey === 'path') cookie.path = val;
else if (lowerKey === 'expires') cookie.expires = val;
else if (lowerKey === 'max-age') cookie.maxAge = val;
else if (lowerKey === 'secure') cookie.secure = true;
else if (lowerKey === 'httponly') cookie.httpOnly = true;
else if (lowerKey === 'samesite') cookie.sameSite = val;
}
// Detect if cookie is being deleted
if (cookie.maxAge !== undefined) {
const maxAgeNum = Number.parseInt(cookie.maxAge, 10);
if (!Number.isNaN(maxAgeNum) && maxAgeNum <= 0) {
cookie.isDeleted = true;
}
} else if (cookie.expires !== undefined) {
// Check if expires date is in the past
try {
const expiresDate = new Date(cookie.expires);
if (expiresDate.getTime() < Date.now()) {
cookie.isDeleted = true;
}
} catch {
// Invalid date, ignore
}
}
return cookie;
}
export function ResponseCookies({ response }: Props) {
const { data: events } = useHttpResponseEvents(response);
const { sentCookies, receivedCookies } = useMemo(() => {
if (!events) return { sentCookies: [], receivedCookies: [] };
// Use Maps to deduplicate by cookie name (latest value wins)
const sentMap = new Map<string, { name: string; value: string }>();
const receivedMap = new Map<string, ParsedCookie>();
for (const event of events) {
const e = event.event;
// Cookie headers sent (header_up with name=cookie)
if (e.type === 'header_up' && e.name.toLowerCase() === 'cookie') {
const cookies = parseCookieHeader(e.value);
for (const cookie of cookies) {
sentMap.set(cookie.name, cookie);
}
}
// Set-Cookie headers received (header_down with name=set-cookie)
if (e.type === 'header_down' && e.name.toLowerCase() === 'set-cookie') {
const cookie = parseSetCookieHeader(e.value);
receivedMap.set(cookie.name, cookie);
}
}
return {
sentCookies: Array.from(sentMap.values()),
receivedCookies: Array.from(receivedMap.values()),
};
}, [events]);
return (
<div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5">
<DetailsBanner
defaultOpen
storageKey={`${response.requestId}.sent_cookies`}
summary={
<h2 className="flex items-center">
Sent Cookies <CountBadge showZero count={sentCookies.length} />
</h2>
}
>
{sentCookies.length === 0 ? (
<NoCookies />
) : (
<KeyValueRows>
{sentCookies.map((cookie, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: none
<KeyValueRow labelColor="primary" key={i} label={cookie.name}>
{cookie.value}
</KeyValueRow>
))}
</KeyValueRows>
)}
</DetailsBanner>
<DetailsBanner
defaultOpen
storageKey={`${response.requestId}.received_cookies`}
summary={
<h2 className="flex items-center">
Received Cookies <CountBadge showZero count={receivedCookies.length} />
</h2>
}
>
{receivedCookies.length === 0 ? (
<NoCookies />
) : (
<div className="flex flex-col gap-4">
{receivedCookies.map((cookie, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: none
<div key={i} className="flex flex-col gap-1">
<div className="flex items-center gap-2 my-1">
<span
className={classNames(
'font-mono text-editor select-auto cursor-auto',
cookie.isDeleted ? 'line-through opacity-60 text-text-subtle' : 'text-text',
)}
>
{cookie.name}
<span className="text-text-subtlest select-auto cursor-auto mx-0.5">=</span>
{cookie.value}
</span>
{cookie.isDeleted && (
<span className="text-xs font-sans text-danger bg-danger/10 px-1.5 py-0.5 rounded">
Deleted
</span>
)}
</div>
<KeyValueRows>
{[
cookie.domain && (
<KeyValueRow labelColor="info" label="Domain" key="domain">
{cookie.domain}
</KeyValueRow>
),
cookie.path && (
<KeyValueRow labelColor="info" label="Path" key="path">
{cookie.path}
</KeyValueRow>
),
cookie.expires && (
<KeyValueRow labelColor="info" label="Expires" key="expires">
{cookie.expires}
</KeyValueRow>
),
cookie.maxAge && (
<KeyValueRow labelColor="info" label="Max-Age" key="maxAge">
{cookie.maxAge}
</KeyValueRow>
),
cookie.secure && (
<KeyValueRow labelColor="info" label="Secure" key="secure">
true
</KeyValueRow>
),
cookie.httpOnly && (
<KeyValueRow labelColor="info" label="HttpOnly" key="httpOnly">
true
</KeyValueRow>
),
cookie.sameSite && (
<KeyValueRow labelColor="info" label="SameSite" key="sameSite">
{cookie.sameSite}
</KeyValueRow>
),
].filter((item): item is JSX.Element => Boolean(item))}
</KeyValueRows>
</div>
))}
</div>
)}
</DetailsBanner>
</div>
);
}
function NoCookies() {
return <span className="text-text-subtlest text-sm italic">No Cookies</span>;
}

View File

@@ -1,101 +0,0 @@
import { openUrl } from '@tauri-apps/plugin-opener';
import type { HttpResponse } from '@yaakapp-internal/models';
import { useMemo } from 'react';
import { CountBadge } from './core/CountBadge';
import { DetailsBanner } from './core/DetailsBanner';
import { IconButton } from './core/IconButton';
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
interface Props {
response: HttpResponse;
}
export function ResponseHeaders({ response }: Props) {
const responseHeaders = useMemo(
() =>
[...response.headers].sort((a, b) =>
a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()),
),
[response.headers],
);
const requestHeaders = useMemo(
() =>
[...response.requestHeaders].sort((a, b) =>
a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()),
),
[response.requestHeaders],
);
return (
<div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5">
<DetailsBanner storageKey={`${response.requestId}.general`} summary={<h2>Info</h2>}>
<KeyValueRows>
<KeyValueRow labelColor="secondary" label="Request URL">
<div className="flex items-center gap-1">
<span className="select-text cursor-text">{response.url}</span>
<IconButton
iconSize="sm"
className="inline-block w-auto !h-auto opacity-50 hover:opacity-100"
icon="external_link"
onClick={() => openUrl(response.url)}
title="Open in browser"
/>
</div>
</KeyValueRow>
<KeyValueRow labelColor="secondary" label="Remote Address">
{response.remoteAddr ?? <span className="text-text-subtlest">--</span>}
</KeyValueRow>
<KeyValueRow labelColor="secondary" label="Version">
{response.version ?? <span className="text-text-subtlest">--</span>}
</KeyValueRow>
</KeyValueRows>
</DetailsBanner>
<DetailsBanner
storageKey={`${response.requestId}.request_headers`}
summary={
<h2 className="flex items-center">
Request Headers <CountBadge showZero count={requestHeaders.length} />
</h2>
}
>
{requestHeaders.length === 0 ? (
<NoHeaders />
) : (
<KeyValueRows>
{requestHeaders.map((h, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: none
<KeyValueRow labelColor="primary" key={i} label={h.name}>
{h.value}
</KeyValueRow>
))}
</KeyValueRows>
)}
</DetailsBanner>
<DetailsBanner
defaultOpen
storageKey={`${response.requestId}.response_headers`}
summary={
<h2 className="flex items-center">
Response Headers <CountBadge showZero count={responseHeaders.length} />
</h2>
}
>
{responseHeaders.length === 0 ? (
<NoHeaders />
) : (
<KeyValueRows>
{responseHeaders.map((h, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: none
<KeyValueRow labelColor="info" key={i} label={h.name}>
{h.value}
</KeyValueRow>
))}
</KeyValueRows>
)}
</DetailsBanner>
</div>
);
}
function NoHeaders() {
return <span className="text-text-subtlest text-sm italic">No Headers</span>;
}

View File

@@ -1,253 +0,0 @@
import type { ClientCertificate } from '@yaakapp-internal/models';
import { patchModel, settingsAtom } from '@yaakapp-internal/models';
import { useAtomValue } from 'jotai';
import { useRef } from 'react';
import { showConfirmDelete } from '../../lib/confirm';
import { Button } from '../core/Button';
import { Checkbox } from '../core/Checkbox';
import { DetailsBanner } from '../core/DetailsBanner';
import { Heading } from '../core/Heading';
import { IconButton } from '../core/IconButton';
import { InlineCode } from '../core/InlineCode';
import { PlainInput } from '../core/PlainInput';
import { Separator } from '../core/Separator';
import { HStack, VStack } from '../core/Stacks';
import { SelectFile } from '../SelectFile';
function createEmptyCertificate(): ClientCertificate {
return {
host: '',
port: null,
crtFile: null,
keyFile: null,
pfxFile: null,
passphrase: null,
enabled: true,
};
}
interface CertificateEditorProps {
certificate: ClientCertificate;
index: number;
onUpdate: (index: number, cert: ClientCertificate) => void;
onRemove: (index: number) => void;
}
function CertificateEditor({ certificate, index, onUpdate, onRemove }: CertificateEditorProps) {
const updateField = <K extends keyof ClientCertificate>(
field: K,
value: ClientCertificate[K],
) => {
onUpdate(index, { ...certificate, [field]: value });
};
const hasPfx = Boolean(certificate.pfxFile && certificate.pfxFile.length > 0);
const hasCrtKey = Boolean(
(certificate.crtFile && certificate.crtFile.length > 0) ||
(certificate.keyFile && certificate.keyFile.length > 0),
);
// Determine certificate type for display
const certType = hasPfx ? 'PFX' : hasCrtKey ? 'CERT' : null;
const defaultOpen = useRef<boolean>(!certificate.host);
return (
<DetailsBanner
defaultOpen={defaultOpen.current}
summary={
<HStack alignItems="center" justifyContent="between" space={2} className="w-full">
<HStack space={1.5}>
<Checkbox
className="ml-1"
checked={certificate.enabled ?? true}
title={certificate.enabled ? 'Disable certificate' : 'Enable certificate'}
hideLabel
onChange={(enabled) => updateField('enabled', enabled)}
/>
{certificate.host ? (
<InlineCode>
{certificate.host || <>&nbsp;</>}
{certificate.port != null && `:${certificate.port}`}
</InlineCode>
) : (
<span className="italic text-sm text-text-subtlest">Configure Certificate</span>
)}
{certType && <InlineCode>{certType}</InlineCode>}
</HStack>
<IconButton
icon="trash"
size="sm"
title="Remove certificate"
className="text-text-subtlest -mr-2"
onClick={() => onRemove(index)}
/>
</HStack>
}
>
<VStack space={3} className="mt-2">
<HStack space={2} alignItems="end">
<PlainInput
leftSlot={
<div className="bg-surface-highlight flex items-center text-editor font-mono px-2 text-text-subtle mr-1">
https://
</div>
}
validate={(value) => {
if (!value) return false;
if (!/^[a-zA-Z0-9_.-]+$/.test(value)) return false;
return true;
}}
label="Host"
placeholder="example.com"
size="sm"
required
defaultValue={certificate.host}
onChange={(host) => updateField('host', host)}
/>
<PlainInput
label="Port"
hideLabel
validate={(value) => {
if (!value) return true;
if (Number.isNaN(parseInt(value, 10))) return false;
return true;
}}
placeholder="443"
leftSlot={
<div className="bg-surface-highlight flex items-center text-editor font-mono px-2 text-text-subtle mr-1">
:
</div>
}
size="sm"
className="w-24"
defaultValue={certificate.port?.toString() ?? ''}
onChange={(port) => updateField('port', port ? parseInt(port, 10) : null)}
/>
</HStack>
<Separator className="my-3" />
<VStack space={2}>
<SelectFile
label="CRT File"
noun="Cert"
filePath={certificate.crtFile ?? null}
size="sm"
disabled={hasPfx}
onChange={({ filePath }) => updateField('crtFile', filePath)}
/>
<SelectFile
label="KEY File"
noun="Key"
filePath={certificate.keyFile ?? null}
size="sm"
disabled={hasPfx}
onChange={({ filePath }) => updateField('keyFile', filePath)}
/>
</VStack>
<Separator className="my-3" />
<SelectFile
label="PFX File"
noun="Key"
filePath={certificate.pfxFile ?? null}
size="sm"
disabled={hasCrtKey}
onChange={({ filePath }) => updateField('pfxFile', filePath)}
/>
<PlainInput
label="Passphrase"
size="sm"
type="password"
defaultValue={certificate.passphrase ?? ''}
onChange={(passphrase) => updateField('passphrase', passphrase || null)}
/>
</VStack>
</DetailsBanner>
);
}
export function SettingsCertificates() {
const settings = useAtomValue(settingsAtom);
const certificates = settings.clientCertificates ?? [];
const updateCertificates = async (newCertificates: ClientCertificate[]) => {
await patchModel(settings, { clientCertificates: newCertificates });
};
const handleAdd = async () => {
const newCert = createEmptyCertificate();
await updateCertificates([...certificates, newCert]);
};
const handleUpdate = async (index: number, cert: ClientCertificate) => {
const newCertificates = [...certificates];
newCertificates[index] = cert;
await updateCertificates(newCertificates);
};
const handleRemove = async (index: number) => {
const cert = certificates[index];
if (cert == null) return;
const host = cert.host || 'this certificate';
const port = cert.port != null ? `:${cert.port}` : '';
const confirmed = await showConfirmDelete({
id: 'confirm-remove-certificate',
title: 'Delete Certificate',
description: (
<>
Permanently delete certificate for{' '}
<InlineCode>
{host}
{port}
</InlineCode>
?
</>
),
});
if (!confirmed) return;
const newCertificates = certificates.filter((_, i) => i !== index);
await updateCertificates(newCertificates);
};
return (
<VStack space={3}>
<div className="mb-3">
<HStack justifyContent="between" alignItems="start">
<div>
<Heading>Client Certificates</Heading>
<p className="text-text-subtle">
Add and manage TLS certificates on a per domain basis
</p>
</div>
<Button variant="border" size="sm" color="secondary" onClick={handleAdd}>
Add Certificate
</Button>
</HStack>
</div>
{certificates.length > 0 && (
<VStack space={3}>
{certificates.map((cert, index) => (
<CertificateEditor
// biome-ignore lint/suspicious/noArrayIndexKey: Index is fine here
key={index}
certificate={cert}
index={index}
onUpdate={handleUpdate}
onRemove={handleRemove}
/>
))}
</VStack>
)}
</VStack>
);
}

View File

@@ -1,357 +0,0 @@
import { patchModel, settingsAtom } from '@yaakapp-internal/models';
import classNames from 'classnames';
import { fuzzyMatch } from 'fuzzbunny';
import { useAtomValue } from 'jotai';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
defaultHotkeys,
formatHotkeyString,
getHotkeyScope,
type HotkeyAction,
hotkeyActions,
hotkeysAtom,
useHotkeyLabel,
} from '../../hooks/useHotKey';
import { capitalize } from '../../lib/capitalize';
import { showDialog } from '../../lib/dialog';
import { Button } from '../core/Button';
import { Dropdown, type DropdownItem } from '../core/Dropdown';
import { Heading } from '../core/Heading';
import { HotkeyRaw } from '../core/Hotkey';
import { Icon } from '@yaakapp-internal/ui';
import { IconButton } from '../core/IconButton';
import { PlainInput } from '../core/PlainInput';
import { HStack, VStack } from '../core/Stacks';
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from '../core/Table';
const HOLD_KEYS = ['Shift', 'Control', 'Alt', 'Meta'];
const LAYOUT_INSENSITIVE_KEYS = [
'Equal',
'Minus',
'BracketLeft',
'BracketRight',
'Backquote',
'Space',
];
/** Convert a KeyboardEvent to a hotkey string like "Meta+Shift+k" or "Control+Shift+k" */
function eventToHotkeyString(e: KeyboardEvent): string | null {
// Don't capture modifier-only key presses
if (HOLD_KEYS.includes(e.key)) {
return null;
}
const parts: string[] = [];
// Add modifiers in consistent order (Meta, Control, Alt, Shift)
if (e.metaKey) {
parts.push('Meta');
}
if (e.ctrlKey) {
parts.push('Control');
}
if (e.altKey) {
parts.push('Alt');
}
if (e.shiftKey) {
parts.push('Shift');
}
// Get the main key - use the same logic as useHotKey.ts
const key = LAYOUT_INSENSITIVE_KEYS.includes(e.code) ? e.code : e.key;
parts.push(key);
return parts.join('+');
}
export function SettingsHotkeys() {
const settings = useAtomValue(settingsAtom);
const hotkeys = useAtomValue(hotkeysAtom);
const [filter, setFilter] = useState('');
const filteredActions = useMemo(() => {
if (!filter.trim()) {
return hotkeyActions;
}
return hotkeyActions.filter((action) => {
const scope = getHotkeyScope(action).replace(/_/g, ' ');
const label = action.replace(/[_.]/g, ' ');
const searchText = `${scope} ${label}`;
return fuzzyMatch(searchText, filter) != null;
});
}, [filter]);
if (settings == null) {
return null;
}
return (
<VStack space={3} className="mb-4">
<div className="mb-3">
<Heading>Keyboard Shortcuts</Heading>
<p className="text-text-subtle">
Click the menu button to add, remove, or reset keyboard shortcuts.
</p>
</div>
<PlainInput
label="Filter"
placeholder="Filter shortcuts..."
defaultValue={filter}
onChange={setFilter}
hideLabel
containerClassName="max-w-xs"
/>
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>Scope</TableHeaderCell>
<TableHeaderCell>Action</TableHeaderCell>
<TableHeaderCell>Shortcut</TableHeaderCell>
<TableHeaderCell></TableHeaderCell>
</TableRow>
</TableHead>
{/* key={filter} forces re-render on filter change to fix Safari table rendering bug */}
<TableBody key={filter}>
{filteredActions.map((action) => (
<HotkeyRow
key={action}
action={action}
currentKeys={hotkeys[action]}
defaultKeys={defaultHotkeys[action]}
onSave={async (keys) => {
const newHotkeys = { ...settings.hotkeys };
if (arraysEqual(keys, defaultHotkeys[action])) {
// Remove from settings if it matches default (use default)
delete newHotkeys[action];
} else {
// Store the keys (including empty array to disable)
newHotkeys[action] = keys;
}
await patchModel(settings, { hotkeys: newHotkeys });
}}
onReset={async () => {
const newHotkeys = { ...settings.hotkeys };
delete newHotkeys[action];
await patchModel(settings, { hotkeys: newHotkeys });
}}
/>
))}
</TableBody>
</Table>
</VStack>
);
}
interface HotkeyRowProps {
action: HotkeyAction;
currentKeys: string[];
defaultKeys: string[];
onSave: (keys: string[]) => Promise<void>;
onReset: () => Promise<void>;
}
function HotkeyRow({ action, currentKeys, defaultKeys, onSave, onReset }: HotkeyRowProps) {
const label = useHotkeyLabel(action);
const scope = capitalize(getHotkeyScope(action).replace(/_/g, ' '));
const isCustomized = !arraysEqual(currentKeys, defaultKeys);
const isDisabled = currentKeys.length === 0;
const handleStartRecording = useCallback(() => {
showDialog({
id: `record-hotkey-${action}`,
title: label,
size: 'sm',
render: ({ hide }) => (
<RecordHotkeyDialog
label={label}
onSave={async (key) => {
await onSave([...currentKeys, key]);
hide();
}}
onCancel={hide}
/>
),
});
}, [action, label, currentKeys, onSave]);
const handleRemove = useCallback(
async (keyToRemove: string) => {
const newKeys = currentKeys.filter((k) => k !== keyToRemove);
await onSave(newKeys);
},
[currentKeys, onSave],
);
const handleClearAll = useCallback(async () => {
await onSave([]);
}, [onSave]);
// Build dropdown items dynamically
const dropdownItems: DropdownItem[] = [
{
label: 'Add Keyboard Shortcut',
leftSlot: <Icon icon="plus" />,
onSelect: handleStartRecording,
},
];
// Add remove options for each existing shortcut
if (!isDisabled) {
currentKeys.forEach((key) => {
dropdownItems.push({
label: (
<HStack space={1.5}>
<span>Remove</span>
<HotkeyRaw labelParts={formatHotkeyString(key)} variant="with-bg" className="text-xs" />
</HStack>
),
leftSlot: <Icon icon="trash" />,
onSelect: () => handleRemove(key),
});
});
if (currentKeys.length > 1) {
dropdownItems.push(
{
type: 'separator',
},
{
label: 'Remove All Shortcuts',
leftSlot: <Icon icon="trash" />,
onSelect: handleClearAll,
},
);
}
}
if (isCustomized) {
dropdownItems.push({
type: 'separator',
});
dropdownItems.push({
label: 'Reset to Default',
leftSlot: <Icon icon="refresh" />,
onSelect: onReset,
});
}
return (
<TableRow>
<TableCell>
<span className="text-sm text-text-subtlest">{scope}</span>
</TableCell>
<TableCell>
<span className="text-sm">{label}</span>
</TableCell>
<TableCell>
<HStack space={1.5} className="py-1">
{isDisabled ? (
<span className="text-text-subtlest">Disabled</span>
) : (
currentKeys.map((k) => (
<HotkeyRaw key={k} labelParts={formatHotkeyString(k)} variant="with-bg" />
))
)}
</HStack>
</TableCell>
<TableCell align="right">
<Dropdown items={dropdownItems}>
<IconButton
icon="ellipsis_vertical"
size="sm"
title="Hotkey actions"
className="ml-auto text-text-subtlest"
/>
</Dropdown>
</TableCell>
</TableRow>
);
}
function arraysEqual(a: string[], b: string[]): boolean {
if (a.length !== b.length) return false;
const sortedA = [...a].sort();
const sortedB = [...b].sort();
return sortedA.every((v, i) => v === sortedB[i]);
}
interface RecordHotkeyDialogProps {
label: string;
onSave: (key: string) => void;
onCancel: () => void;
}
function RecordHotkeyDialog({ label, onSave, onCancel }: RecordHotkeyDialogProps) {
const [recordedKey, setRecordedKey] = useState<string | null>(null);
const [isFocused, setIsFocused] = useState(false);
useEffect(() => {
if (!isFocused) return;
const handleKeyDown = (e: KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.key === 'Escape') {
onCancel();
return;
}
const hotkeyString = eventToHotkeyString(e);
if (hotkeyString) {
setRecordedKey(hotkeyString);
}
};
window.addEventListener('keydown', handleKeyDown, { capture: true });
return () => {
window.removeEventListener('keydown', handleKeyDown, { capture: true });
};
}, [isFocused, onCancel]);
const handleSave = useCallback(() => {
if (recordedKey) {
onSave(recordedKey);
}
}, [recordedKey, onSave]);
return (
<VStack space={4}>
<div>
<p className="text-text-subtle mb-2">
Record a key combination for <span className="font-semibold">{label}</span>
</p>
<button
type="button"
data-disable-hotkey
aria-label="Keyboard shortcut input"
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onClick={(e) => {
e.preventDefault();
e.currentTarget.focus();
}}
className={classNames(
'flex items-center justify-center',
'px-4 py-2 rounded-lg bg-surface-highlight border outline-none cursor-default w-full',
'border-border-subtle focus:border-border-focus',
)}
>
{recordedKey ? (
<HotkeyRaw labelParts={formatHotkeyString(recordedKey)} />
) : (
<span className="text-text-subtlest">Press keys...</span>
)}
</button>
</div>
<HStack space={2} justifyContent="end">
<Button color="secondary" onClick={onCancel}>
Cancel
</Button>
<Button color="primary" onClick={handleSave} disabled={!recordedKey}>
Save
</Button>
</HStack>
</VStack>
);
}

View File

@@ -1,230 +0,0 @@
import type { WebsocketEvent, WebsocketRequest } from '@yaakapp-internal/models';
import { hexy } from 'hexy';
import { useAtomValue } from 'jotai';
import { useMemo, useState } from 'react';
import { useFormatText } from '../hooks/useFormatText';
import {
activeWebsocketConnectionAtom,
activeWebsocketConnectionsAtom,
setPinnedWebsocketConnectionId,
useWebsocketEvents,
} from '../hooks/usePinnedWebsocketConnection';
import { useStateWithDeps } from '../hooks/useStateWithDeps';
import { languageFromContentType } from '../lib/contentType';
import { Button } from './core/Button';
import { Editor } from './core/Editor/LazyEditor';
import { type EventDetailAction, EventDetailHeader, EventViewer } from './core/EventViewer';
import { EventViewerRow } from './core/EventViewerRow';
import { HotkeyList } from './core/HotkeyList';
import { Icon, LoadingIcon } from '@yaakapp-internal/ui';
import { HStack, VStack } from './core/Stacks';
import { WebsocketStatusTag } from './core/WebsocketStatusTag';
import { EmptyStateText } from './EmptyStateText';
import { ErrorBoundary } from './ErrorBoundary';
import { RecentWebsocketConnectionsDropdown } from './RecentWebsocketConnectionsDropdown';
interface Props {
activeRequest: WebsocketRequest;
}
export function WebsocketResponsePane({ activeRequest }: Props) {
const [showLarge, setShowLarge] = useStateWithDeps<boolean>(false, [activeRequest.id]);
const [showingLarge, setShowingLarge] = useState<boolean>(false);
const [hexDumps, setHexDumps] = useState<Record<number, boolean>>({});
const activeConnection = useAtomValue(activeWebsocketConnectionAtom);
const connections = useAtomValue(activeWebsocketConnectionsAtom);
const events = useWebsocketEvents(activeConnection?.id ?? null);
if (activeConnection == null) {
return (
<HotkeyList hotkeys={['request.send', 'model.create', 'sidebar.focus', 'url_bar.focus']} />
);
}
const header = (
<HStack className="pl-3 mb-1 font-mono text-sm text-text-subtle">
<HStack space={2}>
{activeConnection.state !== 'closed' && (
<LoadingIcon size="sm" className="text-text-subtlest" />
)}
<WebsocketStatusTag connection={activeConnection} />
<span>&bull;</span>
<span>{events.length} Messages</span>
</HStack>
<HStack space={0.5} className="ml-auto">
<RecentWebsocketConnectionsDropdown
connections={connections}
activeConnection={activeConnection}
onPinnedConnectionId={setPinnedWebsocketConnectionId}
/>
</HStack>
</HStack>
);
return (
<ErrorBoundary name="Websocket Events">
<EventViewer
events={events}
getEventKey={(event) => event.id}
error={activeConnection.error}
header={header}
splitLayoutName="websocket_events"
defaultRatio={0.4}
renderRow={({ event, isActive, onClick }) => (
<WebsocketEventRow event={event} isActive={isActive} onClick={onClick} />
)}
renderDetail={({ event, index, onClose }) => (
<WebsocketEventDetail
event={event}
hexDump={hexDumps[index] ?? event.messageType === 'binary'}
setHexDump={(v) => setHexDumps({ ...hexDumps, [index]: v })}
showLarge={showLarge}
showingLarge={showingLarge}
setShowLarge={setShowLarge}
setShowingLarge={setShowingLarge}
onClose={onClose}
/>
)}
/>
</ErrorBoundary>
);
}
function WebsocketEventRow({
event,
isActive,
onClick,
}: {
event: WebsocketEvent;
isActive: boolean;
onClick: () => void;
}) {
const { message: messageBytes, isServer, messageType } = event;
const message = messageBytes
? new TextDecoder('utf-8').decode(Uint8Array.from(messageBytes))
: '';
const iconColor =
messageType === 'close' || messageType === 'open' ? 'secondary' : isServer ? 'info' : 'primary';
const icon =
messageType === 'close' || messageType === 'open'
? 'info'
: isServer
? 'arrow_big_down_dash'
: 'arrow_big_up_dash';
const content =
messageType === 'close' ? (
'Disconnected from server'
) : messageType === 'open' ? (
'Connected to server'
) : message === '' ? (
<em className="italic text-text-subtlest">No content</em>
) : (
<span className="text-xs">{message.slice(0, 1000)}</span>
);
return (
<EventViewerRow
isActive={isActive}
onClick={onClick}
icon={<Icon color={iconColor} icon={icon} />}
content={content}
timestamp={event.createdAt}
/>
);
}
function WebsocketEventDetail({
event,
hexDump,
setHexDump,
showLarge,
showingLarge,
setShowLarge,
setShowingLarge,
onClose,
}: {
event: WebsocketEvent;
hexDump: boolean;
setHexDump: (v: boolean) => void;
showLarge: boolean;
showingLarge: boolean;
setShowLarge: (v: boolean) => void;
setShowingLarge: (v: boolean) => void;
onClose: () => void;
}) {
const message = useMemo(() => {
if (hexDump) {
return event.message ? hexy(event.message) : '';
}
return event.message ? new TextDecoder('utf-8').decode(Uint8Array.from(event.message)) : '';
}, [event.message, hexDump]);
const language = languageFromContentType(null, message);
const formattedMessage = useFormatText({ language, text: message, pretty: true });
const title =
event.messageType === 'close'
? 'Connection Closed'
: event.messageType === 'open'
? 'Connection Open'
: `Message ${event.isServer ? 'Received' : 'Sent'}`;
const actions: EventDetailAction[] =
message !== ''
? [
{
key: 'toggle-hexdump',
label: hexDump ? 'Show Message' : 'Show Hexdump',
onClick: () => setHexDump(!hexDump),
},
]
: [];
return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
<EventDetailHeader
title={title}
timestamp={event.createdAt}
actions={actions}
copyText={formattedMessage || undefined}
onClose={onClose}
/>
{!showLarge && event.message.length > 1000 * 1000 ? (
<VStack space={2} className="italic text-text-subtlest">
Message previews larger than 1MB are hidden
<div>
<Button
onClick={() => {
setShowingLarge(true);
setTimeout(() => {
setShowLarge(true);
setShowingLarge(false);
}, 500);
}}
isLoading={showingLarge}
color="secondary"
variant="border"
size="xs"
>
Try Showing
</Button>
</div>
</VStack>
) : event.message.length === 0 ? (
<EmptyStateText>No Content</EmptyStateText>
) : (
<Editor
language={language}
defaultValue={formattedMessage ?? ''}
wrapLines={false}
readOnly={true}
stateKey={null}
/>
)}
</div>
);
}

View File

@@ -1,48 +0,0 @@
import type { Color } from '@yaakapp-internal/plugins';
import classNames from 'classnames';
interface Props {
count: number | true;
count2?: number | true;
className?: string;
color?: Color;
showZero?: boolean;
}
export function CountBadge({ count, count2, className, color, showZero }: Props) {
if (count === 0 && !showZero) return null;
return (
<div
aria-hidden
className={classNames(
className,
'flex items-center',
'opacity-70 border text-4xs rounded mb-0.5 px-1 ml-1 h-4 font-mono',
color == null && 'border-border-subtle',
color === 'primary' && 'text-primary',
color === 'secondary' && 'text-secondary',
color === 'success' && 'text-success',
color === 'notice' && 'text-notice',
color === 'warning' && 'text-warning',
color === 'danger' && 'text-danger',
)}
>
{count === true ? (
<div aria-hidden className="rounded-full h-1 w-1 bg-[currentColor]" />
) : (
count
)}
{count2 != null && (
<>
/
{count2 === true ? (
<div aria-hidden className="rounded-full h-1 w-1 bg-[currentColor]" />
) : (
count2
)}
</>
)}
</div>
);
}

View File

@@ -1,60 +0,0 @@
import classNames from 'classnames';
import { atom, useAtom } from 'jotai';
import type { HTMLAttributes, ReactNode } from 'react';
import { useMemo } from 'react';
import { atomWithKVStorage } from '../../lib/atoms/atomWithKVStorage';
import type { BannerProps } from './Banner';
import { Banner } from './Banner';
interface Props extends HTMLAttributes<HTMLDetailsElement> {
summary: ReactNode;
color?: BannerProps['color'];
defaultOpen?: boolean;
storageKey?: string;
}
export function DetailsBanner({
className,
color,
summary,
children,
defaultOpen,
storageKey,
...extraProps
}: Props) {
// biome-ignore lint/correctness/useExhaustiveDependencies: We only want to recompute the atom when storageKey changes
const openAtom = useMemo(
() =>
storageKey
? atomWithKVStorage<boolean>(['details_banner', storageKey], defaultOpen ?? false)
: atom(defaultOpen ?? false),
[storageKey],
);
const [isOpen, setIsOpen] = useAtom(openAtom);
const handleToggle = (e: React.SyntheticEvent<HTMLDetailsElement>) => {
if (storageKey) {
setIsOpen(e.currentTarget.open);
}
};
return (
<Banner color={color} className={className}>
<details className="group list-none" open={isOpen} onToggle={handleToggle} {...extraProps}>
<summary className="!cursor-default !select-none list-none flex items-center gap-3 focus:outline-none opacity-70">
<div
className={classNames(
'transition-transform',
'group-open:rotate-90',
'w-0 h-0 border-t-[0.3em] border-b-[0.3em] border-l-[0.5em] border-r-0',
'border-t-transparent border-b-transparent border-l-text-subtle',
)}
/>
{summary}
</summary>
<div className="mt-1.5 pb-2">{children}</div>
</details>
</Banner>
);
}

View File

@@ -1,39 +0,0 @@
.cm-wrapper.cm-multiline .cm-mergeView {
@apply h-full w-full overflow-auto pr-0.5;
.cm-mergeViewEditors {
@apply w-full min-h-full;
}
.cm-mergeViewEditor {
@apply w-full min-h-full relative;
.cm-collapsedLines {
@apply bg-none bg-surface border border-border py-1 mx-0.5 text-text opacity-80 hover:opacity-100 rounded cursor-default;
}
}
.cm-line {
@apply pl-1.5;
}
.cm-changedLine {
/* Round top corners only if previous line is not a changed line */
&:not(.cm-changedLine + &) {
@apply rounded-t;
}
/* Round bottom corners only if next line is not a changed line */
&:not(:has(+ .cm-changedLine)) {
@apply rounded-b;
}
}
/* Let content grow and disable individual scrolling for sync */
.cm-editor {
@apply h-auto relative !important;
position: relative !important;
}
.cm-scroller {
@apply overflow-visible !important;
}
}

View File

@@ -1,64 +0,0 @@
import { yaml } from '@codemirror/lang-yaml';
import { syntaxHighlighting } from '@codemirror/language';
import { MergeView } from '@codemirror/merge';
import { EditorView } from '@codemirror/view';
import classNames from 'classnames';
import { useEffect, useRef } from 'react';
import './DiffViewer.css';
import { readonlyExtensions, syntaxHighlightStyle } from './extensions';
interface Props {
/** Original/previous version (left side) */
original: string;
/** Modified/current version (right side) */
modified: string;
className?: string;
}
export function DiffViewer({ original, modified, className }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<MergeView | null>(null);
useEffect(() => {
if (!containerRef.current) return;
// Clean up previous instance
viewRef.current?.destroy();
const sharedExtensions = [
yaml(),
syntaxHighlighting(syntaxHighlightStyle),
...readonlyExtensions,
EditorView.lineWrapping,
];
viewRef.current = new MergeView({
a: {
doc: original,
extensions: sharedExtensions,
},
b: {
doc: modified,
extensions: sharedExtensions,
},
parent: containerRef.current,
collapseUnchanged: { margin: 2, minSize: 3 },
highlightChanges: false,
gutter: true,
orientation: 'a-b',
revertControls: undefined,
});
return () => {
viewRef.current?.destroy();
viewRef.current = null;
};
}, [original, modified]);
return (
<div
ref={containerRef}
className={classNames('cm-wrapper cm-multiline h-full w-full', className)}
/>
);
}

View File

@@ -1,116 +0,0 @@
import { getSearchQuery, searchPanelOpen } from '@codemirror/search';
import type { Extension } from '@codemirror/state';
import { type EditorView, ViewPlugin, type ViewUpdate } from '@codemirror/view';
/**
* A CodeMirror extension that displays the total number of search matches
* inside the built-in search panel.
*/
export function searchMatchCount(): Extension {
return ViewPlugin.fromClass(
class {
private countEl: HTMLElement | null = null;
constructor(private view: EditorView) {
this.updateCount();
}
update(update: ViewUpdate) {
// Recompute when doc changes, search state changes, or selection moves
const query = getSearchQuery(update.state);
const prevQuery = getSearchQuery(update.startState);
const open = searchPanelOpen(update.state);
const prevOpen = searchPanelOpen(update.startState);
if (update.docChanged || update.selectionSet || !query.eq(prevQuery) || open !== prevOpen) {
this.updateCount();
}
}
private updateCount() {
const state = this.view.state;
const open = searchPanelOpen(state);
const query = getSearchQuery(state);
if (!open) {
this.removeCountEl();
return;
}
this.ensureCountEl();
if (!query.search) {
if (this.countEl) {
this.countEl.textContent = '0/0';
}
return;
}
const selection = state.selection.main;
let count = 0;
let currentIndex = 0;
const MAX_COUNT = 9999;
const cursor = query.getCursor(state);
for (let result = cursor.next(); !result.done; result = cursor.next()) {
count++;
const match = result.value;
if (match.from <= selection.from && match.to >= selection.to) {
currentIndex = count;
}
if (count > MAX_COUNT) break;
}
if (this.countEl) {
if (count > MAX_COUNT) {
this.countEl.textContent = `${MAX_COUNT}+`;
} else if (count === 0) {
this.countEl.textContent = '0/0';
} else if (currentIndex > 0) {
this.countEl.textContent = `${currentIndex}/${count}`;
} else {
this.countEl.textContent = `0/${count}`;
}
}
}
private ensureCountEl() {
// Find the search panel in the editor DOM
const panel = this.view.dom.querySelector('.cm-search');
if (!panel) {
this.countEl = null;
return;
}
if (this.countEl && this.countEl.parentElement === panel) {
return; // Already attached
}
this.countEl = document.createElement('span');
this.countEl.className = 'cm-search-match-count';
// Reorder: insert prev button, then next button, then count after the search input
const searchInput = panel.querySelector('input');
const prevBtn = panel.querySelector('button[name="prev"]');
const nextBtn = panel.querySelector('button[name="next"]');
if (searchInput && searchInput.parentElement === panel) {
searchInput.after(this.countEl);
if (prevBtn) this.countEl.after(prevBtn);
if (nextBtn && prevBtn) prevBtn.after(nextBtn);
} else {
panel.prepend(this.countEl);
}
}
private removeCountEl() {
if (this.countEl) {
this.countEl.remove();
this.countEl = null;
}
}
destroy() {
this.removeCountEl();
}
},
);
}

View File

@@ -1,12 +0,0 @@
import { LanguageSupport, LRLanguage } from '@codemirror/language';
import { parser } from './timeline';
export const timelineLanguage = LRLanguage.define({
name: 'timeline',
parser,
languageData: {},
});
export function timeline() {
return new LanguageSupport(timelineLanguage);
}

View File

@@ -1,7 +0,0 @@
import { styleTags, tags as t } from '@lezer/highlight';
export const highlight = styleTags({
OutgoingText: t.propertyName, // > lines - primary color (matches timeline icons)
IncomingText: t.tagName, // < lines - info color (matches timeline icons)
InfoText: t.comment, // * lines - subtle color (matches timeline icons)
});

View File

@@ -1,21 +0,0 @@
@top Timeline { line* }
line { OutgoingLine | IncomingLine | InfoLine | PlainLine }
@skip {} {
OutgoingLine { OutgoingText Newline }
IncomingLine { IncomingText Newline }
InfoLine { InfoText Newline }
PlainLine { PlainText Newline }
}
@tokens {
OutgoingText { "> " ![\n]* }
IncomingText { "< " ![\n]* }
InfoText { "* " ![\n]* }
PlainText { ![\n]+ }
Newline { "\n" }
@precedence { OutgoingText, IncomingText, InfoText, PlainText }
}
@external propSource highlight from "./highlight"

View File

@@ -1,12 +0,0 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
export const
Timeline = 1,
OutgoingLine = 2,
OutgoingText = 3,
Newline = 4,
IncomingLine = 5,
IncomingText = 6,
InfoLine = 7,
InfoText = 8,
PlainLine = 9,
PlainText = 10

View File

@@ -1,18 +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
})

View File

@@ -1,108 +0,0 @@
// biome-ignore-all lint/suspicious/noTemplateCurlyInString: We're testing this, specifically
import { describe, expect, test } from 'vitest';
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();
});
});
});

View File

@@ -1,258 +0,0 @@
import type { Virtualizer } from '@tanstack/react-virtual';
import { format } from 'date-fns';
import type { ReactNode } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { useEventViewerKeyboard } from '../../hooks/useEventViewerKeyboard';
import { CopyIconButton } from '../CopyIconButton';
import { AutoScroller } from './AutoScroller';
import { Banner } from './Banner';
import { Button } from './Button';
import { Separator } from './Separator';
import { SplitLayout } from './SplitLayout';
import { HStack } from './Stacks';
import { IconButton } from './IconButton';
import classNames from 'classnames';
interface EventViewerProps<T> {
/** Array of events to display */
events: T[];
/** Get unique key for each event */
getEventKey: (event: T, index: number) => string;
/** Render the event row - receives event, index, isActive, and onClick */
renderRow: (props: {
event: T;
index: number;
isActive: boolean;
onClick: () => void;
}) => ReactNode;
/** Render the detail pane for the selected event */
renderDetail?: (props: { event: T; index: number; onClose: () => void }) => ReactNode;
/** Optional header above the event list (e.g., connection status) */
header?: ReactNode;
/** Error message to display as a banner */
error?: string | null;
/** Name for SplitLayout state persistence */
splitLayoutName: string;
/** Default ratio for the split (0.0 - 1.0) */
defaultRatio?: number;
/** Enable keyboard navigation (arrow keys) */
enableKeyboardNav?: boolean;
/** Loading state */
isLoading?: boolean;
/** Message to show while loading */
loadingMessage?: string;
/** Message to show when no events */
emptyMessage?: string;
/** Callback when active index changes (for controlled state in parent) */
onActiveIndexChange?: (index: number | null) => void;
}
export function EventViewer<T>({
events,
getEventKey,
renderRow,
renderDetail,
header,
error,
splitLayoutName,
defaultRatio = 0.4,
enableKeyboardNav = true,
isLoading = false,
loadingMessage = 'Loading events...',
emptyMessage = 'No events recorded',
onActiveIndexChange,
}: EventViewerProps<T>) {
const [activeIndex, setActiveIndexInternal] = useState<number | null>(null);
const [isPanelOpen, setIsPanelOpen] = useState(false);
// Wrap setActiveIndex to notify parent
const setActiveIndex = useCallback(
(indexOrUpdater: number | null | ((prev: number | null) => number | null)) => {
setActiveIndexInternal((prev) => {
const newIndex =
typeof indexOrUpdater === 'function' ? indexOrUpdater(prev) : indexOrUpdater;
onActiveIndexChange?.(newIndex);
return newIndex;
});
},
[onActiveIndexChange],
);
const containerRef = useRef<HTMLDivElement>(null);
const virtualizerRef = useRef<Virtualizer<HTMLDivElement, Element> | null>(null);
const activeEvent = useMemo(
() => (activeIndex != null ? events[activeIndex] : null),
[activeIndex, events],
);
// Check if the event list container is focused
const isContainerFocused = useCallback(() => {
return containerRef.current?.contains(document.activeElement) ?? false;
}, []);
// Keyboard navigation
useEventViewerKeyboard({
totalCount: events.length,
activeIndex,
setActiveIndex,
virtualizer: virtualizerRef.current,
isContainerFocused,
enabled: enableKeyboardNav,
closePanel: () => setIsPanelOpen(false),
openPanel: () => setIsPanelOpen(true),
});
// Handle virtualizer ready callback
const handleVirtualizerReady = useCallback(
(virtualizer: Virtualizer<HTMLDivElement, Element>) => {
virtualizerRef.current = virtualizer;
},
[],
);
// Handle row click - select and open panel, scroll into view
const handleRowClick = useCallback(
(index: number) => {
setActiveIndex(index);
setIsPanelOpen(true);
// Scroll to ensure selected item is visible after panel opens
requestAnimationFrame(() => {
virtualizerRef.current?.scrollToIndex(index, { align: 'auto' });
});
},
[setActiveIndex],
);
const handleClose = useCallback(() => {
setIsPanelOpen(false);
}, []);
if (isLoading) {
return <div className="p-3 text-text-subtlest italic">{loadingMessage}</div>;
}
if (events.length === 0 && !error) {
return <div className="p-3 text-text-subtlest italic">{emptyMessage}</div>;
}
return (
<div ref={containerRef} className="h-full">
<SplitLayout
layout="vertical"
name={splitLayoutName}
defaultRatio={defaultRatio}
minHeightPx={10}
firstSlot={({ style }) => (
<div style={style} className="w-full h-full grid grid-rows-[auto_minmax(0,1fr)]">
{header ?? <span aria-hidden />}
<AutoScroller
data={events}
focusable={enableKeyboardNav}
onVirtualizerReady={handleVirtualizerReady}
header={
error && (
<Banner color="danger" className="m-3">
{error}
</Banner>
)
}
render={(event, index) => (
<div key={getEventKey(event, index)}>
{renderRow({
event,
index,
isActive: index === activeIndex,
onClick: () => handleRowClick(index),
})}
</div>
)}
/>
</div>
)}
secondSlot={
activeEvent != null && renderDetail && isPanelOpen
? ({ style }) => (
<div style={style} className="grid grid-rows-[auto_minmax(0,1fr)] bg-surface">
<div className="pb-3 px-2">
<Separator />
</div>
<div className="mx-2 overflow-y-auto">
{renderDetail({ event: activeEvent, index: activeIndex ?? 0, onClose: handleClose })}
</div>
</div>
)
: null
}
/>
</div>
);
}
export interface EventDetailAction {
/** Unique key for React */
key: string;
/** Button label */
label: string;
/** Optional icon */
icon?: ReactNode;
/** Click handler */
onClick: () => void;
}
interface EventDetailHeaderProps {
title: string;
prefix?: ReactNode;
timestamp?: string;
actions?: EventDetailAction[];
copyText?: string;
onClose?: () => void;
}
export function EventDetailHeader({
title,
prefix,
timestamp,
actions,
copyText,
onClose,
}: EventDetailHeaderProps) {
const formattedTime = timestamp ? format(new Date(`${timestamp}Z`), 'HH:mm:ss.SSS') : null;
return (
<div className="flex items-center justify-between gap-2 mb-2 h-xs">
<HStack space={2} className="items-center min-w-0">
{prefix}
<h3 className="font-semibold select-auto cursor-auto truncate">{title}</h3>
</HStack>
<HStack space={2} className="items-center">
{actions?.map((action) => (
<Button key={action.key} variant="border" size="xs" onClick={action.onClick}>
{action.icon}
{action.label}
</Button>
))}
{copyText != null && (
<CopyIconButton text={copyText} size="xs" title="Copy" variant="border" iconSize="sm" />
)}
{formattedTime && (
<span className="text-text-subtlest font-mono text-editor ml-2">{formattedTime}</span>
)}
<div className={classNames(copyText != null || formattedTime || (actions ?? []).length > 0 && "border-l border-l-surface-highlight ml-2 pl-3")}>
<IconButton color="custom" className="text-text-subtle -mr-3" size="xs" icon="x" title="Close event panel" onClick={onClose} />
</div>
</HStack>
</div>
);
}

View File

@@ -1,38 +0,0 @@
import classNames from 'classnames';
import { format } from 'date-fns';
import type { ReactNode } from 'react';
interface EventViewerRowProps {
isActive: boolean;
onClick: () => void;
icon: ReactNode;
content: ReactNode;
timestamp?: string;
}
export function EventViewerRow({
isActive,
onClick,
icon,
content,
timestamp,
}: EventViewerRowProps) {
return (
<div className="px-1">
<button
type="button"
onClick={onClick}
className={classNames(
'w-full grid grid-cols-[auto_minmax(0,1fr)_auto] gap-2 items-center text-left',
'px-1.5 h-xs font-mono text-editor cursor-default group focus:outline-none focus:text-text rounded',
isActive && 'bg-surface-active !text-text',
'text-text-subtle hover:text',
)}
>
{icon}
<div className="w-full truncate">{content}</div>
{timestamp && <div className="opacity-50">{format(`${timestamp}Z`, 'HH:mm:ss.SSS')}</div>}
</button>
</div>
);
}

View File

@@ -1,55 +0,0 @@
import type { HttpResponse, HttpResponseState } from '@yaakapp-internal/models';
import classNames from 'classnames';
interface Props {
response: HttpResponse;
className?: string;
showReason?: boolean;
short?: boolean;
}
export function HttpStatusTag({ response, ...props }: Props) {
const { status, state, statusReason } = response;
return <HttpStatusTagRaw status={status} state={state} statusReason={statusReason} {...props} />;
}
export function HttpStatusTagRaw({
status,
state,
className,
showReason,
statusReason,
short,
}: Omit<Props, 'response'> & {
status: number | string;
state?: HttpResponseState;
statusReason?: string | null;
}) {
let colorClass: string;
let label = `${status}`;
const statusN = typeof status === 'number' ? status : parseInt(status, 10);
if (state === 'initialized') {
label = short ? 'CONN' : 'CONNECTING';
colorClass = 'text-text-subtle';
} else if (statusN < 100) {
label = short ? 'ERR' : 'ERROR';
colorClass = 'text-danger';
} else if (statusN < 200) {
colorClass = 'text-info';
} else if (statusN < 300) {
colorClass = 'text-success';
} else if (statusN < 400) {
colorClass = 'text-primary';
} else if (statusN < 500) {
colorClass = 'text-warning';
} else {
colorClass = 'text-danger';
}
return (
<span className={classNames(className, 'font-mono min-w-0', colorClass)}>
{label} {showReason && statusReason}
</span>
);
}

View File

@@ -1,66 +0,0 @@
import classNames from 'classnames';
import type { ReactNode } from 'react';
export interface RadioCardOption<T extends string> {
value: T;
label: ReactNode;
description?: ReactNode;
}
export interface RadioCardsProps<T extends string> {
value: T | null;
onChange: (value: T) => void;
options: RadioCardOption<T>[];
name: string;
}
export function RadioCards<T extends string>({
value,
onChange,
options,
name,
}: RadioCardsProps<T>) {
return (
<div className="flex flex-col gap-2">
{options.map((option) => {
const selected = value === option.value;
return (
<label
key={option.value}
className={classNames(
'flex items-start gap-3 p-3 rounded-lg border cursor-pointer',
'transition-colors',
selected
? 'border-border-focus'
: 'border-border-subtle hocus:border-text-subtlest',
)}
>
<input
type="radio"
name={name}
value={option.value}
checked={selected}
onChange={() => onChange(option.value)}
className="sr-only"
/>
<div
className={classNames(
'mt-1 w-4 h-4 flex-shrink-0 rounded-full border',
'flex items-center justify-center',
selected ? 'border-focus' : 'border-border',
)}
>
{selected && <div className="w-2 h-2 rounded-full bg-text" />}
</div>
<div className="flex flex-col gap-0.5">
<span className="font-semibold text-text">{option.label}</span>
{option.description && (
<span className="text-sm text-text-subtle">{option.description}</span>
)}
</div>
</label>
);
})}
</div>
);
}

View File

@@ -1,20 +0,0 @@
import { formatSize } from '@yaakapp-internal/lib/formatSize';
interface Props {
contentLength: number;
contentLengthCompressed?: number | null;
}
export function SizeTag({ contentLength, contentLengthCompressed }: Props) {
return (
<span
className="font-mono"
title={
`${contentLength} bytes` +
(contentLengthCompressed ? `\n${contentLengthCompressed} bytes compressed` : '')
}
>
{formatSize(contentLength)}
</span>
);
}

View File

@@ -1,584 +0,0 @@
import type { DragEndEvent, DragMoveEvent, DragStartEvent } from '@dnd-kit/core';
import {
closestCenter,
DndContext,
DragOverlay,
PointerSensor,
useDraggable,
useDroppable,
useSensor,
useSensors,
} from '@dnd-kit/core';
import classNames from 'classnames';
import type { ReactNode, Ref } from 'react';
import {
forwardRef,
memo,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { useKeyValue } from '../../../hooks/useKeyValue';
import { computeSideForDragMove, DropMarker } from '@yaakapp-internal/ui';
import { ErrorBoundary } from '../../ErrorBoundary';
import type { ButtonProps } from '../Button';
import { Button } from '../Button';
import { Icon } from '@yaakapp-internal/ui';
import type { RadioDropdownProps } from '../RadioDropdown';
import { RadioDropdown } from '../RadioDropdown';
export type TabItem =
| {
value: string;
label: string;
hidden?: boolean;
leftSlot?: ReactNode;
rightSlot?: ReactNode;
}
| {
value: string;
options: Omit<RadioDropdownProps, 'children'>;
leftSlot?: ReactNode;
rightSlot?: ReactNode;
};
interface TabsStorage {
order: string[];
activeTabs: Record<string, string>;
}
export interface TabsRef {
/** Programmatically set the active tab */
setActiveTab: (value: string) => void;
}
interface Props {
label: string;
/** Default tab value. If not provided, defaults to first tab. */
defaultValue?: string;
/** Called when active tab changes */
onChangeValue?: (value: string) => void;
tabs: TabItem[];
tabListClassName?: string;
className?: string;
children: ReactNode;
addBorders?: boolean;
layout?: 'horizontal' | 'vertical';
/** Storage key for persisting tab order and active tab. When provided, enables drag-to-reorder and active tab persistence. */
storageKey?: string | string[];
/** Key to identify which context this tab belongs to (e.g., request ID). Used for per-context active tab persistence. */
activeTabKey?: string;
}
export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
{
defaultValue,
onChangeValue: onChangeValueProp,
label,
children,
tabs: originalTabs,
className,
tabListClassName,
addBorders,
layout = 'vertical',
storageKey,
activeTabKey,
}: Props,
forwardedRef: Ref<TabsRef>,
) {
const ref = useRef<HTMLDivElement | null>(null);
const reorderable = !!storageKey;
// Use key-value storage for persistence if storageKey is provided
// Handle migration from old format (string[]) to new format (TabsStorage)
const { value: rawStorage, set: setStorage } = useKeyValue<TabsStorage | string[]>({
namespace: 'no_sync',
key: storageKey ?? ['tabs', 'default'],
fallback: { order: [], activeTabs: {} },
});
// Migrate old format (string[]) to new format (TabsStorage)
const storage: TabsStorage = Array.isArray(rawStorage)
? { order: rawStorage, activeTabs: {} }
: (rawStorage ?? { order: [], activeTabs: {} });
const savedOrder = storage.order;
// Get the active tab value - prefer storage (if activeTabKey), then defaultValue, then first tab
const storedActiveTab = activeTabKey ? storage?.activeTabs?.[activeTabKey] : undefined;
const [internalValue, setInternalValue] = useState<string | undefined>(undefined);
const value = storedActiveTab ?? internalValue ?? defaultValue ?? originalTabs[0]?.value;
// Helper to normalize storage (handle migration from old format)
const normalizeStorage = useCallback(
(s: TabsStorage | string[]): TabsStorage =>
Array.isArray(s) ? { order: s, activeTabs: {} } : s,
[],
);
// Handle tab change - update internal state, storage if we have a key, and call prop callback
const onChangeValue = useCallback(
async (newValue: string) => {
setInternalValue(newValue);
if (storageKey && activeTabKey) {
await setStorage((s) => {
const normalized = normalizeStorage(s);
return {
...normalized,
activeTabs: { ...normalized.activeTabs, [activeTabKey]: newValue },
};
});
}
onChangeValueProp?.(newValue);
},
[storageKey, activeTabKey, setStorage, onChangeValueProp, normalizeStorage],
);
// Expose imperative methods via ref
useImperativeHandle(
forwardedRef,
() => ({
setActiveTab: (value: string) => {
onChangeValue(value);
},
}),
[onChangeValue],
);
// Helper to save order
const setSavedOrder = useCallback(
async (order: string[]) => {
await setStorage((s) => {
const normalized = normalizeStorage(s);
return { ...normalized, order };
});
},
[setStorage, normalizeStorage],
);
// State for ordered tabs
const [orderedTabs, setOrderedTabs] = useState<TabItem[]>(originalTabs);
const [isDragging, setIsDragging] = useState<TabItem | null>(null);
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
// Reorder tabs based on saved order when tabs or savedOrder changes
useEffect(() => {
if (!storageKey || savedOrder == null || savedOrder.length === 0) {
setOrderedTabs(originalTabs);
return;
}
// Create a map of tab values to tab items
const tabMap = new Map(originalTabs.map((tab) => [tab.value, tab]));
// Reorder based on saved order, adding any new tabs at the end
const reordered: TabItem[] = [];
const seenValues = new Set<string>();
// Add tabs in saved order
for (const value of savedOrder) {
const tab = tabMap.get(value);
if (tab) {
reordered.push(tab);
seenValues.add(value);
}
}
// Add any new tabs that weren't in the saved order
for (const tab of originalTabs) {
if (!seenValues.has(tab.value)) {
reordered.push(tab);
}
}
setOrderedTabs(reordered);
}, [originalTabs, savedOrder, storageKey]);
const tabs = storageKey ? orderedTabs : originalTabs;
// Update tabs when value changes
useEffect(() => {
const tabs = ref.current?.querySelectorAll<HTMLDivElement>('[data-tab]');
for (const tab of tabs ?? []) {
const v = tab.getAttribute('data-tab');
const parent = tab.closest('.tabs-container');
if (parent !== ref.current) {
// Tab is part of a nested tab container, so ignore it
} else if (v === value) {
tab.setAttribute('data-state', 'active');
tab.setAttribute('aria-hidden', 'false');
tab.style.display = 'block';
} else {
tab.setAttribute('data-state', 'inactive');
tab.setAttribute('aria-hidden', 'true');
tab.style.display = 'none';
}
}
}, [value]);
// Drag and drop handlers
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
const onDragStart = useCallback(
(e: DragStartEvent) => {
const tab = tabs.find((t) => t.value === e.active.id);
setIsDragging(tab ?? null);
},
[tabs],
);
const onDragMove = useCallback(
(e: DragMoveEvent) => {
const overId = e.over?.id as string | undefined;
if (!overId) return setHoveredIndex(null);
const overTab = tabs.find((t) => t.value === overId);
if (overTab == null) return setHoveredIndex(null);
// For vertical layout, tabs are arranged horizontally (side-by-side)
const orientation = layout === 'vertical' ? 'horizontal' : 'vertical';
const side = computeSideForDragMove(overTab.value, e, orientation);
// If computeSideForDragMove returns null (shouldn't happen but be safe), default to null
if (side === null) return setHoveredIndex(null);
const overIndex = tabs.findIndex((t) => t.value === overId);
const hoveredIndex = overIndex + (side === 'before' ? 0 : 1);
setHoveredIndex(hoveredIndex);
},
[tabs, layout],
);
const onDragCancel = useCallback(() => {
setIsDragging(null);
setHoveredIndex(null);
}, []);
const onDragEnd = useCallback(
(e: DragEndEvent) => {
setIsDragging(null);
setHoveredIndex(null);
const activeId = e.active.id as string | undefined;
const overId = e.over?.id as string | undefined;
if (!activeId || !overId || activeId === overId) return;
const from = tabs.findIndex((t) => t.value === activeId);
const baseTo = tabs.findIndex((t) => t.value === overId);
const to = hoveredIndex ?? (baseTo === -1 ? from : baseTo);
if (from !== -1 && to !== -1 && from !== to) {
const newTabs = [...tabs];
const [moved] = newTabs.splice(from, 1);
if (moved === undefined) return;
newTabs.splice(to > from ? to - 1 : to, 0, moved);
setOrderedTabs(newTabs);
// Save order to storage
setSavedOrder(newTabs.map((t) => t.value)).catch(console.error);
}
},
[tabs, hoveredIndex, setSavedOrder],
);
const tabButtons = useMemo(() => {
const items: ReactNode[] = [];
tabs.forEach((t, i) => {
if ('hidden' in t && t.hidden) {
return;
}
const isActive = t.value === value;
const showDropMarkerBefore = hoveredIndex === i;
if (showDropMarkerBefore) {
items.push(
<div
key={`marker-${t.value}`}
className={classNames('relative', layout === 'vertical' ? 'w-0' : 'h-0')}
>
<DropMarker orientation={layout === 'vertical' ? 'vertical' : 'horizontal'} />
</div>,
);
}
items.push(
<TabButton
key={t.value}
tab={t}
isActive={isActive}
addBorders={addBorders}
layout={layout}
reorderable={reorderable}
isDragging={isDragging?.value === t.value}
onChangeValue={onChangeValue}
/>,
);
});
return items;
}, [tabs, value, addBorders, layout, reorderable, isDragging, onChangeValue, hoveredIndex]);
const tabList = (
<div
role="tablist"
aria-label={label}
className={classNames(
tabListClassName,
addBorders && layout === 'horizontal' && 'pl-3 -ml-1',
addBorders && layout === 'vertical' && 'ml-0 mb-2',
'flex items-center hide-scrollbars',
layout === 'horizontal' && 'h-full overflow-auto p-2',
layout === 'vertical' && 'overflow-x-auto overflow-y-visible ',
// Give space for button focus states within overflow boundary.
!addBorders && layout === 'vertical' && 'py-1 pl-3 -ml-5 pr-1',
)}
>
<div
className={classNames(
layout === 'horizontal' && 'flex flex-col w-full pb-3 mb-auto',
layout === 'vertical' && 'flex flex-row flex-shrink-0 w-full',
)}
>
{tabButtons}
{hoveredIndex === tabs.length && (
<div className={classNames('relative', layout === 'vertical' ? 'w-0' : 'h-0')}>
<DropMarker orientation={layout === 'vertical' ? 'vertical' : 'horizontal'} />
</div>
)}
</div>
</div>
);
return (
<div
ref={ref}
className={classNames(
className,
'tabs-container',
'h-full grid',
layout === 'horizontal' && 'grid-rows-1 grid-cols-[auto_minmax(0,1fr)]',
layout === 'vertical' && 'grid-rows-[auto_minmax(0,1fr)] grid-cols-1',
)}
>
{reorderable ? (
<DndContext
autoScroll
sensors={sensors}
onDragMove={onDragMove}
onDragEnd={onDragEnd}
onDragStart={onDragStart}
onDragCancel={onDragCancel}
collisionDetection={closestCenter}
>
{tabList}
<DragOverlay dropAnimation={null}>
{isDragging && (
<TabButton
tab={isDragging}
isActive={isDragging.value === value}
addBorders={addBorders}
layout={layout}
reorderable={false}
isDragging={false}
onChangeValue={onChangeValue}
overlay
/>
)}
</DragOverlay>
</DndContext>
) : (
tabList
)}
{children}
</div>
);
});
interface TabButtonProps {
tab: TabItem;
isActive: boolean;
addBorders?: boolean;
layout: 'horizontal' | 'vertical';
reorderable: boolean;
isDragging: boolean;
onChangeValue?: (value: string) => void;
overlay?: boolean;
}
function TabButton({
tab,
isActive,
addBorders,
layout,
reorderable,
isDragging,
onChangeValue,
overlay = false,
}: TabButtonProps) {
const {
attributes,
listeners,
setNodeRef: setDraggableRef,
} = useDraggable({
id: tab.value,
disabled: !reorderable,
// The button inside handles focus
attributes: { tabIndex: -1 },
});
const { setNodeRef: setDroppableRef } = useDroppable({
id: tab.value,
disabled: !reorderable,
});
const handleSetWrapperRef = useCallback(
(n: HTMLDivElement | null) => {
if (reorderable) {
setDraggableRef(n);
setDroppableRef(n);
}
},
[reorderable, setDraggableRef, setDroppableRef],
);
const btnProps: Partial<ButtonProps> = {
color: 'custom',
justify: layout === 'horizontal' ? 'start' : 'center',
onClick: isActive
? undefined
: (e: React.MouseEvent) => {
e.preventDefault(); // Prevent dropdown from opening on first click
onChangeValue?.(tab.value);
},
className: classNames(
'flex items-center rounded whitespace-nowrap',
'!px-2 ml-[1px]',
'outline-none',
'ring-none',
'focus-visible-or-class:outline-2',
addBorders && 'border focus-visible:bg-surface-highlight',
isActive ? 'text-text' : 'text-text-subtle',
isActive && addBorders
? 'border-surface-active bg-surface-active'
: layout === 'vertical'
? 'border-border-subtle'
: 'border-transparent',
layout === 'horizontal' && 'min-w-[10rem]',
isDragging && 'opacity-50',
overlay && 'opacity-80',
),
};
const buttonContent = (() => {
if ('options' in tab) {
const option = tab.options.items.find((i) => 'value' in i && i.value === tab.options.value);
return (
<RadioDropdown
key={tab.value}
items={tab.options.items}
itemsAfter={tab.options.itemsAfter}
itemsBefore={tab.options.itemsBefore}
value={tab.options.value}
onChange={tab.options.onChange}
>
<Button
leftSlot={tab.leftSlot}
rightSlot={
<div className="flex items-center">
{tab.rightSlot}
<Icon
size="sm"
icon="chevron_down"
className={classNames(
'ml-1',
isActive ? 'text-text-subtle' : 'text-text-subtlest',
)}
/>
</div>
}
{...btnProps}
>
{option && 'shortLabel' in option && option.shortLabel
? option.shortLabel
: (option?.label ?? 'Unknown')}
</Button>
</RadioDropdown>
);
}
return (
<Button leftSlot={tab.leftSlot} rightSlot={tab.rightSlot} {...btnProps}>
{'label' in tab && tab.label ? tab.label : tab.value}
</Button>
);
})();
// Apply drag handlers to wrapper, not button
const wrapperProps = reorderable && !overlay ? { ...attributes, ...listeners } : {};
return (
<div
ref={handleSetWrapperRef}
className={classNames('relative', layout === 'vertical' && 'mr-2')}
{...wrapperProps}
>
{buttonContent}
</div>
);
}
interface TabContentProps {
value: string;
children: ReactNode;
className?: string;
}
export const TabContent = memo(function TabContent({
value,
children,
className,
}: TabContentProps) {
return (
<ErrorBoundary name={`Tab ${value}`}>
<div
tabIndex={-1}
data-tab={value}
className={classNames(className, 'tab-content', 'hidden w-full h-full pt-2')}
>
{children}
</div>
</ErrorBoundary>
);
});
/**
* Programmatically set the active tab for a Tabs component that uses storageKey + activeTabKey.
* This is useful when you need to change the tab from outside the component (e.g., in response to an event).
*/
export async function setActiveTab({
storageKey,
activeTabKey,
value,
}: {
storageKey: string;
activeTabKey: string;
value: string;
}): Promise<void> {
const { getKeyValue, setKeyValue } = await import('../../../lib/keyValueStore');
const current = getKeyValue<TabsStorage>({
namespace: 'no_sync',
key: storageKey,
fallback: { order: [], activeTabs: {} },
});
await setKeyValue({
namespace: 'no_sync',
key: storageKey,
value: {
...current,
activeTabs: { ...current.activeTabs, [activeTabKey]: value },
},
});
}

View File

@@ -1,655 +0,0 @@
import { useGit } 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 } 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 { showDialog } from '../../lib/dialog';
import { showPrompt } from '../../lib/prompt';
import { showErrorToast, showToast } from '../../lib/toast';
import { Banner } from '../core/Banner';
import type { DropdownItem } from '../core/Dropdown';
import { Dropdown } from '../core/Dropdown';
import { Icon } from '@yaakapp-internal/ui';
import { InlineCode } from '../core/InlineCode';
import { gitCallbacks } from './callbacks';
import { GitCommitDialog } from './GitCommitDialog';
import { GitRemotesDialog } from './GitRemotesDialog';
import { handlePullResult, handlePushResult } from './git-util';
import { HistoryDialog } from './HistoryDialog';
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 [refreshKey, regenerateKey] = useRandomKey();
const [
{ status, log },
{
createBranch,
deleteBranch,
deleteRemoteBranch,
renameBranch,
mergeBranch,
push,
pull,
checkout,
resetChanges,
init,
},
] = useGit(syncDir, gitCallbacks(syncDir), refreshKey);
const localBranches = status.data?.localBranches ?? [];
const remoteBranches = status.data?.remoteBranches ?? [];
const remoteOnlyBranches = remoteBranches.filter(
(b) => !localBranches.includes(b.replace(/^origin\//, '')),
);
if (workspace == null) {
return null;
}
const noRepo = status.error?.includes('not found');
if (noRepo) {
return <SetupGitDropdown workspaceId={workspace.id} initRepo={init.mutate} />;
}
// Still loading
if (status.data == null) {
return null;
}
const currentBranch = status.data.headRefShorthand;
const hasChanges = status.data.entries.some((e) => e.status !== 'current');
const hasRemotes = (status.data.origins ?? []).length > 0;
const { ahead, behind } = status.data;
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 });
},
},
);
};
const items: DropdownItem[] = [
{
label: 'View History...',
hidden: (log.data ?? []).length === 0,
leftSlot: <Icon icon="history" />,
onSelect: async () => {
showDialog({
id: 'git-history',
size: 'md',
title: 'Commit History',
noPadding: true,
render: () => <HistoryDialog log={log.data ?? []} />,
});
},
},
{
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',
});
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>
</>
),
});
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;
}),
];
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('data'),
},
{ 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>
);
}

View File

@@ -1,26 +0,0 @@
import type { GitCallbacks } from '@yaakapp-internal/git';
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 }),
};
}

View File

@@ -1,50 +0,0 @@
import { showPromptForm } from '../../lib/prompt-form';
import { Banner } from '../core/Banner';
import { InlineCode } from '../core/InlineCode';
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 };
}

View File

@@ -1,102 +0,0 @@
import type { DivergedStrategy } from '@yaakapp-internal/git';
import { useState } from 'react';
import { showDialog } from '../../lib/dialog';
import { Button } from '../core/Button';
import { InlineCode } from '../core/InlineCode';
import { RadioCards } from '../core/RadioCards';
import { HStack } from '../core/Stacks';
type Resolution = 'force_reset' | 'merge';
const resolutionLabel: Record<Resolution, string> = {
force_reset: 'Force Pull',
merge: 'Merge',
};
interface DivergedDialogProps {
remote: string;
branch: string;
onResult: (strategy: DivergedStrategy) => void;
onHide: () => void;
}
function DivergedDialog({ remote, branch, onResult, onHide }: DivergedDialogProps) {
const [selected, setSelected] = useState<Resolution | null>(null);
const handleSubmit = () => {
if (selected == null) return;
onResult(selected);
onHide();
};
const handleCancel = () => {
onResult('cancel');
onHide();
};
return (
<div className="flex flex-col gap-4 mb-4">
<p className="text-text-subtle">
Your local branch has diverged from{' '}
<InlineCode>
{remote}/{branch}
</InlineCode>. How would you like to resolve this?
</p>
<RadioCards
name="diverged-strategy"
value={selected}
onChange={setSelected}
options={[
{
value: 'merge',
label: 'Merge Commit',
description: 'Combining local and remote changes into a single merge commit',
},
{
value: 'force_reset',
label: 'Force Pull',
description: 'Discard local commits and reset to match the remote branch',
},
]}
/>
<HStack space={2} justifyContent="start" className="flex-row-reverse">
<Button
color={selected === 'force_reset' ? 'danger' : 'primary'}
disabled={selected == null}
onClick={handleSubmit}
>
{selected != null ? resolutionLabel[selected] : 'Select an option'}
</Button>
<Button variant="border" onClick={handleCancel}>
Cancel
</Button>
</HStack>
</div>
);
}
export async function promptDivergedStrategy({
remote,
branch,
}: {
remote: string;
branch: string;
}): Promise<DivergedStrategy> {
return new Promise((resolve) => {
showDialog({
id: 'git-diverged',
title: 'Branches Diverged',
hideX: true,
size: 'sm',
disableBackdropClose: true,
onClose: () => resolve('cancel'),
render: ({ hide }) =>
DivergedDialog({
remote,
branch,
onHide: hide,
onResult: resolve,
}),
});
});
}

View File

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

View File

@@ -1,27 +0,0 @@
import { convertFileSrc } from '@tauri-apps/api/core';
import { useEffect, useState } from 'react';
interface Props {
bodyPath?: string;
data?: Uint8Array;
}
export function AudioViewer({ bodyPath, data }: Props) {
const [src, setSrc] = useState<string>();
useEffect(() => {
if (bodyPath) {
setSrc(convertFileSrc(bodyPath));
} else if (data) {
const blob = new Blob([new Uint8Array(data)], { type: 'audio/mpeg' });
const url = URL.createObjectURL(blob);
setSrc(url);
return () => URL.revokeObjectURL(url);
} else {
setSrc(undefined);
}
}, [bodyPath, data]);
// biome-ignore lint/a11y/useMediaCaption: none
return <audio className="w-full" controls src={src} />;
}

View File

@@ -1,50 +0,0 @@
import classNames from 'classnames';
import Papa from 'papaparse';
import { useMemo } from 'react';
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from '../core/Table';
interface Props {
text: string | null;
className?: string;
}
export function CsvViewer({ text, className }: Props) {
return (
<div className="overflow-auto h-full">
<CsvViewerInner text={text} className={className} />
</div>
);
}
export function CsvViewerInner({ text, className }: { text: string | null; className?: string }) {
const parsed = useMemo(() => {
if (text == null) return null;
return Papa.parse<Record<string, string>>(text, { header: true, skipEmptyLines: true });
}, [text]);
if (parsed === null) return null;
return (
<div className="overflow-auto h-full">
<Table className={classNames(className, 'text-sm')}>
<TableHead>
<TableRow>
{parsed.meta.fields?.map((field) => (
<TableHeaderCell key={field}>{field}</TableHeaderCell>
))}
</TableRow>
</TableHead>
<TableBody>
{parsed.data.map((row, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: none
<TableRow key={i}>
{parsed.meta.fields?.map((key) => (
<TableCell key={key}>{row[key] ?? ''}</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}

View File

@@ -1,157 +0,0 @@
import type { HttpResponse } from '@yaakapp-internal/models';
import type { ServerSentEvent } from '@yaakapp-internal/sse';
import classNames from 'classnames';
import { Fragment, useMemo, useState } from 'react';
import { useFormatText } from '../../hooks/useFormatText';
import { useResponseBodyEventSource } from '../../hooks/useResponseBodyEventSource';
import { isJSON } from '../../lib/contentType';
import { Button } from '../core/Button';
import type { EditorProps } from '../core/Editor/Editor';
import { Editor } from '../core/Editor/LazyEditor';
import { EventDetailHeader, EventViewer } from '../core/EventViewer';
import { EventViewerRow } from '../core/EventViewerRow';
import { Icon } from '@yaakapp-internal/ui';
import { InlineCode } from '../core/InlineCode';
import { HStack, VStack } from '../core/Stacks';
interface Props {
response: HttpResponse;
}
export function EventStreamViewer({ response }: Props) {
return (
<Fragment
key={response.id} // force a refresh when the response changes
>
<ActualEventStreamViewer response={response} />
</Fragment>
);
}
function ActualEventStreamViewer({ response }: Props) {
const [showLarge, setShowLarge] = useState<boolean>(false);
const [showingLarge, setShowingLarge] = useState<boolean>(false);
const events = useResponseBodyEventSource(response);
return (
<EventViewer
events={events.data ?? []}
getEventKey={(_, index) => String(index)}
error={events.error ? String(events.error) : null}
splitLayoutName="sse_events"
defaultRatio={0.4}
renderRow={({ event, index, isActive, onClick }) => (
<EventViewerRow
isActive={isActive}
onClick={onClick}
icon={<Icon color="info" title="Server Message" icon="arrow_big_down_dash" />}
content={
<HStack space={2} className="items-center">
<EventLabels event={event} index={index} isActive={isActive} />
<span className="truncate text-xs">{event.data.slice(0, 1000)}</span>
</HStack>
}
/>
)}
renderDetail={({ event, index, onClose }) => (
<EventDetail
event={event}
index={index}
showLarge={showLarge}
showingLarge={showingLarge}
setShowLarge={setShowLarge}
setShowingLarge={setShowingLarge}
onClose={onClose}
/>
)}
/>
);
}
function EventDetail({
event,
index,
showLarge,
showingLarge,
setShowLarge,
setShowingLarge,
onClose,
}: {
event: ServerSentEvent;
index: number;
showLarge: boolean;
showingLarge: boolean;
setShowLarge: (v: boolean) => void;
setShowingLarge: (v: boolean) => void;
onClose: () => void;
}) {
const language = useMemo<'text' | 'json'>(() => {
if (!event?.data) return 'text';
return isJSON(event?.data) ? 'json' : 'text';
}, [event?.data]);
return (
<div className="flex flex-col h-full">
<EventDetailHeader
title="Message Received"
prefix={<EventLabels event={event} index={index} />}
onClose={onClose}
/>
{!showLarge && event.data.length > 1000 * 1000 ? (
<VStack space={2} className="italic text-text-subtlest">
Message previews larger than 1MB are hidden
<div>
<Button
onClick={() => {
setShowingLarge(true);
setTimeout(() => {
setShowLarge(true);
setShowingLarge(false);
}, 500);
}}
isLoading={showingLarge}
color="secondary"
variant="border"
size="xs"
>
Try Showing
</Button>
</div>
</VStack>
) : (
<FormattedEditor language={language} text={event.data} />
)}
</div>
);
}
function FormattedEditor({ text, language }: { text: string; language: EditorProps['language'] }) {
const formatted = useFormatText({ text, language, pretty: true });
if (formatted == null) return null;
return <Editor readOnly defaultValue={formatted} language={language} stateKey={null} />;
}
function EventLabels({
className,
event,
index,
isActive,
}: {
event: ServerSentEvent;
index: number;
className?: string;
isActive?: boolean;
}) {
return (
<HStack space={1.5} alignItems="center" className={className}>
<InlineCode className={classNames('py-0', isActive && 'bg-text-subtlest text-text')}>
{event.id ?? index}
</InlineCode>
{event.eventType && (
<InlineCode className={classNames('py-0', isActive && 'bg-text-subtlest text-text')}>
{event.eventType}
</InlineCode>
)}
</HStack>
);
}

View File

@@ -1,39 +0,0 @@
import { convertFileSrc } from '@tauri-apps/api/core';
import classNames from 'classnames';
import { useEffect, useState } from 'react';
type Props = { className?: string } & (
| {
bodyPath: string;
}
| {
data: ArrayBuffer;
}
);
export function ImageViewer({ className, ...props }: Props) {
const [src, setSrc] = useState<string>();
const bodyPath = 'bodyPath' in props ? props.bodyPath : null;
const data = 'data' in props ? props.data : null;
useEffect(() => {
if (bodyPath != null) {
setSrc(convertFileSrc(bodyPath));
} else if (data != null) {
const blob = new Blob([data], { type: 'image/png' });
const url = URL.createObjectURL(blob);
setSrc(url);
return () => URL.revokeObjectURL(url);
} else {
setSrc(undefined);
}
}, [bodyPath, data]);
return (
<img
src={src}
alt="Response preview"
className={classNames(className, 'max-w-full max-h-full')}
/>
);
}

View File

@@ -1,22 +0,0 @@
import classNames from 'classnames';
import { JsonAttributeTree } from '../core/JsonAttributeTree';
interface Props {
text: string;
className?: string;
}
export function JsonViewer({ text, className }: Props) {
let parsed = {};
try {
parsed = JSON.parse(text);
} catch {
// Nothing yet
}
return (
<div className={classNames(className, 'overflow-x-auto h-full')}>
<JsonAttributeTree attrValue={parsed} />
</div>
);
}

View File

@@ -1,137 +0,0 @@
import { type MultipartPart, parseMultipart } from '@mjackson/multipart-parser';
import { lazy, Suspense, useMemo } from 'react';
import { languageFromContentType } from '../../lib/contentType';
import { Banner } from '../core/Banner';
import { Icon, LoadingIcon } from '@yaakapp-internal/ui';
import { TabContent, Tabs } from '../core/Tabs/Tabs';
import { AudioViewer } from './AudioViewer';
import { CsvViewer } from './CsvViewer';
import { ImageViewer } from './ImageViewer';
import { SvgViewer } from './SvgViewer';
import { TextViewer } from './TextViewer';
import { VideoViewer } from './VideoViewer';
import { WebPageViewer } from './WebPageViewer';
const PdfViewer = lazy(() => import('./PdfViewer').then((m) => ({ default: m.PdfViewer })));
interface Props {
data: Uint8Array;
boundary: string;
idPrefix?: string;
}
export function MultipartViewer({ data, boundary, idPrefix = 'multipart' }: Props) {
const parseResult = useMemo(() => {
try {
const maxFileSize = 1024 * 1024 * 10; // 10MB
const parsed = parseMultipart(data, { boundary, maxFileSize });
const parts = Array.from(parsed);
return { parts, error: null };
} catch (err) {
return { parts: [], error: err instanceof Error ? err.message : String(err) };
}
}, [data, boundary]);
const { parts, error } = parseResult;
if (error) {
return (
<Banner color="danger" className="m-3">
Failed to parse multipart data: {error}
</Banner>
);
}
if (parts.length === 0) {
return (
<Banner color="info" className="m-3">
No multipart parts found
</Banner>
);
}
return (
<Tabs
addBorders
label="Multipart"
layout="horizontal"
tabListClassName="border-r border-r-border -ml-3"
tabs={parts.map((part, i) => ({
label: part.name ?? '',
value: tabValue(part, i),
rightSlot:
part.filename && part.headers.contentType.mediaType?.startsWith('image/') ? (
<div className="h-5 w-5 overflow-auto flex items-center justify-end">
<ImageViewer
data={part.arrayBuffer}
className="ml-auto w-auto rounded overflow-hidden"
/>
</div>
) : part.filename ? (
<Icon icon="file" />
) : null,
}))}
>
{parts.map((part, i) => (
<TabContent
// biome-ignore lint/suspicious/noArrayIndexKey: Nothing else to key on
key={idPrefix + part.name + i}
value={tabValue(part, i)}
className="pl-3 !pt-0"
>
<Part part={part} />
</TabContent>
))}
</Tabs>
);
}
function Part({ part }: { part: MultipartPart }) {
const mimeType = part.headers.contentType.mediaType ?? null;
const contentTypeHeader = part.headers.get('content-type');
const { uint8Array, content, detectedLanguage } = useMemo(() => {
const uint8Array = new Uint8Array(part.arrayBuffer);
const content = new TextDecoder().decode(part.arrayBuffer);
const detectedLanguage = languageFromContentType(contentTypeHeader, content);
return { uint8Array, content, detectedLanguage };
}, [part, contentTypeHeader]);
if (mimeType?.match(/^image\/svg/i)) {
return <SvgViewer text={content} className="pb-2" />;
}
if (mimeType?.match(/^image/i)) {
return <ImageViewer data={part.arrayBuffer} className="pb-2" />;
}
if (mimeType?.match(/^audio/i)) {
return <AudioViewer data={uint8Array} />;
}
if (mimeType?.match(/^video/i)) {
return <VideoViewer data={uint8Array} />;
}
if (mimeType?.match(/csv|tab-separated/i)) {
return <CsvViewer text={content} className="bg-primary h-10 w-10" />;
}
if (mimeType?.match(/^text\/html/i) || detectedLanguage === 'html') {
return <WebPageViewer html={content} />;
}
if (mimeType?.match(/pdf/i)) {
return (
<Suspense fallback={<LoadingIcon />}>
<PdfViewer data={uint8Array} />
</Suspense>
);
}
return <TextViewer text={content} language={detectedLanguage} stateKey={null} />;
}
function tabValue(part: MultipartPart, i: number) {
return `${part.name ?? ''}::${i}`;
}

View File

@@ -1,30 +0,0 @@
import { useEffect, useState } from 'react';
interface Props {
text: string;
className?: string;
}
export function SvgViewer({ text, className }: Props) {
const [src, setSrc] = useState<string | null>(null);
useEffect(() => {
if (!text) {
return setSrc(null);
}
const blob = new Blob([text], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(blob);
setSrc(url);
return () => URL.revokeObjectURL(url);
}, [text]);
if (src == null) {
return null;
}
return (
<img src={src} alt="Response preview" className={className ?? 'max-w-full max-h-full pb-2'} />
);
}

View File

@@ -1,27 +0,0 @@
import { convertFileSrc } from '@tauri-apps/api/core';
import { useEffect, useState } from 'react';
interface Props {
bodyPath?: string;
data?: Uint8Array;
}
export function VideoViewer({ bodyPath, data }: Props) {
const [src, setSrc] = useState<string>();
useEffect(() => {
if (bodyPath) {
setSrc(convertFileSrc(bodyPath));
} else if (data) {
const blob = new Blob([new Uint8Array(data)], { type: 'video/mp4' });
const url = URL.createObjectURL(blob);
setSrc(url);
return () => URL.revokeObjectURL(url);
} else {
setSrc(undefined);
}
}, [bodyPath, data]);
// biome-ignore lint/a11y/useMediaCaption: none
return <video className="w-full" controls src={src} />;
}

View File

@@ -1,28 +0,0 @@
import { useMemo } from 'react';
interface Props {
html: string;
baseUrl?: string;
}
export function WebPageViewer({ html, baseUrl }: Props) {
const contentForIframe: string | undefined = useMemo(() => {
if (baseUrl && html.includes('<head>')) {
return html.replace(/<head>/gi, `<head><base href="${baseUrl}"/>`);
}
return html;
}, [baseUrl, html]);
return (
<div className="h-full pb-3">
<iframe
key={html ? 'has-body' : 'no-body'}
title="Yaak response preview"
srcDoc={contentForIframe}
sandbox="allow-scripts allow-forms"
referrerPolicy="no-referrer"
className="h-full w-full rounded-lg border border-border-subtle"
/>
</div>
);
}

View File

@@ -1,171 +0,0 @@
import type { Folder } from '@yaakapp-internal/models';
import { modelTypeLabel, patchModel } from '@yaakapp-internal/models';
import { useMemo } from 'react';
import { openFolderSettings } from '../commands/openFolderSettings';
import { openWorkspaceSettings } from '../commands/openWorkspaceSettings';
import { Icon } from '@yaakapp-internal/ui';
import { IconTooltip } from '../components/core/IconTooltip';
import { InlineCode } from '../components/core/InlineCode';
import { HStack } from '../components/core/Stacks';
import type { TabItem } from '../components/core/Tabs/Tabs';
import { capitalize } from '../lib/capitalize';
import { showConfirm } from '../lib/confirm';
import { resolvedModelName } from '../lib/resolvedModelName';
import { useHttpAuthenticationSummaries } from './useHttpAuthentication';
import type { AuthenticatedModel } from './useInheritedAuthentication';
import { useInheritedAuthentication } from './useInheritedAuthentication';
import { useModelAncestors } from './useModelAncestors';
export function useAuthTab<T extends string>(tabValue: T, model: AuthenticatedModel | null) {
const authentication = useHttpAuthenticationSummaries();
const inheritedAuth = useInheritedAuthentication(model);
const ancestors = useModelAncestors(model);
const parentModel = ancestors[0] ?? null;
return useMemo<TabItem[]>(() => {
if (model == null) return [];
const tab: TabItem = {
value: tabValue,
label: 'Auth',
options: {
value: model.authenticationType,
items: [
...authentication.map((a) => ({
label: a.label || 'UNKNOWN',
shortLabel: a.shortLabel,
value: a.name,
})),
{ type: 'separator' },
{
label: 'Inherit from Parent',
shortLabel:
inheritedAuth != null && inheritedAuth.authenticationType !== 'none' ? (
<HStack space={1.5}>
{authentication.find((a) => a.name === inheritedAuth.authenticationType)
?.shortLabel ?? 'UNKNOWN'}
<IconTooltip
icon="magic_wand"
iconSize="xs"
content="Authentication was inherited from an ancestor"
/>
</HStack>
) : (
'Auth'
),
value: null,
},
{ label: 'No Auth', shortLabel: 'No Auth', value: 'none' },
],
itemsAfter: (() => {
const actions: (
| { type: 'separator'; label: string }
| { label: string; leftSlot: React.ReactNode; onSelect: () => Promise<void> }
)[] = [];
// Promote: move auth from current model up to parent
if (
parentModel &&
model.authenticationType &&
model.authenticationType !== 'none' &&
(parentModel.authenticationType == null || parentModel.authenticationType === 'none')
) {
actions.push(
{ type: 'separator', label: 'Actions' },
{
label: `Promote to ${capitalize(parentModel.model)}`,
leftSlot: (
<Icon
icon={parentModel.model === 'workspace' ? 'corner_right_up' : 'folder_up'}
/>
),
onSelect: async () => {
const confirmed = await showConfirm({
id: 'promote-auth-confirm',
title: 'Promote Authentication',
confirmText: 'Promote',
description: (
<>
Move authentication config to{' '}
<InlineCode>{resolvedModelName(parentModel)}</InlineCode>?
</>
),
});
if (confirmed) {
await patchModel(model, { authentication: {}, authenticationType: null });
await patchModel(parentModel, {
authentication: model.authentication,
authenticationType: model.authenticationType,
});
if (parentModel.model === 'folder') {
openFolderSettings(parentModel.id, 'auth');
} else {
openWorkspaceSettings('auth');
}
}
},
},
);
}
// Copy from ancestor: copy auth config down to current model
const ancestorWithAuth = ancestors.find(
(a) => a.authenticationType != null && a.authenticationType !== 'none',
);
if (ancestorWithAuth) {
if (actions.length === 0) {
actions.push({ type: 'separator', label: 'Actions' });
}
actions.push({
label: `Copy from ${modelTypeLabel(ancestorWithAuth)}`,
leftSlot: (
<Icon
icon={
ancestorWithAuth.model === 'workspace' ? 'corner_right_down' : 'folder_down'
}
/>
),
onSelect: async () => {
const confirmed = await showConfirm({
id: 'copy-auth-confirm',
title: 'Copy Authentication',
confirmText: 'Copy',
description: (
<>
Copy{' '}
{authentication.find((a) => a.name === ancestorWithAuth.authenticationType)
?.label ?? 'authentication'}{' '}
config from <InlineCode>{resolvedModelName(ancestorWithAuth)}</InlineCode>?
This will override the current authentication but will not affect the{' '}
{modelTypeLabel(ancestorWithAuth).toLowerCase()}.
</>
),
});
if (confirmed) {
await patchModel(model, {
authentication: { ...ancestorWithAuth.authentication },
authenticationType: ancestorWithAuth.authenticationType,
});
}
},
});
}
return actions.length > 0 ? actions : undefined;
})(),
onChange: async (authenticationType) => {
let authentication: Folder['authentication'] = model.authentication;
if (model.authenticationType !== authenticationType) {
authentication = {
// Reset auth if changing types
};
}
await patchModel(model, { authentication, authenticationType });
},
},
};
return [tab];
}, [authentication, inheritedAuth, model, parentModel, tabValue, ancestors]);
}

View File

@@ -1,85 +0,0 @@
import type { Virtualizer } from '@tanstack/react-virtual';
import { useCallback } from 'react';
import { useKey } from 'react-use';
interface UseEventViewerKeyboardProps {
totalCount: number;
activeIndex: number | null;
setActiveIndex: (index: number | null) => void;
virtualizer?: Virtualizer<HTMLDivElement, Element> | null;
isContainerFocused: () => boolean;
enabled?: boolean;
closePanel?: () => void;
openPanel?: () => void;
}
export function useEventViewerKeyboard({
totalCount,
activeIndex,
setActiveIndex,
virtualizer,
isContainerFocused,
enabled = true,
closePanel,
openPanel,
}: UseEventViewerKeyboardProps) {
const selectPrev = useCallback(() => {
if (totalCount === 0) return;
const newIndex = activeIndex == null ? 0 : Math.max(0, activeIndex - 1);
setActiveIndex(newIndex);
virtualizer?.scrollToIndex(newIndex, { align: 'auto' });
}, [activeIndex, setActiveIndex, totalCount, virtualizer]);
const selectNext = useCallback(() => {
if (totalCount === 0) return;
const newIndex = activeIndex == null ? 0 : Math.min(totalCount - 1, activeIndex + 1);
setActiveIndex(newIndex);
virtualizer?.scrollToIndex(newIndex, { align: 'auto' });
}, [activeIndex, setActiveIndex, totalCount, virtualizer]);
useKey(
(e) => e.key === 'ArrowUp' || e.key === 'k',
(e) => {
if (!enabled || !isContainerFocused()) return;
e.preventDefault();
selectPrev();
},
undefined,
[enabled, isContainerFocused, selectPrev],
);
useKey(
(e) => e.key === 'ArrowDown' || e.key === 'j',
(e) => {
if (!enabled || !isContainerFocused()) return;
e.preventDefault();
selectNext();
},
undefined,
[enabled, isContainerFocused, selectNext],
);
useKey(
(e) => e.key === 'Escape',
(e) => {
if (!enabled || !isContainerFocused()) return;
e.preventDefault();
closePanel?.();
},
undefined,
[enabled, isContainerFocused, closePanel],
);
useKey(
(e) => e.key === 'Enter' || e.key === ' ',
(e) => {
if (!enabled || !isContainerFocused() || activeIndex == null) return;
e.preventDefault();
openPanel?.();
},
undefined,
[enabled, isContainerFocused, activeIndex, openPanel],
);
}

View File

@@ -1,50 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import type { Folder } from '@yaakapp-internal/models';
import type {
CallFolderActionRequest,
FolderAction,
GetFolderActionsResponse,
} from '@yaakapp-internal/plugins';
import { useMemo } from 'react';
import { invokeCmd } from '../lib/tauri';
import { usePluginsKey } from './usePlugins';
export type CallableFolderAction = Pick<FolderAction, 'label' | 'icon'> & {
call: (folder: Folder) => Promise<void>;
};
export function useFolderActions() {
const pluginsKey = usePluginsKey();
const actionsResult = useQuery<CallableFolderAction[]>({
queryKey: ['folder_actions', pluginsKey],
queryFn: () => getFolderActions(),
});
// biome-ignore lint/correctness/useExhaustiveDependencies: none
const actions = useMemo(() => {
return actionsResult.data ?? [];
}, [JSON.stringify(actionsResult.data)]);
return actions;
}
export async function getFolderActions() {
const responses = await invokeCmd<GetFolderActionsResponse[]>('cmd_folder_actions');
const actions = responses.flatMap((r) =>
r.actions.map((a, i) => ({
label: a.label,
icon: a.icon,
call: async (folder: Folder) => {
const payload: CallFolderActionRequest = {
index: i,
pluginRefId: r.pluginRefId,
args: { folder },
};
await invokeCmd('cmd_call_folder_action', { req: payload });
},
})),
);
return actions;
}

View File

@@ -1,32 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import type { HttpResponse } from '@yaakapp-internal/models';
import { invokeCmd } from '../lib/tauri';
export function useHttpRequestBody(response: HttpResponse | null) {
return useQuery({
placeholderData: (prev) => prev, // Keep previous data on refetch
queryKey: ['request_body', response?.id, response?.state, response?.requestContentLength],
enabled: (response?.requestContentLength ?? 0) > 0,
queryFn: async () => {
return getRequestBodyText(response);
},
});
}
export async function getRequestBodyText(response: HttpResponse | null) {
if (response?.id == null) {
return null;
}
const data = await invokeCmd<number[] | null>('cmd_http_request_body', {
responseId: response.id,
});
if (data == null) {
return null;
}
const body = new Uint8Array(data);
const bodyText = new TextDecoder('utf-8', { fatal: false }).decode(body);
return { body, bodyText };
}

View File

@@ -1,29 +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';
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
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 };
}

View File

@@ -1,14 +0,0 @@
import type { TimelineViewMode } from '../components/HttpResponsePane';
import { useKeyValue } from './useKeyValue';
const DEFAULT_VIEW_MODE: TimelineViewMode = 'timeline';
export function useTimelineViewMode() {
const { set, value } = useKeyValue<TimelineViewMode>({
namespace: 'no_sync',
key: 'timeline_view_mode',
fallback: DEFAULT_VIEW_MODE,
});
return [value ?? DEFAULT_VIEW_MODE, set] as const;
}

View File

@@ -1,52 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import type { WebsocketRequest } from '@yaakapp-internal/models';
import type {
CallWebsocketRequestActionRequest,
GetWebsocketRequestActionsResponse,
WebsocketRequestAction,
} from '@yaakapp-internal/plugins';
import { useMemo } from 'react';
import { invokeCmd } from '../lib/tauri';
import { usePluginsKey } from './usePlugins';
export type CallableWebSocketRequestAction = Pick<WebsocketRequestAction, 'label' | 'icon'> & {
call: (request: WebsocketRequest) => Promise<void>;
};
export function useWebsocketRequestActions() {
const pluginsKey = usePluginsKey();
const actionsResult = useQuery<CallableWebSocketRequestAction[]>({
queryKey: ['websocket_request_actions', pluginsKey],
queryFn: () => getWebsocketRequestActions(),
});
// biome-ignore lint/correctness/useExhaustiveDependencies: none
const actions = useMemo(() => {
return actionsResult.data ?? [];
}, [JSON.stringify(actionsResult.data)]);
return actions;
}
export async function getWebsocketRequestActions() {
const responses = await invokeCmd<GetWebsocketRequestActionsResponse[]>(
'cmd_websocket_request_actions',
);
const actions = responses.flatMap((r) =>
r.actions.map((a: WebsocketRequestAction, i: number) => ({
label: a.label,
icon: a.icon,
call: async (websocketRequest: WebsocketRequest) => {
const payload: CallWebsocketRequestActionRequest = {
index: i,
pluginRefId: r.pluginRefId,
args: { websocketRequest },
};
await invokeCmd('cmd_call_websocket_request_action', { req: payload });
},
})),
);
return actions;
}

View File

@@ -1,50 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import type { Workspace } from '@yaakapp-internal/models';
import type {
CallWorkspaceActionRequest,
GetWorkspaceActionsResponse,
WorkspaceAction,
} from '@yaakapp-internal/plugins';
import { useMemo } from 'react';
import { invokeCmd } from '../lib/tauri';
import { usePluginsKey } from './usePlugins';
export type CallableWorkspaceAction = Pick<WorkspaceAction, 'label' | 'icon'> & {
call: (workspace: Workspace) => Promise<void>;
};
export function useWorkspaceActions() {
const pluginsKey = usePluginsKey();
const actionsResult = useQuery<CallableWorkspaceAction[]>({
queryKey: ['workspace_actions', pluginsKey],
queryFn: () => getWorkspaceActions(),
});
// biome-ignore lint/correctness/useExhaustiveDependencies: none
const actions = useMemo(() => {
return actionsResult.data ?? [];
}, [JSON.stringify(actionsResult.data)]);
return actions;
}
export async function getWorkspaceActions() {
const responses = await invokeCmd<GetWorkspaceActionsResponse[]>('cmd_workspace_actions');
const actions = responses.flatMap((r) =>
r.actions.map((a, i) => ({
label: a.label,
icon: a.icon,
call: async (workspace: Workspace) => {
const payload: CallWorkspaceActionRequest = {
index: i,
pluginRefId: r.pluginRefId,
args: { workspace },
};
await invokeCmd('cmd_call_workspace_action', { req: payload });
},
})),
);
return actions;
}

View File

@@ -1,25 +0,0 @@
import deepEqual from "@gilbarbara/deep-equal";
import type { UpdateInfo } from "@yaakapp-internal/tauri-client";
import type { Atom } from "jotai";
import { atom } from "jotai";
import { selectAtom } from "jotai/utils";
import type { SplitLayoutLayout } from "../components/core/SplitLayout";
import { atomWithKVStorage } from "./atoms/atomWithKVStorage";
export function deepEqualAtom<T>(a: Atom<T>) {
return selectAtom(
a,
(v) => v,
(a, b) => deepEqual(a, b),
);
}
export const workspaceLayoutAtom = atomWithKVStorage<SplitLayoutLayout>(
"workspace_layout",
"horizontal",
);
export const updateAvailableAtom = atom<Omit<
UpdateInfo,
"replyEventId"
> | null>(null);

View File

@@ -1,6 +0,0 @@
export function capitalize(str: string): string {
return str
.split(' ')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}

View File

@@ -1,8 +0,0 @@
import type { HttpRequestHeader } from '@yaakapp-internal/models';
import { invokeCmd } from './tauri';
/**
* Global default headers fetched from the backend.
* These are static and fetched once on module load.
*/
export const defaultHeaders: HttpRequestHeader[] = await invokeCmd('cmd_default_headers');

View File

@@ -1,15 +0,0 @@
import type { SyncModel } from '@yaakapp-internal/git';
import { stringify } from 'yaml';
/**
* Convert a SyncModel to a clean YAML string for diffing.
* Removes noisy fields like updatedAt that change on every edit.
*/
export function modelToYaml(model: SyncModel | null): string {
if (!model) return '';
return stringify(model, {
indent: 2,
lineWidth: 0,
});
}

View File

@@ -1,39 +0,0 @@
import type { DragMoveEvent } from '@dnd-kit/core';
export function computeSideForDragMove(
id: string,
e: DragMoveEvent,
orientation: 'vertical' | 'horizontal' = 'vertical',
): 'before' | 'after' | null {
if (e.over == null || e.over.id !== id) {
return null;
}
if (e.active.rect.current.initial == null) return null;
const overRect = e.over.rect;
if (orientation === 'horizontal') {
// For horizontal layouts (tabs side-by-side), use left/right logic
const activeLeft =
e.active.rect.current.translated?.left ?? e.active.rect.current.initial.left + e.delta.x;
const pointerX = activeLeft + e.active.rect.current.initial.width / 2;
const hoverLeft = overRect.left;
const hoverRight = overRect.right;
const hoverMiddleX = hoverLeft + (hoverRight - hoverLeft) / 2;
return pointerX < hoverMiddleX ? 'before' : 'after'; // 'before' = left, 'after' = right
} else {
// For vertical layouts, use top/bottom logic
const activeTop =
e.active.rect.current.translated?.top ?? e.active.rect.current.initial.top + e.delta.y;
const pointerY = activeTop + e.active.rect.current.initial.height / 2;
const hoverTop = overRect.top;
const hoverBottom = overRect.bottom;
const hoverMiddleY = (hoverBottom - hoverTop) / 2;
const hoverClientY = pointerY - hoverTop;
return hoverClientY < hoverMiddleY ? 'before' : 'after';
}
}

View File

@@ -1,322 +0,0 @@
import { emit } from "@tauri-apps/api/event";
import { openUrl } from "@tauri-apps/plugin-opener";
import { debounce } from "@yaakapp-internal/lib";
import type {
FormInput,
InternalEvent,
JsonPrimitive,
ShowToastRequest,
} from "@yaakapp-internal/plugins";
import { updateAllPlugins } from "@yaakapp-internal/plugins";
import type {
PluginUpdateNotification,
UpdateInfo,
UpdateResponse,
YaakNotification,
} from "@yaakapp-internal/tauri-client";
import { openSettings } from "../commands/openSettings";
import { Button } from "../components/core/Button";
import { ButtonInfiniteLoading } from "../components/core/ButtonInfiniteLoading";
import { Icon } from "@yaakapp-internal/ui";
import { HStack, VStack } from "../components/core/Stacks";
// Listen for toasts
import { listenToTauriEvent } from "../hooks/useListenToTauriEvent";
import { updateAvailableAtom } from "./atoms";
import { stringToColor } from "./color";
import { generateId } from "./generateId";
import { jotaiStore } from "./jotai";
import { showPrompt } from "./prompt";
import { showPromptForm } from "./prompt-form";
import { invokeCmd } from "./tauri";
import { showToast } from "./toast";
export function initGlobalListeners() {
listenToTauriEvent<ShowToastRequest>("show_toast", (event) => {
showToast({ ...event.payload });
});
listenToTauriEvent("settings", () => openSettings.mutate(null));
// Track active dynamic form dialogs so follow-up input updates can reach them
const activeForms = new Map<string, (inputs: FormInput[]) => void>();
// Listen for plugin events
listenToTauriEvent<InternalEvent>(
"plugin_event",
async ({ payload: event }) => {
if (event.payload.type === "prompt_text_request") {
const value = await showPrompt(event.payload);
const result: InternalEvent = {
id: generateId(),
replyId: event.id,
pluginName: event.pluginName,
pluginRefId: event.pluginRefId,
context: event.context,
payload: {
type: "prompt_text_response",
value,
},
};
await emit(event.id, result);
} else if (event.payload.type === "prompt_form_request") {
if (event.replyId != null) {
// Follow-up update from plugin runtime — update the active dialog's inputs
const updateInputs = activeForms.get(event.replyId);
if (updateInputs) {
updateInputs(event.payload.inputs);
}
return;
}
// Initial request — show the dialog with bidirectional support
const emitFormResponse = (
values: Record<string, JsonPrimitive> | null,
done: boolean,
) => {
const result: InternalEvent = {
id: generateId(),
replyId: event.id,
pluginName: event.pluginName,
pluginRefId: event.pluginRefId,
context: event.context,
payload: {
type: "prompt_form_response",
values,
done,
},
};
emit(event.id, result);
};
const values = await showPromptForm({
id: event.payload.id,
title: event.payload.title,
description: event.payload.description,
size: event.payload.size,
inputs: event.payload.inputs,
confirmText: event.payload.confirmText,
cancelText: event.payload.cancelText,
onValuesChange: debounce(
(values) => emitFormResponse(values, false),
150,
),
onInputsUpdated: (cb) => activeForms.set(event.id, cb),
});
// Clean up and send final response
activeForms.delete(event.id);
emitFormResponse(values, true);
}
},
);
listenToTauriEvent<string>(
"update_installed",
async ({ payload: version }) => {
console.log("Got update installed event", version);
showUpdateInstalledToast(version);
},
);
// Listen for update events
listenToTauriEvent<UpdateInfo>("update_available", async ({ payload }) => {
console.log("Got update available", payload);
showUpdateAvailableToast(payload);
});
listenToTauriEvent<YaakNotification>("notification", ({ payload }) => {
console.log("Got notification event", payload);
showNotificationToast(payload);
});
// Listen for plugin update events
listenToTauriEvent<PluginUpdateNotification>(
"plugin_updates_available",
({ payload }) => {
console.log("Got plugin updates event", payload);
showPluginUpdatesToast(payload);
},
);
}
function showUpdateInstalledToast(version: string) {
const UPDATE_TOAST_ID = "update-info";
showToast({
id: UPDATE_TOAST_ID,
color: "primary",
timeout: null,
message: (
<VStack>
<h2 className="font-semibold">Yaak {version} was installed</h2>
<p className="text-text-subtle text-sm">
Start using the new version now?
</p>
</VStack>
),
action: ({ hide }) => (
<ButtonInfiniteLoading
size="xs"
className="mr-auto min-w-[5rem]"
color="primary"
loadingChildren="Restarting..."
onClick={() => {
hide();
setTimeout(() => invokeCmd("cmd_restart", {}), 200);
}}
>
Relaunch Yaak
</ButtonInfiniteLoading>
),
});
}
async function showUpdateAvailableToast(updateInfo: UpdateInfo) {
const UPDATE_TOAST_ID = "update-info";
const { version, replyEventId, downloaded } = updateInfo;
jotaiStore.set(updateAvailableAtom, { version, downloaded });
// Acknowledge the event, so we don't time out and try the fallback update logic
await emit<UpdateResponse>(replyEventId, { type: "ack" });
showToast({
id: UPDATE_TOAST_ID,
color: "info",
timeout: null,
message: (
<VStack>
<h2 className="font-semibold">Yaak {version} is available</h2>
<p className="text-text-subtle text-sm">
{downloaded ? "Do you want to install" : "Download and install"} the
update?
</p>
</VStack>
),
action: () => (
<HStack space={1.5}>
<ButtonInfiniteLoading
size="xs"
color="info"
className="min-w-[10rem]"
loadingChildren={downloaded ? "Installing..." : "Downloading..."}
onClick={async () => {
await emit<UpdateResponse>(replyEventId, {
type: "action",
action: "install",
});
}}
>
{downloaded ? "Install Now" : "Download and Install"}
</ButtonInfiniteLoading>
<Button
size="xs"
color="info"
variant="border"
rightSlot={<Icon icon="external_link" />}
onClick={async () => {
await openUrl(`https://yaak.app/changelog/${version}`);
}}
>
What&apos;s New
</Button>
</HStack>
),
});
}
function showPluginUpdatesToast(updateInfo: PluginUpdateNotification) {
const PLUGIN_UPDATE_TOAST_ID = "plugin-updates";
const count = updateInfo.updateCount;
const pluginNames = updateInfo.plugins.map((p: { name: string }) => p.name);
showToast({
id: PLUGIN_UPDATE_TOAST_ID,
color: "info",
timeout: null,
message: (
<VStack>
<h2 className="font-semibold">
{count === 1 ? "1 plugin update" : `${count} plugin updates`}{" "}
available
</h2>
<p className="text-text-subtle text-sm">
{count === 1
? pluginNames[0]
: `${pluginNames.slice(0, 2).join(", ")}${count > 2 ? `, and ${count - 2} more` : ""}`}
</p>
</VStack>
),
action: ({ hide }) => (
<HStack space={1.5}>
<ButtonInfiniteLoading
size="xs"
color="info"
className="min-w-[5rem]"
loadingChildren="Updating..."
onClick={async () => {
const updated = await updateAllPlugins();
hide();
if (updated.length > 0) {
showToast({
color: "success",
message: `Successfully updated ${updated.length} plugin${updated.length === 1 ? "" : "s"}`,
});
}
}}
>
Update All
</ButtonInfiniteLoading>
<Button
size="xs"
color="info"
variant="border"
onClick={() => {
hide();
openSettings.mutate("plugins:installed");
}}
>
View Updates
</Button>
</HStack>
),
});
}
function showNotificationToast(n: YaakNotification) {
const actionUrl = n.action?.url;
const actionLabel = n.action?.label;
showToast({
id: n.id,
timeout: n.timeout ?? null,
color: stringToColor(n.color) ?? undefined,
message: (
<VStack>
{n.title && <h2 className="font-semibold">{n.title}</h2>}
<p className="text-text-subtle text-sm">{n.message}</p>
</VStack>
),
onClose: () => {
invokeCmd("cmd_dismiss_notification", { notificationId: n.id }).catch(
console.error,
);
},
action: ({ hide }) => {
return actionLabel && actionUrl ? (
<Button
size="xs"
color={stringToColor(n.color) ?? undefined}
className="mr-auto min-w-[5rem]"
rightSlot={<Icon icon="external_link" />}
onClick={() => {
hide();
return openUrl(actionUrl);
}}
>
{actionLabel}
</Button>
) : null;
},
});
}

View File

@@ -1,95 +0,0 @@
import type { HttpResponseEvent } from '@yaakapp-internal/models';
import { describe, expect, test } from 'vitest';
import { getCookieCounts } from './model_util';
function makeEvent(
type: string,
name: string,
value: string,
): HttpResponseEvent {
return {
id: 'test',
model: 'http_response_event',
responseId: 'resp',
workspaceId: 'ws',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
event: { type, name, value } as HttpResponseEvent['event'],
};
}
describe('getCookieCounts', () => {
test('returns zeros for undefined events', () => {
expect(getCookieCounts(undefined)).toEqual({ sent: 0, received: 0 });
});
test('returns zeros for empty events', () => {
expect(getCookieCounts([])).toEqual({ sent: 0, received: 0 });
});
test('counts single sent cookie', () => {
const events = [makeEvent('header_up', 'Cookie', 'session=abc123')];
expect(getCookieCounts(events)).toEqual({ sent: 1, received: 0 });
});
test('counts multiple sent cookies in one header', () => {
const events = [makeEvent('header_up', 'Cookie', 'a=1; b=2; c=3')];
expect(getCookieCounts(events)).toEqual({ sent: 3, received: 0 });
});
test('counts single received cookie', () => {
const events = [makeEvent('header_down', 'Set-Cookie', 'session=abc123; Path=/')];
expect(getCookieCounts(events)).toEqual({ sent: 0, received: 1 });
});
test('counts multiple received cookies from multiple headers', () => {
const events = [
makeEvent('header_down', 'Set-Cookie', 'a=1; Path=/'),
makeEvent('header_down', 'Set-Cookie', 'b=2; HttpOnly'),
makeEvent('header_down', 'Set-Cookie', 'c=3; Secure'),
];
expect(getCookieCounts(events)).toEqual({ sent: 0, received: 3 });
});
test('deduplicates sent cookies by name', () => {
const events = [
makeEvent('header_up', 'Cookie', 'session=old'),
makeEvent('header_up', 'Cookie', 'session=new'),
];
expect(getCookieCounts(events)).toEqual({ sent: 1, received: 0 });
});
test('deduplicates received cookies by name', () => {
const events = [
makeEvent('header_down', 'Set-Cookie', 'token=abc; Path=/'),
makeEvent('header_down', 'Set-Cookie', 'token=xyz; Path=/'),
];
expect(getCookieCounts(events)).toEqual({ sent: 0, received: 1 });
});
test('counts both sent and received cookies', () => {
const events = [
makeEvent('header_up', 'Cookie', 'a=1; b=2; c=3'),
makeEvent('header_down', 'Set-Cookie', 'x=10; Path=/'),
makeEvent('header_down', 'Set-Cookie', 'y=20; Path=/'),
makeEvent('header_down', 'Set-Cookie', 'z=30; Path=/'),
];
expect(getCookieCounts(events)).toEqual({ sent: 3, received: 3 });
});
test('ignores non-cookie headers', () => {
const events = [
makeEvent('header_up', 'Content-Type', 'application/json'),
makeEvent('header_down', 'Content-Length', '123'),
];
expect(getCookieCounts(events)).toEqual({ sent: 0, received: 0 });
});
test('handles case-insensitive header names', () => {
const events = [
makeEvent('header_up', 'COOKIE', 'a=1'),
makeEvent('header_down', 'SET-COOKIE', 'b=2; Path=/'),
];
expect(getCookieCounts(events)).toEqual({ sent: 1, received: 1 });
});
});

View File

@@ -1,8 +0,0 @@
export type { Appearance } from "@yaakapp-internal/theme";
export {
getCSSAppearance,
getWindowAppearance,
resolveAppearance,
subscribeToPreferredAppearance,
subscribeToWindowAppearanceChange,
} from "@yaakapp-internal/theme";

View File

@@ -1,45 +0,0 @@
import type { GetThemesResponse } from "@yaakapp-internal/plugins";
import { defaultDarkTheme, defaultLightTheme } from "@yaakapp-internal/theme";
import { invokeCmd } from "../tauri";
import type { Appearance } from "./appearance";
import { resolveAppearance } from "./appearance";
export { defaultDarkTheme, defaultLightTheme } from "@yaakapp-internal/theme";
export async function getThemes() {
const themes = (
await invokeCmd<GetThemesResponse[]>("cmd_get_themes")
).flatMap((t) => t.themes);
themes.sort((a, b) => a.label.localeCompare(b.label));
// Remove duplicates, in case multiple plugins provide the same theme
const uniqueThemes = Array.from(
new Map(themes.map((t) => [t.id, t])).values(),
);
return { themes: [defaultDarkTheme, defaultLightTheme, ...uniqueThemes] };
}
export async function getResolvedTheme(
preferredAppearance: Appearance,
appearanceSetting: string,
themeLight: string,
themeDark: string,
) {
const appearance = resolveAppearance(preferredAppearance, appearanceSetting);
const { themes } = await getThemes();
const darkThemes = themes.filter((t) => t.dark);
const lightThemes = themes.filter((t) => !t.dark);
const dark =
darkThemes.find((t) => t.id === themeDark) ??
darkThemes[0] ??
defaultDarkTheme;
const light =
lightThemes.find((t) => t.id === themeLight) ??
lightThemes[0] ??
defaultLightTheme;
const active = appearance === "dark" ? dark : light;
return { dark, light, active };
}

View File

@@ -1,13 +0,0 @@
export type {
YaakColorKey,
YaakColors,
YaakTheme,
} from "@yaakapp-internal/theme";
export {
addThemeStylesToDocument,
applyThemeToDocument,
completeTheme,
getThemeCSS,
indent,
setThemeOnDocument,
} from "@yaakapp-internal/theme";

View File

@@ -1 +0,0 @@
export { YaakColor } from "@yaakapp-internal/theme";

View File

@@ -1,47 +0,0 @@
import "./main.css";
import { RouterProvider } from "@tanstack/react-router";
import { type } from "@tauri-apps/plugin-os";
import {
changeModelStoreWorkspace,
initModelStore,
} from "@yaakapp-internal/models";
import { setPlatformOnDocument } from "@yaakapp-internal/theme";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { initSync } from "./init/sync";
import { initGlobalListeners } from "./lib/initGlobalListeners";
import { jotaiStore } from "./lib/jotai";
import { router } from "./lib/router";
const osType = type();
setPlatformOnDocument(osType);
window.addEventListener("keydown", (e) => {
const rx = /input|select|textarea/i;
const target = e.target;
if (e.key !== "Backspace") return;
if (!(target instanceof Element)) return;
if (target.getAttribute("contenteditable") !== null) return;
if (
!rx.test(target.tagName) ||
("disabled" in target && target.disabled) ||
("readOnly" in target && target.readOnly)
) {
e.preventDefault();
}
});
// Initialize a bunch of watchers
initSync();
initModelStore(jotaiStore);
initGlobalListeners();
await changeModelStoreWorkspace(null); // Load global models
console.log("Creating React root");
createRoot(document.getElementById("root") as HTMLElement).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
);

View File

@@ -1,16 +0,0 @@
const sharedConfig = require("@yaakapp-internal/tailwind-config");
/** @type {import('tailwindcss').Config} */
module.exports = {
...sharedConfig,
content: [
"./*.{html,ts,tsx}",
"./commands/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./hooks/**/*.{ts,tsx}",
"./init/**/*.{ts,tsx}",
"./lib/**/*.{ts,tsx}",
"./routes/**/*.{ts,tsx}",
"../../packages/ui/src/**/*.{ts,tsx}",
],
};

View File

@@ -1,27 +0,0 @@
{
"compilerOptions": {
"target": "es2021",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"useDefineForClassFields": true,
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"paths": {
"@yaakapp-internal/theme": ["../../packages/theme/src/index.ts"],
"@yaakapp-internal/theme/*": ["../../packages/theme/src/*"],
"@yaakapp-internal/ui": ["../../packages/ui/src/index.ts"],
"@yaakapp-internal/ui/*": ["../../packages/ui/src/*"],
},
},
"include": ["."],
"exclude": ["vite.config.ts"],
"references": [{ "path": "./tsconfig.node.json" }],
}

View File

@@ -1,67 +0,0 @@
// @ts-ignore
import { tanstackRouter } from "@tanstack/router-plugin/vite";
import react from "@vitejs/plugin-react";
import { createRequire } from "node:module";
import path from "node:path";
import { defineConfig, normalizePath } from "vite";
import { viteStaticCopy } from "vite-plugin-static-copy";
import svgr from "vite-plugin-svgr";
import topLevelAwait from "vite-plugin-top-level-await";
import wasm from "vite-plugin-wasm";
const require = createRequire(import.meta.url);
const cMapsDir = normalizePath(
path.join(path.dirname(require.resolve("pdfjs-dist/package.json")), "cmaps"),
);
const standardFontsDir = normalizePath(
path.join(
path.dirname(require.resolve("pdfjs-dist/package.json")),
"standard_fonts",
),
);
// https://vitejs.dev/config/
export default defineConfig(async () => {
return {
plugins: [
wasm(),
tanstackRouter({
target: "react",
routesDirectory: "./routes",
generatedRouteTree: "./routeTree.gen.ts",
autoCodeSplitting: true,
}),
svgr(),
react(),
topLevelAwait(),
viteStaticCopy({
targets: [
{ src: cMapsDir, dest: "" },
{ src: standardFontsDir, dest: "" },
],
}),
],
build: {
sourcemap: true,
outDir: "../../dist/apps/yaak-client",
emptyOutDir: true,
rollupOptions: {
output: {
// Make chunk names readable
chunkFileNames: "assets/chunk-[name]-[hash].js",
entryFileNames: "assets/entry-[name]-[hash].js",
assetFileNames: "assets/asset-[name]-[hash][extname]",
},
},
},
clearScreen: false,
server: {
port: parseInt(
process.env.YAAK_CLIENT_DEV_PORT ?? process.env.YAAK_DEV_PORT ?? "1420",
10,
),
strictPort: true,
},
envPrefix: ["VITE_", "TAURI_"],
};
});

View File

@@ -1,31 +0,0 @@
import { Button, type ButtonProps } from "@yaakapp-internal/ui";
import { useCallback, useState } from "react";
import type { ActionInvocation } from "@yaakapp-internal/proxy-lib";
import { useActionMetadata } from "./hooks";
import { rpc } from "./rpc";
type ActionButtonProps = Omit<ButtonProps, "onClick" | "children"> & {
action: ActionInvocation;
/** Override the label from metadata */
children?: React.ReactNode;
};
export function ActionButton({ action, children, ...props }: ActionButtonProps) {
const meta = useActionMetadata(action);
const [busy, setBusy] = useState(false);
const onClick = useCallback(async () => {
setBusy(true);
try {
await rpc("execute_action", action);
} finally {
setBusy(false);
}
}, [action]);
return (
<Button {...props} disabled={props.disabled || busy} isLoading={busy} onClick={onClick}>
{children ?? meta?.label ?? "…"}
</Button>
);
}

View File

@@ -1,169 +0,0 @@
import type { HttpExchange } from "@yaakapp-internal/proxy-lib";
import { Tree } from "@yaakapp-internal/ui";
import type { TreeNode } from "@yaakapp-internal/ui";
import { atom, useAtomValue } from "jotai";
import { atomFamily } from "jotai/utils";
import { useCallback, useMemo } from "react";
import { httpExchangesAtom } from "./store";
/** A node in the sidebar tree — either a domain or a path segment. */
export type SidebarItem = {
id: string;
label: string;
exchangeIds: string[];
};
const collapsedAtom = atomFamily((treeId: string) =>
atom<Record<string, boolean>>({}),
);
const sidebarTreeAtom = atom<TreeNode<SidebarItem>>((get) => {
const exchanges = get(httpExchangesAtom);
return buildTree(exchanges);
});
/**
* Build a domain → path-segment trie from a flat list of exchanges.
*
* Example: Given URLs
* GET https://api.example.com/v1/users
* GET https://api.example.com/v1/users/123
* POST https://api.example.com/v1/orders
*
* Produces:
* api.example.com
* /v1
* /users
* /123
* /orders
*/
function buildTree(exchanges: HttpExchange[]): TreeNode<SidebarItem> {
const root: SidebarItem = { id: "root", label: "All Traffic", exchangeIds: [] };
const rootNode: TreeNode<SidebarItem> = {
item: root,
parent: null,
depth: 0,
children: [],
};
// Intermediate trie structure for building
type TrieNode = {
id: string;
label: string;
exchangeIds: string[];
children: Map<string, TrieNode>;
};
const domainMap = new Map<string, TrieNode>();
for (const ex of exchanges) {
let hostname: string;
let segments: string[];
try {
const url = new URL(ex.url);
hostname = url.host;
segments = url.pathname.split("/").filter(Boolean);
} catch {
hostname = ex.url;
segments = [];
}
// Get or create domain node
let domainNode = domainMap.get(hostname);
if (!domainNode) {
domainNode = {
id: `domain:${hostname}`,
label: hostname,
exchangeIds: [],
children: new Map(),
};
domainMap.set(hostname, domainNode);
}
domainNode.exchangeIds.push(ex.id);
// Walk path segments
let current = domainNode;
const pathSoFar: string[] = [];
for (const seg of segments) {
pathSoFar.push(seg);
let child = current.children.get(seg);
if (!child) {
child = {
id: `path:${hostname}/${pathSoFar.join("/")}`,
label: `/${seg}`,
exchangeIds: [],
children: new Map(),
};
current.children.set(seg, child);
}
child.exchangeIds.push(ex.id);
current = child;
}
}
// Convert trie to TreeNode structure
function toTreeNode(
trie: TrieNode,
parent: TreeNode<SidebarItem>,
depth: number,
): TreeNode<SidebarItem> {
const node: TreeNode<SidebarItem> = {
item: {
id: trie.id,
label: trie.label,
exchangeIds: trie.exchangeIds,
},
parent,
depth,
children: [],
};
for (const child of trie.children.values()) {
node.children!.push(toTreeNode(child, node, depth + 1));
}
return node;
}
// Sort domains alphabetically, add to root
const sortedDomains = [...domainMap.values()].sort((a, b) =>
a.label.localeCompare(b.label),
);
for (const domain of sortedDomains) {
rootNode.children!.push(toTreeNode(domain, rootNode, 1));
}
return rootNode;
}
function ItemInner({ item }: { item: SidebarItem }) {
const count = item.exchangeIds.length;
return (
<div className="flex items-center gap-2 w-full min-w-0">
<span className="truncate">{item.label}</span>
{count > 0 && (
<span className="text-text-subtlest text-2xs shrink-0">{count}</span>
)}
</div>
);
}
export function Sidebar() {
const tree = useAtomValue(sidebarTreeAtom);
const treeId = "proxy-sidebar";
const getItemKey = useCallback((item: SidebarItem) => item.id, []);
return (
<aside className="x-theme-sidebar h-full w-[250px] min-w-0 overflow-y-auto border-r border-border-subtle">
<div className="pt-2 text-xs">
<Tree
treeId={treeId}
collapsedAtom={collapsedAtom(treeId)}
className="px-2 pb-10"
root={tree}
getItemKey={getItemKey}
ItemInner={ItemInner}
/>
</div>
</aside>
);
}

View File

@@ -1,35 +0,0 @@
import { useEffect, useState } from "react";
import type {
ActionInvocation,
ActionMetadata,
} from "@yaakapp-internal/proxy-lib";
import { rpc } from "./rpc";
let cachedActions: [ActionInvocation, ActionMetadata][] | null = null;
/** Fetch and cache all action metadata. */
async function getActions(): Promise<[ActionInvocation, ActionMetadata][]> {
if (!cachedActions) {
const { actions } = await rpc("list_actions", {});
cachedActions = actions;
}
return cachedActions;
}
/** Look up metadata for a specific action invocation. */
export function useActionMetadata(
action: ActionInvocation,
): ActionMetadata | null {
const [meta, setMeta] = useState<ActionMetadata | null>(null);
useEffect(() => {
getActions().then((actions) => {
const match = actions.find(
([inv]) => inv.scope === action.scope && inv.action === action.action,
);
setMeta(match?.[1] ?? null);
});
}, [action]);
return meta;
}

View File

@@ -1,64 +0,0 @@
import type {
ActionInvocation,
ActionMetadata,
} from "@yaakapp-internal/proxy-lib";
import { rpc } from "./rpc";
type ActionBinding = {
invocation: ActionInvocation;
meta: ActionMetadata;
keys: { key: string; ctrl: boolean; shift: boolean; alt: boolean; meta: boolean };
};
/** Parse a hotkey string like "Ctrl+Shift+P" into its parts. */
function parseHotkey(hotkey: string): ActionBinding["keys"] {
const parts = hotkey.split("+").map((p) => p.trim().toLowerCase());
return {
ctrl: parts.includes("ctrl") || parts.includes("control"),
shift: parts.includes("shift"),
alt: parts.includes("alt"),
meta: parts.includes("meta") || parts.includes("cmd") || parts.includes("command"),
key: parts.filter(
(p) => !["ctrl", "control", "shift", "alt", "meta", "cmd", "command"].includes(p),
)[0] ?? "",
};
}
function matchesEvent(binding: ActionBinding["keys"], e: KeyboardEvent): boolean {
return (
e.ctrlKey === binding.ctrl &&
e.shiftKey === binding.shift &&
e.altKey === binding.alt &&
e.metaKey === binding.meta &&
e.key.toLowerCase() === binding.key
);
}
/** Fetch all actions from Rust and register a global keydown listener. */
export async function initHotkeys(): Promise<() => void> {
const { actions } = await rpc("list_actions", {});
const bindings: ActionBinding[] = actions
.filter(
(entry): entry is [ActionInvocation, ActionMetadata & { defaultHotkey: string }] =>
entry[1].defaultHotkey != null,
)
.map(([invocation, meta]) => ({
invocation,
meta,
keys: parseHotkey(meta.defaultHotkey),
}));
function onKeyDown(e: KeyboardEvent) {
for (const binding of bindings) {
if (matchesEvent(binding.keys, e)) {
e.preventDefault();
rpc("execute_action", binding.invocation);
return;
}
}
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}

View File

@@ -1,27 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Yaak Proxy</title>
<style>
html,
body {
background-color: white;
}
@media (prefers-color-scheme: dark) {
html,
body {
background-color: #1b1a29;
}
}
</style>
</head>
<body class="text-base">
<div id="root"></div>
<script type="module" src="/theme.ts"></script>
<script type="module" src="/main.tsx"></script>
</body>
</html>

View File

@@ -1,92 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html,
body,
#root {
@apply w-full h-full overflow-hidden text-text bg-surface;
}
:root {
--font-family-interface: "";
--font-family-editor: "";
}
:root {
font-variant-ligatures: none;
}
html[data-platform="linux"] {
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
::selection {
@apply bg-selection;
}
:not(a),
:not(input):not(textarea),
:not(input):not(textarea)::after,
:not(input):not(textarea)::before {
@apply select-none cursor-default;
}
input,
textarea {
&::placeholder {
@apply text-placeholder;
}
}
a,
a[href] * {
@apply cursor-pointer !important;
}
table th {
@apply text-left;
}
:not(iframe) {
&::-webkit-scrollbar,
&::-webkit-scrollbar-corner {
@apply w-[8px] h-[8px] bg-transparent;
}
&::-webkit-scrollbar-track {
@apply bg-transparent;
}
&::-webkit-scrollbar-thumb {
@apply bg-text-subtlest rounded-[4px] opacity-20;
}
&::-webkit-scrollbar-thumb:hover {
@apply opacity-40 !important;
}
}
.hide-scrollbars {
&::-webkit-scrollbar-corner,
&::-webkit-scrollbar {
@apply hidden !important;
}
}
.rtl {
direction: rtl;
}
:root {
color-scheme: light dark;
--transition-duration: 100ms ease-in-out;
--color-white: 255 100% 100%;
--color-black: 255 0% 0%;
}
}

View File

@@ -1,115 +0,0 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { type } from "@tauri-apps/plugin-os";
import { HeaderSize } from "@yaakapp-internal/ui";
import { ActionButton } from "./ActionButton";
import { Sidebar } from "./Sidebar";
import classNames from "classnames";
import { createStore, Provider, useAtomValue } from "jotai";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./main.css";
import { initHotkeys } from "./hotkeys";
import { listen, rpc } from "./rpc";
import { applyChange, dataAtom, httpExchangesAtom, replaceAll } from "./store";
const queryClient = new QueryClient();
const jotaiStore = createStore();
// Load initial models from the database
rpc("list_models", {}).then((res) => {
jotaiStore.set(dataAtom, (prev) =>
replaceAll(prev, "http_exchange", res.httpExchanges),
);
});
// Register hotkeys from action metadata
initHotkeys();
// Subscribe to model change events from the backend
listen("model_write", (payload) => {
jotaiStore.set(dataAtom, (prev) =>
applyChange(prev, "http_exchange", payload.model, payload.change),
);
});
function App() {
const osType = type();
const exchanges = useAtomValue(httpExchangesAtom);
return (
<div
className={classNames(
"h-full w-full grid grid-rows-[auto_1fr]",
osType === "linux" && "border border-border-subtle",
)}
>
<HeaderSize
size="lg"
osType={osType}
hideWindowControls={false}
useNativeTitlebar={false}
interfaceScale={1}
className="x-theme-appHeader bg-surface"
>
<div
data-tauri-drag-region
className="flex items-center px-2 text-sm font-semibold text-text-subtle"
>
Yaak Proxy
</div>
</HeaderSize>
<div className="grid grid-cols-[auto_1fr] min-h-0">
<Sidebar />
<main className="overflow-auto p-4">
<div className="flex items-center gap-3 mb-4">
<ActionButton
action={{ scope: "global", action: "proxy_start" }}
size="sm"
tone="primary"
/>
<ActionButton
action={{ scope: "global", action: "proxy_stop" }}
size="sm"
variant="border"
/>
</div>
<div className="text-xs font-mono">
{exchanges.length === 0 ? (
<p className="text-text-subtlest">No traffic yet</p>
) : (
<table className="w-full text-left">
<thead>
<tr className="text-text-subtlest border-b border-border-subtle">
<th className="py-1 pr-3 font-medium">Method</th>
<th className="py-1 pr-3 font-medium">URL</th>
<th className="py-1 pr-3 font-medium">Status</th>
</tr>
</thead>
<tbody>
{exchanges.map((ex) => (
<tr key={ex.id} className="border-b border-border-subtle">
<td className="py-1 pr-3">{ex.method}</td>
<td className="py-1 pr-3 truncate max-w-md">{ex.url}</td>
<td className="py-1 pr-3">{ex.resStatus ?? "—"}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</main>
</div>
</div>
);
}
createRoot(document.getElementById("root") as HTMLElement).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<Provider store={jotaiStore}>
<App />
</Provider>
</QueryClientProvider>
</StrictMode>,
);

View File

@@ -1,31 +0,0 @@
{
"name": "@yaakapp/yaak-proxy",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite dev --force",
"build": "vite build",
"lint": "tsc --noEmit"
},
"dependencies": {
"@tanstack/react-query": "^5.90.5",
"@tauri-apps/api": "^2.9.1",
"@tauri-apps/plugin-os": "^2.3.2",
"@yaakapp-internal/theme": "^1.0.0",
"@yaakapp-internal/model-store": "^1.0.0",
"@yaakapp-internal/proxy-lib": "^1.0.0",
"@yaakapp-internal/ui": "^1.0.0",
"classnames": "^2.5.1",
"jotai": "^2.18.0",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"typescript": "^5.8.3",
"vite": "^7.0.8"
}
}

View File

@@ -1,7 +0,0 @@
module.exports = {
plugins: [
require("@tailwindcss/nesting")(require("postcss-nesting")),
require("tailwindcss"),
require("autoprefixer"),
],
};

View File

@@ -1,30 +0,0 @@
import { invoke } from "@tauri-apps/api/core";
import { listen as tauriListen } from "@tauri-apps/api/event";
import type {
RpcEventSchema,
RpcSchema,
} from "@yaakapp-internal/proxy-lib";
type Req<K extends keyof RpcSchema> = RpcSchema[K][0];
type Res<K extends keyof RpcSchema> = RpcSchema[K][1];
export async function rpc<K extends keyof RpcSchema>(
cmd: K,
payload: Req<K>,
): Promise<Res<K>> {
return invoke("rpc", { cmd, payload }) as Promise<Res<K>>;
}
/** Subscribe to a backend event. Returns an unsubscribe function. */
export function listen<K extends keyof RpcEventSchema>(
event: K & string,
callback: (payload: RpcEventSchema[K]) => void,
): () => void {
let unsub: (() => void) | null = null;
tauriListen<RpcEventSchema[K]>(event, (e) => callback(e.payload))
.then((fn) => {
unsub = fn;
})
.catch(console.error);
return () => unsub?.();
}

View File

@@ -1,15 +0,0 @@
import { createModelStore } from "@yaakapp-internal/model-store";
import type { HttpExchange } from "@yaakapp-internal/proxy-lib";
type ProxyModels = {
http_exchange: HttpExchange;
};
export const { dataAtom, applyChange, replaceAll, listAtom, orderedListAtom } =
createModelStore<ProxyModels>(["http_exchange"]);
export const httpExchangesAtom = orderedListAtom(
"http_exchange",
"createdAt",
"desc",
);

View File

@@ -1,7 +0,0 @@
const sharedConfig = require("@yaakapp-internal/tailwind-config");
/** @type {import('tailwindcss').Config} */
module.exports = {
...sharedConfig,
content: ["./*.{html,ts,tsx}", "../../packages/ui/src/**/*.{ts,tsx}"],
};

View File

@@ -1,9 +0,0 @@
import {
applyThemeToDocument,
defaultDarkTheme,
platformFromUserAgent,
setPlatformOnDocument,
} from "@yaakapp-internal/theme";
setPlatformOnDocument(platformFromUserAgent(navigator.userAgent));
applyThemeToDocument(defaultDarkTheme);

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