mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-10 15:16:06 +01:00
Compare commits
2 Commits
wip/yaak-p
...
v2025.9.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e6523f477 | ||
|
|
0c8d180928 |
@@ -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
|
||||
@@ -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
|
||||
[](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".
|
||||
@@ -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
|
||||
@@ -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
9
.gitattributes
vendored
@@ -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
|
||||
|
||||
18
.github/pull_request_template.md
vendored
18
.github/pull_request_template.md
vendored
@@ -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. -->
|
||||
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
50
.github/workflows/claude.yml
vendored
50
.github/workflows/claude.yml
vendored
@@ -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:*)'
|
||||
|
||||
52
.github/workflows/flathub.yml
vendored
52
.github/workflows/flathub.yml
vendored
@@ -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
|
||||
59
.github/workflows/release-api-npm.yml
vendored
59
.github/workflows/release-api-npm.yml
vendored
@@ -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
|
||||
179
.github/workflows/release-app.yml
vendored
179
.github/workflows/release-app.yml
vendored
@@ -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
|
||||
218
.github/workflows/release-cli-npm.yml
vendored
218
.github/workflows/release-cli-npm.yml
vendored
@@ -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
131
.github/workflows/release.yml
vendored
Normal 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
25
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
node scripts/git-hooks/post-checkout.mjs "$@"
|
||||
@@ -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
|
||||
@@ -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).
|
||||
91
Cargo.toml
91
Cargo.toml
@@ -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
|
||||
14
README.md
14
README.md
@@ -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://github.com/MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a> <a href="https://github.com/dharsanb"><img src="https://github.com/dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a> <a href="https://github.com/railwayapp"><img src="https://github.com/railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a> <a href="https://github.com/caseyamcl"><img src="https://github.com/caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a> <a href="https://github.com/bytebase"><img src="https://github.com/bytebase.png" width="80px" alt="User avatar: bytebase" /></a> <a href="https://github.com/"><img src="https://raw.githubusercontent.com/JamesIves/github-sponsors-readme-action/dev/.github/assets/placeholder.png" width="80px" alt="User avatar: " /></a> <!-- sponsors-premium -->
|
||||
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https://github.com/MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a> <a href="https://github.com/dharsanb"><img src="https://github.com/dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a> <a href="https://github.com/railwayapp"><img src="https://github.com/railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a> <a href="https://github.com/caseyamcl"><img src="https://github.com/caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a> <a href="https://github.com/"><img src="https://raw.githubusercontent.com/JamesIves/github-sponsors-readme-action/dev/.github/assets/placeholder.png" width="80px" alt="User avatar: " /></a> <!-- sponsors-premium -->
|
||||
</p>
|
||||
<p align="center">
|
||||
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https://github.com/seanwash.png" width="50px" alt="User avatar: seanwash" /></a> <a href="https://github.com/jerath"><img src="https://github.com/jerath.png" width="50px" alt="User avatar: jerath" /></a> <a href="https://github.com/itsa-sh"><img src="https://github.com/itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a> <a href="https://github.com/dmmulroy"><img src="https://github.com/dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a> <a href="https://github.com/timcole"><img src="https://github.com/timcole.png" width="50px" alt="User avatar: timcole" /></a> <a href="https://github.com/VLZH"><img src="https://github.com/VLZH.png" width="50px" alt="User avatar: VLZH" /></a> <a href="https://github.com/terasaka2k"><img src="https://github.com/terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a> <a href="https://github.com/andriyor"><img src="https://github.com/andriyor.png" width="50px" alt="User avatar: andriyor" /></a> <a href="https://github.com/majudhu"><img src="https://github.com/majudhu.png" width="50px" alt="User avatar: majudhu" /></a> <a href="https://github.com/axelrindle"><img src="https://github.com/axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a> <a href="https://github.com/jirizverina"><img src="https://github.com/jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a> <a href="https://github.com/chip-well"><img src="https://github.com/chip-well.png" width="50px" alt="User avatar: chip-well" /></a> <a href="https://github.com/GRAYAH"><img src="https://github.com/GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a> <a href="https://github.com/flashblaze"><img src="https://github.com/flashblaze.png" width="50px" alt="User avatar: flashblaze" /></a> <a href="https://github.com/Frostist"><img src="https://github.com/Frostist.png" width="50px" alt="User avatar: Frostist" /></a> <!-- sponsors-base -->
|
||||
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https://github.com/seanwash.png" width="50px" alt="User avatar: seanwash" /></a> <a href="https://github.com/jerath"><img src="https://github.com/jerath.png" width="50px" alt="User avatar: jerath" /></a> <a href="https://github.com/itsa-sh"><img src="https://github.com/itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a> <a href="https://github.com/dmmulroy"><img src="https://github.com/dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a> <a href="https://github.com/timcole"><img src="https://github.com/timcole.png" width="50px" alt="User avatar: timcole" /></a> <a href="https://github.com/VLZH"><img src="https://github.com/VLZH.png" width="50px" alt="User avatar: VLZH" /></a> <a href="https://github.com/terasaka2k"><img src="https://github.com/terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a> <a href="https://github.com/andriyor"><img src="https://github.com/andriyor.png" width="50px" alt="User avatar: andriyor" /></a> <a href="https://github.com/majudhu"><img src="https://github.com/majudhu.png" width="50px" alt="User avatar: majudhu" /></a> <a href="https://github.com/axelrindle"><img src="https://github.com/axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a> <a href="https://github.com/jirizverina"><img src="https://github.com/jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a> <a href="https://github.com/chip-well"><img src="https://github.com/chip-well.png" width="50px" alt="User avatar: chip-well" /></a> <a href="https://github.com/GRAYAH"><img src="https://github.com/GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a> <!-- sponsors-base -->
|
||||
</p>
|
||||
|
||||

|
||||
@@ -58,15 +58,13 @@ Built with [Tauri](https://tauri.app), Rust, and React, it’s 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)
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import classNames from 'classnames';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { memo } from 'react';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
}
|
||||
|
||||
export const DropMarker = memo(
|
||||
function DropMarker({ className, style, orientation = 'horizontal' }: Props) {
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
className={classNames(
|
||||
className,
|
||||
'absolute pointer-events-none z-50',
|
||||
orientation === 'horizontal' && 'w-full',
|
||||
orientation === 'vertical' && 'w-0 top-0 bottom-0',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
'absolute bg-primary rounded-full',
|
||||
orientation === 'horizontal' && 'left-2 right-2 -bottom-[0.1rem] h-[0.2rem]',
|
||||
orientation === 'vertical' && '-left-[0.1rem] top-0 bottom-0 w-[0.2rem]',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
() => true,
|
||||
);
|
||||
@@ -1,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>
|
||||
);
|
||||
}
|
||||
@@ -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' };
|
||||
}
|
||||
@@ -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>•</span>
|
||||
<HttpResponseDurationTag response={activeResponse} />
|
||||
<span>•</span>
|
||||
<SizeTag
|
||||
contentLength={activeResponse.contentLength ?? 0}
|
||||
contentLengthCompressed={activeResponse.contentLengthCompressed}
|
||||
/>
|
||||
</HStack>
|
||||
{shouldShowRedirectDropWarning ? (
|
||||
<Tooltip
|
||||
tabIndex={0}
|
||||
className="my-auto pl-3 flex-shrink-0 max-w-full justify-self-end overflow-hidden"
|
||||
content={
|
||||
<VStack alignItems="start" space={1} className="text-xs">
|
||||
<span className="font-medium text-warning">
|
||||
Redirect changed this request
|
||||
</span>
|
||||
{redirectDropWarning.droppedBodyCount > 0 && (
|
||||
<span>
|
||||
Body dropped on {redirectDropWarning.droppedBodyCount}{' '}
|
||||
{redirectDropWarning.droppedBodyCount === 1
|
||||
? 'redirect hop'
|
||||
: 'redirect hops'}
|
||||
</span>
|
||||
)}
|
||||
{redirectDropWarning.droppedHeaders.length > 0 && (
|
||||
<span>
|
||||
Headers dropped:{' '}
|
||||
<span className="font-mono">
|
||||
{redirectDropWarning.droppedHeaders.join(', ')}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="text-text-subtle">See Timeline for details.</span>
|
||||
</VStack>
|
||||
}
|
||||
>
|
||||
<span className="inline-flex min-w-0">
|
||||
<PillButton
|
||||
color="warning"
|
||||
className="font-sans text-sm !flex-shrink max-w-full"
|
||||
innerClassName="flex items-center"
|
||||
leftSlot={<Icon icon="alert_triangle" size="xs" color="warning" />}
|
||||
>
|
||||
<span className="truncate">
|
||||
{getRedirectWarningLabel(redirectDropWarning)}
|
||||
</span>
|
||||
</PillButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className="justify-self-end flex-shrink-0">
|
||||
<RecentHttpResponsesDropdown
|
||||
responses={responses}
|
||||
activeResponse={activeResponse}
|
||||
onPinnedResponseId={setPinnedResponseId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
<div className="overflow-hidden flex flex-col min-h-0">
|
||||
{activeResponse?.error && (
|
||||
<Banner color="danger" className="mx-3 mt-1 flex-shrink-0">
|
||||
{activeResponse.error}
|
||||
</Banner>
|
||||
)}
|
||||
{/* Show tabs if we have any data (headers, body, etc.) even if there's an error */}
|
||||
<Tabs
|
||||
tabs={tabs}
|
||||
label="Response"
|
||||
className="ml-3 mr-3 mb-3 min-h-0 flex-1"
|
||||
tabListClassName="mt-0.5 -mb-1.5"
|
||||
storageKey="http_response_tabs"
|
||||
activeTabKey={activeRequestId}
|
||||
>
|
||||
<TabContent value={TAB_BODY}>
|
||||
<ErrorBoundary name="Http Response Viewer">
|
||||
<Suspense>
|
||||
<ConfirmLargeResponse response={activeResponse}>
|
||||
{activeResponse.state === 'initialized' ? (
|
||||
<EmptyStateText>
|
||||
<VStack space={3}>
|
||||
<HStack space={3}>
|
||||
<LoadingIcon className="text-text-subtlest" />
|
||||
Sending Request
|
||||
</HStack>
|
||||
<Button size="sm" variant="border" onClick={() => cancel.mutate()}>
|
||||
Cancel
|
||||
</Button>
|
||||
</VStack>
|
||||
</EmptyStateText>
|
||||
) : activeResponse.state === 'closed' &&
|
||||
(activeResponse.contentLength ?? 0) === 0 ? (
|
||||
<EmptyStateText>Empty</EmptyStateText>
|
||||
) : mimeType?.match(/^text\/event-stream/i) && viewMode === 'pretty' ? (
|
||||
<EventStreamViewer response={activeResponse} />
|
||||
) : mimeType?.match(/^image\/svg/) ? (
|
||||
<HttpSvgViewer response={activeResponse} />
|
||||
) : mimeType?.match(/^image/i) ? (
|
||||
<EnsureCompleteResponse response={activeResponse} Component={ImageViewer} />
|
||||
) : mimeType?.match(/^audio/i) ? (
|
||||
<EnsureCompleteResponse response={activeResponse} Component={AudioViewer} />
|
||||
) : mimeType?.match(/^video/i) ? (
|
||||
<EnsureCompleteResponse response={activeResponse} Component={VideoViewer} />
|
||||
) : mimeType?.match(/^multipart/i) && viewMode === 'pretty' ? (
|
||||
<HttpMultipartViewer response={activeResponse} />
|
||||
) : mimeType?.match(/pdf/i) ? (
|
||||
<EnsureCompleteResponse response={activeResponse} Component={PdfViewer} />
|
||||
) : mimeType?.match(/csv|tab-separated/i) && viewMode === 'pretty' ? (
|
||||
<HttpCsvViewer className="pb-2" response={activeResponse} />
|
||||
) : (
|
||||
<HTMLOrTextViewer
|
||||
textViewerClassName="-mr-2 bg-surface" // Pull to the right
|
||||
response={activeResponse}
|
||||
pretty={viewMode === 'pretty'}
|
||||
/>
|
||||
)}
|
||||
</ConfirmLargeResponse>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_REQUEST}>
|
||||
<ConfirmLargeResponseRequest response={activeResponse}>
|
||||
<RequestBodyViewer response={activeResponse} />
|
||||
</ConfirmLargeResponseRequest>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_HEADERS}>
|
||||
<ResponseHeaders response={activeResponse} />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_COOKIES}>
|
||||
<ResponseCookies response={activeResponse} />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_TIMELINE}>
|
||||
<HttpResponseTimeline response={activeResponse} viewMode={timelineViewMode} />
|
||||
</TabContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getRedirectDropWarning(
|
||||
events: HttpResponseEvent[] | undefined,
|
||||
): RedirectDropWarning | null {
|
||||
if (events == null || events.length === 0) return null;
|
||||
|
||||
let droppedBodyCount = 0;
|
||||
const droppedHeaders = new Set<string>();
|
||||
for (const e of events) {
|
||||
const event = e.event;
|
||||
if (event.type !== 'redirect') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.dropped_body) {
|
||||
droppedBodyCount += 1;
|
||||
}
|
||||
for (const headerName of event.dropped_headers ?? []) {
|
||||
pushHeaderName(droppedHeaders, headerName);
|
||||
}
|
||||
}
|
||||
|
||||
if (droppedBodyCount === 0 && droppedHeaders.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
droppedBodyCount,
|
||||
droppedHeaders: Array.from(droppedHeaders).sort(),
|
||||
};
|
||||
}
|
||||
|
||||
function pushHeaderName(headers: Set<string>, headerName: string): void {
|
||||
const existing = Array.from(headers).find((h) => h.toLowerCase() === headerName.toLowerCase());
|
||||
if (existing == null) {
|
||||
headers.add(headerName);
|
||||
}
|
||||
}
|
||||
|
||||
function getRedirectWarningLabel(warning: RedirectDropWarning): string {
|
||||
if (warning.droppedBodyCount > 0 && warning.droppedHeaders.length > 0) {
|
||||
return 'Dropped body and headers';
|
||||
}
|
||||
if (warning.droppedBodyCount > 0) {
|
||||
return 'Dropped body';
|
||||
}
|
||||
return 'Dropped headers';
|
||||
}
|
||||
|
||||
function EnsureCompleteResponse({
|
||||
response,
|
||||
Component,
|
||||
}: {
|
||||
response: HttpResponse;
|
||||
Component: ComponentType<{ bodyPath: string }>;
|
||||
}) {
|
||||
if (response.bodyPath === null) {
|
||||
return <div>Empty response body</div>;
|
||||
}
|
||||
|
||||
// Wait until the response has been fully-downloaded
|
||||
if (response.state !== 'closed') {
|
||||
return (
|
||||
<EmptyStateText>
|
||||
<LoadingIcon />
|
||||
</EmptyStateText>
|
||||
);
|
||||
}
|
||||
|
||||
return <Component bodyPath={response.bodyPath} />;
|
||||
}
|
||||
|
||||
function HttpSvgViewer({ response }: { response: HttpResponse }) {
|
||||
const body = useResponseBodyText({ response, filter: null });
|
||||
|
||||
if (!body.data) return null;
|
||||
|
||||
return <SvgViewer text={body.data} />;
|
||||
}
|
||||
|
||||
function HttpCsvViewer({ response, className }: { response: HttpResponse; className?: string }) {
|
||||
const body = useResponseBodyText({ response, filter: null });
|
||||
|
||||
return <CsvViewer text={body.data ?? null} className={className} />;
|
||||
}
|
||||
|
||||
function HttpMultipartViewer({ response }: { response: HttpResponse }) {
|
||||
const body = useResponseBodyBytes({ response });
|
||||
|
||||
if (body.data == null) return null;
|
||||
|
||||
const contentTypeHeader = getContentTypeFromHeaders(response.headers);
|
||||
const boundary = contentTypeHeader?.split('boundary=')[1] ?? 'unknown';
|
||||
|
||||
return <MultipartViewer data={body.data} boundary={boundary} idPrefix={response.id} />;
|
||||
}
|
||||
@@ -1,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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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}`} />
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 || <> </>}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>•</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
});
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
})
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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'} />
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -1,6 +0,0 @@
|
||||
export function capitalize(str: string): string {
|
||||
return str
|
||||
.split(' ')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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'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;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
export type { Appearance } from "@yaakapp-internal/theme";
|
||||
export {
|
||||
getCSSAppearance,
|
||||
getWindowAppearance,
|
||||
resolveAppearance,
|
||||
subscribeToPreferredAppearance,
|
||||
subscribeToWindowAppearanceChange,
|
||||
} from "@yaakapp-internal/theme";
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
export type {
|
||||
YaakColorKey,
|
||||
YaakColors,
|
||||
YaakTheme,
|
||||
} from "@yaakapp-internal/theme";
|
||||
export {
|
||||
addThemeStylesToDocument,
|
||||
applyThemeToDocument,
|
||||
completeTheme,
|
||||
getThemeCSS,
|
||||
indent,
|
||||
setThemeOnDocument,
|
||||
} from "@yaakapp-internal/theme";
|
||||
@@ -1 +0,0 @@
|
||||
export { YaakColor } from "@yaakapp-internal/theme";
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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}",
|
||||
],
|
||||
};
|
||||
@@ -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" }],
|
||||
}
|
||||
@@ -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_"],
|
||||
};
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: [
|
||||
require("@tailwindcss/nesting")(require("postcss-nesting")),
|
||||
require("tailwindcss"),
|
||||
require("autoprefixer"),
|
||||
],
|
||||
};
|
||||
@@ -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?.();
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
@@ -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}"],
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user