mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-05-17 21:27:05 +02:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a52032988 | |||
| 4b7497a908 |
+7
-18
@@ -1,27 +1,23 @@
|
|||||||
# Claude Context: Detaching Tauri from Yaak
|
# Claude Context: Detaching Tauri from Yaak
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
Make Yaak runnable as a standalone CLI without Tauri as a dependency. The core Rust crates in `crates/` should be usable independently, while Tauri-specific code lives in `crates-tauri/`.
|
Make Yaak runnable as a standalone CLI without Tauri as a dependency. The core Rust crates in `crates/` should be usable independently, while Tauri-specific code lives in `crates-tauri/`.
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
crates/ # Core crates - should NOT depend on Tauri
|
crates/ # Core crates - should NOT depend on Tauri
|
||||||
crates-tauri/ # Tauri-specific crates (yaak-app-client, yaak-tauri-utils, etc.)
|
crates-tauri/ # Tauri-specific crates (yaak-app, yaak-tauri-utils, etc.)
|
||||||
crates-cli/ # CLI crate (yaak-cli)
|
crates-cli/ # CLI crate (yaak-cli)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Completed Work
|
## Completed Work
|
||||||
|
|
||||||
### 1. Folder Restructure
|
### 1. Folder Restructure
|
||||||
|
- Moved Tauri-dependent app code to `crates-tauri/yaak-app/`
|
||||||
- Moved Tauri-dependent app code to `crates-tauri/yaak-app-client/`
|
|
||||||
- Created `crates-tauri/yaak-tauri-utils/` for shared Tauri utilities (window traits, api_client, error handling)
|
- Created `crates-tauri/yaak-tauri-utils/` for shared Tauri utilities (window traits, api_client, error handling)
|
||||||
- Created `crates-cli/yaak-cli/` for the standalone CLI
|
- Created `crates-cli/yaak-cli/` for the standalone CLI
|
||||||
|
|
||||||
### 2. Decoupled Crates (no longer depend on Tauri)
|
### 2. Decoupled Crates (no longer depend on Tauri)
|
||||||
|
|
||||||
- **yaak-models**: Uses `init_standalone()` pattern for CLI database access
|
- **yaak-models**: Uses `init_standalone()` pattern for CLI database access
|
||||||
- **yaak-http**: Removed Tauri plugin, HttpConnectionManager initialized in yaak-app setup
|
- **yaak-http**: Removed Tauri plugin, HttpConnectionManager initialized in yaak-app setup
|
||||||
- **yaak-common**: Only contains Tauri-free utilities (serde, platform)
|
- **yaak-common**: Only contains Tauri-free utilities (serde, platform)
|
||||||
@@ -29,7 +25,6 @@ crates-cli/ # CLI crate (yaak-cli)
|
|||||||
- **yaak-grpc**: Replaced AppHandle with GrpcConfig struct, uses tokio::process::Command instead of Tauri sidecar
|
- **yaak-grpc**: Replaced AppHandle with GrpcConfig struct, uses tokio::process::Command instead of Tauri sidecar
|
||||||
|
|
||||||
### 3. CLI Implementation
|
### 3. CLI Implementation
|
||||||
|
|
||||||
- Basic CLI at `crates-cli/yaak-cli/src/main.rs`
|
- Basic CLI at `crates-cli/yaak-cli/src/main.rs`
|
||||||
- Commands: workspaces, requests, send (by ID), get (ad-hoc URL), create
|
- Commands: workspaces, requests, send (by ID), get (ad-hoc URL), create
|
||||||
- Uses same database as Tauri app via `yaak_models::init_standalone()`
|
- Uses same database as Tauri app via `yaak_models::init_standalone()`
|
||||||
@@ -37,36 +32,31 @@ crates-cli/ # CLI crate (yaak-cli)
|
|||||||
## Remaining Work
|
## Remaining Work
|
||||||
|
|
||||||
### Crates Still Depending on Tauri (in `crates/`)
|
### Crates Still Depending on Tauri (in `crates/`)
|
||||||
|
|
||||||
1. **yaak-git** (3 files) - Moderate complexity
|
1. **yaak-git** (3 files) - Moderate complexity
|
||||||
2. **yaak-plugins** (13 files) - **Hardest** - deeply integrated with Tauri for plugin-to-window communication
|
2. **yaak-plugins** (13 files) - **Hardest** - deeply integrated with Tauri for plugin-to-window communication
|
||||||
3. **yaak-sync** (4 files) - Moderate complexity
|
3. **yaak-sync** (4 files) - Moderate complexity
|
||||||
4. **yaak-ws** (5 files) - Moderate complexity
|
4. **yaak-ws** (5 files) - Moderate complexity
|
||||||
|
|
||||||
### Pattern for Decoupling
|
### Pattern for Decoupling
|
||||||
|
|
||||||
1. Remove Tauri plugin `init()` function from the crate
|
1. Remove Tauri plugin `init()` function from the crate
|
||||||
2. Move commands to `yaak-app/src/commands.rs` or keep inline in `lib.rs`
|
2. Move commands to `yaak-app/src/commands.rs` or keep inline in `lib.rs`
|
||||||
3. Move extension traits (e.g., `SomethingManagerExt`) to yaak-app or yaak-tauri-utils
|
3. Move extension traits (e.g., `SomethingManagerExt`) to yaak-app or yaak-tauri-utils
|
||||||
4. Initialize managers in yaak-app's `.setup()` block
|
4. Initialize managers in yaak-app's `.setup()` block
|
||||||
5. Remove `tauri` from Cargo.toml dependencies
|
5. Remove `tauri` from Cargo.toml dependencies
|
||||||
6. Update `crates-tauri/yaak-app-client/capabilities/default.json` to remove the plugin permission
|
6. Update `crates-tauri/yaak-app/capabilities/default.json` to remove the plugin permission
|
||||||
7. Replace `tauri::async_runtime::block_on` with `tokio::runtime::Handle::current().block_on()`
|
7. Replace `tauri::async_runtime::block_on` with `tokio::runtime::Handle::current().block_on()`
|
||||||
|
|
||||||
## Key Files
|
## Key Files
|
||||||
|
- `crates-tauri/yaak-app/src/lib.rs` - Main Tauri app, setup block initializes managers
|
||||||
- `crates-tauri/yaak-app-client/src/lib.rs` - Main Tauri app, setup block initializes managers
|
- `crates-tauri/yaak-app/src/commands.rs` - Migrated Tauri commands
|
||||||
- `crates-tauri/yaak-app-client/src/commands.rs` - Migrated Tauri commands
|
- `crates-tauri/yaak-app/src/models_ext.rs` - Database plugin and extension traits
|
||||||
- `crates-tauri/yaak-app-client/src/models_ext.rs` - Database plugin and extension traits
|
|
||||||
- `crates-tauri/yaak-tauri-utils/src/window.rs` - WorkspaceWindowTrait for window state
|
- `crates-tauri/yaak-tauri-utils/src/window.rs` - WorkspaceWindowTrait for window state
|
||||||
- `crates/yaak-models/src/lib.rs` - Contains `init_standalone()` for CLI usage
|
- `crates/yaak-models/src/lib.rs` - Contains `init_standalone()` for CLI usage
|
||||||
|
|
||||||
## Git Branch
|
## Git Branch
|
||||||
|
|
||||||
Working on `detach-tauri` branch.
|
Working on `detach-tauri` branch.
|
||||||
|
|
||||||
## Recent Commits
|
## Recent Commits
|
||||||
|
|
||||||
```
|
```
|
||||||
c40cff40 Remove Tauri dependencies from yaak-crypto and yaak-grpc
|
c40cff40 Remove Tauri dependencies from yaak-crypto and yaak-grpc
|
||||||
df495f1d Move Tauri utilities from yaak-common to yaak-tauri-utils
|
df495f1d Move Tauri utilities from yaak-common to yaak-tauri-utils
|
||||||
@@ -77,7 +67,6 @@ e718a5f1 Refactor models_ext to use init_standalone from yaak-models
|
|||||||
```
|
```
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
- Run `cargo check -p <crate>` to verify a crate builds without Tauri
|
- Run `cargo check -p <crate>` to verify a crate builds without Tauri
|
||||||
- Run `npm run client:dev` to test the Tauri app still works
|
- Run `npm run app-dev` to test the Tauri app still works
|
||||||
- Run `cargo run -p yaak-cli -- --help` to test the CLI
|
- Run `cargo run -p yaak-cli -- --help` to test the CLI
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
description: Review a PR in a new worktree
|
||||||
|
allowed-tools: Bash(git worktree:*), Bash(gh pr:*)
|
||||||
|
---
|
||||||
|
|
||||||
|
Review a GitHub pull request in a new git worktree.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
/review-pr <PR_NUMBER>
|
||||||
|
```
|
||||||
|
|
||||||
|
## What to do
|
||||||
|
|
||||||
|
1. List all open pull requests and ask the user to select one
|
||||||
|
2. Get PR information using `gh pr view <PR_NUMBER> --json number,headRefName`
|
||||||
|
3. Extract the branch name from the PR
|
||||||
|
4. Create a new worktree at `../yaak-worktrees/pr-<PR_NUMBER>` using `git worktree add` with a timeout of at least 300000ms (5 minutes) since the post-checkout hook runs a bootstrap script
|
||||||
|
5. Checkout the PR branch in the new worktree using `gh pr checkout <PR_NUMBER>`
|
||||||
|
6. The post-checkout hook will automatically:
|
||||||
|
- Create `.env.local` with unique ports
|
||||||
|
- Copy editor config folders
|
||||||
|
- Run `npm install && npm run bootstrap`
|
||||||
|
7. Inform the user:
|
||||||
|
- Where the worktree was created
|
||||||
|
- What ports were assigned
|
||||||
|
- How to access it (cd command)
|
||||||
|
- How to run the dev server
|
||||||
|
- How to remove the worktree when done
|
||||||
|
|
||||||
|
## Example Output
|
||||||
|
|
||||||
|
```
|
||||||
|
Created worktree for PR #123 at ../yaak-worktrees/pr-123
|
||||||
|
Branch: feature-auth
|
||||||
|
Ports: Vite (1421), MCP (64344)
|
||||||
|
|
||||||
|
To start working:
|
||||||
|
cd ../yaak-worktrees/pr-123
|
||||||
|
npm run app-dev
|
||||||
|
|
||||||
|
To remove when done:
|
||||||
|
git worktree remove ../yaak-worktrees/pr-123
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- If the PR doesn't exist, show a helpful error
|
||||||
|
- If the worktree already exists, inform the user and ask if they want to remove and recreate it
|
||||||
|
- If `gh` CLI is not available, inform the user to install it
|
||||||
@@ -37,14 +37,3 @@ The skill generates markdown-formatted release notes following this structure:
|
|||||||
|
|
||||||
**IMPORTANT**: Always add a blank lines around the markdown code fence and output the markdown code block last
|
**IMPORTANT**: Always add a blank lines around the markdown code fence and output the markdown code block last
|
||||||
**IMPORTANT**: PRs by `@gschier` should not mention the @username
|
**IMPORTANT**: PRs by `@gschier` should not mention the @username
|
||||||
**IMPORTANT**: These are app release notes. Exclude CLI-only changes (commits prefixed with `cli:` or only touching `crates-cli/`) since the CLI has its own release process.
|
|
||||||
|
|
||||||
## After Generating Release Notes
|
|
||||||
|
|
||||||
After 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".
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Worktree Management Skill
|
||||||
|
|
||||||
|
## Creating Worktrees
|
||||||
|
|
||||||
|
When creating git worktrees for this project, ALWAYS use the path format:
|
||||||
|
```
|
||||||
|
../yaak-worktrees/<NAME>
|
||||||
|
```
|
||||||
|
|
||||||
|
For example:
|
||||||
|
- `git worktree add ../yaak-worktrees/feature-auth`
|
||||||
|
- `git worktree add ../yaak-worktrees/bugfix-login`
|
||||||
|
- `git worktree add ../yaak-worktrees/refactor-api`
|
||||||
|
|
||||||
|
## What Happens Automatically
|
||||||
|
|
||||||
|
The post-checkout hook will automatically:
|
||||||
|
1. Create `.env.local` with unique ports (YAAK_DEV_PORT and YAAK_PLUGIN_MCP_SERVER_PORT)
|
||||||
|
2. Copy gitignored editor config folders (.zed, .idea, etc.)
|
||||||
|
3. Run `npm install && npm run bootstrap`
|
||||||
|
|
||||||
|
## Deleting Worktrees
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git worktree remove ../yaak-worktrees/<NAME>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Port Assignments
|
||||||
|
|
||||||
|
- Main worktree: 1420 (Vite), 64343 (MCP)
|
||||||
|
- First worktree: 1421, 64344
|
||||||
|
- Second worktree: 1422, 64345
|
||||||
|
- etc.
|
||||||
|
|
||||||
|
Each worktree can run `npm run app-dev` simultaneously without conflicts.
|
||||||
@@ -1,49 +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`.
|
|
||||||
- These are app release notes. Exclude CLI-only changes (commits prefixed with `cli:` or only touching `crates-cli/`) since the CLI has its own release process.
|
|
||||||
|
|
||||||
## Release Creation Prompt
|
|
||||||
|
|
||||||
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`.
|
|
||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
crates-tauri/yaak-app-client/vendored/**/* linguist-generated=true
|
crates-tauri/yaak-app/vendored/**/* linguist-generated=true
|
||||||
crates-tauri/yaak-app-client/gen/schemas/**/* linguist-generated=true
|
crates-tauri/yaak-app/gen/schemas/**/* linguist-generated=true
|
||||||
**/bindings/* linguist-generated=true
|
**/bindings/* linguist-generated=true
|
||||||
crates/yaak-templates/pkg/* linguist-generated=true
|
crates/yaak-templates/pkg/* linguist-generated=true
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
---
|
---
|
||||||
name: Bug report
|
name: Bug report
|
||||||
about: Create a report to help us improve
|
about: Create a report to help us improve
|
||||||
title: ""
|
title: ''
|
||||||
labels: ""
|
labels: ''
|
||||||
assignees: ""
|
assignees: ''
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Describe the bug**
|
**Describe the bug**
|
||||||
@@ -11,7 +12,6 @@ A clear and concise description of what the bug is.
|
|||||||
|
|
||||||
**To Reproduce**
|
**To Reproduce**
|
||||||
Steps to reproduce the behavior:
|
Steps to reproduce the behavior:
|
||||||
|
|
||||||
1. Go to '...'
|
1. Go to '...'
|
||||||
2. Click on '....'
|
2. Click on '....'
|
||||||
3. Scroll down to '....'
|
3. Scroll down to '....'
|
||||||
@@ -24,17 +24,15 @@ A clear and concise description of what you expected to happen.
|
|||||||
If applicable, add screenshots to help explain your problem.
|
If applicable, add screenshots to help explain your problem.
|
||||||
|
|
||||||
**Desktop (please complete the following information):**
|
**Desktop (please complete the following information):**
|
||||||
|
- OS: [e.g. iOS]
|
||||||
- OS: [e.g. iOS]
|
- Browser [e.g. chrome, safari]
|
||||||
- Browser [e.g. chrome, safari]
|
- Version [e.g. 22]
|
||||||
- Version [e.g. 22]
|
|
||||||
|
|
||||||
**Smartphone (please complete the following information):**
|
**Smartphone (please complete the following information):**
|
||||||
|
- Device: [e.g. iPhone6]
|
||||||
- Device: [e.g. iPhone6]
|
- OS: [e.g. iOS8.1]
|
||||||
- OS: [e.g. iOS8.1]
|
- Browser [e.g. stock browser, safari]
|
||||||
- Browser [e.g. stock browser, safari]
|
- Version [e.g. 22]
|
||||||
- Version [e.g. 22]
|
|
||||||
|
|
||||||
**Additional context**
|
**Additional context**
|
||||||
Add any other context about the problem here.
|
Add any other context about the problem here.
|
||||||
|
|||||||
@@ -1,19 +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. -->
|
|
||||||
@@ -14,20 +14,17 @@ jobs:
|
|||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: voidzero-dev/setup-vp@v1
|
- uses: actions/setup-node@v4
|
||||||
with:
|
|
||||||
node-version: "24"
|
|
||||||
cache: true
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
with:
|
with:
|
||||||
shared-key: ci
|
shared-key: ci
|
||||||
cache-on-failure: true
|
cache-on-failure: true
|
||||||
|
|
||||||
- run: vp install
|
- run: npm ci
|
||||||
- run: npm run bootstrap
|
- run: npm run bootstrap
|
||||||
- run: npm run lint
|
- run: npm run lint
|
||||||
- name: Run JS Tests
|
- name: Run JS Tests
|
||||||
run: vp test
|
run: npm test
|
||||||
- name: Run Rust Tests
|
- name: Run Rust Tests
|
||||||
run: cargo test --all
|
run: cargo test --all
|
||||||
|
|||||||
@@ -47,3 +47,4 @@ jobs:
|
|||||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||||
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -1,185 +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 Vite+
|
|
||||||
uses: voidzero-dev/setup-vp@v1
|
|
||||||
with:
|
|
||||||
node-version: "24"
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- 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: vp install
|
|
||||||
- run: npm run bootstrap
|
|
||||||
env:
|
|
||||||
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
|
|
||||||
- run: npm run lint
|
|
||||||
- name: Run JS Tests
|
|
||||||
run: vp test
|
|
||||||
- name: Run Rust Tests
|
|
||||||
run: cargo test --all --exclude yaak-cli
|
|
||||||
|
|
||||||
- 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
|
|
||||||
projectPath: ./crates-tauri/yaak-app-client
|
|
||||||
args: "${{ matrix.args }} --config ./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
|
|
||||||
Push-Location crates-tauri/yaak-app-client
|
|
||||||
npx tauri bundle ${{ matrix.args }} --bundles nsis --config ./tauri.release.conf.json --config '{"bundle":{"createUpdaterArtifacts":true,"windows":{"nsis":{"installMode":"perMachine"}}}}'
|
|
||||||
Pop-Location
|
|
||||||
$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
|
|
||||||
@@ -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
|
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
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:
|
||||||
|
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
|
||||||
|
|
||||||
|
- 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 ./crates-tauri/yaak-app/tauri.release.conf.json'
|
||||||
@@ -16,23 +16,23 @@ jobs:
|
|||||||
uses: JamesIves/github-sponsors-readme-action@v1
|
uses: JamesIves/github-sponsors-readme-action@v1
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.SPONSORS_PAT }}
|
token: ${{ secrets.SPONSORS_PAT }}
|
||||||
file: "README.md"
|
file: 'README.md'
|
||||||
maximum: 1999
|
maximum: 1999
|
||||||
template: '<a href="https://github.com/{{{ login }}}"><img src="{{{ avatarUrl }}}" width="50px" alt="User avatar: {{{ login }}}" /></a> '
|
template: '<a href="https://github.com/{{{ login }}}"><img src="{{{ avatarUrl }}}" width="50px" alt="User avatar: {{{ login }}}" /></a> '
|
||||||
active-only: false
|
active-only: false
|
||||||
include-private: true
|
include-private: true
|
||||||
marker: "sponsors-base"
|
marker: 'sponsors-base'
|
||||||
|
|
||||||
- name: Generate Sponsors
|
- name: Generate Sponsors
|
||||||
uses: JamesIves/github-sponsors-readme-action@v1
|
uses: JamesIves/github-sponsors-readme-action@v1
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.SPONSORS_PAT }}
|
token: ${{ secrets.SPONSORS_PAT }}
|
||||||
file: "README.md"
|
file: 'README.md'
|
||||||
minimum: 2000
|
minimum: 2000
|
||||||
template: '<a href="https://github.com/{{{ login }}}"><img src="{{{ avatarUrl }}}" width="80px" alt="User avatar: {{{ login }}}" /></a> '
|
template: '<a href="https://github.com/{{{ login }}}"><img src="{{{ avatarUrl }}}" width="80px" alt="User avatar: {{{ login }}}" /></a> '
|
||||||
active-only: false
|
active-only: false
|
||||||
include-private: true
|
include-private: true
|
||||||
marker: "sponsors-premium"
|
marker: 'sponsors-premium'
|
||||||
|
|
||||||
# ⚠️ Note: You can use any deployment step here to automatically push the README
|
# ⚠️ Note: You can use any deployment step here to automatically push the README
|
||||||
# changes back to your branch.
|
# changes back to your branch.
|
||||||
@@ -41,4 +41,4 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
branch: main
|
branch: main
|
||||||
force: false
|
force: false
|
||||||
folder: "."
|
folder: '.'
|
||||||
|
|||||||
+1
-15
@@ -39,22 +39,8 @@ codebook.toml
|
|||||||
target
|
target
|
||||||
|
|
||||||
# Per-worktree Tauri config (generated by post-checkout hook)
|
# Per-worktree Tauri config (generated by post-checkout hook)
|
||||||
crates-tauri/yaak-app-client/tauri.worktree.conf.json
|
crates-tauri/yaak-app/tauri.worktree.conf.json
|
||||||
crates-tauri/yaak-app-proxy/tauri.worktree.conf.json
|
|
||||||
|
|
||||||
# Tauri auto-generated permission files
|
# Tauri auto-generated permission files
|
||||||
**/permissions/autogenerated
|
**/permissions/autogenerated
|
||||||
**/permissions/schemas
|
**/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 @@
|
|||||||
24.14.0
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# vite-plugin-wasm has not yet declared Vite 8 in its peerDependencies
|
|
||||||
legacy-peer-deps=true
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
**/bindings/**
|
|
||||||
**/routeTree.gen.ts
|
|
||||||
crates/yaak-templates/pkg/**
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"printWidth": 100,
|
|
||||||
"ignorePatterns": [
|
|
||||||
"**/bindings/**",
|
|
||||||
"crates/yaak-templates/pkg/**",
|
|
||||||
"apps/yaak-client/routeTree.gen.ts"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
vp lint
|
|
||||||
vp staged
|
|
||||||
Vendored
+1
-5
@@ -1,7 +1,3 @@
|
|||||||
{
|
{
|
||||||
"recommendations": [
|
"recommendations": ["biomejs.biome", "rust-lang.rust-analyzer", "bradlc.vscode-tailwindcss"]
|
||||||
"rust-lang.rust-analyzer",
|
|
||||||
"bradlc.vscode-tailwindcss",
|
|
||||||
"VoidZero.vite-plus-extension-pack"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+3
-5
@@ -1,8 +1,6 @@
|
|||||||
{
|
{
|
||||||
"editor.defaultFormatter": "oxc.oxc-vscode",
|
"editor.defaultFormatter": "biomejs.biome",
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"editor.formatOnSaveMode": "file",
|
"biome.enabled": true,
|
||||||
"editor.codeActionsOnSave": {
|
"biome.lint.format.enable": true
|
||||||
"source.fixAll.oxc": "explicit"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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).
|
|
||||||
Generated
+751
-2958
File diff suppressed because it is too large
Load Diff
+26
-48
@@ -1,38 +1,28 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = [
|
members = [
|
||||||
"crates/yaak",
|
# Shared crates (no Tauri dependency)
|
||||||
# Common/foundation crates
|
"crates/yaak-core",
|
||||||
"crates/common/yaak-database",
|
"crates/yaak-common",
|
||||||
"crates/common/yaak-rpc",
|
"crates/yaak-crypto",
|
||||||
# Shared crates (no Tauri dependency)
|
"crates/yaak-git",
|
||||||
"crates/yaak-core",
|
"crates/yaak-grpc",
|
||||||
"crates/yaak-common",
|
"crates/yaak-http",
|
||||||
"crates/yaak-crypto",
|
"crates/yaak-models",
|
||||||
"crates/yaak-git",
|
"crates/yaak-plugins",
|
||||||
"crates/yaak-grpc",
|
"crates/yaak-sse",
|
||||||
"crates/yaak-http",
|
"crates/yaak-sync",
|
||||||
"crates/yaak-models",
|
"crates/yaak-templates",
|
||||||
"crates/yaak-plugins",
|
"crates/yaak-tls",
|
||||||
"crates/yaak-sse",
|
"crates/yaak-ws",
|
||||||
"crates/yaak-sync",
|
# CLI crates
|
||||||
"crates/yaak-templates",
|
"crates-cli/yaak-cli",
|
||||||
"crates/yaak-tls",
|
# Tauri-specific crates
|
||||||
"crates/yaak-ws",
|
"crates-tauri/yaak-app",
|
||||||
"crates/yaak-api",
|
"crates-tauri/yaak-fonts",
|
||||||
"crates/yaak-proxy",
|
"crates-tauri/yaak-license",
|
||||||
# Proxy-specific crates
|
"crates-tauri/yaak-mac-window",
|
||||||
"crates-proxy/yaak-proxy-lib",
|
"crates-tauri/yaak-tauri-utils",
|
||||||
# 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]
|
[workspace.dependencies]
|
||||||
@@ -43,25 +33,19 @@ log = "0.4.29"
|
|||||||
reqwest = "0.12.20"
|
reqwest = "0.12.20"
|
||||||
rustls = { version = "0.23.34", default-features = false }
|
rustls = { version = "0.23.34", default-features = false }
|
||||||
rustls-platform-verifier = "0.6.2"
|
rustls-platform-verifier = "0.6.2"
|
||||||
schemars = { version = "0.8.22", features = ["chrono"] }
|
|
||||||
serde = "1.0.228"
|
serde = "1.0.228"
|
||||||
serde_json = "1.0.145"
|
serde_json = "1.0.145"
|
||||||
sha2 = "0.10.9"
|
sha2 = "0.10.9"
|
||||||
tauri = "2.11.1"
|
tauri = "2.9.5"
|
||||||
tauri-plugin = "2.6.1"
|
tauri-plugin = "2.5.2"
|
||||||
tauri-plugin-dialog = "2.7.1"
|
tauri-plugin-dialog = "2.4.2"
|
||||||
tauri-plugin-shell = "2.3.5"
|
tauri-plugin-shell = "2.3.3"
|
||||||
thiserror = "2.0.17"
|
thiserror = "2.0.17"
|
||||||
tokio = "1.48.0"
|
tokio = "1.48.0"
|
||||||
ts-rs = "11.1.0"
|
ts-rs = "11.1.0"
|
||||||
|
|
||||||
# Internal crates - common/foundation
|
|
||||||
yaak-database = { path = "crates/common/yaak-database" }
|
|
||||||
yaak-rpc = { path = "crates/common/yaak-rpc" }
|
|
||||||
|
|
||||||
# Internal crates - shared
|
# Internal crates - shared
|
||||||
yaak-core = { path = "crates/yaak-core" }
|
yaak-core = { path = "crates/yaak-core" }
|
||||||
yaak = { path = "crates/yaak" }
|
|
||||||
yaak-common = { path = "crates/yaak-common" }
|
yaak-common = { path = "crates/yaak-common" }
|
||||||
yaak-crypto = { path = "crates/yaak-crypto" }
|
yaak-crypto = { path = "crates/yaak-crypto" }
|
||||||
yaak-git = { path = "crates/yaak-git" }
|
yaak-git = { path = "crates/yaak-git" }
|
||||||
@@ -74,18 +58,12 @@ yaak-sync = { path = "crates/yaak-sync" }
|
|||||||
yaak-templates = { path = "crates/yaak-templates" }
|
yaak-templates = { path = "crates/yaak-templates" }
|
||||||
yaak-tls = { path = "crates/yaak-tls" }
|
yaak-tls = { path = "crates/yaak-tls" }
|
||||||
yaak-ws = { path = "crates/yaak-ws" }
|
yaak-ws = { path = "crates/yaak-ws" }
|
||||||
yaak-api = { path = "crates/yaak-api" }
|
|
||||||
yaak-proxy = { path = "crates/yaak-proxy" }
|
|
||||||
|
|
||||||
# Internal crates - proxy
|
|
||||||
yaak-proxy-lib = { path = "crates-proxy/yaak-proxy-lib" }
|
|
||||||
|
|
||||||
# Internal crates - Tauri-specific
|
# Internal crates - Tauri-specific
|
||||||
yaak-fonts = { path = "crates-tauri/yaak-fonts" }
|
yaak-fonts = { path = "crates-tauri/yaak-fonts" }
|
||||||
yaak-license = { path = "crates-tauri/yaak-license" }
|
yaak-license = { path = "crates-tauri/yaak-license" }
|
||||||
yaak-mac-window = { path = "crates-tauri/yaak-mac-window" }
|
yaak-mac-window = { path = "crates-tauri/yaak-mac-window" }
|
||||||
yaak-tauri-utils = { path = "crates-tauri/yaak-tauri-utils" }
|
yaak-tauri-utils = { path = "crates-tauri/yaak-tauri-utils" }
|
||||||
yaak-window = { path = "crates-tauri/yaak-window" }
|
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
strip = false
|
strip = false
|
||||||
|
|||||||
+11
-9
@@ -11,16 +11,14 @@ begin.
|
|||||||
|
|
||||||
Make sure you have the following tools installed:
|
Make sure you have the following tools installed:
|
||||||
|
|
||||||
- [Node.js](https://nodejs.org/en/download/package-manager) (v24+)
|
- [Node.js](https://nodejs.org/en/download/package-manager)
|
||||||
- [Rust](https://www.rust-lang.org/tools/install)
|
- [Rust](https://www.rust-lang.org/tools/install)
|
||||||
- [Vite+](https://vite.dev/guide/vite-plus) (`vp` CLI)
|
|
||||||
|
|
||||||
Check the installations with the following commands:
|
Check the installations with the following commands:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
node -v
|
node -v
|
||||||
npm -v
|
npm -v
|
||||||
vp --version
|
|
||||||
rustc --version
|
rustc --version
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -63,9 +61,9 @@ _Note: For safety, development builds use a separate database location from prod
|
|||||||
lezer-generator components/core/Editor/<LANG>/<LANG>.grammar > components/core/Editor/<LANG>/<LANG>.ts
|
lezer-generator components/core/Editor/<LANG>/<LANG>.grammar > components/core/Editor/<LANG>/<LANG>.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
## Linting and Formatting
|
## Linting & Formatting
|
||||||
|
|
||||||
This repo uses [Vite+](https://vite.dev/guide/vite-plus) for linting (oxlint) and formatting (oxfmt).
|
This repo uses Biome for linting and formatting (replacing ESLint + Prettier).
|
||||||
|
|
||||||
- Lint the entire repo:
|
- Lint the entire repo:
|
||||||
|
|
||||||
@@ -73,6 +71,12 @@ This repo uses [Vite+](https://vite.dev/guide/vite-plus) for linting (oxlint) an
|
|||||||
npm run lint
|
npm run lint
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- Auto-fix lint issues where possible:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run lint:fix
|
||||||
|
```
|
||||||
|
|
||||||
- Format code:
|
- Format code:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -80,7 +84,5 @@ npm run format
|
|||||||
```
|
```
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
- Many workspace packages also expose the same scripts (`lint`, `lint:fix`, and `format`).
|
||||||
- A pre-commit hook runs `vp lint` automatically on commit.
|
- TypeScript type-checking still runs separately via `tsc --noEmit` in relevant packages.
|
||||||
- Some workspace packages also run `tsc --noEmit` for type-checking.
|
|
||||||
- VS Code users should install the recommended extensions for format-on-save support.
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/JamesIves/github-sponsors-readme-action">
|
<a href="https://github.com/JamesIves/github-sponsors-readme-action">
|
||||||
<img width="200px" src="https://github.com/mountain-loop/yaak/raw/main/crates-tauri/yaak-app-client/icons/icon.png">
|
<img width="200px" src="https://github.com/mountain-loop/yaak/raw/main/src-tauri/icons/icon.png">
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -16,20 +16,24 @@
|
|||||||
</p>
|
</p>
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<p align="center">
|
<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/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 -->
|
||||||
</p>
|
</p>
|
||||||
<p align="center">
|
<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>
|
</p>
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
Yaak is an offline-first API client designed to stay out of your way while giving you everything you need when you need it.
|
Yaak is an offline-first API client designed to stay out of your way while giving you everything you need when you need it.
|
||||||
Built with [Tauri](https://tauri.app), Rust, and React, it’s fast, lightweight, and private. No telemetry, no VC funding, and no cloud lock-in.
|
Built with [Tauri](https://tauri.app), Rust, and React, it’s fast, lightweight, and private. No telemetry, no VC funding, and no cloud lock-in.
|
||||||
|
|
||||||
|
|
||||||
### 🌐 Work with any API
|
### 🌐 Work with any API
|
||||||
|
|
||||||
- Import collections from Postman, Insomnia, OpenAPI, Swagger, or Curl.
|
- Import collections from Postman, Insomnia, OpenAPI, Swagger, or Curl.
|
||||||
@@ -37,34 +41,30 @@ Built with [Tauri](https://tauri.app), Rust, and React, it’s fast, lightweight
|
|||||||
- Filter and inspect responses with JSONPath or XPath.
|
- Filter and inspect responses with JSONPath or XPath.
|
||||||
|
|
||||||
### 🔐 Stay secure
|
### 🔐 Stay secure
|
||||||
|
|
||||||
- Use OAuth 2.0, JWT, Basic Auth, or custom plugins for authentication.
|
- Use OAuth 2.0, JWT, Basic Auth, or custom plugins for authentication.
|
||||||
- Secure sensitive values with encrypted secrets.
|
- Secure sensitive values with encrypted secrets.
|
||||||
- Store secrets in your OS keychain.
|
- Store secrets in your OS keychain.
|
||||||
|
|
||||||
### ☁️ Organize & collaborate
|
### ☁️ Organize & collaborate
|
||||||
|
|
||||||
- Group requests into workspaces and nested folders.
|
- Group requests into workspaces and nested folders.
|
||||||
- Use environment variables to switch between dev, staging, and prod.
|
- Use environment variables to switch between dev, staging, and prod.
|
||||||
- Mirror workspaces to your filesystem for versioning in Git or syncing with Dropbox.
|
- Mirror workspaces to your filesystem for versioning in Git or syncing with Dropbox.
|
||||||
|
|
||||||
### 🧩 Extend & customize
|
### 🧩 Extend & customize
|
||||||
|
|
||||||
- Insert dynamic values like UUIDs or timestamps with template tags.
|
- Insert dynamic values like UUIDs or timestamps with template tags.
|
||||||
- Pick from built-in themes or build your own.
|
- Pick from built-in themes or build your own.
|
||||||
- Create plugins to extend authentication, template tags, or the UI.
|
- Create plugins to extend authentication, template tags, or the UI.
|
||||||
|
|
||||||
|
|
||||||
## Contribution Policy
|
## Contribution Policy
|
||||||
|
|
||||||
> [!IMPORTANT]
|
Yaak is open source but only accepting contributions for bug fixes. To get started,
|
||||||
> Community PRs are currently limited to bug fixes and small-scope improvements.
|
visit [`DEVELOPMENT.md`](DEVELOPMENT.md) for tips on setting up your environment.
|
||||||
> 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.
|
|
||||||
|
|
||||||
## Useful Resources
|
## Useful Resources
|
||||||
|
|
||||||
- [Feedback and Bug Reports](https://feedback.yaak.app)
|
- [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 Postman](https://yaak.app/alternatives/postman)
|
||||||
- [Yaak vs Bruno](https://yaak.app/alternatives/bruno)
|
- [Yaak vs Bruno](https://yaak.app/alternatives/bruno)
|
||||||
- [Yaak vs Insomnia](https://yaak.app/alternatives/insomnia)
|
- [Yaak vs Insomnia](https://yaak.app/alternatives/insomnia)
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import type { GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-internal/models";
|
|
||||||
|
|
||||||
import { MoveToWorkspaceDialog } from "../components/MoveToWorkspaceDialog";
|
|
||||||
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
|
||||||
import { createFastMutation } from "../hooks/useFastMutation";
|
|
||||||
import { pluralizeCount } from "../lib/pluralize";
|
|
||||||
import { showDialog } from "../lib/dialog";
|
|
||||||
import { jotaiStore } from "../lib/jotai";
|
|
||||||
|
|
||||||
export const moveToWorkspace = createFastMutation({
|
|
||||||
mutationKey: ["move_workspace"],
|
|
||||||
mutationFn: async (requests: (HttpRequest | GrpcRequest | WebsocketRequest)[]) => {
|
|
||||||
const activeWorkspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
|
||||||
if (activeWorkspaceId == null) return;
|
|
||||||
if (requests.length === 0) return;
|
|
||||||
|
|
||||||
const title =
|
|
||||||
requests.length === 1 ? "Move Request" : `Move ${pluralizeCount("Request", requests.length)}`;
|
|
||||||
|
|
||||||
showDialog({
|
|
||||||
id: "change-workspace",
|
|
||||||
title,
|
|
||||||
size: "sm",
|
|
||||||
render: ({ hide }) => (
|
|
||||||
<MoveToWorkspaceDialog
|
|
||||||
onDone={hide}
|
|
||||||
requests={requests}
|
|
||||||
activeWorkspaceId={activeWorkspaceId}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { getModel } from "@yaakapp-internal/models";
|
|
||||||
import type { FolderSettingsTab } from "../components/FolderSettingsDialog";
|
|
||||||
import { FolderSettingsDialog } from "../components/FolderSettingsDialog";
|
|
||||||
import { showDialog } from "../lib/dialog";
|
|
||||||
|
|
||||||
export function openFolderSettings(folderId: string, tab?: FolderSettingsTab) {
|
|
||||||
const folder = getModel("folder", folderId);
|
|
||||||
if (folder == null) return;
|
|
||||||
showDialog({
|
|
||||||
id: "folder-settings",
|
|
||||||
title: null,
|
|
||||||
size: "lg",
|
|
||||||
className: "h-[50rem]",
|
|
||||||
noPadding: true,
|
|
||||||
render: () => <FolderSettingsDialog folderId={folderId} tab={tab} />,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { applySync, calculateSyncFsOnly } from "@yaakapp-internal/sync";
|
|
||||||
import { createFastMutation } from "../hooks/useFastMutation";
|
|
||||||
import { showSimpleAlert } from "../lib/alert";
|
|
||||||
import { router } from "../lib/router";
|
|
||||||
|
|
||||||
export const openWorkspaceFromSyncDir = createFastMutation<void, void, string>({
|
|
||||||
mutationKey: [],
|
|
||||||
mutationFn: async (dir) => {
|
|
||||||
const ops = await calculateSyncFsOnly(dir);
|
|
||||||
|
|
||||||
const workspace = ops
|
|
||||||
.map((o) => (o.type === "dbCreate" && o.fs.model.type === "workspace" ? o.fs.model : null))
|
|
||||||
.filter((m) => m)[0];
|
|
||||||
|
|
||||||
if (workspace == null) {
|
|
||||||
showSimpleAlert("Failed to Open", "No workspace found in directory");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await applySync(workspace.id, dir, ops);
|
|
||||||
|
|
||||||
await router.navigate({
|
|
||||||
to: "/workspaces/$workspaceId",
|
|
||||||
params: { workspaceId: workspace.id },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import type { WorkspaceSettingsTab } from "../components/WorkspaceSettingsDialog";
|
|
||||||
import { WorkspaceSettingsDialog } from "../components/WorkspaceSettingsDialog";
|
|
||||||
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
|
||||||
import { jotaiStore } from "../lib/jotai";
|
|
||||||
|
|
||||||
export function openWorkspaceSettings(tab?: WorkspaceSettingsTab) {
|
|
||||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
|
||||||
if (workspaceId == null) return;
|
|
||||||
WorkspaceSettingsDialog.show(workspaceId, tab);
|
|
||||||
}
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
import { open } from "@tauri-apps/plugin-dialog";
|
|
||||||
import { gitClone } from "@yaakapp-internal/git";
|
|
||||||
import { Banner, VStack } from "@yaakapp-internal/ui";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
|
|
||||||
import { appInfo } from "../lib/appInfo";
|
|
||||||
import { showErrorToast } from "../lib/toast";
|
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import { Checkbox } from "./core/Checkbox";
|
|
||||||
import { IconButton } from "./core/IconButton";
|
|
||||||
import { PlainInput } from "./core/PlainInput";
|
|
||||||
import { promptCredentials } from "./git/credentials";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
hide: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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,659 +0,0 @@
|
|||||||
import type { Cookie } from "@yaakapp-internal/models";
|
|
||||||
import { cookieJarsAtom, patchModel } from "@yaakapp-internal/models";
|
|
||||||
import { formatDate } from "date-fns/format";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { type ComponentProps, useMemo, useState } from "react";
|
|
||||||
import { showDialog } from "../lib/dialog";
|
|
||||||
import { jotaiStore } from "../lib/jotai";
|
|
||||||
import { cookieDomain } from "../lib/model_util";
|
|
||||||
import {
|
|
||||||
Icon,
|
|
||||||
SplitLayout,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeaderCell,
|
|
||||||
TableRow,
|
|
||||||
TruncatedWideTableCell,
|
|
||||||
} from "@yaakapp-internal/ui";
|
|
||||||
import { IconButton } from "./core/IconButton";
|
|
||||||
import { Checkbox } from "./core/Checkbox";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { EventDetailHeader } from "./core/EventViewer";
|
|
||||||
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
|
|
||||||
import { EmptyStateText } from "./EmptyStateText";
|
|
||||||
import { PlainInput } from "./core/PlainInput";
|
|
||||||
import { Select } from "./core/Select";
|
|
||||||
import { showAlert } from "../lib/alert";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
cookieJarId: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CookieDialog = ({ cookieJarId }: Props) => {
|
|
||||||
const cookieJars = useAtomValue(cookieJarsAtom);
|
|
||||||
const cookieJar = cookieJars?.find((c) => c.id === cookieJarId);
|
|
||||||
const [filter, setFilter] = useState("");
|
|
||||||
const [filterUpdateKey, setFilterUpdateKey] = useState(0);
|
|
||||||
const [selectedCookieKey, setSelectedCookieKey] = useState<string | null>(null);
|
|
||||||
const [editingCookieKey, setEditingCookieKey] = useState<string | null>(null);
|
|
||||||
const [draftCookie, setDraftCookie] = useState<Cookie | null>(null);
|
|
||||||
const [draftExpiresInput, setDraftExpiresInput] = useState("");
|
|
||||||
const filteredCookies = useMemo(() => {
|
|
||||||
return cookieJar?.cookies.filter((cookie) => cookieMatchesFilter(cookie, filter)) ?? [];
|
|
||||||
}, [cookieJar?.cookies, filter]);
|
|
||||||
const selectedCookie = useMemo(
|
|
||||||
() =>
|
|
||||||
selectedCookieKey == null
|
|
||||||
? null
|
|
||||||
: (filteredCookies.find((cookie) => cookieKey(cookie) === selectedCookieKey) ?? null),
|
|
||||||
[filteredCookies, selectedCookieKey],
|
|
||||||
);
|
|
||||||
const detailCookie = draftCookie ?? selectedCookie;
|
|
||||||
const isCreatingCookie = editingCookieKey === NEW_COOKIE_KEY;
|
|
||||||
const isEditingCookie = draftCookie != null;
|
|
||||||
|
|
||||||
const handleAddCookie = () => {
|
|
||||||
setSelectedCookieKey(null);
|
|
||||||
setEditingCookieKey(NEW_COOKIE_KEY);
|
|
||||||
setDraftCookie(newCookieDraft());
|
|
||||||
setDraftExpiresInput("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditCookie = () => {
|
|
||||||
if (selectedCookie == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setEditingCookieKey(cookieKey(selectedCookie));
|
|
||||||
setDraftCookie(selectedCookie);
|
|
||||||
setDraftExpiresInput(cookieExpiresInputValue(selectedCookie));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancelEdit = () => {
|
|
||||||
if (isCreatingCookie) {
|
|
||||||
setSelectedCookieKey(null);
|
|
||||||
}
|
|
||||||
setEditingCookieKey(null);
|
|
||||||
setDraftCookie(null);
|
|
||||||
setDraftExpiresInput("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseDetails = () => {
|
|
||||||
if (isEditingCookie) {
|
|
||||||
handleCancelEdit();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelectedCookieKey(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveCookie = () => {
|
|
||||||
if (cookieJar == null || draftCookie == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let nextCookie = normalizeCookie(draftCookie);
|
|
||||||
if (nextCookie.name.trim().length === 0) {
|
|
||||||
showAlert({
|
|
||||||
id: "invalid-cookie-name",
|
|
||||||
title: "Invalid Cookie",
|
|
||||||
body: "Cookie name is required.",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nextCookie.expires !== "SessionEnd") {
|
|
||||||
const expires = cookieExpiresFromInput(draftExpiresInput);
|
|
||||||
if (expires == null) {
|
|
||||||
showAlert({
|
|
||||||
id: "invalid-cookie-expires",
|
|
||||||
title: "Invalid Cookie",
|
|
||||||
body: "Cookie expiration must be a valid date.",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
nextCookie = { ...nextCookie, expires };
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextCookieKey = cookieKey(nextCookie);
|
|
||||||
const nextCookies = cookieJar.cookies.filter((cookie) => {
|
|
||||||
const key = cookieKey(cookie);
|
|
||||||
if (editingCookieKey != null && key === editingCookieKey) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return key !== nextCookieKey;
|
|
||||||
});
|
|
||||||
|
|
||||||
patchModel(cookieJar, { cookies: [...nextCookies, nextCookie] });
|
|
||||||
setSelectedCookieKey(nextCookieKey);
|
|
||||||
setEditingCookieKey(null);
|
|
||||||
setDraftCookie(null);
|
|
||||||
setDraftExpiresInput("");
|
|
||||||
};
|
|
||||||
|
|
||||||
if (cookieJar == null) {
|
|
||||||
return <div>No cookie jar selected</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="pb-2 grid grid-rows-[auto_minmax(0,1fr)] space-y-2">
|
|
||||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
|
|
||||||
<PlainInput
|
|
||||||
name="cookie-filter"
|
|
||||||
label="Filter cookies"
|
|
||||||
hideLabel
|
|
||||||
placeholder="Filter cookies"
|
|
||||||
defaultValue={filter}
|
|
||||||
forceUpdateKey={filterUpdateKey}
|
|
||||||
onChange={setFilter}
|
|
||||||
rightSlot={
|
|
||||||
filter.length > 0 && (
|
|
||||||
<IconButton
|
|
||||||
className="!bg-transparent !h-auto min-h-full opacity-50 hover:opacity-100 -mr-1"
|
|
||||||
icon="x"
|
|
||||||
title="Clear filter"
|
|
||||||
onClick={() => {
|
|
||||||
setFilter("");
|
|
||||||
setFilterUpdateKey((key) => key + 1);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<IconButton icon="plus" size="sm" title="Add cookie" onClick={handleAddCookie} />
|
|
||||||
</div>
|
|
||||||
{cookieJar.cookies.length === 0 && detailCookie == null ? (
|
|
||||||
<EmptyStateText>
|
|
||||||
Cookies will appear when a response includes a Set-Cookie header.
|
|
||||||
</EmptyStateText>
|
|
||||||
) : filteredCookies.length === 0 && detailCookie == null ? (
|
|
||||||
<EmptyStateText>No cookies match the current filter.</EmptyStateText>
|
|
||||||
) : (
|
|
||||||
<SplitLayout
|
|
||||||
layout="vertical"
|
|
||||||
storageKey="cookie-dialog-details"
|
|
||||||
defaultRatio={0.65}
|
|
||||||
className="-mx-2"
|
|
||||||
minHeightPx={10}
|
|
||||||
firstSlot={({ style }) =>
|
|
||||||
filteredCookies.length === 0 ? (
|
|
||||||
<div style={style}>
|
|
||||||
<EmptyStateText>No cookies match the current filter.</EmptyStateText>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Table scrollable style={style} className="pr-0.5">
|
|
||||||
<TableHead>
|
|
||||||
<TableRow>
|
|
||||||
<TableHeaderCell>Name</TableHeaderCell>
|
|
||||||
<TableHeaderCell>Value</TableHeaderCell>
|
|
||||||
<TableHeaderCell>Domain</TableHeaderCell>
|
|
||||||
<TableHeaderCell>Path</TableHeaderCell>
|
|
||||||
<TableHeaderCell>Expires</TableHeaderCell>
|
|
||||||
<TableHeaderCell>Size</TableHeaderCell>
|
|
||||||
<TableHeaderCell>HTTP Only</TableHeaderCell>
|
|
||||||
<TableHeaderCell>Secure</TableHeaderCell>
|
|
||||||
<TableHeaderCell>Same Site</TableHeaderCell>
|
|
||||||
<TableHeaderCell>
|
|
||||||
<IconButton
|
|
||||||
icon="list_x"
|
|
||||||
size="sm"
|
|
||||||
className="text-text-subtle"
|
|
||||||
title="Clear all cookies"
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedCookieKey(null);
|
|
||||||
setEditingCookieKey(null);
|
|
||||||
setDraftCookie(null);
|
|
||||||
setDraftExpiresInput("");
|
|
||||||
patchModel(cookieJar, { cookies: [] });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</TableHeaderCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<TableBody className="[&_td]:select-auto [&_td]:cursor-auto">
|
|
||||||
{filteredCookies.map((c: Cookie) => {
|
|
||||||
const key = cookieKey(c);
|
|
||||||
const isSelected = key === selectedCookieKey;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TableRow
|
|
||||||
key={key}
|
|
||||||
className={classNames(
|
|
||||||
"group/tr cursor-default",
|
|
||||||
isSelected && "[&_td]:bg-surface-highlight",
|
|
||||||
!isSelected && "hover:[&_td]:bg-surface-hover",
|
|
||||||
)}
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedCookieKey(key);
|
|
||||||
setEditingCookieKey(null);
|
|
||||||
setDraftCookie(null);
|
|
||||||
setDraftExpiresInput("");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TableCell className={classNames("pl-2", isSelected && "rounded-l")}>
|
|
||||||
{c.name}
|
|
||||||
</TableCell>
|
|
||||||
<TruncatedWideTableCell className="min-w-[10rem]">
|
|
||||||
{c.value}
|
|
||||||
</TruncatedWideTableCell>
|
|
||||||
<TableCell>{cookieDomain(c)}</TableCell>
|
|
||||||
<TableCell>{c.path}</TableCell>
|
|
||||||
<TableCell>{cookieExpires(c)}</TableCell>
|
|
||||||
<TableCell>{cookieSize(c)}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Icon
|
|
||||||
icon={c.httpOnly ? "check" : "x"}
|
|
||||||
className={classNames(!c.httpOnly && "opacity-10")}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Icon
|
|
||||||
icon={c.secure ? "check" : "x"}
|
|
||||||
className={classNames(!c.secure && "opacity-10")}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{c.sameSite}</TableCell>
|
|
||||||
<TableCell className="rounded-r pr-2">
|
|
||||||
<IconButton
|
|
||||||
icon="trash"
|
|
||||||
size="xs"
|
|
||||||
iconSize="sm"
|
|
||||||
title="Delete"
|
|
||||||
className="text-text-subtlest ml-auto group-hover/tr:text-text transition-colors"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
if (isSelected) {
|
|
||||||
setSelectedCookieKey(null);
|
|
||||||
}
|
|
||||||
if (editingCookieKey === key) {
|
|
||||||
setEditingCookieKey(null);
|
|
||||||
setDraftCookie(null);
|
|
||||||
setDraftExpiresInput("");
|
|
||||||
}
|
|
||||||
patchModel(cookieJar, {
|
|
||||||
cookies: cookieJar.cookies.filter(
|
|
||||||
(c2: Cookie) => cookieKey(c2) !== key,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
secondSlot={
|
|
||||||
detailCookie == null
|
|
||||||
? null
|
|
||||||
: ({ style }) => (
|
|
||||||
<div
|
|
||||||
style={style}
|
|
||||||
className="grid grid-rows-[auto_minmax(0,1fr)] bg-surface border-t border-border pt-2"
|
|
||||||
>
|
|
||||||
<EventDetailHeader
|
|
||||||
title={isCreatingCookie ? "New Cookie" : detailCookie.name || "Cookie"}
|
|
||||||
copyText={isEditingCookie ? undefined : detailCookie.value}
|
|
||||||
actions={
|
|
||||||
isEditingCookie
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
key: "save",
|
|
||||||
label: isCreatingCookie ? "Create" : "Save",
|
|
||||||
onClick: handleSaveCookie,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "cancel",
|
|
||||||
label: "Cancel",
|
|
||||||
onClick: handleCancelEdit,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
key: "edit",
|
|
||||||
label: "Edit",
|
|
||||||
onClick: handleEditCookie,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
onClose={handleCloseDetails}
|
|
||||||
/>
|
|
||||||
{isEditingCookie ? (
|
|
||||||
<CookieEditor
|
|
||||||
cookie={detailCookie}
|
|
||||||
expiresInputValue={draftExpiresInput}
|
|
||||||
onChange={setDraftCookie}
|
|
||||||
onExpiresInputChange={setDraftExpiresInput}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<CookieDetails cookie={detailCookie} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
CookieDialog.show = (cookieJarId: string | null) => {
|
|
||||||
const cookieJar = jotaiStore.get(cookieJarsAtom)?.find((jar) => jar.id === cookieJarId);
|
|
||||||
if (cookieJar == null) {
|
|
||||||
showAlert({
|
|
||||||
id: "invalid-jar",
|
|
||||||
body: `Failed to find cookie jar for ID: ${cookieJarId}`,
|
|
||||||
title: "Invalid Cookie Jar",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
showDialog({
|
|
||||||
id: "cookies",
|
|
||||||
title: `${cookieJar.name} Cookies`,
|
|
||||||
size: "full",
|
|
||||||
render: () => <CookieDialog cookieJarId={cookieJarId} />,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
function CookieDetails({ cookie }: { cookie: Cookie }) {
|
|
||||||
return (
|
|
||||||
<div className="overflow-y-auto">
|
|
||||||
<KeyValueRows selectable>
|
|
||||||
<CookieKeyValueRow label="Name">{cookie.name}</CookieKeyValueRow>
|
|
||||||
<CookieKeyValueRow label="Value" enableCopy copyText={cookie.value}>
|
|
||||||
<pre className="whitespace-pre-wrap break-all">{cookie.value}</pre>
|
|
||||||
</CookieKeyValueRow>
|
|
||||||
<CookieKeyValueRow label="Domain">{cookieDomain(cookie)}</CookieKeyValueRow>
|
|
||||||
<CookieKeyValueRow label="Path">{cookie.path}</CookieKeyValueRow>
|
|
||||||
<CookieKeyValueRow label="Expires">{cookieExpires(cookie)}</CookieKeyValueRow>
|
|
||||||
<CookieKeyValueRow label="Size">{cookieSize(cookie)}</CookieKeyValueRow>
|
|
||||||
<CookieKeyValueRow label="HTTP Only">{cookie.httpOnly ? "Yes" : "No"}</CookieKeyValueRow>
|
|
||||||
<CookieKeyValueRow label="Secure">{cookie.secure ? "Yes" : "No"}</CookieKeyValueRow>
|
|
||||||
{cookie.sameSite && (
|
|
||||||
<CookieKeyValueRow label="Same Site">{cookie.sameSite}</CookieKeyValueRow>
|
|
||||||
)}
|
|
||||||
</KeyValueRows>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CookieEditor({
|
|
||||||
cookie,
|
|
||||||
expiresInputValue,
|
|
||||||
onChange,
|
|
||||||
onExpiresInputChange,
|
|
||||||
}: {
|
|
||||||
cookie: Cookie;
|
|
||||||
expiresInputValue: string;
|
|
||||||
onChange: (cookie: Cookie) => void;
|
|
||||||
onExpiresInputChange: (value: string) => void;
|
|
||||||
}) {
|
|
||||||
const sessionCookie = cookie.expires === "SessionEnd";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="overflow-y-auto">
|
|
||||||
<KeyValueRows>
|
|
||||||
<CookieKeyValueRow align="middle" label="Name">
|
|
||||||
<CookieTextInput
|
|
||||||
required
|
|
||||||
autoFocus
|
|
||||||
value={cookie.name}
|
|
||||||
onChange={(name) => onChange({ ...cookie, name })}
|
|
||||||
/>
|
|
||||||
</CookieKeyValueRow>
|
|
||||||
<CookieKeyValueRow label="Value">
|
|
||||||
<CookieTextarea
|
|
||||||
value={cookie.value}
|
|
||||||
onChange={(value) => onChange({ ...cookie, value })}
|
|
||||||
/>
|
|
||||||
</CookieKeyValueRow>
|
|
||||||
<CookieKeyValueRow align="middle" label="Domain">
|
|
||||||
<CookieTextInput
|
|
||||||
value={cookieDomainInputValue(cookie)}
|
|
||||||
placeholder="n/a"
|
|
||||||
onChange={(domain) => onChange(cookieWithDomain(cookie, domain))}
|
|
||||||
/>
|
|
||||||
</CookieKeyValueRow>
|
|
||||||
<CookieKeyValueRow align="middle" label="Path">
|
|
||||||
<CookieTextInput
|
|
||||||
value={cookie.path}
|
|
||||||
placeholder="/"
|
|
||||||
onChange={(path) => onChange({ ...cookie, path })}
|
|
||||||
/>
|
|
||||||
</CookieKeyValueRow>
|
|
||||||
<CookieKeyValueRow label="Expires">
|
|
||||||
<div className="grid gap-1">
|
|
||||||
<Checkbox
|
|
||||||
checked={sessionCookie}
|
|
||||||
title="Session cookie"
|
|
||||||
onChange={(checked) => {
|
|
||||||
if (checked) {
|
|
||||||
onChange({ ...cookie, expires: "SessionEnd" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const expiresInput =
|
|
||||||
cookieExpiresFromInput(expiresInputValue) == null
|
|
||||||
? defaultCookieExpiresInputValue()
|
|
||||||
: expiresInputValue;
|
|
||||||
|
|
||||||
onExpiresInputChange(expiresInput);
|
|
||||||
onChange({
|
|
||||||
...cookie,
|
|
||||||
expires: cookieExpiresFromInput(expiresInput)!,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<CookieTextInput
|
|
||||||
value={sessionCookie ? "" : expiresInputValue}
|
|
||||||
disabled={sessionCookie}
|
|
||||||
onChange={(value) => {
|
|
||||||
onExpiresInputChange(value);
|
|
||||||
|
|
||||||
const expires = cookieExpiresFromInput(value);
|
|
||||||
if (expires != null) {
|
|
||||||
onChange({ ...cookie, expires });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CookieKeyValueRow>
|
|
||||||
<CookieKeyValueRow label="Size">{cookieSize(cookie)}</CookieKeyValueRow>
|
|
||||||
<CookieKeyValueRow align="middle" label="HTTP Only">
|
|
||||||
<Checkbox
|
|
||||||
hideLabel
|
|
||||||
title="HTTP Only"
|
|
||||||
checked={cookie.httpOnly}
|
|
||||||
onChange={(httpOnly) => onChange({ ...cookie, httpOnly })}
|
|
||||||
/>
|
|
||||||
</CookieKeyValueRow>
|
|
||||||
<CookieKeyValueRow align="middle" label="Secure">
|
|
||||||
<Checkbox
|
|
||||||
hideLabel
|
|
||||||
title="Secure"
|
|
||||||
checked={cookie.secure}
|
|
||||||
onChange={(secure) => onChange({ ...cookie, secure })}
|
|
||||||
/>
|
|
||||||
</CookieKeyValueRow>
|
|
||||||
<CookieKeyValueRow align="middle" label="Same Site">
|
|
||||||
<Select
|
|
||||||
hideLabel
|
|
||||||
name="cookie-same-site"
|
|
||||||
label="Same Site"
|
|
||||||
value={cookie.sameSite ?? ""}
|
|
||||||
size="xs"
|
|
||||||
className="w-full"
|
|
||||||
options={[
|
|
||||||
{ label: "n/a", value: "" },
|
|
||||||
{ label: "Lax", value: "Lax" },
|
|
||||||
{ label: "Strict", value: "Strict" },
|
|
||||||
{ label: "None", value: "None" },
|
|
||||||
]}
|
|
||||||
onChange={(sameSite) =>
|
|
||||||
onChange({
|
|
||||||
...cookie,
|
|
||||||
sameSite: sameSite === "" ? null : (sameSite as Cookie["sameSite"]),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</CookieKeyValueRow>
|
|
||||||
</KeyValueRows>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CookieKeyValueRow({ labelClassName, ...props }: ComponentProps<typeof KeyValueRow>) {
|
|
||||||
return <KeyValueRow labelClassName={classNames("w-[7rem]", labelClassName)} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function CookieTextInput({
|
|
||||||
autoFocus,
|
|
||||||
disabled,
|
|
||||||
onChange,
|
|
||||||
placeholder,
|
|
||||||
required,
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
autoFocus?: boolean;
|
|
||||||
disabled?: boolean;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
placeholder?: string;
|
|
||||||
required?: boolean;
|
|
||||||
value: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
autoFocus={autoFocus}
|
|
||||||
className={cookieInputClassName}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(event) => onChange(event.target.value)}
|
|
||||||
placeholder={placeholder}
|
|
||||||
required={required}
|
|
||||||
type="text"
|
|
||||||
value={value}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CookieTextarea({ onChange, value }: { onChange: (value: string) => void; value: string }) {
|
|
||||||
return (
|
|
||||||
<textarea
|
|
||||||
className={classNames(cookieInputClassName, "min-h-[5rem] resize-y")}
|
|
||||||
onChange={(event) => onChange(event.target.value)}
|
|
||||||
value={value}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const NEW_COOKIE_KEY = "__new-cookie__";
|
|
||||||
const cookieInputClassName = classNames(
|
|
||||||
"w-full min-w-0 rounded bg-transparent px-1 py-0.5",
|
|
||||||
"border border-transparent outline-none",
|
|
||||||
"hover:border-border-subtle focus:border-border-focus",
|
|
||||||
"disabled:opacity-disabled disabled:border-dotted",
|
|
||||||
);
|
|
||||||
|
|
||||||
function cookieSize(cookie: Cookie) {
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
return encoder.encode(cookie.name).length + encoder.encode(cookie.value).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
function newCookieDraft(): Cookie {
|
|
||||||
return {
|
|
||||||
name: "",
|
|
||||||
value: "",
|
|
||||||
domain: "NotPresent",
|
|
||||||
expires: "SessionEnd",
|
|
||||||
path: "/",
|
|
||||||
secure: false,
|
|
||||||
httpOnly: false,
|
|
||||||
sameSite: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeCookie(cookie: Cookie): Cookie {
|
|
||||||
return {
|
|
||||||
...cookie,
|
|
||||||
name: cookie.name.trim(),
|
|
||||||
path: cookie.path.trim() || "/",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function cookieDomainInputValue(cookie: Cookie) {
|
|
||||||
const domain = cookieDomain(cookie);
|
|
||||||
return domain === "n/a" ? "" : domain;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cookieWithDomain(cookie: Cookie, domain: string): Cookie {
|
|
||||||
const trimmedDomain = domain.trim();
|
|
||||||
if (trimmedDomain.length === 0) {
|
|
||||||
return { ...cookie, domain: "NotPresent" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cookie.domain !== "NotPresent" && cookie.domain !== "Empty" && "Suffix" in cookie.domain) {
|
|
||||||
return { ...cookie, domain: { Suffix: trimmedDomain } };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...cookie, domain: { HostOnly: trimmedDomain } };
|
|
||||||
}
|
|
||||||
|
|
||||||
function cookieExpires(cookie: Cookie) {
|
|
||||||
if (cookie.expires === "SessionEnd") {
|
|
||||||
return "Session";
|
|
||||||
}
|
|
||||||
|
|
||||||
const expiresSeconds = Number(cookie.expires.AtUtc);
|
|
||||||
if (!Number.isFinite(expiresSeconds)) {
|
|
||||||
return cookie.expires.AtUtc;
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = new Date(expiresSeconds * 1000);
|
|
||||||
return formatDate(date, "MMM d, yyyy, h:mm:ss a");
|
|
||||||
}
|
|
||||||
|
|
||||||
function cookieExpiresInputValue(cookie: Cookie) {
|
|
||||||
if (cookie.expires === "SessionEnd") {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const expiresSeconds = Number(cookie.expires.AtUtc);
|
|
||||||
if (!Number.isFinite(expiresSeconds)) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Date(expiresSeconds * 1000).toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultCookieExpiresInputValue() {
|
|
||||||
return new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function cookieExpiresFromInput(value: string): Cookie["expires"] | null {
|
|
||||||
const time = new Date(value).getTime();
|
|
||||||
if (!Number.isFinite(time)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { AtUtc: `${Math.floor(time / 1000)}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
function cookieMatchesFilter(cookie: Cookie, filter: string) {
|
|
||||||
const query = filter.trim().toLowerCase();
|
|
||||||
if (query.length === 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [cookie.name, cookie.value, cookieDomain(cookie)].some((value) =>
|
|
||||||
value.toLowerCase().includes(query),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function cookieKey(cookie: Cookie) {
|
|
||||||
return JSON.stringify([cookie.name, cookieDomain(cookie), cookie.path]);
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { useTimedBoolean } from "@yaakapp-internal/ui";
|
|
||||||
import { copyToClipboard } from "../lib/copy";
|
|
||||||
import { showToast } from "../lib/toast";
|
|
||||||
import type { ButtonProps } from "./core/Button";
|
|
||||||
import { Button } from "./core/Button";
|
|
||||||
|
|
||||||
interface Props extends Omit<ButtonProps, "onClick"> {
|
|
||||||
text: string | (() => Promise<string | null>);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CopyButton({ text, ...props }: Props) {
|
|
||||||
const [copied, setCopied] = useTimedBoolean();
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
{...props}
|
|
||||||
onClick={async () => {
|
|
||||||
const content = typeof text === "function" ? await text() : text;
|
|
||||||
if (content == null) {
|
|
||||||
showToast({
|
|
||||||
id: "failed-to-copy",
|
|
||||||
color: "danger",
|
|
||||||
message: "Failed to copy",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
copyToClipboard(content, { disableToast: true });
|
|
||||||
setCopied();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{copied ? "Copied" : "Copy"}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { IconButton, type IconButtonProps, useTimedBoolean } from "@yaakapp-internal/ui";
|
|
||||||
import { copyToClipboard } from "../lib/copy";
|
|
||||||
import { showToast } from "../lib/toast";
|
|
||||||
|
|
||||||
interface Props extends Omit<IconButtonProps, "onClick" | "icon"> {
|
|
||||||
text: string | (() => Promise<string | null>);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CopyIconButton({ text, ...props }: Props) {
|
|
||||||
const [copied, setCopied] = useTimedBoolean();
|
|
||||||
return (
|
|
||||||
<IconButton
|
|
||||||
{...props}
|
|
||||||
icon={copied ? "check" : "copy"}
|
|
||||||
showConfirm
|
|
||||||
onClick={async () => {
|
|
||||||
const content = typeof text === "function" ? await text() : text;
|
|
||||||
if (content == null) {
|
|
||||||
showToast({
|
|
||||||
id: "failed-to-copy",
|
|
||||||
color: "danger",
|
|
||||||
message: "Failed to copy",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
copyToClipboard(content, { disableToast: true });
|
|
||||||
setCopied();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
import type { DnsOverride, Workspace } from "@yaakapp-internal/models";
|
|
||||||
import { patchModel } from "@yaakapp-internal/models";
|
|
||||||
import { fireAndForget } from "../lib/fireAndForget";
|
|
||||||
import {
|
|
||||||
HStack,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeaderCell,
|
|
||||||
TableRow,
|
|
||||||
VStack,
|
|
||||||
} from "@yaakapp-internal/ui";
|
|
||||||
import { useCallback, useId, useMemo } from "react";
|
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import { Checkbox } from "./core/Checkbox";
|
|
||||||
import { IconButton } from "./core/IconButton";
|
|
||||||
import { PlainInput } from "./core/PlainInput";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
workspace: Workspace;
|
|
||||||
}
|
|
||||||
|
|
||||||
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[]) => {
|
|
||||||
fireAndForget(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,209 +0,0 @@
|
|||||||
import { createWorkspaceModel, foldersAtom, patchModel } from "@yaakapp-internal/models";
|
|
||||||
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { Fragment, useMemo } from "react";
|
|
||||||
import { useAuthTab } from "../hooks/useAuthTab";
|
|
||||||
import { useEnvironmentsBreakdown } from "../hooks/useEnvironmentsBreakdown";
|
|
||||||
import { useHeadersTab } from "../hooks/useHeadersTab";
|
|
||||||
import { useInheritedHeaders } from "../hooks/useInheritedHeaders";
|
|
||||||
import { useModelAncestors } from "../hooks/useModelAncestors";
|
|
||||||
import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
|
|
||||||
import { hideDialog } from "../lib/dialog";
|
|
||||||
import { CopyIconButton } from "./CopyIconButton";
|
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import { CountBadge } from "./core/CountBadge";
|
|
||||||
import { Input } from "./core/Input";
|
|
||||||
import { Link } from "./core/Link";
|
|
||||||
import type { TabItem } from "./core/Tabs/Tabs";
|
|
||||||
import { TabContent, Tabs } from "./core/Tabs/Tabs";
|
|
||||||
import { EmptyStateText } from "./EmptyStateText";
|
|
||||||
import { EnvironmentEditor } from "./EnvironmentEditor";
|
|
||||||
import { HeadersEditor } from "./HeadersEditor";
|
|
||||||
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
|
|
||||||
import { MarkdownEditor } from "./MarkdownEditor";
|
|
||||||
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
folderId: string | null;
|
|
||||||
tab?: FolderSettingsTab;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TAB_AUTH = "auth";
|
|
||||||
const TAB_HEADERS = "headers";
|
|
||||||
const TAB_SETTINGS = "settings";
|
|
||||||
const TAB_VARIABLES = "variables";
|
|
||||||
const TAB_GENERAL = "general";
|
|
||||||
|
|
||||||
export type FolderSettingsTab =
|
|
||||||
| typeof TAB_AUTH
|
|
||||||
| typeof TAB_HEADERS
|
|
||||||
| typeof TAB_GENERAL
|
|
||||||
| typeof TAB_SETTINGS
|
|
||||||
| 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 numSettingsOverrides = folder == null ? 0 : countOverriddenSettings(folder);
|
|
||||||
|
|
||||||
const tabs = useMemo<TabItem[]>(() => {
|
|
||||||
if (folder == null) return [];
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
value: TAB_GENERAL,
|
|
||||||
label: "General",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: TAB_SETTINGS,
|
|
||||||
label: "Settings",
|
|
||||||
rightSlot: <CountBadge count={numSettingsOverrides} />,
|
|
||||||
},
|
|
||||||
...headersTab,
|
|
||||||
...authTab,
|
|
||||||
{
|
|
||||||
value: TAB_VARIABLES,
|
|
||||||
label: "Variables",
|
|
||||||
rightSlot: numVars > 0 ? <CountBadge count={numVars} /> : null,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}, [authTab, folder, headersTab, numSettingsOverrides, 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_SETTINGS} className="overflow-y-auto h-full px-4">
|
|
||||||
<ModelSettingsEditor model={folder} />
|
|
||||||
</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,38 +0,0 @@
|
|||||||
import { activeRequestAtom } from "../hooks/useActiveRequest";
|
|
||||||
import { useSubscribeActiveWorkspaceId } from "../hooks/useActiveWorkspace";
|
|
||||||
import { useActiveWorkspaceChangedToast } from "../hooks/useActiveWorkspaceChangedToast";
|
|
||||||
import { useHotKey, useSubscribeHotKeys } from "../hooks/useHotKey";
|
|
||||||
import { useSubscribeHttpAuthentication } from "../hooks/useHttpAuthentication";
|
|
||||||
import { useSyncFontSizeSetting } from "../hooks/useSyncFontSizeSetting";
|
|
||||||
import { useSyncWorkspaceChildModels } from "../hooks/useSyncWorkspaceChildModels";
|
|
||||||
import { useSyncZoomSetting } from "../hooks/useSyncZoomSetting";
|
|
||||||
import { useSubscribeTemplateFunctions } from "../hooks/useTemplateFunctions";
|
|
||||||
import { jotaiStore } from "../lib/jotai";
|
|
||||||
import { renameModelWithPrompt } from "../lib/renameModelWithPrompt";
|
|
||||||
|
|
||||||
export function GlobalHooks() {
|
|
||||||
useSyncZoomSetting();
|
|
||||||
useSyncFontSizeSetting();
|
|
||||||
|
|
||||||
useSubscribeActiveWorkspaceId();
|
|
||||||
|
|
||||||
useSyncWorkspaceChildModels();
|
|
||||||
useSubscribeTemplateFunctions();
|
|
||||||
useSubscribeHttpAuthentication();
|
|
||||||
|
|
||||||
// Other useful things
|
|
||||||
useActiveWorkspaceChangedToast();
|
|
||||||
useSubscribeHotKeys();
|
|
||||||
|
|
||||||
useHotKey(
|
|
||||||
"request.rename",
|
|
||||||
async () => {
|
|
||||||
const model = jotaiStore.get(activeRequestAtom);
|
|
||||||
if (model == null) return;
|
|
||||||
await renameModelWithPrompt(model);
|
|
||||||
},
|
|
||||||
{ allowDefault: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
import type { GrpcEvent, GrpcRequest } from "@yaakapp-internal/models";
|
|
||||||
import { HStack, Icon, type IconProps, LoadingIcon, VStack } from "@yaakapp-internal/ui";
|
|
||||||
import { useAtomValue, useSetAtom } from "jotai";
|
|
||||||
import type { CSSProperties } from "react";
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
activeGrpcConnectionAtom,
|
|
||||||
activeGrpcConnections,
|
|
||||||
pinnedGrpcConnectionIdAtom,
|
|
||||||
useGrpcEvents,
|
|
||||||
} from "../hooks/usePinnedGrpcConnection";
|
|
||||||
import { useStateWithDeps } from "../hooks/useStateWithDeps";
|
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import { Editor } from "./core/Editor/LazyEditor";
|
|
||||||
import { EventDetailHeader, EventViewer } from "./core/EventViewer";
|
|
||||||
import { EventViewerRow } from "./core/EventViewerRow";
|
|
||||||
import { HotkeyList } from "./core/HotkeyList";
|
|
||||||
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
|
|
||||||
import { EmptyStateText } from "./EmptyStateText";
|
|
||||||
import { ErrorBoundary } from "./ErrorBoundary";
|
|
||||||
import { RecentGrpcConnectionsDropdown } from "./RecentGrpcConnectionsDropdown";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
style?: CSSProperties;
|
|
||||||
className?: string;
|
|
||||||
activeRequest: GrpcRequest;
|
|
||||||
methodType:
|
|
||||||
| "unary"
|
|
||||||
| "client_streaming"
|
|
||||||
| "server_streaming"
|
|
||||||
| "streaming"
|
|
||||||
| "no-schema"
|
|
||||||
| "no-method";
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
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}
|
|
||||||
splitLayoutStorageKey="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,427 +0,0 @@
|
|||||||
import type { HttpResponse, HttpResponseEvent } from "@yaakapp-internal/models";
|
|
||||||
import { Banner, HStack, Icon, LoadingIcon, VStack } from "@yaakapp-internal/ui";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import type { ComponentType, CSSProperties } from "react";
|
|
||||||
import { lazy, Suspense, useMemo } from "react";
|
|
||||||
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
|
|
||||||
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
|
|
||||||
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
|
|
||||||
import { useResponseBodyBytes, useResponseBodyText } from "../hooks/useResponseBodyText";
|
|
||||||
import { useResponseViewMode } from "../hooks/useResponseViewMode";
|
|
||||||
import { useTimelineViewMode } from "../hooks/useTimelineViewMode";
|
|
||||||
import { getMimeTypeFromContentType } from "../lib/contentType";
|
|
||||||
import { getContentTypeFromHeaders, getCookieCounts } from "../lib/model_util";
|
|
||||||
import { ConfirmLargeResponse } from "./ConfirmLargeResponse";
|
|
||||||
import { ConfirmLargeResponseRequest } from "./ConfirmLargeResponseRequest";
|
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import { CountBadge } from "./core/CountBadge";
|
|
||||||
import { HotkeyList } from "./core/HotkeyList";
|
|
||||||
import { HttpResponseDurationTag } from "./core/HttpResponseDurationTag";
|
|
||||||
import { HttpStatusTag } from "./core/HttpStatusTag";
|
|
||||||
import { PillButton } from "./core/PillButton";
|
|
||||||
import { SizeTag } from "./core/SizeTag";
|
|
||||||
import type { TabItem } from "./core/Tabs/Tabs";
|
|
||||||
import { TabContent, Tabs } from "./core/Tabs/Tabs";
|
|
||||||
import { Tooltip } from "./core/Tooltip";
|
|
||||||
import { EmptyStateText } from "./EmptyStateText";
|
|
||||||
import { ErrorBoundary } from "./ErrorBoundary";
|
|
||||||
import { HttpResponseTimeline } from "./HttpResponseTimeline";
|
|
||||||
import { RecentHttpResponsesDropdown } from "./RecentHttpResponsesDropdown";
|
|
||||||
import { RequestBodyViewer } from "./RequestBodyViewer";
|
|
||||||
import { ResponseCookies } from "./ResponseCookies";
|
|
||||||
import { ResponseHeaders } from "./ResponseHeaders";
|
|
||||||
import { AudioViewer } from "./responseViewers/AudioViewer";
|
|
||||||
import { CsvViewer } from "./responseViewers/CsvViewer";
|
|
||||||
import { EventStreamViewer } from "./responseViewers/EventStreamViewer";
|
|
||||||
import { HTMLOrTextViewer } from "./responseViewers/HTMLOrTextViewer";
|
|
||||||
import { ImageViewer } from "./responseViewers/ImageViewer";
|
|
||||||
import { MultipartViewer } from "./responseViewers/MultipartViewer";
|
|
||||||
import { SvgViewer } from "./responseViewers/SvgViewer";
|
|
||||||
import { VideoViewer } from "./responseViewers/VideoViewer";
|
|
||||||
|
|
||||||
const PdfViewer = lazy(() =>
|
|
||||||
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
|
|
||||||
);
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
style?: CSSProperties;
|
|
||||||
className?: string;
|
|
||||||
activeRequestId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TAB_BODY = "body";
|
|
||||||
const TAB_REQUEST = "request";
|
|
||||||
const TAB_HEADERS = "headers";
|
|
||||||
const TAB_COOKIES = "cookies";
|
|
||||||
const TAB_TIMELINE = "timeline";
|
|
||||||
|
|
||||||
export type TimelineViewMode = "timeline" | "text";
|
|
||||||
|
|
||||||
interface RedirectDropWarning {
|
|
||||||
droppedBodyCount: number;
|
|
||||||
droppedHeaders: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
|
||||||
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId);
|
|
||||||
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
|
|
||||||
const [timelineViewMode, setTimelineViewMode] = useTimelineViewMode();
|
|
||||||
const contentType = getContentTypeFromHeaders(activeResponse?.headers ?? null);
|
|
||||||
const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence;
|
|
||||||
|
|
||||||
const responseEvents = useHttpResponseEvents(activeResponse);
|
|
||||||
const redirectDropWarning = useMemo(
|
|
||||||
() => getRedirectDropWarning(responseEvents.data),
|
|
||||||
[responseEvents.data],
|
|
||||||
);
|
|
||||||
const shouldShowRedirectDropWarning =
|
|
||||||
activeResponse?.state === "closed" && redirectDropWarning != null;
|
|
||||||
|
|
||||||
const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]);
|
|
||||||
|
|
||||||
const tabs = useMemo<TabItem[]>(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
value: TAB_BODY,
|
|
||||||
label: "Response",
|
|
||||||
options: {
|
|
||||||
value: viewMode,
|
|
||||||
onChange: setViewMode,
|
|
||||||
items: [
|
|
||||||
{ label: "Response", value: "pretty" },
|
|
||||||
...(mimeType?.startsWith("image")
|
|
||||||
? []
|
|
||||||
: [{ label: "Response (Raw)", shortLabel: "Raw", value: "raw" }]),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: TAB_REQUEST,
|
|
||||||
label: "Request",
|
|
||||||
rightSlot:
|
|
||||||
(activeResponse?.requestContentLength ?? 0) > 0 ? <CountBadge count={true} /> : null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: TAB_HEADERS,
|
|
||||||
label: "Headers",
|
|
||||||
rightSlot: (
|
|
||||||
<CountBadge
|
|
||||||
count={activeResponse?.requestHeaders.length ?? 0}
|
|
||||||
count2={activeResponse?.headers.length ?? 0}
|
|
||||||
showZero
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: TAB_COOKIES,
|
|
||||||
label: "Cookies",
|
|
||||||
rightSlot:
|
|
||||||
cookieCounts.sent > 0 || cookieCounts.received > 0 ? (
|
|
||||||
<CountBadge count={cookieCounts.sent} count2={cookieCounts.received} showZero />
|
|
||||||
) : null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: TAB_TIMELINE,
|
|
||||||
rightSlot: <CountBadge count={responseEvents.data?.length ?? 0} />,
|
|
||||||
options: {
|
|
||||||
value: timelineViewMode,
|
|
||||||
onChange: (v) => setTimelineViewMode((v as TimelineViewMode) ?? "timeline"),
|
|
||||||
items: [
|
|
||||||
{ label: "Timeline", value: "timeline" },
|
|
||||||
{ label: "Timeline (Text)", shortLabel: "Timeline", value: "text" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
activeResponse?.headers,
|
|
||||||
activeResponse?.requestContentLength,
|
|
||||||
activeResponse?.requestHeaders.length,
|
|
||||||
cookieCounts.sent,
|
|
||||||
cookieCounts.received,
|
|
||||||
mimeType,
|
|
||||||
responseEvents.data?.length,
|
|
||||||
setViewMode,
|
|
||||||
viewMode,
|
|
||||||
timelineViewMode,
|
|
||||||
setTimelineViewMode,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const cancel = useCancelHttpResponse(activeResponse?.id ?? null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={style}
|
|
||||||
className={classNames(
|
|
||||||
className,
|
|
||||||
"x-theme-responsePane",
|
|
||||||
"max-h-full h-full",
|
|
||||||
"bg-surface rounded-md border border-border-subtle overflow-hidden",
|
|
||||||
"relative",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{activeResponse == null ? (
|
|
||||||
<HotkeyList hotkeys={["request.send", "model.create", "sidebar.focus", "url_bar.focus"]} />
|
|
||||||
) : (
|
|
||||||
<div className="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1">
|
|
||||||
<HStack
|
|
||||||
className={classNames(
|
|
||||||
"text-text-subtle w-full flex-shrink-0",
|
|
||||||
// Remove a bit of space because the tabs have lots too
|
|
||||||
"-mb-1.5",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{activeResponse && (
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
"grid grid-cols-[auto_minmax(4rem,1fr)_auto]",
|
|
||||||
"cursor-default select-none",
|
|
||||||
"whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<HStack space={2} className="w-full flex-shrink-0">
|
|
||||||
{activeResponse.state !== "closed" && <LoadingIcon size="sm" />}
|
|
||||||
<HttpStatusTag showReason response={activeResponse} />
|
|
||||||
<span>•</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,456 +0,0 @@
|
|||||||
import type {
|
|
||||||
AnyModel,
|
|
||||||
HttpResponse,
|
|
||||||
HttpResponseEvent,
|
|
||||||
HttpResponseEventData,
|
|
||||||
} from "@yaakapp-internal/models";
|
|
||||||
import { foldersAtom, workspacesAtom } from "@yaakapp-internal/models";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { type ReactNode, useMemo, useState } from "react";
|
|
||||||
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
|
|
||||||
import { useAllRequests } from "../hooks/useAllRequests";
|
|
||||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
|
||||||
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"
|
|
||||||
splitLayoutStorageKey="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 settingSourceModels = useSettingSourceModels();
|
|
||||||
|
|
||||||
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>
|
|
||||||
{e.source_model != null ? (
|
|
||||||
<KeyValueRow label="Source">{formatSettingSource(e, settingSourceModels)}</KeyValueRow>
|
|
||||||
) : null}
|
|
||||||
</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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function useSettingSourceModels() {
|
|
||||||
const requests = useAllRequests();
|
|
||||||
const folders = useAtomValue(foldersAtom);
|
|
||||||
const workspaces = useAtomValue(workspacesAtom);
|
|
||||||
|
|
||||||
return useMemo<AnyModel[]>(
|
|
||||||
() => [...requests, ...folders, ...workspaces],
|
|
||||||
[requests, folders, workspaces],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatSettingSource(
|
|
||||||
event: Extract<HttpResponseEventData, { type: "setting" }>,
|
|
||||||
models: AnyModel[],
|
|
||||||
): string {
|
|
||||||
const sourceModel = event.source_model;
|
|
||||||
if (sourceModel == null || sourceModel === "default") {
|
|
||||||
return "Default";
|
|
||||||
}
|
|
||||||
|
|
||||||
const model =
|
|
||||||
event.source_id == null
|
|
||||||
? null
|
|
||||||
: (models.find((m) => m.model === sourceModel && m.id === event.source_id) ?? null);
|
|
||||||
const name = model == null ? event.source_name : resolvedModelName(model);
|
|
||||||
const label = sourceModel.replaceAll("_", " ");
|
|
||||||
return name == null || name.length === 0 ? label : `${name} (${label})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
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,122 +0,0 @@
|
|||||||
import { linter } from "@codemirror/lint";
|
|
||||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
|
||||||
import { patchModel } from "@yaakapp-internal/models";
|
|
||||||
import { Banner, Icon } from "@yaakapp-internal/ui";
|
|
||||||
import { useCallback, useMemo } from "react";
|
|
||||||
import { useKeyValue } from "../hooks/useKeyValue";
|
|
||||||
import { fireAndForget } from "../lib/fireAndForget";
|
|
||||||
import { textLikelyContainsJsonComments } from "../lib/jsonComments";
|
|
||||||
import type { DropdownItem } from "./core/Dropdown";
|
|
||||||
import { Dropdown } from "./core/Dropdown";
|
|
||||||
import type { EditorProps } from "./core/Editor/Editor";
|
|
||||||
import { jsonParseLinter } from "./core/Editor/json-lint";
|
|
||||||
import { Editor } from "./core/Editor/LazyEditor";
|
|
||||||
import { IconButton } from "./core/IconButton";
|
|
||||||
import { IconTooltip } from "./core/IconTooltip";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
forceUpdateKey: string;
|
|
||||||
heightMode: EditorProps["heightMode"];
|
|
||||||
request: HttpRequest;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function JsonBodyEditor({ forceUpdateKey, heightMode, request }: Props) {
|
|
||||||
const handleChange = useCallback(
|
|
||||||
(text: string) => patchModel(request, { body: { ...request.body, text } }),
|
|
||||||
[request],
|
|
||||||
);
|
|
||||||
|
|
||||||
const autoFix = request.body?.sendJsonComments !== true;
|
|
||||||
|
|
||||||
const lintExtension = useMemo(
|
|
||||||
() =>
|
|
||||||
linter(
|
|
||||||
jsonParseLinter(
|
|
||||||
autoFix
|
|
||||||
? { allowComments: true, allowTrailingCommas: true }
|
|
||||||
: { allowComments: false, allowTrailingCommas: false },
|
|
||||||
),
|
|
||||||
),
|
|
||||||
[autoFix],
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasComments = useMemo(
|
|
||||||
() => textLikelyContainsJsonComments(request.body?.text ?? ""),
|
|
||||||
[request.body?.text],
|
|
||||||
);
|
|
||||||
|
|
||||||
const { value: bannerDismissed, set: setBannerDismissed } = useKeyValue<boolean>({
|
|
||||||
namespace: "no_sync",
|
|
||||||
key: ["json-fix-3", request.workspaceId],
|
|
||||||
fallback: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleToggleAutoFix = useCallback(() => {
|
|
||||||
const newBody = { ...request.body };
|
|
||||||
if (autoFix) {
|
|
||||||
newBody.sendJsonComments = true;
|
|
||||||
} else {
|
|
||||||
delete newBody.sendJsonComments;
|
|
||||||
}
|
|
||||||
fireAndForget(patchModel(request, { body: newBody }));
|
|
||||||
}, [request, autoFix]);
|
|
||||||
|
|
||||||
const handleDropdownOpen = useCallback(() => {
|
|
||||||
if (!bannerDismissed) {
|
|
||||||
fireAndForget(setBannerDismissed(true));
|
|
||||||
}
|
|
||||||
}, [bannerDismissed, setBannerDismissed]);
|
|
||||||
|
|
||||||
const showBanner = hasComments && autoFix && !bannerDismissed;
|
|
||||||
|
|
||||||
const stripMessage = "Automatically strip comments and trailing commas before sending";
|
|
||||||
const actions = useMemo<EditorProps["actions"]>(
|
|
||||||
() => [
|
|
||||||
showBanner && (
|
|
||||||
<Banner color="notice" className="!opacity-100 h-sm !py-0 !px-2 flex items-center text-xs">
|
|
||||||
<p className="inline-flex items-center gap-1 min-w-0">
|
|
||||||
<span className="truncate">Auto-fix enabled</span>
|
|
||||||
<Icon icon="arrow_right" size="sm" className="opacity-disabled" />
|
|
||||||
</p>
|
|
||||||
</Banner>
|
|
||||||
),
|
|
||||||
<div key="settings" className="!opacity-100 !shadow">
|
|
||||||
<Dropdown
|
|
||||||
onOpen={handleDropdownOpen}
|
|
||||||
items={
|
|
||||||
[
|
|
||||||
{
|
|
||||||
label: "Automatically Fix JSON",
|
|
||||||
keepOpenOnSelect: true,
|
|
||||||
onSelect: handleToggleAutoFix,
|
|
||||||
rightSlot: <IconTooltip content={stripMessage} />,
|
|
||||||
leftSlot: (
|
|
||||||
<Icon icon={autoFix ? "check_square_checked" : "check_square_unchecked"} />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
] satisfies DropdownItem[]
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<IconButton size="sm" variant="border" icon="settings" title="JSON Settings" />
|
|
||||||
</Dropdown>
|
|
||||||
</div>,
|
|
||||||
],
|
|
||||||
[handleDropdownOpen, handleToggleAutoFix, autoFix, showBanner],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Editor
|
|
||||||
forceUpdateKey={forceUpdateKey}
|
|
||||||
autocompleteFunctions
|
|
||||||
autocompleteVariables
|
|
||||||
placeholder="..."
|
|
||||||
heightMode={heightMode}
|
|
||||||
defaultValue={`${request.body?.text ?? ""}`}
|
|
||||||
language="json"
|
|
||||||
onChange={handleChange}
|
|
||||||
stateKey={`json.${request.id}`}
|
|
||||||
actions={actions}
|
|
||||||
lintExtension={lintExtension}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import type { CSSProperties } from "react";
|
|
||||||
import ReactMarkdown, { type Components } from "react-markdown";
|
|
||||||
import { PrismLight as SyntaxHighlighter } from "react-syntax-highlighter";
|
|
||||||
import remarkGfm from "remark-gfm";
|
|
||||||
import { ErrorBoundary } from "./ErrorBoundary";
|
|
||||||
import { Prose } from "./Prose";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
children: string | null;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Markdown({ children, className }: Props) {
|
|
||||||
if (children == null) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Prose className={className}>
|
|
||||||
<ErrorBoundary name="Markdown">
|
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
|
||||||
{children}
|
|
||||||
</ReactMarkdown>
|
|
||||||
</ErrorBoundary>
|
|
||||||
</Prose>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const prismTheme = {
|
|
||||||
'pre[class*="language-"]': {
|
|
||||||
// Needs to be here, so the lib doesn't add its own
|
|
||||||
},
|
|
||||||
|
|
||||||
// Syntax tokens
|
|
||||||
comment: { color: "var(--textSubtle)" },
|
|
||||||
prolog: { color: "var(--textSubtle)" },
|
|
||||||
doctype: { color: "var(--textSubtle)" },
|
|
||||||
cdata: { color: "var(--textSubtle)" },
|
|
||||||
|
|
||||||
punctuation: { color: "var(--textSubtle)" },
|
|
||||||
|
|
||||||
property: { color: "var(--primary)" },
|
|
||||||
"attr-name": { color: "var(--primary)" },
|
|
||||||
|
|
||||||
string: { color: "var(--notice)" },
|
|
||||||
char: { color: "var(--notice)" },
|
|
||||||
|
|
||||||
number: { color: "var(--info)" },
|
|
||||||
constant: { color: "var(--info)" },
|
|
||||||
symbol: { color: "var(--info)" },
|
|
||||||
|
|
||||||
boolean: { color: "var(--warning)" },
|
|
||||||
"attr-value": { color: "var(--warning)" },
|
|
||||||
|
|
||||||
variable: { color: "var(--success)" },
|
|
||||||
|
|
||||||
tag: { color: "var(--info)" },
|
|
||||||
operator: { color: "var(--danger)" },
|
|
||||||
keyword: { color: "var(--danger)" },
|
|
||||||
function: { color: "var(--success)" },
|
|
||||||
"class-name": { color: "var(--primary)" },
|
|
||||||
builtin: { color: "var(--danger)" },
|
|
||||||
selector: { color: "var(--danger)" },
|
|
||||||
inserted: { color: "var(--success)" },
|
|
||||||
deleted: { color: "var(--danger)" },
|
|
||||||
regex: { color: "var(--warning)" },
|
|
||||||
|
|
||||||
important: { color: "var(--danger)", fontWeight: "bold" },
|
|
||||||
italic: { fontStyle: "italic" },
|
|
||||||
bold: { fontWeight: "bold" },
|
|
||||||
entity: { cursor: "help" },
|
|
||||||
};
|
|
||||||
|
|
||||||
const lineStyle: CSSProperties = {
|
|
||||||
paddingRight: "1.5em",
|
|
||||||
paddingLeft: "0",
|
|
||||||
opacity: 0.5,
|
|
||||||
};
|
|
||||||
|
|
||||||
const markdownComponents: Partial<Components> = {
|
|
||||||
// Ensure links open in external browser by adding target="_blank"
|
|
||||||
a: ({ href, children, ...rest }) => {
|
|
||||||
if (href && !href.match(/https?:\/\//)) {
|
|
||||||
href = `http://${href}`;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<a target="_blank" rel="noreferrer noopener" href={href} {...rest}>
|
|
||||||
{children}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
code(props) {
|
|
||||||
const { children, className, ref, ...extraProps } = props;
|
|
||||||
extraProps.node = undefined;
|
|
||||||
|
|
||||||
const match = /language-(\w+)/.exec(className || "");
|
|
||||||
return match ? (
|
|
||||||
<SyntaxHighlighter
|
|
||||||
{...extraProps}
|
|
||||||
CodeTag="code"
|
|
||||||
showLineNumbers
|
|
||||||
PreTag="div"
|
|
||||||
lineNumberStyle={lineStyle}
|
|
||||||
language={match[1]}
|
|
||||||
style={prismTheme}
|
|
||||||
>
|
|
||||||
{String(children as string).replace(/\n$/, "")}
|
|
||||||
</SyntaxHighlighter>
|
|
||||||
) : (
|
|
||||||
<code {...extraProps} ref={ref} className={className}>
|
|
||||||
{children}
|
|
||||||
</code>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,321 +0,0 @@
|
|||||||
import type {
|
|
||||||
Folder,
|
|
||||||
GrpcRequest,
|
|
||||||
HttpRequest,
|
|
||||||
InheritedBoolSetting,
|
|
||||||
InheritedIntSetting,
|
|
||||||
WebsocketRequest,
|
|
||||||
Workspace,
|
|
||||||
} from "@yaakapp-internal/models";
|
|
||||||
import { patchModel } from "@yaakapp-internal/models";
|
|
||||||
import { useModelAncestors } from "../hooks/useModelAncestors";
|
|
||||||
import { Checkbox } from "./core/Checkbox";
|
|
||||||
import { PlainInput } from "./core/PlainInput";
|
|
||||||
import {
|
|
||||||
SettingOverrideRow,
|
|
||||||
SettingRowBoolean,
|
|
||||||
SettingRowNumber,
|
|
||||||
SettingsList,
|
|
||||||
SettingsSection,
|
|
||||||
} from "./core/SettingRow";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
showSectionTitles?: boolean;
|
|
||||||
model: ModelWithSettings;
|
|
||||||
}
|
|
||||||
|
|
||||||
type ModelWithSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest;
|
|
||||||
type ModelWithHttpSettings = Workspace | Folder | HttpRequest;
|
|
||||||
type ModelWithCookieSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest;
|
|
||||||
type BooleanSetting = boolean | InheritedBoolSetting;
|
|
||||||
type IntegerSetting = number | InheritedIntSetting;
|
|
||||||
type CookieSettingsPatch = {
|
|
||||||
settingSendCookies?: ModelWithCookieSettings["settingSendCookies"];
|
|
||||||
settingStoreCookies?: ModelWithCookieSettings["settingStoreCookies"];
|
|
||||||
};
|
|
||||||
type HttpSettingsPatch = {
|
|
||||||
settingValidateCertificates?: ModelWithHttpSettings["settingValidateCertificates"];
|
|
||||||
settingFollowRedirects?: ModelWithHttpSettings["settingFollowRedirects"];
|
|
||||||
settingRequestTimeout?: ModelWithHttpSettings["settingRequestTimeout"];
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ModelSettingsEditor({ model, showSectionTitles = false }: Props) {
|
|
||||||
const ancestors = useModelAncestors(model);
|
|
||||||
const supportsHttpSettings =
|
|
||||||
model.model === "workspace" || model.model === "folder" || model.model === "http_request";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsList className="space-y-8">
|
|
||||||
{supportsHttpSettings && (
|
|
||||||
<SettingsSection title={showSectionTitles ? "Requests" : null}>
|
|
||||||
<IntegerSettingRow
|
|
||||||
title="Request Timeout"
|
|
||||||
description="Maximum request duration in milliseconds. Set to 0 to disable the timeout."
|
|
||||||
name="settingRequestTimeout"
|
|
||||||
setting={model.settingRequestTimeout}
|
|
||||||
inheritedValue={resolveInheritedValue(
|
|
||||||
ancestors,
|
|
||||||
"settingRequestTimeout",
|
|
||||||
model.settingRequestTimeout,
|
|
||||||
)}
|
|
||||||
onChange={(settingRequestTimeout) =>
|
|
||||||
patchHttpSettings(model, {
|
|
||||||
settingRequestTimeout,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<BooleanSettingRow
|
|
||||||
title="Validate TLS certificates"
|
|
||||||
description="When disabled, skip validation of server certificates."
|
|
||||||
setting={model.settingValidateCertificates}
|
|
||||||
inheritedValue={resolveInheritedValue(
|
|
||||||
ancestors,
|
|
||||||
"settingValidateCertificates",
|
|
||||||
model.settingValidateCertificates,
|
|
||||||
)}
|
|
||||||
onChange={(settingValidateCertificates) =>
|
|
||||||
patchHttpSettings(model, {
|
|
||||||
settingValidateCertificates,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<BooleanSettingRow
|
|
||||||
title="Follow redirects"
|
|
||||||
description="Follow HTTP redirects automatically."
|
|
||||||
setting={model.settingFollowRedirects}
|
|
||||||
inheritedValue={resolveInheritedValue(
|
|
||||||
ancestors,
|
|
||||||
"settingFollowRedirects",
|
|
||||||
model.settingFollowRedirects,
|
|
||||||
)}
|
|
||||||
onChange={(settingFollowRedirects) =>
|
|
||||||
patchHttpSettings(model, {
|
|
||||||
settingFollowRedirects,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
)}
|
|
||||||
<SettingsSection title={supportsHttpSettings || showSectionTitles ? "Cookies" : null}>
|
|
||||||
<BooleanSettingRow
|
|
||||||
title="Automatically send cookies"
|
|
||||||
description="Attach matching cookies from the active cookie jar to outgoing requests."
|
|
||||||
setting={model.settingSendCookies}
|
|
||||||
inheritedValue={resolveInheritedValue(
|
|
||||||
ancestors,
|
|
||||||
"settingSendCookies",
|
|
||||||
model.settingSendCookies,
|
|
||||||
)}
|
|
||||||
onChange={(settingSendCookies) =>
|
|
||||||
patchCookieSettings(model, {
|
|
||||||
settingSendCookies,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<BooleanSettingRow
|
|
||||||
title="Automatically store cookies"
|
|
||||||
description="Save cookies from Set-Cookie response headers to the active cookie jar."
|
|
||||||
setting={model.settingStoreCookies}
|
|
||||||
inheritedValue={resolveInheritedValue(
|
|
||||||
ancestors,
|
|
||||||
"settingStoreCookies",
|
|
||||||
model.settingStoreCookies,
|
|
||||||
)}
|
|
||||||
onChange={(settingStoreCookies) =>
|
|
||||||
patchCookieSettings(model, {
|
|
||||||
settingStoreCookies,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
</SettingsList>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function countOverriddenSettings(model: ModelWithSettings) {
|
|
||||||
const settings: (BooleanSetting | IntegerSetting)[] = [
|
|
||||||
model.settingSendCookies,
|
|
||||||
model.settingStoreCookies,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (model.model === "workspace" || model.model === "folder" || model.model === "http_request") {
|
|
||||||
settings.push(
|
|
||||||
model.settingValidateCertificates,
|
|
||||||
model.settingFollowRedirects,
|
|
||||||
model.settingRequestTimeout,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return settings.filter((setting) => isInheritedSetting(setting) && setting.enabled === true)
|
|
||||||
.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
function patchCookieSettings(model: ModelWithCookieSettings, patch: Partial<CookieSettingsPatch>) {
|
|
||||||
if (model.model === "workspace") return patchModel(model, patch as Partial<Workspace>);
|
|
||||||
if (model.model === "folder") return patchModel(model, patch as Partial<Folder>);
|
|
||||||
if (model.model === "http_request") return patchModel(model, patch as Partial<HttpRequest>);
|
|
||||||
if (model.model === "websocket_request")
|
|
||||||
return patchModel(model, patch as Partial<WebsocketRequest>);
|
|
||||||
return patchModel(model, patch as Partial<GrpcRequest>);
|
|
||||||
}
|
|
||||||
|
|
||||||
function patchHttpSettings(model: ModelWithHttpSettings, patch: Partial<HttpSettingsPatch>) {
|
|
||||||
if (model.model === "workspace") return patchModel(model, patch as Partial<Workspace>);
|
|
||||||
if (model.model === "folder") return patchModel(model, patch as Partial<Folder>);
|
|
||||||
return patchModel(model, patch as Partial<HttpRequest>);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BooleanSettingRow({
|
|
||||||
description,
|
|
||||||
inheritedValue,
|
|
||||||
setting,
|
|
||||||
title,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
description: string;
|
|
||||||
inheritedValue: boolean;
|
|
||||||
setting: BooleanSetting;
|
|
||||||
title: string;
|
|
||||||
onChange: (setting: BooleanSetting) => void;
|
|
||||||
}) {
|
|
||||||
const inherited = isInheritedSetting(setting);
|
|
||||||
const overridden = inherited ? setting.enabled === true : false;
|
|
||||||
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
|
|
||||||
|
|
||||||
if (!inherited) {
|
|
||||||
return (
|
|
||||||
<SettingRowBoolean
|
|
||||||
checked={value}
|
|
||||||
title={title}
|
|
||||||
description={description}
|
|
||||||
onChange={(value) => onChange(value)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingOverrideRow
|
|
||||||
title={title}
|
|
||||||
description={description}
|
|
||||||
overridden={overridden}
|
|
||||||
onResetOverride={() => onChange({ ...setting, enabled: false })}
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
hideLabel
|
|
||||||
size="md"
|
|
||||||
title={title}
|
|
||||||
checked={value}
|
|
||||||
onChange={(value) => onChange({ ...setting, enabled: true, value })}
|
|
||||||
/>
|
|
||||||
</SettingOverrideRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function IntegerSettingRow({
|
|
||||||
description,
|
|
||||||
inheritedValue,
|
|
||||||
name,
|
|
||||||
setting,
|
|
||||||
title,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
description: string;
|
|
||||||
inheritedValue: number;
|
|
||||||
name: string;
|
|
||||||
setting: IntegerSetting;
|
|
||||||
title: string;
|
|
||||||
onChange: (setting: IntegerSetting) => void;
|
|
||||||
}) {
|
|
||||||
const inherited = isInheritedSetting(setting);
|
|
||||||
const overridden = inherited ? setting.enabled === true : false;
|
|
||||||
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
|
|
||||||
const showReset = overridden && value !== inheritedValue;
|
|
||||||
|
|
||||||
if (!inherited) {
|
|
||||||
return (
|
|
||||||
<SettingRowNumber
|
|
||||||
name={name}
|
|
||||||
title={title}
|
|
||||||
description={description}
|
|
||||||
value={value}
|
|
||||||
placeholder="0"
|
|
||||||
validate={(value) => value === "" || Number.parseInt(value, 10) >= 0}
|
|
||||||
onChange={(value) => onChange(value)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingOverrideRow
|
|
||||||
title={title}
|
|
||||||
description={description}
|
|
||||||
overridden={showReset}
|
|
||||||
onResetOverride={() => onChange({ ...setting, enabled: false })}
|
|
||||||
>
|
|
||||||
<PlainInput
|
|
||||||
hideLabel
|
|
||||||
name={name}
|
|
||||||
label={title}
|
|
||||||
size="sm"
|
|
||||||
type="number"
|
|
||||||
placeholder="0"
|
|
||||||
defaultValue={`${value}`}
|
|
||||||
containerClassName="!w-48"
|
|
||||||
validate={(value) => value === "" || Number.parseInt(value, 10) >= 0}
|
|
||||||
onChange={(value) =>
|
|
||||||
onChange({
|
|
||||||
...setting,
|
|
||||||
enabled: true,
|
|
||||||
value: Number.parseInt(value, 10) || 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SettingOverrideRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isInheritedSetting<T>(
|
|
||||||
setting: T | { enabled?: boolean; value: T },
|
|
||||||
): setting is { enabled?: boolean; value: T } {
|
|
||||||
return typeof setting === "object" && setting != null && "value" in setting;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveInheritedValue(
|
|
||||||
ancestors: (Folder | Workspace)[],
|
|
||||||
key: "settingRequestTimeout",
|
|
||||||
fallback: IntegerSetting,
|
|
||||||
): number;
|
|
||||||
function resolveInheritedValue(
|
|
||||||
ancestors: (Folder | Workspace)[],
|
|
||||||
key: BooleanWorkspaceSettingKey,
|
|
||||||
fallback: BooleanSetting,
|
|
||||||
): boolean;
|
|
||||||
function resolveInheritedValue(
|
|
||||||
ancestors: (Folder | Workspace)[],
|
|
||||||
key: keyof WorkspaceSettings,
|
|
||||||
fallback: BooleanSetting | IntegerSetting,
|
|
||||||
) {
|
|
||||||
for (const ancestor of ancestors) {
|
|
||||||
const setting = ancestor[key] as BooleanSetting | IntegerSetting;
|
|
||||||
if (isInheritedSetting(setting)) {
|
|
||||||
if (setting.enabled === true) {
|
|
||||||
return setting.value;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
return setting;
|
|
||||||
}
|
|
||||||
|
|
||||||
return isInheritedSetting(fallback) ? fallback.value : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
type WorkspaceSettings = Pick<
|
|
||||||
Workspace,
|
|
||||||
| "settingFollowRedirects"
|
|
||||||
| "settingRequestTimeout"
|
|
||||||
| "settingSendCookies"
|
|
||||||
| "settingStoreCookies"
|
|
||||||
| "settingValidateCertificates"
|
|
||||||
>;
|
|
||||||
|
|
||||||
type BooleanWorkspaceSettingKey = Exclude<keyof WorkspaceSettings, "settingRequestTimeout">;
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import type { GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-internal/models";
|
|
||||||
import { patchModel, workspacesAtom } from "@yaakapp-internal/models";
|
|
||||||
import { InlineCode, VStack } from "@yaakapp-internal/ui";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { pluralizeCount } from "../lib/pluralize";
|
|
||||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
|
||||||
import { router } from "../lib/router";
|
|
||||||
import { showToast } from "../lib/toast";
|
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import { Select } from "./core/Select";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
activeWorkspaceId: string;
|
|
||||||
requests: (HttpRequest | GrpcRequest | WebsocketRequest)[];
|
|
||||||
onDone: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MoveToWorkspaceDialog({ onDone, requests, activeWorkspaceId }: Props) {
|
|
||||||
const workspaces = useAtomValue(workspacesAtom);
|
|
||||||
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string>(activeWorkspaceId);
|
|
||||||
|
|
||||||
const targetWorkspace = workspaces.find((w) => w.id === selectedWorkspaceId);
|
|
||||||
const isSameWorkspace = selectedWorkspaceId === activeWorkspaceId;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VStack space={4} className="mb-4">
|
|
||||||
<Select
|
|
||||||
label="Target Workspace"
|
|
||||||
name="workspace"
|
|
||||||
value={selectedWorkspaceId}
|
|
||||||
onChange={setSelectedWorkspaceId}
|
|
||||||
options={workspaces.map((w) => ({
|
|
||||||
label: w.id === activeWorkspaceId ? `${w.name} (current)` : w.name,
|
|
||||||
value: w.id,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
color="primary"
|
|
||||||
disabled={isSameWorkspace}
|
|
||||||
onClick={async () => {
|
|
||||||
const patch = {
|
|
||||||
workspaceId: selectedWorkspaceId,
|
|
||||||
folderId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
await Promise.all(requests.map((r) => patchModel(r, patch)));
|
|
||||||
|
|
||||||
// Hide after a moment, to give time for requests to disappear
|
|
||||||
setTimeout(onDone, 100);
|
|
||||||
showToast({
|
|
||||||
id: "workspace-moved",
|
|
||||||
message:
|
|
||||||
requests.length === 1 && requests[0] != null ? (
|
|
||||||
<>
|
|
||||||
<InlineCode>{resolvedModelName(requests[0])}</InlineCode> moved to{" "}
|
|
||||||
<InlineCode>{targetWorkspace?.name ?? "unknown"}</InlineCode>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{pluralizeCount("request", requests.length)} moved to{" "}
|
|
||||||
<InlineCode>{targetWorkspace?.name ?? "unknown"}</InlineCode>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
action: ({ hide }) => (
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
color="secondary"
|
|
||||||
className="mr-auto min-w-[5rem]"
|
|
||||||
onClick={async () => {
|
|
||||||
await router.navigate({
|
|
||||||
to: "/workspaces/$workspaceId",
|
|
||||||
params: { workspaceId: selectedWorkspaceId },
|
|
||||||
});
|
|
||||||
hide();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Switch to Workspace
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{requests.length === 1 ? "Move" : `Move ${pluralizeCount("Request", requests.length)}`}
|
|
||||||
</Button>
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import classNames from "classnames";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import "./Prose.css";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
children: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Prose({ className, ...props }: Props) {
|
|
||||||
return <div className={classNames("prose", className)} {...props} />;
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { workspacesAtom } from "@yaakapp-internal/models";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { getRecentCookieJars } from "../hooks/useRecentCookieJars";
|
|
||||||
import { getRecentEnvironments } from "../hooks/useRecentEnvironments";
|
|
||||||
import { getRecentRequests } from "../hooks/useRecentRequests";
|
|
||||||
import { useRecentWorkspaces } from "../hooks/useRecentWorkspaces";
|
|
||||||
import { fireAndForget } from "../lib/fireAndForget";
|
|
||||||
import { router } from "../lib/router";
|
|
||||||
|
|
||||||
export function RedirectToLatestWorkspace() {
|
|
||||||
const workspaces = useAtomValue(workspacesAtom);
|
|
||||||
const recentWorkspaces = useRecentWorkspaces();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (workspaces.length === 0 || recentWorkspaces == null) {
|
|
||||||
console.log("No workspaces found to redirect to. Skipping.", {
|
|
||||||
workspaces,
|
|
||||||
recentWorkspaces,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
fireAndForget(
|
|
||||||
(async () => {
|
|
||||||
const workspaceId = recentWorkspaces[0] ?? workspaces[0]?.id ?? "n/a";
|
|
||||||
const environmentId = (await getRecentEnvironments(workspaceId))[0] ?? null;
|
|
||||||
const cookieJarId = (await getRecentCookieJars(workspaceId))[0] ?? null;
|
|
||||||
const requestId = (await getRecentRequests(workspaceId))[0] ?? null;
|
|
||||||
const params = { workspaceId };
|
|
||||||
const search = {
|
|
||||||
cookie_jar_id: cookieJarId,
|
|
||||||
environment_id: environmentId,
|
|
||||||
request_id: requestId,
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log("Redirecting to workspace", params, search);
|
|
||||||
await router.navigate({ to: "/workspaces/$workspaceId", params, search });
|
|
||||||
})(),
|
|
||||||
);
|
|
||||||
}, [recentWorkspaces, workspaces, workspaces.length]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
|
||||||
import { patchModel } from "@yaakapp-internal/models";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { memo, useCallback, useMemo } from "react";
|
|
||||||
import { showPrompt } from "../lib/prompt";
|
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import type { DropdownItem } from "./core/Dropdown";
|
|
||||||
import { HttpMethodTag, HttpMethodTagRaw } from "./core/HttpMethodTag";
|
|
||||||
import { Icon } from "@yaakapp-internal/ui";
|
|
||||||
import type { RadioDropdownItem } from "./core/RadioDropdown";
|
|
||||||
import { RadioDropdown } from "./core/RadioDropdown";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
request: HttpRequest;
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const radioItems: RadioDropdownItem<string>[] = [
|
|
||||||
"GET",
|
|
||||||
"PUT",
|
|
||||||
"POST",
|
|
||||||
"PATCH",
|
|
||||||
"DELETE",
|
|
||||||
"OPTIONS",
|
|
||||||
"QUERY",
|
|
||||||
"HEAD",
|
|
||||||
].map((m) => ({
|
|
||||||
value: m,
|
|
||||||
label: <HttpMethodTagRaw method={m} />,
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const RequestMethodDropdown = memo(function RequestMethodDropdown({
|
|
||||||
request,
|
|
||||||
className,
|
|
||||||
}: Props) {
|
|
||||||
const handleChange = useCallback(
|
|
||||||
async (method: string) => {
|
|
||||||
await patchModel(request, { method });
|
|
||||||
},
|
|
||||||
[request],
|
|
||||||
);
|
|
||||||
|
|
||||||
const itemsAfter = useMemo<DropdownItem[]>(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
key: "custom",
|
|
||||||
label: "CUSTOM",
|
|
||||||
leftSlot: <Icon icon="sparkles" />,
|
|
||||||
onSelect: async () => {
|
|
||||||
const newMethod = await showPrompt({
|
|
||||||
id: "custom-method",
|
|
||||||
label: "Http Method",
|
|
||||||
title: "Custom Method",
|
|
||||||
confirmText: "Save",
|
|
||||||
description: "Enter a custom method name",
|
|
||||||
placeholder: "CUSTOM",
|
|
||||||
});
|
|
||||||
if (newMethod == null) return;
|
|
||||||
await handleChange(newMethod);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[handleChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RadioDropdown
|
|
||||||
value={request.method}
|
|
||||||
items={radioItems}
|
|
||||||
itemsAfter={itemsAfter}
|
|
||||||
onChange={handleChange}
|
|
||||||
>
|
|
||||||
<Button size="xs" className={classNames(className, "text-text-subtle hover:text")}>
|
|
||||||
<HttpMethodTag request={request} noAlias />
|
|
||||||
</Button>
|
|
||||||
</RadioDropdown>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
import { revealItemInDir } from "@tauri-apps/plugin-opener";
|
|
||||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
|
||||||
import { Heading, VStack } from "@yaakapp-internal/ui";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
|
|
||||||
import { useCheckForUpdates } from "../../hooks/useCheckForUpdates";
|
|
||||||
import { appInfo } from "../../lib/appInfo";
|
|
||||||
import { revealInFinderText } from "../../lib/reveal";
|
|
||||||
import { CargoFeature } from "../CargoFeature";
|
|
||||||
import { IconButton } from "../core/IconButton";
|
|
||||||
import {
|
|
||||||
ModelSettingRowBoolean,
|
|
||||||
ModelSettingRowNumber,
|
|
||||||
ModelSettingSelectControl,
|
|
||||||
SettingValue,
|
|
||||||
SettingRow,
|
|
||||||
SettingRowBoolean,
|
|
||||||
SettingRowSelect,
|
|
||||||
SettingsList,
|
|
||||||
SettingsSection,
|
|
||||||
} from "../core/SettingRow";
|
|
||||||
|
|
||||||
export function SettingsGeneral() {
|
|
||||||
const workspace = useAtomValue(activeWorkspaceAtom);
|
|
||||||
const settings = useAtomValue(settingsAtom);
|
|
||||||
const checkForUpdates = useCheckForUpdates();
|
|
||||||
|
|
||||||
if (settings == null || workspace == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VStack space={1.5} className="mb-4">
|
|
||||||
<div className="mb-4">
|
|
||||||
<Heading>General</Heading>
|
|
||||||
<p className="text-text-subtle">Configure general settings for update behavior and more.</p>
|
|
||||||
</div>
|
|
||||||
<SettingsList className="space-y-8">
|
|
||||||
<CargoFeature feature="updater">
|
|
||||||
<SettingsSection title="Updates">
|
|
||||||
<SettingRow
|
|
||||||
title="Update Channel"
|
|
||||||
description="Choose whether Yaak should use stable releases or beta releases."
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-[12rem_auto] gap-1">
|
|
||||||
<ModelSettingSelectControl
|
|
||||||
model={settings}
|
|
||||||
modelKey="updateChannel"
|
|
||||||
label="Update Channel"
|
|
||||||
selectClassName="!w-full"
|
|
||||||
options={[
|
|
||||||
{ label: "Stable", value: "stable" },
|
|
||||||
{ label: "Beta", value: "beta" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
variant="border"
|
|
||||||
size="sm"
|
|
||||||
title="Check for updates"
|
|
||||||
icon="refresh"
|
|
||||||
spin={checkForUpdates.isPending}
|
|
||||||
onClick={() => checkForUpdates.mutateAsync()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<SettingRowSelect
|
|
||||||
title="Update Behavior"
|
|
||||||
description="Choose whether updates are installed automatically or manually."
|
|
||||||
name="autoupdate"
|
|
||||||
value={settings.autoupdate ? "auto" : "manual"}
|
|
||||||
onChange={(v) => patchModel(settings, { autoupdate: v === "auto" })}
|
|
||||||
options={[
|
|
||||||
{ label: "Automatic", value: "auto" },
|
|
||||||
{ label: "Manual", value: "manual" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ModelSettingRowBoolean
|
|
||||||
model={settings}
|
|
||||||
modelKey="autoDownloadUpdates"
|
|
||||||
title="Automatically download updates"
|
|
||||||
description="Download Yaak updates in the background so they are ready to install."
|
|
||||||
disabled={!settings.autoupdate}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ModelSettingRowBoolean
|
|
||||||
model={settings}
|
|
||||||
modelKey="checkNotifications"
|
|
||||||
title="Check for notifications"
|
|
||||||
description="Periodically ping Yaak servers to check for relevant notifications."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SettingRowBoolean
|
|
||||||
title="Send anonymous usage statistics"
|
|
||||||
description="Yaak is local-first and does not collect analytics or usage data."
|
|
||||||
disabled
|
|
||||||
checked={false}
|
|
||||||
onChange={() => {}}
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
</CargoFeature>
|
|
||||||
|
|
||||||
<SettingsSection
|
|
||||||
title={
|
|
||||||
<>
|
|
||||||
Workspace{" "}
|
|
||||||
<span className="inline-block bg-surface-highlight px-2 py-0.5 rounded text">
|
|
||||||
{workspace.name}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ModelSettingRowNumber
|
|
||||||
model={workspace}
|
|
||||||
modelKey="settingRequestTimeout"
|
|
||||||
title="Request Timeout"
|
|
||||||
description="Maximum request duration in milliseconds. Set to 0 to disable the timeout."
|
|
||||||
placeholder="0"
|
|
||||||
required
|
|
||||||
validate={(value) => Number.parseInt(value, 10) >= 0}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ModelSettingRowBoolean
|
|
||||||
model={workspace}
|
|
||||||
modelKey="settingValidateCertificates"
|
|
||||||
title="Validate TLS certificates"
|
|
||||||
description="When disabled, skip validation of server certificates."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ModelSettingRowBoolean
|
|
||||||
model={workspace}
|
|
||||||
modelKey="settingFollowRedirects"
|
|
||||||
title="Follow redirects"
|
|
||||||
description="Follow HTTP redirects automatically."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ModelSettingRowBoolean
|
|
||||||
model={workspace}
|
|
||||||
modelKey="settingSendCookies"
|
|
||||||
title="Automatically send cookies"
|
|
||||||
description="Attach matching cookies from the active cookie jar to outgoing requests."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ModelSettingRowBoolean
|
|
||||||
model={workspace}
|
|
||||||
modelKey="settingStoreCookies"
|
|
||||||
title="Automatically store cookies"
|
|
||||||
description="Save cookies from Set-Cookie response headers to the active cookie jar."
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection title="App Info">
|
|
||||||
<SettingRow title="Version" description="Current Yaak version.">
|
|
||||||
<SettingValue value={appInfo.version} />
|
|
||||||
</SettingRow>
|
|
||||||
<SettingRow
|
|
||||||
title="Data Directory"
|
|
||||||
description="Where Yaak stores application data."
|
|
||||||
controlClassName="min-w-0 max-w-[min(42rem,55vw)] gap-2"
|
|
||||||
>
|
|
||||||
<SettingValue
|
|
||||||
value={appInfo.appDataDir}
|
|
||||||
actions={[
|
|
||||||
{
|
|
||||||
title: revealInFinderText,
|
|
||||||
icon: "folder_open",
|
|
||||||
onClick: () => revealItemInDir(appInfo.appDataDir),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
<SettingRow
|
|
||||||
title="Logs Directory"
|
|
||||||
description="Where Yaak writes application logs."
|
|
||||||
controlClassName="min-w-0 max-w-[min(42rem,55vw)] gap-2"
|
|
||||||
>
|
|
||||||
<SettingValue
|
|
||||||
value={appInfo.appLogDir}
|
|
||||||
actions={[
|
|
||||||
{
|
|
||||||
title: revealInFinderText,
|
|
||||||
icon: "folder_open",
|
|
||||||
onClick: () => revealItemInDir(appInfo.appLogDir),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
</SettingsSection>
|
|
||||||
</SettingsList>
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,271 +0,0 @@
|
|||||||
import { type } from "@tauri-apps/plugin-os";
|
|
||||||
import { useFonts } from "@yaakapp-internal/fonts";
|
|
||||||
import { useLicense } from "@yaakapp-internal/license";
|
|
||||||
import type { EditorKeymap, Settings } from "@yaakapp-internal/models";
|
|
||||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
|
||||||
import { clamp, Heading, VStack } from "@yaakapp-internal/ui";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
|
|
||||||
import { showConfirm } from "../../lib/confirm";
|
|
||||||
import { invokeCmd } from "../../lib/tauri";
|
|
||||||
import { CargoFeature } from "../CargoFeature";
|
|
||||||
import { Button } from "../core/Button";
|
|
||||||
import { Checkbox } from "../core/Checkbox";
|
|
||||||
import { Link } from "../core/Link";
|
|
||||||
import {
|
|
||||||
ModelSettingRowBoolean,
|
|
||||||
ModelSettingRowSelect,
|
|
||||||
SettingRow,
|
|
||||||
SettingRowBoolean,
|
|
||||||
SettingRowSelect,
|
|
||||||
SettingSelectControl,
|
|
||||||
SettingsList,
|
|
||||||
SettingsSection,
|
|
||||||
} from "../core/SettingRow";
|
|
||||||
|
|
||||||
const NULL_FONT_VALUE = "__NULL_FONT__";
|
|
||||||
|
|
||||||
const fontSizeOptions = [
|
|
||||||
8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30,
|
|
||||||
].map((n) => ({ label: `${n}`, value: `${n}` }));
|
|
||||||
|
|
||||||
const keymaps: { value: EditorKeymap; label: string }[] = [
|
|
||||||
{ value: "default", label: "Default" },
|
|
||||||
{ value: "vim", label: "Vim" },
|
|
||||||
{ value: "vscode", label: "VSCode" },
|
|
||||||
{ value: "emacs", label: "Emacs" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function SettingsInterface() {
|
|
||||||
const workspace = useAtomValue(activeWorkspaceAtom);
|
|
||||||
const settings = useAtomValue(settingsAtom);
|
|
||||||
const fonts = useFonts();
|
|
||||||
|
|
||||||
if (settings == null || workspace == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VStack space={1.5} className="mb-4">
|
|
||||||
<div className="mb-3">
|
|
||||||
<Heading>Interface</Heading>
|
|
||||||
<p className="text-text-subtle">Tweak settings related to the user interface.</p>
|
|
||||||
</div>
|
|
||||||
<SettingsList className="space-y-8">
|
|
||||||
<SettingsSection title="Workspaces">
|
|
||||||
<SettingRowSelect
|
|
||||||
title="Open workspace behavior"
|
|
||||||
description="Choose what happens when opening another workspace."
|
|
||||||
name="switchWorkspaceBehavior"
|
|
||||||
value={
|
|
||||||
settings.openWorkspaceNewWindow === true
|
|
||||||
? "new"
|
|
||||||
: settings.openWorkspaceNewWindow === false
|
|
||||||
? "current"
|
|
||||||
: "ask"
|
|
||||||
}
|
|
||||||
onChange={async (v) => {
|
|
||||||
if (v === "current") await patchModel(settings, { openWorkspaceNewWindow: false });
|
|
||||||
else if (v === "new") await patchModel(settings, { openWorkspaceNewWindow: true });
|
|
||||||
else await patchModel(settings, { openWorkspaceNewWindow: null });
|
|
||||||
}}
|
|
||||||
options={[
|
|
||||||
{ label: "Always ask", value: "ask" },
|
|
||||||
{ label: "Open in current window", value: "current" },
|
|
||||||
{ label: "Open in new window", value: "new" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection title="Fonts">
|
|
||||||
<SettingRow
|
|
||||||
title="Interface font"
|
|
||||||
description="Font used for Yaak interface controls."
|
|
||||||
controlClassName="gap-1"
|
|
||||||
>
|
|
||||||
{fonts.data && (
|
|
||||||
<SettingSelectControl
|
|
||||||
name="uiFont"
|
|
||||||
label="Interface font"
|
|
||||||
selectClassName="!w-72"
|
|
||||||
value={settings.interfaceFont ?? NULL_FONT_VALUE}
|
|
||||||
defaultValue={NULL_FONT_VALUE}
|
|
||||||
options={[
|
|
||||||
{ label: "System default", value: NULL_FONT_VALUE },
|
|
||||||
...fonts.data.uiFonts.map((f) => ({ label: f, value: f })),
|
|
||||||
...fonts.data.editorFonts.map((f) => ({ label: f, value: f })),
|
|
||||||
]}
|
|
||||||
onChange={async (v) => {
|
|
||||||
const interfaceFont = v === NULL_FONT_VALUE ? null : v;
|
|
||||||
await patchModel(settings, { interfaceFont });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<SettingSelectControl
|
|
||||||
name="interfaceFontSize"
|
|
||||||
label="Interface Font Size"
|
|
||||||
selectClassName="!w-20"
|
|
||||||
value={`${settings.interfaceFontSize}`}
|
|
||||||
defaultValue="14"
|
|
||||||
options={fontSizeOptions}
|
|
||||||
onChange={(v) => patchModel(settings, { interfaceFontSize: Number.parseInt(v, 10) })}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="Editor font"
|
|
||||||
description="Font used in request and response editors."
|
|
||||||
controlClassName="gap-1"
|
|
||||||
>
|
|
||||||
{fonts.data && (
|
|
||||||
<SettingSelectControl
|
|
||||||
name="editorFont"
|
|
||||||
label="Editor font"
|
|
||||||
selectClassName="!w-72"
|
|
||||||
value={settings.editorFont ?? NULL_FONT_VALUE}
|
|
||||||
defaultValue={NULL_FONT_VALUE}
|
|
||||||
options={[
|
|
||||||
{ label: "System default", value: NULL_FONT_VALUE },
|
|
||||||
...fonts.data.editorFonts.map((f) => ({ label: f, value: f })),
|
|
||||||
]}
|
|
||||||
onChange={async (v) => {
|
|
||||||
const editorFont = v === NULL_FONT_VALUE ? null : v;
|
|
||||||
await patchModel(settings, { editorFont });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<SettingSelectControl
|
|
||||||
name="editorFontSize"
|
|
||||||
label="Editor Font Size"
|
|
||||||
selectClassName="!w-20"
|
|
||||||
value={`${settings.editorFontSize}`}
|
|
||||||
defaultValue="12"
|
|
||||||
options={fontSizeOptions}
|
|
||||||
onChange={(v) =>
|
|
||||||
patchModel(settings, {
|
|
||||||
editorFontSize: clamp(Number.parseInt(v, 10) || 14, 8, 30),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection title="Editor">
|
|
||||||
<ModelSettingRowSelect
|
|
||||||
model={settings}
|
|
||||||
modelKey="editorKeymap"
|
|
||||||
title="Editor keymap"
|
|
||||||
description="Keyboard shortcut preset used by text editors."
|
|
||||||
options={keymaps}
|
|
||||||
/>
|
|
||||||
<ModelSettingRowBoolean
|
|
||||||
model={settings}
|
|
||||||
modelKey="editorSoftWrap"
|
|
||||||
title="Wrap editor lines"
|
|
||||||
description="Wrap long lines in request and response editors."
|
|
||||||
/>
|
|
||||||
<ModelSettingRowBoolean
|
|
||||||
model={settings}
|
|
||||||
modelKey="coloredMethods"
|
|
||||||
title="Colorize request methods"
|
|
||||||
description="Use method-specific colors for HTTP request methods."
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection title="Window">
|
|
||||||
<NativeTitlebarSetting settings={settings} />
|
|
||||||
{type() !== "macos" && (
|
|
||||||
<ModelSettingRowBoolean
|
|
||||||
model={settings}
|
|
||||||
modelKey="hideWindowControls"
|
|
||||||
title="Hide window controls"
|
|
||||||
description="Hide the close, maximize, and minimize controls on Windows or Linux."
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<CargoFeature feature="license">
|
|
||||||
<LicenseSettings settings={settings} />
|
|
||||||
</CargoFeature>
|
|
||||||
</SettingsList>
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NativeTitlebarSetting({ settings }: { settings: Settings }) {
|
|
||||||
const [nativeTitlebar, setNativeTitlebar] = useState(settings.useNativeTitlebar);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingRow
|
|
||||||
title="Native title bar"
|
|
||||||
description="Use the operating system's standard title bar and window controls."
|
|
||||||
controlClassName="gap-2"
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
hideLabel
|
|
||||||
size="md"
|
|
||||||
checked={nativeTitlebar}
|
|
||||||
title="Native title bar"
|
|
||||||
onChange={setNativeTitlebar}
|
|
||||||
/>
|
|
||||||
{settings.useNativeTitlebar !== nativeTitlebar && (
|
|
||||||
<Button
|
|
||||||
color="primary"
|
|
||||||
size="xs"
|
|
||||||
onClick={async () => {
|
|
||||||
await patchModel(settings, { useNativeTitlebar: nativeTitlebar });
|
|
||||||
await invokeCmd("cmd_restart");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Apply and Restart
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</SettingRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function LicenseSettings({ settings }: { settings: Settings }) {
|
|
||||||
const license = useLicense();
|
|
||||||
if (license.check.data?.status !== "personal_use") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsSection title="License">
|
|
||||||
<SettingRowBoolean
|
|
||||||
checked={settings.hideLicenseBadge}
|
|
||||||
title="Hide personal use badge"
|
|
||||||
description="Hide the personal-use badge from the interface."
|
|
||||||
onChange={async (hideLicenseBadge) => {
|
|
||||||
if (hideLicenseBadge) {
|
|
||||||
const confirmed = await showConfirm({
|
|
||||||
id: "hide-license-badge",
|
|
||||||
title: "Confirm Personal Use",
|
|
||||||
confirmText: "Confirm",
|
|
||||||
description: (
|
|
||||||
<VStack space={3}>
|
|
||||||
<p>Hey there 👋🏼</p>
|
|
||||||
<p>
|
|
||||||
Yaak is free for personal projects and learning.{" "}
|
|
||||||
<strong>If you’re using Yaak at work, a license is required.</strong>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Licenses help keep Yaak independent and sustainable.{" "}
|
|
||||||
<Link href="https://yaak.app/pricing?s=badge">Purchase a License →</Link>
|
|
||||||
</p>
|
|
||||||
</VStack>
|
|
||||||
),
|
|
||||||
requireTyping: "Personal Use",
|
|
||||||
color: "info",
|
|
||||||
});
|
|
||||||
if (!confirmed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await patchModel(settings, { hideLicenseBadge });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
|
||||||
import type { ProxySetting } from "@yaakapp-internal/models";
|
|
||||||
import { Heading, InlineCode, VStack } from "@yaakapp-internal/ui";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import {
|
|
||||||
SettingRowBoolean,
|
|
||||||
SettingRowSelect,
|
|
||||||
SettingRowText,
|
|
||||||
SettingsList,
|
|
||||||
SettingsSection,
|
|
||||||
} from "../core/SettingRow";
|
|
||||||
|
|
||||||
export function SettingsProxy() {
|
|
||||||
const settings = useAtomValue(settingsAtom);
|
|
||||||
const proxy = enabledProxyOrDefault(settings.proxy);
|
|
||||||
|
|
||||||
const patchProxy = async (patch: Partial<EnabledProxySetting>) => {
|
|
||||||
await patchModel(settings, {
|
|
||||||
proxy: {
|
|
||||||
...proxy,
|
|
||||||
...patch,
|
|
||||||
auth: Object.hasOwn(patch, "auth") ? (patch.auth ?? null) : proxy.auth,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VStack space={1.5} className="mb-4">
|
|
||||||
<div className="mb-3">
|
|
||||||
<Heading>Proxy</Heading>
|
|
||||||
<p className="text-text-subtle">
|
|
||||||
Configure a proxy server for HTTP requests. Useful for corporate firewalls, debugging
|
|
||||||
traffic, or routing through specific infrastructure.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<SettingsList className="space-y-8">
|
|
||||||
<SettingsSection title="Proxy">
|
|
||||||
<SettingRowSelect
|
|
||||||
title="Proxy"
|
|
||||||
description="Choose how Yaak should discover or use proxy settings."
|
|
||||||
name="proxy"
|
|
||||||
value={settings.proxy?.type ?? "automatic"}
|
|
||||||
onChange={async (v) => {
|
|
||||||
if (v === "automatic") {
|
|
||||||
await patchModel(settings, { proxy: undefined });
|
|
||||||
} else if (v === "enabled") {
|
|
||||||
await patchModel(settings, { proxy });
|
|
||||||
} else {
|
|
||||||
await patchModel(settings, { proxy: { type: "disabled" } });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
options={[
|
|
||||||
{ label: "Automatic proxy detection", value: "automatic" },
|
|
||||||
{ label: "Custom proxy configuration", value: "enabled" },
|
|
||||||
{ label: "No proxy", value: "disabled" },
|
|
||||||
]}
|
|
||||||
selectClassName="!w-64"
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
{settings.proxy?.type === "enabled" && (
|
|
||||||
<>
|
|
||||||
<SettingsSection title="Custom Proxy">
|
|
||||||
<SettingRowBoolean
|
|
||||||
checked={!settings.proxy.disabled}
|
|
||||||
title="Enable proxy"
|
|
||||||
description="Temporarily disable the proxy without losing the configuration."
|
|
||||||
onChange={(enabled) => patchProxy({ disabled: !enabled })}
|
|
||||||
/>
|
|
||||||
<SettingRowText
|
|
||||||
name="proxyHttp"
|
|
||||||
title={
|
|
||||||
<>
|
|
||||||
Proxy for <InlineCode>http://</InlineCode> traffic
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
description="Proxy host used for unencrypted HTTP traffic."
|
|
||||||
value={settings.proxy.http}
|
|
||||||
placeholder="localhost:9090"
|
|
||||||
onChange={(http) => patchProxy({ http })}
|
|
||||||
/>
|
|
||||||
<SettingRowText
|
|
||||||
name="proxyHttps"
|
|
||||||
title={
|
|
||||||
<>
|
|
||||||
Proxy for <InlineCode>https://</InlineCode> traffic
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
description="Proxy host used for HTTPS traffic."
|
|
||||||
value={settings.proxy.https}
|
|
||||||
placeholder="localhost:9090"
|
|
||||||
onChange={(https) => patchProxy({ https })}
|
|
||||||
/>
|
|
||||||
<SettingRowText
|
|
||||||
name="proxyBypass"
|
|
||||||
title="Proxy Bypass"
|
|
||||||
description="Comma-separated list of hosts that should bypass the proxy."
|
|
||||||
value={settings.proxy.bypass}
|
|
||||||
placeholder="127.0.0.1, *.example.com, localhost:3000"
|
|
||||||
inputWidthClassName="!w-96"
|
|
||||||
onChange={(bypass) => patchProxy({ bypass })}
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection title="Authentication">
|
|
||||||
<SettingRowBoolean
|
|
||||||
checked={settings.proxy.auth != null}
|
|
||||||
title="Enable authentication"
|
|
||||||
description="Send proxy credentials with proxied requests."
|
|
||||||
onChange={(enabled) =>
|
|
||||||
patchProxy({ auth: enabled ? { user: "", password: "" } : null })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{settings.proxy.auth != null && (
|
|
||||||
<>
|
|
||||||
<SettingRowText
|
|
||||||
required
|
|
||||||
name="proxyUser"
|
|
||||||
title="User"
|
|
||||||
description="Username for proxy authentication."
|
|
||||||
value={settings.proxy.auth.user}
|
|
||||||
placeholder="myUser"
|
|
||||||
onChange={(user) =>
|
|
||||||
patchProxy({
|
|
||||||
auth: {
|
|
||||||
user,
|
|
||||||
password:
|
|
||||||
settings.proxy?.type === "enabled"
|
|
||||||
? (settings.proxy.auth?.password ?? "")
|
|
||||||
: "",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<SettingRowText
|
|
||||||
name="proxyPassword"
|
|
||||||
title="Password"
|
|
||||||
description="Password for proxy authentication."
|
|
||||||
value={settings.proxy.auth.password}
|
|
||||||
placeholder="s3cretPassw0rd"
|
|
||||||
type="password"
|
|
||||||
onChange={(password) =>
|
|
||||||
patchProxy({
|
|
||||||
auth: {
|
|
||||||
user:
|
|
||||||
settings.proxy?.type === "enabled"
|
|
||||||
? (settings.proxy.auth?.user ?? "")
|
|
||||||
: "",
|
|
||||||
password,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</SettingsSection>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</SettingsList>
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type EnabledProxySetting = Extract<ProxySetting, { type: "enabled" }>;
|
|
||||||
|
|
||||||
function enabledProxyOrDefault(proxy: ProxySetting | null): EnabledProxySetting {
|
|
||||||
if (proxy?.type === "enabled") return proxy;
|
|
||||||
|
|
||||||
return {
|
|
||||||
disabled: false,
|
|
||||||
type: "enabled",
|
|
||||||
http: "",
|
|
||||||
https: "",
|
|
||||||
auth: { user: "", password: "" },
|
|
||||||
bypass: "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
|
||||||
import { Heading, HStack, Icon, type IconProps, VStack } from "@yaakapp-internal/ui";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { lazy, Suspense } from "react";
|
|
||||||
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
|
|
||||||
import { useResolvedAppearance } from "../../hooks/useResolvedAppearance";
|
|
||||||
import { useResolvedTheme } from "../../hooks/useResolvedTheme";
|
|
||||||
import type { ButtonProps } from "../core/Button";
|
|
||||||
import { IconButton } from "../core/IconButton";
|
|
||||||
import { Link } from "../core/Link";
|
|
||||||
import type { SelectProps } from "../core/Select";
|
|
||||||
import {
|
|
||||||
ModelSettingRowSelect,
|
|
||||||
SettingRowSelect,
|
|
||||||
SettingsList,
|
|
||||||
SettingsSection,
|
|
||||||
} from "../core/SettingRow";
|
|
||||||
|
|
||||||
const Editor = lazy(() => import("../core/Editor/Editor").then((m) => ({ default: m.Editor })));
|
|
||||||
|
|
||||||
const buttonColors: ButtonProps["color"][] = [
|
|
||||||
"primary",
|
|
||||||
"info",
|
|
||||||
"success",
|
|
||||||
"notice",
|
|
||||||
"warning",
|
|
||||||
"danger",
|
|
||||||
"secondary",
|
|
||||||
"default",
|
|
||||||
];
|
|
||||||
|
|
||||||
const icons: IconProps["icon"][] = [
|
|
||||||
"info",
|
|
||||||
"box",
|
|
||||||
"update",
|
|
||||||
"alert_triangle",
|
|
||||||
"arrow_big_right_dash",
|
|
||||||
"download",
|
|
||||||
"copy",
|
|
||||||
"magic_wand",
|
|
||||||
"settings",
|
|
||||||
"trash",
|
|
||||||
"sparkles",
|
|
||||||
"pencil",
|
|
||||||
"paste",
|
|
||||||
"search",
|
|
||||||
"send_horizontal",
|
|
||||||
];
|
|
||||||
|
|
||||||
export function SettingsTheme() {
|
|
||||||
const workspace = useAtomValue(activeWorkspaceAtom);
|
|
||||||
const settings = useAtomValue(settingsAtom);
|
|
||||||
const appearance = useResolvedAppearance();
|
|
||||||
const activeTheme = useResolvedTheme();
|
|
||||||
|
|
||||||
if (settings == null || workspace == null || activeTheme.data == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lightThemes: SelectProps<string>["options"] = activeTheme.data.themes
|
|
||||||
.filter((theme) => !theme.dark)
|
|
||||||
.map((theme) => ({
|
|
||||||
label: theme.label,
|
|
||||||
value: theme.id,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const darkThemes: SelectProps<string>["options"] = activeTheme.data.themes
|
|
||||||
.filter((theme) => theme.dark)
|
|
||||||
.map((theme) => ({
|
|
||||||
label: theme.label,
|
|
||||||
value: theme.id,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VStack space={1.5} className="mb-4">
|
|
||||||
<div className="mb-3">
|
|
||||||
<Heading>Theme</Heading>
|
|
||||||
<p className="text-text-subtle">
|
|
||||||
Make Yaak your own by selecting a theme, or{" "}
|
|
||||||
<Link href="https://yaak.app/docs/plugin-development/plugins-quick-start">
|
|
||||||
Create Your Own
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<SettingsList className="space-y-8">
|
|
||||||
<SettingsSection title="Theme">
|
|
||||||
<ModelSettingRowSelect
|
|
||||||
model={settings}
|
|
||||||
modelKey="appearance"
|
|
||||||
title="Appearance"
|
|
||||||
description="Choose whether Yaak follows your system appearance or uses a fixed mode."
|
|
||||||
options={[
|
|
||||||
{ label: "Automatic", value: "system" },
|
|
||||||
{ label: "Light", value: "light" },
|
|
||||||
{ label: "Dark", value: "dark" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
{(settings.appearance === "system" || settings.appearance === "light") && (
|
|
||||||
<SettingRowSelect
|
|
||||||
name="lightTheme"
|
|
||||||
title="Light theme"
|
|
||||||
description="Theme used when Yaak is in light mode."
|
|
||||||
value={activeTheme.data.light.id}
|
|
||||||
options={lightThemes}
|
|
||||||
onChange={(themeLight) => patchModel(settings, { themeLight })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{(settings.appearance === "system" || settings.appearance === "dark") && (
|
|
||||||
<SettingRowSelect
|
|
||||||
name="darkTheme"
|
|
||||||
title="Dark theme"
|
|
||||||
description="Theme used when Yaak is in dark mode."
|
|
||||||
value={activeTheme.data.dark.id}
|
|
||||||
options={darkThemes}
|
|
||||||
onChange={(themeDark) => patchModel(settings, { themeDark })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection title="Preview">
|
|
||||||
<VStack
|
|
||||||
space={3}
|
|
||||||
className="mt-4 w-full bg-surface p-3 border border-dashed border-border-subtle rounded overflow-x-auto"
|
|
||||||
>
|
|
||||||
<HStack className="text" space={1.5}>
|
|
||||||
<Icon icon={appearance === "dark" ? "moon" : "sun"} />
|
|
||||||
<strong>{activeTheme.data.active.label}</strong>
|
|
||||||
<em>(preview)</em>
|
|
||||||
</HStack>
|
|
||||||
<HStack space={1.5} className="w-full">
|
|
||||||
{buttonColors.map((c, i) => (
|
|
||||||
<IconButton
|
|
||||||
key={c}
|
|
||||||
color={c}
|
|
||||||
size="2xs"
|
|
||||||
iconSize="xs"
|
|
||||||
icon={icons[i % icons.length] ?? "info"}
|
|
||||||
iconClassName="text"
|
|
||||||
title={`${c}`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{buttonColors.map((c, i) => (
|
|
||||||
<IconButton
|
|
||||||
key={c}
|
|
||||||
color={c}
|
|
||||||
variant="border"
|
|
||||||
size="2xs"
|
|
||||||
iconSize="xs"
|
|
||||||
icon={icons[i % icons.length] ?? "info"}
|
|
||||||
iconClassName="text"
|
|
||||||
title={`${c}`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</HStack>
|
|
||||||
<Suspense>
|
|
||||||
<Editor
|
|
||||||
defaultValue={[
|
|
||||||
"let foo = { // Demo code editor",
|
|
||||||
' foo: ("bar" || "baz" ?? \'qux\'),',
|
|
||||||
" baz: [1, 10.2, null, false, true],",
|
|
||||||
"};",
|
|
||||||
].join("\n")}
|
|
||||||
heightMode="auto"
|
|
||||||
language="javascript"
|
|
||||||
stateKey={null}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
</VStack>
|
|
||||||
</SettingsSection>
|
|
||||||
</SettingsList>
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
|
||||||
import { useLicense } from "@yaakapp-internal/license";
|
|
||||||
import { useRef } from "react";
|
|
||||||
import { openSettings } from "../commands/openSettings";
|
|
||||||
import { useCheckForUpdates } from "../hooks/useCheckForUpdates";
|
|
||||||
import { useExportData } from "../hooks/useExportData";
|
|
||||||
import { appInfo } from "../lib/appInfo";
|
|
||||||
import { showDialog } from "../lib/dialog";
|
|
||||||
import { importData } from "../lib/importData";
|
|
||||||
import type { DropdownRef } from "./core/Dropdown";
|
|
||||||
import { Dropdown } from "./core/Dropdown";
|
|
||||||
import { Icon } from "@yaakapp-internal/ui";
|
|
||||||
import { IconButton } from "./core/IconButton";
|
|
||||||
import { KeyboardShortcutsDialog } from "./KeyboardShortcutsDialog";
|
|
||||||
|
|
||||||
export function SettingsDropdown() {
|
|
||||||
const exportData = useExportData();
|
|
||||||
const dropdownRef = useRef<DropdownRef>(null);
|
|
||||||
const checkForUpdates = useCheckForUpdates();
|
|
||||||
const { check } = useLicense();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dropdown
|
|
||||||
ref={dropdownRef}
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
label: "Settings",
|
|
||||||
hotKeyAction: "settings.show",
|
|
||||||
leftSlot: <Icon icon="settings" />,
|
|
||||||
onSelect: () => openSettings.mutate(null),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Keyboard shortcuts",
|
|
||||||
hotKeyAction: "hotkeys.showHelp",
|
|
||||||
leftSlot: <Icon icon="keyboard" />,
|
|
||||||
onSelect: () => {
|
|
||||||
showDialog({
|
|
||||||
id: "hotkey",
|
|
||||||
title: "Keyboard Shortcuts",
|
|
||||||
size: "dynamic",
|
|
||||||
render: () => <KeyboardShortcutsDialog />,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Plugins",
|
|
||||||
leftSlot: <Icon icon="puzzle" />,
|
|
||||||
onSelect: () => openSettings.mutate("plugins"),
|
|
||||||
},
|
|
||||||
{ type: "separator", label: "Share Workspace(s)" },
|
|
||||||
{
|
|
||||||
label: "Import Data",
|
|
||||||
leftSlot: <Icon icon="folder_input" />,
|
|
||||||
onSelect: () => importData.mutate(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Export Data",
|
|
||||||
leftSlot: <Icon icon="folder_output" />,
|
|
||||||
onSelect: () => exportData.mutate(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Create Run Button",
|
|
||||||
leftSlot: <Icon icon="rocket" />,
|
|
||||||
onSelect: () => openUrl("https://yaak.app/button/new"),
|
|
||||||
},
|
|
||||||
{ type: "separator", label: `Yaak v${appInfo.version}` },
|
|
||||||
{
|
|
||||||
label: "Check for Updates",
|
|
||||||
leftSlot: <Icon icon="update" />,
|
|
||||||
hidden: !appInfo.featureUpdater,
|
|
||||||
onSelect: () => checkForUpdates.mutate(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Purchase License",
|
|
||||||
color: "success",
|
|
||||||
hidden: check.data == null || check.data.status === "active",
|
|
||||||
leftSlot: <Icon icon="circle_dollar_sign" />,
|
|
||||||
rightSlot: <Icon icon="external_link" color="success" className="opacity-60" />,
|
|
||||||
onSelect: () => openUrl("https://yaak.app/pricing"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Install CLI",
|
|
||||||
hidden: appInfo.cliVersion != null,
|
|
||||||
leftSlot: <Icon icon="square_terminal" />,
|
|
||||||
rightSlot: <Icon icon="external_link" color="secondary" />,
|
|
||||||
onSelect: () => openUrl("https://yaak.app/docs/cli"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Feedback",
|
|
||||||
leftSlot: <Icon icon="chat" />,
|
|
||||||
rightSlot: <Icon icon="external_link" color="secondary" />,
|
|
||||||
onSelect: () => openUrl("https://yaak.app/feedback"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Changelog",
|
|
||||||
leftSlot: <Icon icon="cake" />,
|
|
||||||
rightSlot: <Icon icon="external_link" color="secondary" />,
|
|
||||||
onSelect: () => openUrl(`https://yaak.app/changelog/${appInfo.version}`),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
size="sm"
|
|
||||||
title="Main Menu"
|
|
||||||
icon="settings"
|
|
||||||
iconColor="secondary"
|
|
||||||
className="pointer-events-auto"
|
|
||||||
/>
|
|
||||||
</Dropdown>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,39 +0,0 @@
|
|||||||
import { HStack } from "@yaakapp-internal/ui";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { useFloatingSidebarHidden } from "../hooks/useFloatingSidebarHidden";
|
|
||||||
import { useSidebarHidden } from "../hooks/useSidebarHidden";
|
|
||||||
import { CreateDropdown } from "./CreateDropdown";
|
|
||||||
import { IconButton } from "./core/IconButton";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
floating?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SidebarActions({ floating = false }: Props) {
|
|
||||||
const [sidebarHidden, setSidebarHidden] = useSidebarHidden();
|
|
||||||
const [floatingHidden, setFloatingHidden] = useFloatingSidebarHidden();
|
|
||||||
|
|
||||||
const hidden = floating ? floatingHidden : sidebarHidden;
|
|
||||||
const setHidden = useMemo(
|
|
||||||
() => (floating ? setFloatingHidden : setSidebarHidden),
|
|
||||||
[floating, setFloatingHidden, setSidebarHidden],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<HStack className="h-full">
|
|
||||||
<IconButton
|
|
||||||
onClick={async () => {
|
|
||||||
await setHidden(!hidden);
|
|
||||||
}}
|
|
||||||
className="pointer-events-auto"
|
|
||||||
size="sm"
|
|
||||||
title="Toggle sidebar"
|
|
||||||
icon={hidden ? "left_panel_hidden" : "left_panel_visible"}
|
|
||||||
iconColor="secondary"
|
|
||||||
/>
|
|
||||||
<CreateDropdown hotKeyAction="model.create">
|
|
||||||
<IconButton size="sm" icon="plus_circle" iconColor="secondary" title="Add Resource" />
|
|
||||||
</CreateDropdown>
|
|
||||||
</HStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
import { readDir } from "@tauri-apps/plugin-fs";
|
|
||||||
import { Banner, VStack } from "@yaakapp-internal/ui";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
|
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import { Checkbox } from "./core/Checkbox";
|
|
||||||
import { SettingRowBoolean, SettingRowDirectory } from "./core/SettingRow";
|
|
||||||
import { SelectFile } from "./SelectFile";
|
|
||||||
|
|
||||||
export interface SyncToFilesystemSettingProps {
|
|
||||||
layout?: "form" | "settings";
|
|
||||||
onChange: (args: { filePath: string | null; initGit?: boolean }) => void;
|
|
||||||
onCreateNewWorkspace: () => void;
|
|
||||||
value: { filePath: string | null; initGit?: boolean };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SyncToFilesystemSetting({
|
|
||||||
layout = "form",
|
|
||||||
onChange,
|
|
||||||
onCreateNewWorkspace,
|
|
||||||
value,
|
|
||||||
}: SyncToFilesystemSettingProps) {
|
|
||||||
const [syncDir, setSyncDir] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleFilePathChange = async (filePath: string | null) => {
|
|
||||||
if (filePath != null) {
|
|
||||||
const files = await readDir(filePath);
|
|
||||||
if (files.length > 0) {
|
|
||||||
setSyncDir(filePath);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setSyncDir(null);
|
|
||||||
onChange({ ...value, filePath });
|
|
||||||
};
|
|
||||||
|
|
||||||
if (layout === "settings") {
|
|
||||||
return (
|
|
||||||
<VStack className="w-full" space={0}>
|
|
||||||
{syncDir && (
|
|
||||||
<Banner color="notice" className="mb-3 flex flex-col gap-1.5">
|
|
||||||
<p>Directory is not empty. Do you want to open it instead?</p>
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
variant="border"
|
|
||||||
color="notice"
|
|
||||||
size="xs"
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
openWorkspaceFromSyncDir.mutate(syncDir);
|
|
||||||
onCreateNewWorkspace();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Open Workspace
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Banner>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<SettingRowDirectory
|
|
||||||
title="Local directory sync"
|
|
||||||
description="Sync data to a folder for backup and Git integration."
|
|
||||||
filePath={value.filePath}
|
|
||||||
onChange={handleFilePathChange}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{value.filePath && typeof value.initGit === "boolean" && (
|
|
||||||
<SettingRowBoolean
|
|
||||||
checked={value.initGit}
|
|
||||||
title="Initialize Git Repo"
|
|
||||||
description="Create a Git repository in the selected sync directory."
|
|
||||||
onChange={(initGit) => onChange({ ...value, initGit })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VStack className="w-full my-2" space={3}>
|
|
||||||
{syncDir && (
|
|
||||||
<Banner color="notice" className="flex flex-col gap-1.5">
|
|
||||||
<p>Directory is not empty. Do you want to open it instead?</p>
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
variant="border"
|
|
||||||
color="notice"
|
|
||||||
size="xs"
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
openWorkspaceFromSyncDir.mutate(syncDir);
|
|
||||||
onCreateNewWorkspace();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Open Workspace
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Banner>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<SelectFile
|
|
||||||
directory
|
|
||||||
label="Local directory sync"
|
|
||||||
size="xs"
|
|
||||||
noun="Directory"
|
|
||||||
help="Sync data to a folder for backup and Git integration."
|
|
||||||
filePath={value.filePath}
|
|
||||||
onChange={async ({ filePath }) => handleFilePathChange(filePath)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{value.filePath && typeof value.initGit === "boolean" && (
|
|
||||||
<Checkbox
|
|
||||||
checked={value.initGit}
|
|
||||||
onChange={(initGit) => onChange({ ...value, initGit })}
|
|
||||||
title="Initialize Git Repo"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import type { WebsocketRequest } from "@yaakapp-internal/models";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import type { CSSProperties } from "react";
|
|
||||||
|
|
||||||
import { SplitLayout } from "@yaakapp-internal/ui";
|
|
||||||
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
|
|
||||||
import { workspaceLayoutAtom } from "../lib/atoms";
|
|
||||||
import { WebsocketRequestPane } from "./WebsocketRequestPane";
|
|
||||||
import { WebsocketResponsePane } from "./WebsocketResponsePane";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
activeRequest: WebsocketRequest;
|
|
||||||
style: CSSProperties;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WebsocketRequestLayout({ activeRequest, style }: Props) {
|
|
||||||
const workspaceLayout = useAtomValue(workspaceLayoutAtom);
|
|
||||||
const activeWorkspace = useAtomValue(activeWorkspaceAtom);
|
|
||||||
const wsId = activeWorkspace?.id ?? "n/a";
|
|
||||||
return (
|
|
||||||
<SplitLayout
|
|
||||||
storageKey={`websocket_layout::${wsId}`}
|
|
||||||
className="p-3 gap-1.5"
|
|
||||||
layout={workspaceLayout}
|
|
||||||
style={style}
|
|
||||||
firstSlot={({ orientation, style }) => (
|
|
||||||
<WebsocketRequestPane
|
|
||||||
style={style}
|
|
||||||
activeRequest={activeRequest}
|
|
||||||
fullHeight={orientation === "horizontal"}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
secondSlot={({ style }) => (
|
|
||||||
<div
|
|
||||||
style={style}
|
|
||||||
className={classNames(
|
|
||||||
"x-theme-responsePane",
|
|
||||||
"max-h-full h-full grid grid-rows-[minmax(0,1fr)] grid-cols-1",
|
|
||||||
"bg-surface rounded-md border border-border-subtle",
|
|
||||||
"shadow relative",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<WebsocketResponsePane activeRequest={activeRequest} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
import type { WebsocketEvent, WebsocketRequest } from "@yaakapp-internal/models";
|
|
||||||
import { HStack, Icon, LoadingIcon, VStack } from "@yaakapp-internal/ui";
|
|
||||||
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 { 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}
|
|
||||||
splitLayoutStorageKey="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,221 +0,0 @@
|
|||||||
import { type } from "@tauri-apps/plugin-os";
|
|
||||||
import { settingsAtom, workspacesAtom } from "@yaakapp-internal/models";
|
|
||||||
import { Banner, HeaderSize, HStack, SidebarLayout } from "@yaakapp-internal/ui";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import * as m from "motion/react-m";
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
useEnsureActiveCookieJar,
|
|
||||||
useSubscribeActiveCookieJarId,
|
|
||||||
} from "../hooks/useActiveCookieJar";
|
|
||||||
import {
|
|
||||||
activeEnvironmentAtom,
|
|
||||||
useSubscribeActiveEnvironmentId,
|
|
||||||
} from "../hooks/useActiveEnvironment";
|
|
||||||
import { activeFolderAtom } from "../hooks/useActiveFolder";
|
|
||||||
import { useSubscribeActiveFolderId } from "../hooks/useActiveFolderId";
|
|
||||||
import { activeRequestAtom } from "../hooks/useActiveRequest";
|
|
||||||
import { useSubscribeActiveRequestId } from "../hooks/useActiveRequestId";
|
|
||||||
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
|
|
||||||
import { useFloatingSidebarHidden } from "../hooks/useFloatingSidebarHidden";
|
|
||||||
import { useHotKey } from "../hooks/useHotKey";
|
|
||||||
import { useSubscribeRecentCookieJars } from "../hooks/useRecentCookieJars";
|
|
||||||
import { useSubscribeRecentEnvironments } from "../hooks/useRecentEnvironments";
|
|
||||||
import { useSubscribeRecentRequests } from "../hooks/useRecentRequests";
|
|
||||||
import { useSubscribeRecentWorkspaces } from "../hooks/useRecentWorkspaces";
|
|
||||||
import { useSidebarHidden } from "../hooks/useSidebarHidden";
|
|
||||||
import { useSidebarWidth } from "../hooks/useSidebarWidth";
|
|
||||||
import { useSyncWorkspaceRequestTitle } from "../hooks/useSyncWorkspaceRequestTitle";
|
|
||||||
import { duplicateRequestOrFolderAndNavigate } from "../lib/duplicateRequestOrFolderAndNavigate";
|
|
||||||
import { importData } from "../lib/importData";
|
|
||||||
import { jotaiStore } from "../lib/jotai";
|
|
||||||
import { CreateDropdown } from "./CreateDropdown";
|
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import { HotkeyList } from "./core/HotkeyList";
|
|
||||||
import { FeedbackLink } from "./core/Link";
|
|
||||||
import { ErrorBoundary } from "./ErrorBoundary";
|
|
||||||
import { FolderLayout } from "./FolderLayout";
|
|
||||||
import { GrpcConnectionLayout } from "./GrpcConnectionLayout";
|
|
||||||
import { HttpRequestLayout } from "./HttpRequestLayout";
|
|
||||||
import Sidebar from "./Sidebar";
|
|
||||||
import { SidebarActions } from "./SidebarActions";
|
|
||||||
import { WebsocketRequestLayout } from "./WebsocketRequestLayout";
|
|
||||||
import { WorkspaceHeader } from "./WorkspaceHeader";
|
|
||||||
|
|
||||||
const body = { gridArea: "body" };
|
|
||||||
|
|
||||||
export function Workspace() {
|
|
||||||
// First, subscribe to some things applicable to workspaces
|
|
||||||
useGlobalWorkspaceHooks();
|
|
||||||
|
|
||||||
const workspaces = useAtomValue(workspacesAtom);
|
|
||||||
const settings = useAtomValue(settingsAtom);
|
|
||||||
const osType = type();
|
|
||||||
const [width, setWidth] = useSidebarWidth();
|
|
||||||
const [sidebarHidden, setSidebarHidden] = useSidebarHidden();
|
|
||||||
const [floatingSidebarHidden, setFloatingSidebarHidden] = useFloatingSidebarHidden();
|
|
||||||
const activeEnvironment = useAtomValue(activeEnvironmentAtom);
|
|
||||||
const [floating, setFloating] = useState(false);
|
|
||||||
|
|
||||||
const environmentBgStyle = useMemo(() => {
|
|
||||||
if (activeEnvironment?.color == null) return undefined;
|
|
||||||
const background = `linear-gradient(to right, ${activeEnvironment.color} 15%, transparent 40%)`;
|
|
||||||
return { background };
|
|
||||||
}, [activeEnvironment?.color]);
|
|
||||||
|
|
||||||
// We're loading still
|
|
||||||
if (workspaces.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const header = (
|
|
||||||
<HeaderSize
|
|
||||||
data-tauri-drag-region
|
|
||||||
size="lg"
|
|
||||||
className="relative x-theme-appHeader bg-surface"
|
|
||||||
osType={osType}
|
|
||||||
hideWindowControls={settings.hideWindowControls}
|
|
||||||
useNativeTitlebar={settings.useNativeTitlebar}
|
|
||||||
interfaceScale={settings.interfaceScale}
|
|
||||||
>
|
|
||||||
<div className="absolute inset-0 pointer-events-none">
|
|
||||||
<div style={environmentBgStyle} className="absolute inset-0 opacity-[0.07]" />
|
|
||||||
<div
|
|
||||||
style={environmentBgStyle}
|
|
||||||
className="absolute left-0 right-0 -bottom-[1px] h-[1px] opacity-20"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<WorkspaceHeader className="pointer-events-none" floatingSidebar={floating} />
|
|
||||||
</HeaderSize>
|
|
||||||
);
|
|
||||||
|
|
||||||
const workspaceBody = (
|
|
||||||
<ErrorBoundary name="Workspace Body">
|
|
||||||
<WorkspaceBody />
|
|
||||||
</ErrorBoundary>
|
|
||||||
);
|
|
||||||
|
|
||||||
const sidebarContent = floating ? (
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
"x-theme-sidebar",
|
|
||||||
"h-full bg-surface border-r border-border-subtle",
|
|
||||||
"grid grid-rows-[auto_1fr]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<HeaderSize
|
|
||||||
hideControls
|
|
||||||
size="lg"
|
|
||||||
className="border-transparent flex items-center"
|
|
||||||
osType={osType}
|
|
||||||
hideWindowControls={settings.hideWindowControls}
|
|
||||||
useNativeTitlebar={settings.useNativeTitlebar}
|
|
||||||
interfaceScale={settings.interfaceScale}
|
|
||||||
>
|
|
||||||
<SidebarActions floating />
|
|
||||||
</HeaderSize>
|
|
||||||
<ErrorBoundary name="Sidebar (Floating)">
|
|
||||||
<Sidebar />
|
|
||||||
</ErrorBoundary>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="x-theme-sidebar overflow-hidden bg-surface h-full">
|
|
||||||
<ErrorBoundary name="Sidebar">
|
|
||||||
<Sidebar className="border-r border-border-subtle" />
|
|
||||||
</ErrorBoundary>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid w-full h-full grid-rows-[auto_minmax(0,1fr)]">
|
|
||||||
{header}
|
|
||||||
<SidebarLayout
|
|
||||||
width={width ?? 250}
|
|
||||||
onWidthChange={setWidth}
|
|
||||||
hidden={sidebarHidden ?? false}
|
|
||||||
onHiddenChange={(hidden) => setSidebarHidden(hidden)}
|
|
||||||
floatingHidden={floatingSidebarHidden ?? true}
|
|
||||||
onFloatingHiddenChange={(hidden) => setFloatingSidebarHidden(hidden)}
|
|
||||||
onFloatingChange={setFloating}
|
|
||||||
sidebar={sidebarContent}
|
|
||||||
>
|
|
||||||
{workspaceBody}
|
|
||||||
</SidebarLayout>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function WorkspaceBody() {
|
|
||||||
const activeRequest = useAtomValue(activeRequestAtom);
|
|
||||||
const activeFolder = useAtomValue(activeFolderAtom);
|
|
||||||
const activeWorkspace = useAtomValue(activeWorkspaceAtom);
|
|
||||||
|
|
||||||
if (activeWorkspace == null) {
|
|
||||||
return (
|
|
||||||
<m.div
|
|
||||||
className="m-auto"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
// Delay the entering because the workspaces might load after a slight delay
|
|
||||||
transition={{ delay: 0.5 }}
|
|
||||||
>
|
|
||||||
<Banner color="warning" className="max-w-[30rem]">
|
|
||||||
The active workspace was not found. Select a workspace from the header menu or report this
|
|
||||||
bug to <FeedbackLink />
|
|
||||||
</Banner>
|
|
||||||
</m.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeRequest?.model === "grpc_request") {
|
|
||||||
return <GrpcConnectionLayout style={body} />;
|
|
||||||
}
|
|
||||||
if (activeRequest?.model === "websocket_request") {
|
|
||||||
return <WebsocketRequestLayout style={body} activeRequest={activeRequest} />;
|
|
||||||
}
|
|
||||||
if (activeRequest?.model === "http_request") {
|
|
||||||
return <HttpRequestLayout activeRequest={activeRequest} style={body} />;
|
|
||||||
}
|
|
||||||
if (activeFolder != null) {
|
|
||||||
return <FolderLayout folder={activeFolder} style={body} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<HotkeyList
|
|
||||||
hotkeys={["model.create", "sidebar.focus", "settings.show"]}
|
|
||||||
bottomSlot={
|
|
||||||
<HStack space={1} justifyContent="center" className="mt-3">
|
|
||||||
<Button variant="border" size="sm" onClick={() => importData.mutate()}>
|
|
||||||
Import
|
|
||||||
</Button>
|
|
||||||
<CreateDropdown hideFolder>
|
|
||||||
<Button variant="border" forDropdown size="sm">
|
|
||||||
New Request
|
|
||||||
</Button>
|
|
||||||
</CreateDropdown>
|
|
||||||
</HStack>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function useGlobalWorkspaceHooks() {
|
|
||||||
useEnsureActiveCookieJar();
|
|
||||||
|
|
||||||
useSubscribeActiveRequestId();
|
|
||||||
useSubscribeActiveFolderId();
|
|
||||||
useSubscribeActiveEnvironmentId();
|
|
||||||
useSubscribeActiveCookieJarId();
|
|
||||||
|
|
||||||
useSubscribeRecentRequests();
|
|
||||||
useSubscribeRecentWorkspaces();
|
|
||||||
useSubscribeRecentEnvironments();
|
|
||||||
useSubscribeRecentCookieJars();
|
|
||||||
|
|
||||||
useSyncWorkspaceRequestTitle();
|
|
||||||
|
|
||||||
useHotKey("model.duplicate", () =>
|
|
||||||
duplicateRequestOrFolderAndNavigate(jotaiStore.get(activeRequestAtom)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
import { open } from "@tauri-apps/plugin-dialog";
|
|
||||||
import { revealItemInDir } from "@tauri-apps/plugin-opener";
|
|
||||||
import { getModel, settingsAtom, workspacesAtom } from "@yaakapp-internal/models";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { memo, useCallback, useMemo } from "react";
|
|
||||||
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
|
|
||||||
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
|
|
||||||
import { switchWorkspace } from "../commands/switchWorkspace";
|
|
||||||
import {
|
|
||||||
activeWorkspaceAtom,
|
|
||||||
activeWorkspaceIdAtom,
|
|
||||||
activeWorkspaceMetaAtom,
|
|
||||||
} from "../hooks/useActiveWorkspace";
|
|
||||||
import { useCreateWorkspace } from "../hooks/useCreateWorkspace";
|
|
||||||
import { useDeleteSendHistory } from "../hooks/useDeleteSendHistory";
|
|
||||||
import { useWorkspaceActions } from "../hooks/useWorkspaceActions";
|
|
||||||
import { showDialog } from "../lib/dialog";
|
|
||||||
import { jotaiStore } from "../lib/jotai";
|
|
||||||
import { revealInFinderText } from "../lib/reveal";
|
|
||||||
import { CloneGitRepositoryDialog } from "./CloneGitRepositoryDialog";
|
|
||||||
import type { ButtonProps } from "./core/Button";
|
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import type { DropdownItem } from "./core/Dropdown";
|
|
||||||
import { Icon } from "@yaakapp-internal/ui";
|
|
||||||
import type { RadioDropdownItem } from "./core/RadioDropdown";
|
|
||||||
import { RadioDropdown } from "./core/RadioDropdown";
|
|
||||||
import { SwitchWorkspaceDialog } from "./SwitchWorkspaceDialog";
|
|
||||||
|
|
||||||
type Props = Pick<ButtonProps, "className" | "justify" | "forDropdown" | "leftSlot">;
|
|
||||||
|
|
||||||
export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
|
|
||||||
className,
|
|
||||||
...buttonProps
|
|
||||||
}: Props) {
|
|
||||||
const workspaces = useAtomValue(workspacesAtom);
|
|
||||||
const workspace = useAtomValue(activeWorkspaceAtom);
|
|
||||||
const createWorkspace = useCreateWorkspace();
|
|
||||||
const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);
|
|
||||||
const { mutate: deleteSendHistory } = useDeleteSendHistory();
|
|
||||||
const workspaceActions = useWorkspaceActions();
|
|
||||||
|
|
||||||
const openCloneGitRepositoryDialog = useCallback(() => {
|
|
||||||
showDialog({
|
|
||||||
id: "clone-git-repository",
|
|
||||||
size: "md",
|
|
||||||
title: "Clone Git Repository",
|
|
||||||
render: ({ hide }) => <CloneGitRepositoryDialog hide={hide} />,
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const { workspaceItems, itemsAfter, itemsBefore } = useMemo<{
|
|
||||||
workspaceItems: RadioDropdownItem[];
|
|
||||||
itemsAfter: DropdownItem[];
|
|
||||||
itemsBefore: DropdownItem[];
|
|
||||||
}>(() => {
|
|
||||||
const workspaceItems: RadioDropdownItem[] = workspaces.map((w) => ({
|
|
||||||
key: w.id,
|
|
||||||
label: w.name,
|
|
||||||
value: w.id,
|
|
||||||
leftSlot: w.id === workspace?.id ? <Icon icon="check" /> : <Icon icon="empty" />,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const itemsBefore: DropdownItem[] = [
|
|
||||||
{
|
|
||||||
label: "New Workspace",
|
|
||||||
leftSlot: <Icon icon="plus" />,
|
|
||||||
submenu: [
|
|
||||||
{
|
|
||||||
label: "Create Empty",
|
|
||||||
leftSlot: <Icon icon="plus_circle" />,
|
|
||||||
onSelect: createWorkspace,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Open Folder",
|
|
||||||
leftSlot: <Icon icon="folder_open" />,
|
|
||||||
onSelect: async () => {
|
|
||||||
const dir = await open({
|
|
||||||
title: "Select Workspace Directory",
|
|
||||||
directory: true,
|
|
||||||
multiple: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (dir == null) return;
|
|
||||||
openWorkspaceFromSyncDir.mutate(dir);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Clone Git Repository",
|
|
||||||
leftSlot: <Icon icon="hard_drive_download" />,
|
|
||||||
onSelect: openCloneGitRepositoryDialog,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const itemsAfter: DropdownItem[] = [
|
|
||||||
...workspaceActions.map((a) => ({
|
|
||||||
label: a.label,
|
|
||||||
leftSlot: <Icon icon={a.icon ?? "empty"} />,
|
|
||||||
onSelect: async () => {
|
|
||||||
if (workspace != null) await a.call(workspace);
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
...(workspaceActions.length > 0 ? [{ type: "separator" as const }] : []),
|
|
||||||
{
|
|
||||||
label: "Workspace Settings",
|
|
||||||
leftSlot: <Icon icon="settings" />,
|
|
||||||
hotKeyAction: "workspace_settings.show",
|
|
||||||
onSelect: openWorkspaceSettings,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: revealInFinderText,
|
|
||||||
hidden: workspaceMeta == null || workspaceMeta.settingSyncDir == null,
|
|
||||||
leftSlot: <Icon icon="folder_symlink" />,
|
|
||||||
onSelect: async () => {
|
|
||||||
if (workspaceMeta?.settingSyncDir == null) return;
|
|
||||||
await revealItemInDir(workspaceMeta.settingSyncDir);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Clear Send History",
|
|
||||||
color: "warning",
|
|
||||||
leftSlot: <Icon icon="history" />,
|
|
||||||
onSelect: deleteSendHistory,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return { workspaceItems, itemsAfter, itemsBefore };
|
|
||||||
}, [
|
|
||||||
workspaces,
|
|
||||||
workspaceMeta,
|
|
||||||
deleteSendHistory,
|
|
||||||
createWorkspace,
|
|
||||||
openCloneGitRepositoryDialog,
|
|
||||||
workspace?.id,
|
|
||||||
workspace,
|
|
||||||
workspaceActions.map,
|
|
||||||
workspaceActions.length,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleSwitchWorkspace = useCallback(async (workspaceId: string | null) => {
|
|
||||||
if (workspaceId == null) return;
|
|
||||||
|
|
||||||
const settings = jotaiStore.get(settingsAtom);
|
|
||||||
const activeWorkspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
|
||||||
if (workspaceId === activeWorkspaceId) {
|
|
||||||
// Always open a new window if the selected one is already active
|
|
||||||
switchWorkspace.mutate({ workspaceId, inNewWindow: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (typeof settings.openWorkspaceNewWindow === "boolean") {
|
|
||||||
switchWorkspace.mutate({ workspaceId, inNewWindow: settings.openWorkspaceNewWindow });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspace = getModel("workspace", workspaceId);
|
|
||||||
if (workspace == null) return;
|
|
||||||
|
|
||||||
showDialog({
|
|
||||||
id: "switch-workspace",
|
|
||||||
size: "sm",
|
|
||||||
title: "Switch Workspace",
|
|
||||||
render: ({ hide }) => <SwitchWorkspaceDialog workspace={workspace} hide={hide} />,
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RadioDropdown
|
|
||||||
items={workspaceItems}
|
|
||||||
itemsAfter={itemsAfter}
|
|
||||||
itemsBefore={itemsBefore}
|
|
||||||
onChange={handleSwitchWorkspace}
|
|
||||||
value={workspace?.id ?? null}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
className={classNames(
|
|
||||||
className,
|
|
||||||
"text !px-2 truncate",
|
|
||||||
workspace === null && "italic opacity-disabled",
|
|
||||||
)}
|
|
||||||
{...buttonProps}
|
|
||||||
>
|
|
||||||
{workspace?.name ?? "Workspace"}
|
|
||||||
</Button>
|
|
||||||
</RadioDropdown>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import { HStack, Icon } from "@yaakapp-internal/ui";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
|
||||||
import { memo } from "react";
|
|
||||||
import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from "../hooks/useActiveWorkspace";
|
|
||||||
import { useToggleCommandPalette } from "../hooks/useToggleCommandPalette";
|
|
||||||
import { workspaceLayoutAtom } from "../lib/atoms";
|
|
||||||
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
|
|
||||||
import { CookieDropdown } from "./CookieDropdown";
|
|
||||||
import { IconButton } from "./core/IconButton";
|
|
||||||
import { PillButton } from "./core/PillButton";
|
|
||||||
import { EnvironmentActionsDropdown } from "./EnvironmentActionsDropdown";
|
|
||||||
import { ImportCurlButton } from "./ImportCurlButton";
|
|
||||||
import { LicenseBadge } from "./LicenseBadge";
|
|
||||||
import { RecentRequestsDropdown } from "./RecentRequestsDropdown";
|
|
||||||
import { SettingsDropdown } from "./SettingsDropdown";
|
|
||||||
import { SidebarActions } from "./SidebarActions";
|
|
||||||
import { WorkspaceActionsDropdown } from "./WorkspaceActionsDropdown";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
className?: string;
|
|
||||||
floatingSidebar?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const WorkspaceHeader = memo(function WorkspaceHeader({
|
|
||||||
className,
|
|
||||||
floatingSidebar,
|
|
||||||
}: Props) {
|
|
||||||
const togglePalette = useToggleCommandPalette();
|
|
||||||
const [workspaceLayout, setWorkspaceLayout] = useAtom(workspaceLayoutAtom);
|
|
||||||
const workspace = useAtomValue(activeWorkspaceAtom);
|
|
||||||
const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);
|
|
||||||
const showEncryptionSetup =
|
|
||||||
workspace != null &&
|
|
||||||
workspaceMeta != null &&
|
|
||||||
workspace.encryptionKeyChallenge != null &&
|
|
||||||
workspaceMeta.encryptionKey == null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
className,
|
|
||||||
"grid grid-cols-[auto_minmax(0,1fr)_auto] items-center w-full h-full",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<HStack space={0.5} className={classNames("flex-1 pointer-events-none")}>
|
|
||||||
<SidebarActions floating={floatingSidebar} />
|
|
||||||
<CookieDropdown />
|
|
||||||
<HStack className="min-w-0">
|
|
||||||
<WorkspaceActionsDropdown />
|
|
||||||
<Icon icon="chevron_right" color="secondary" />
|
|
||||||
<EnvironmentActionsDropdown className="w-auto pointer-events-auto" />
|
|
||||||
</HStack>
|
|
||||||
</HStack>
|
|
||||||
<div className="pointer-events-none w-full max-w-[30vw] mx-auto flex justify-center">
|
|
||||||
<RecentRequestsDropdown />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 flex gap-1 items-center h-full justify-end pointer-events-none pr-1">
|
|
||||||
<ImportCurlButton />
|
|
||||||
{showEncryptionSetup ? (
|
|
||||||
<PillButton color="danger" onClick={setupOrConfigureEncryption}>
|
|
||||||
Enter Encryption Key
|
|
||||||
</PillButton>
|
|
||||||
) : (
|
|
||||||
<LicenseBadge />
|
|
||||||
)}
|
|
||||||
<IconButton
|
|
||||||
icon={
|
|
||||||
workspaceLayout === "responsive"
|
|
||||||
? "magic_wand"
|
|
||||||
: workspaceLayout === "horizontal"
|
|
||||||
? "columns_2"
|
|
||||||
: "rows_2"
|
|
||||||
}
|
|
||||||
title={`Change to ${workspaceLayout === "horizontal" ? "vertical" : "horizontal"} layout`}
|
|
||||||
size="sm"
|
|
||||||
iconColor="secondary"
|
|
||||||
onClick={() =>
|
|
||||||
setWorkspaceLayout((prev) => (prev === "horizontal" ? "vertical" : "horizontal"))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
icon="search"
|
|
||||||
title="Search or execute a command"
|
|
||||||
size="sm"
|
|
||||||
hotkeyAction="command_palette.toggle"
|
|
||||||
iconColor="secondary"
|
|
||||||
onClick={togglePalette}
|
|
||||||
/>
|
|
||||||
<SettingsDropdown />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { Button as BaseButton, type ButtonProps as BaseButtonProps } from "@yaakapp-internal/ui";
|
|
||||||
import { forwardRef, useImperativeHandle, useRef } from "react";
|
|
||||||
import type { HotkeyAction } from "../../hooks/useHotKey";
|
|
||||||
import { useFormattedHotkey, useHotKey } from "../../hooks/useHotKey";
|
|
||||||
|
|
||||||
export type ButtonProps = BaseButtonProps & {
|
|
||||||
hotkeyAction?: HotkeyAction;
|
|
||||||
hotkeyLabelOnly?: boolean;
|
|
||||||
hotkeyPriority?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
|
||||||
{ hotkeyAction, hotkeyPriority, hotkeyLabelOnly, title, ...props }: ButtonProps,
|
|
||||||
ref,
|
|
||||||
) {
|
|
||||||
const hotkeyTrigger = useFormattedHotkey(hotkeyAction ?? null)?.join("");
|
|
||||||
const fullTitle = hotkeyTrigger ? `${title ?? ""} ${hotkeyTrigger}`.trim() : title;
|
|
||||||
|
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
||||||
useImperativeHandle<HTMLButtonElement | null, HTMLButtonElement | null>(
|
|
||||||
ref,
|
|
||||||
() => buttonRef.current,
|
|
||||||
);
|
|
||||||
|
|
||||||
useHotKey(
|
|
||||||
hotkeyAction ?? null,
|
|
||||||
() => {
|
|
||||||
buttonRef.current?.click();
|
|
||||||
},
|
|
||||||
{ priority: hotkeyPriority, enable: !hotkeyLabelOnly },
|
|
||||||
);
|
|
||||||
|
|
||||||
return <BaseButton ref={buttonRef} title={fullTitle} {...props} />;
|
|
||||||
});
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import { HStack, Icon } from "@yaakapp-internal/ui";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { IconTooltip } from "./IconTooltip";
|
|
||||||
|
|
||||||
export interface CheckboxProps {
|
|
||||||
checked: boolean | "indeterminate";
|
|
||||||
title: ReactNode;
|
|
||||||
onChange: (checked: boolean) => void;
|
|
||||||
className?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
inputWrapperClassName?: string;
|
|
||||||
hideLabel?: boolean;
|
|
||||||
fullWidth?: boolean;
|
|
||||||
help?: ReactNode;
|
|
||||||
size?: "sm" | "md";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Checkbox({
|
|
||||||
checked,
|
|
||||||
onChange,
|
|
||||||
className,
|
|
||||||
inputWrapperClassName,
|
|
||||||
disabled,
|
|
||||||
title,
|
|
||||||
hideLabel,
|
|
||||||
fullWidth,
|
|
||||||
help,
|
|
||||||
size = "sm",
|
|
||||||
}: CheckboxProps) {
|
|
||||||
return (
|
|
||||||
<HStack
|
|
||||||
as="label"
|
|
||||||
alignItems="center"
|
|
||||||
space={2}
|
|
||||||
className={classNames(className, "text-text mr-auto")}
|
|
||||||
>
|
|
||||||
<div className={classNames(inputWrapperClassName, "x-theme-input", "relative flex mr-0.5")}>
|
|
||||||
<input
|
|
||||||
aria-hidden
|
|
||||||
className={classNames(
|
|
||||||
"appearance-none flex-shrink-0 border border-border",
|
|
||||||
size === "sm" && "w-4 h-4",
|
|
||||||
size === "md" && "w-5 h-5",
|
|
||||||
"rounded outline-none ring-0",
|
|
||||||
!disabled && "hocus:border-border-focus hocus:bg-focus/[5%]",
|
|
||||||
disabled && "border-dotted",
|
|
||||||
)}
|
|
||||||
type="checkbox"
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={() => {
|
|
||||||
onChange(checked === "indeterminate" ? true : !checked);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
|
||||||
<Icon
|
|
||||||
size={size}
|
|
||||||
className={classNames(disabled && "opacity-disabled")}
|
|
||||||
icon={checked === "indeterminate" ? "minus" : checked ? "check" : "empty"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{!hideLabel && (
|
|
||||||
<div
|
|
||||||
className={classNames("text-sm", fullWidth && "w-full", disabled && "opacity-disabled")}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{help && <IconTooltip content={help} />}
|
|
||||||
</HStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,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,12 +0,0 @@
|
|||||||
import { lazy, Suspense } from "react";
|
|
||||||
import type { EditorProps } from "./Editor";
|
|
||||||
|
|
||||||
const Editor_ = lazy(() => import("./Editor").then((m) => ({ default: m.Editor })));
|
|
||||||
|
|
||||||
export function Editor(props: EditorProps) {
|
|
||||||
return (
|
|
||||||
<Suspense>
|
|
||||||
<Editor_ {...props} />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,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,11 +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,21 +0,0 @@
|
|||||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
|
||||||
import { LRParser } from "@lezer/lr";
|
|
||||||
import { highlight } from "./highlight";
|
|
||||||
export const parser = LRParser.deserialize({
|
|
||||||
version: 14,
|
|
||||||
states:
|
|
||||||
"!pQQOPOOO`OPO'#C^OeOPO'#CaOjOPO'#CcOoOPO'#CeOOOO'#Ci'#CiOOOO'#Cg'#CgQQOPOOOOOO,58x,58xOOOO,58{,58{OOOO,58},58}OOOO,59P,59POOOO-E6e-E6e",
|
|
||||||
stateData: "z~ORPOUQOWROYSO~OSWO~OSXO~OSYO~OSZO~ORUWYW~",
|
|
||||||
goto: "m^PP_PP_P_P_PcPiTTOVQVOR[VTUOV",
|
|
||||||
nodeNames:
|
|
||||||
"⚠ Timeline OutgoingLine OutgoingText Newline IncomingLine IncomingText InfoLine InfoText PlainLine PlainText",
|
|
||||||
maxTerm: 13,
|
|
||||||
propSources: [highlight],
|
|
||||||
skippedNodes: [0],
|
|
||||||
repeatNodeCount: 1,
|
|
||||||
tokenData:
|
|
||||||
"%h~RZOYtYZ!]Zztz{!b{!^t!^!_#d!_!`t!`!a$f!a;'St;'S;=`!V<%lOt~ySY~OYtZ;'St;'S;=`!V<%lOt~!YP;=`<%lt~!bOS~~!gUY~OYtZptpq!yq;'St;'S;=`!V<%lOt~#QSW~Y~OY!yZ;'S!y;'S;=`#^<%lO!y~#aP;=`<%l!y~#iUY~OYtZptpq#{q;'St;'S;=`!V<%lOt~$SSU~Y~OY#{Z;'S#{;'S;=`$`<%lO#{~$cP;=`<%l#{~$kUY~OYtZptpq$}q;'St;'S;=`!V<%lOt~%USR~Y~OY$}Z;'S$};'S;=`%b<%lO$}~%eP;=`<%l$}",
|
|
||||||
tokenizers: [0],
|
|
||||||
topRules: { Timeline: [0, 1] },
|
|
||||||
tokenPrec: 36,
|
|
||||||
});
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
/* oxlint-disable no-template-curly-in-string */
|
|
||||||
|
|
||||||
import { describe, expect, test } from "vite-plus/test";
|
|
||||||
import { parser } from "./twig";
|
|
||||||
|
|
||||||
function getNodeNames(input: string): string[] {
|
|
||||||
const tree = parser.parse(input);
|
|
||||||
const nodes: string[] = [];
|
|
||||||
const cursor = tree.cursor();
|
|
||||||
do {
|
|
||||||
if (cursor.name !== "Template") {
|
|
||||||
nodes.push(cursor.name);
|
|
||||||
}
|
|
||||||
} while (cursor.next());
|
|
||||||
return nodes;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasTag(input: string): boolean {
|
|
||||||
return getNodeNames(input).includes("Tag");
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasError(input: string): boolean {
|
|
||||||
return getNodeNames(input).includes("⚠");
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("twig grammar", () => {
|
|
||||||
describe("${[var]} format (valid template tags)", () => {
|
|
||||||
test("parses simple variable as Tag", () => {
|
|
||||||
expect(hasTag("${[var]}")).toBe(true);
|
|
||||||
expect(hasError("${[var]}")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("parses variable with whitespace as Tag", () => {
|
|
||||||
expect(hasTag("${[ var ]}")).toBe(true);
|
|
||||||
expect(hasError("${[ var ]}")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("parses embedded variable as Tag", () => {
|
|
||||||
expect(hasTag("hello ${[name]} world")).toBe(true);
|
|
||||||
expect(hasError("hello ${[name]} world")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("parses function call as Tag", () => {
|
|
||||||
expect(hasTag("${[fn()]}")).toBe(true);
|
|
||||||
expect(hasError("${[fn()]}")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("${var} format (should be plain text, not tags)", () => {
|
|
||||||
test("parses ${var} as plain Text without errors", () => {
|
|
||||||
expect(hasTag("${var}")).toBe(false);
|
|
||||||
expect(hasError("${var}")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("parses embedded ${var} as plain Text", () => {
|
|
||||||
expect(hasTag("hello ${name} world")).toBe(false);
|
|
||||||
expect(hasError("hello ${name} world")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("parses JSON with ${var} as plain Text", () => {
|
|
||||||
const json = '{"key": "${value}"}';
|
|
||||||
expect(hasTag(json)).toBe(false);
|
|
||||||
expect(hasError(json)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("parses multiple ${var} as plain Text", () => {
|
|
||||||
expect(hasTag("${a} and ${b}")).toBe(false);
|
|
||||||
expect(hasError("${a} and ${b}")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("mixed content", () => {
|
|
||||||
test("distinguishes ${var} from ${[var]} in same string", () => {
|
|
||||||
const input = "${plain} and ${[tag]}";
|
|
||||||
expect(hasTag(input)).toBe(true);
|
|
||||||
expect(hasError(input)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("parses JSON with ${[var]} as having Tag", () => {
|
|
||||||
const json = '{"key": "${[value]}"}';
|
|
||||||
expect(hasTag(json)).toBe(true);
|
|
||||||
expect(hasError(json)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("edge cases", () => {
|
|
||||||
test("handles $ at end of string", () => {
|
|
||||||
expect(hasError("hello$")).toBe(false);
|
|
||||||
expect(hasTag("hello$")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles ${ at end of string without crash", () => {
|
|
||||||
// Incomplete syntax may produce errors, but should not crash
|
|
||||||
expect(() => parser.parse("hello${")).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles ${[ without closing without crash", () => {
|
|
||||||
// Unclosed tag may produce partial match, but should not crash
|
|
||||||
expect(() => parser.parse("${[unclosed")).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles empty ${[]}", () => {
|
|
||||||
// Empty tags may or may not be valid depending on grammar
|
|
||||||
// Just ensure no crash
|
|
||||||
expect(() => parser.parse("${[]}")).not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { genericCompletion } from "../genericCompletion";
|
|
||||||
|
|
||||||
export const completions = genericCompletion({
|
|
||||||
options: [
|
|
||||||
{ label: "http://", type: "constant" },
|
|
||||||
{ label: "https://", type: "constant" },
|
|
||||||
],
|
|
||||||
minMatch: 1,
|
|
||||||
});
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
import type { Virtualizer } from "@tanstack/react-virtual";
|
|
||||||
import { Banner, HStack, SplitLayout } from "@yaakapp-internal/ui";
|
|
||||||
import classNames from "classnames";
|
|
||||||
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 { Button } from "./Button";
|
|
||||||
import { IconButton } from "./IconButton";
|
|
||||||
import { Separator } from "./Separator";
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
/** Key for SplitLayout state persistence */
|
|
||||||
splitLayoutStorageKey: 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,
|
|
||||||
splitLayoutStorageKey,
|
|
||||||
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"
|
|
||||||
storageKey={splitLayoutStorageKey}
|
|
||||||
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,15 +0,0 @@
|
|||||||
import classNames from "classnames";
|
|
||||||
import type { HotkeyAction } from "../../hooks/useHotKey";
|
|
||||||
import { useHotkeyLabel } from "../../hooks/useHotKey";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
action: HotkeyAction;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HotkeyLabel({ action, className }: Props) {
|
|
||||||
const label = useHotkeyLabel(action);
|
|
||||||
return (
|
|
||||||
<span className={classNames(className, "text-text-subtle whitespace-nowrap")}>{label}</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import type { GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-internal/models";
|
|
||||||
import { settingsAtom } from "@yaakapp-internal/models";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { memo } from "react";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
request: HttpRequest | GrpcRequest | WebsocketRequest;
|
|
||||||
className?: string;
|
|
||||||
short?: boolean;
|
|
||||||
noAlias?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const methodNames: Record<string, string> = {
|
|
||||||
get: "GET",
|
|
||||||
put: "PUT",
|
|
||||||
post: "POST",
|
|
||||||
patch: "PTCH",
|
|
||||||
delete: "DELE",
|
|
||||||
options: "OPTN",
|
|
||||||
head: "HEAD",
|
|
||||||
query: "QURY",
|
|
||||||
graphql: "GQL",
|
|
||||||
grpc: "GRPC",
|
|
||||||
websocket: "WS",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const HttpMethodTag = memo(function HttpMethodTag({
|
|
||||||
request,
|
|
||||||
className,
|
|
||||||
short,
|
|
||||||
noAlias,
|
|
||||||
}: Props) {
|
|
||||||
const method =
|
|
||||||
request.model === "http_request" && request.bodyType === "graphql" && !noAlias
|
|
||||||
? "graphql"
|
|
||||||
: request.model === "grpc_request"
|
|
||||||
? "grpc"
|
|
||||||
: request.model === "websocket_request"
|
|
||||||
? "websocket"
|
|
||||||
: request.method;
|
|
||||||
|
|
||||||
return <HttpMethodTagRaw method={method} className={className} short={short} />;
|
|
||||||
});
|
|
||||||
|
|
||||||
export function HttpMethodTagRaw({
|
|
||||||
className,
|
|
||||||
method,
|
|
||||||
short,
|
|
||||||
forceColor,
|
|
||||||
}: {
|
|
||||||
method: string;
|
|
||||||
className?: string;
|
|
||||||
short?: boolean;
|
|
||||||
forceColor?: boolean;
|
|
||||||
}) {
|
|
||||||
let label = method.toUpperCase();
|
|
||||||
if (short) {
|
|
||||||
label = methodNames[method.toLowerCase()] ?? method.slice(0, 4);
|
|
||||||
label = label.padEnd(4, " ");
|
|
||||||
}
|
|
||||||
|
|
||||||
const m = method.toUpperCase();
|
|
||||||
|
|
||||||
const settings = useAtomValue(settingsAtom);
|
|
||||||
const colored = forceColor || settings.coloredMethods;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={classNames(
|
|
||||||
className,
|
|
||||||
!colored && "text-text-subtle",
|
|
||||||
colored && m === "GRAPHQL" && "text-info",
|
|
||||||
colored && m === "WEBSOCKET" && "text-info",
|
|
||||||
colored && m === "GRPC" && "text-info",
|
|
||||||
colored && m === "QUERY" && "text-text-subtle",
|
|
||||||
colored && m === "OPTIONS" && "text-info",
|
|
||||||
colored && m === "HEAD" && "text-text-subtle",
|
|
||||||
colored && m === "GET" && "text-primary",
|
|
||||||
colored && m === "PUT" && "text-warning",
|
|
||||||
colored && m === "PATCH" && "text-notice",
|
|
||||||
colored && m === "POST" && "text-success",
|
|
||||||
colored && m === "DELETE" && "text-danger",
|
|
||||||
"font-mono flex-shrink-0 whitespace-pre",
|
|
||||||
"pt-[0.15em]", // Fix for monospace font not vertically centering
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import {
|
|
||||||
IconButton as BaseIconButton,
|
|
||||||
type IconButtonProps as BaseIconButtonProps,
|
|
||||||
} from "@yaakapp-internal/ui";
|
|
||||||
import { forwardRef, useImperativeHandle, useRef } from "react";
|
|
||||||
import type { HotkeyAction } from "../../hooks/useHotKey";
|
|
||||||
import { useFormattedHotkey, useHotKey } from "../../hooks/useHotKey";
|
|
||||||
|
|
||||||
export type IconButtonProps = BaseIconButtonProps & {
|
|
||||||
hotkeyAction?: HotkeyAction;
|
|
||||||
hotkeyLabelOnly?: boolean;
|
|
||||||
hotkeyPriority?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(
|
|
||||||
{ hotkeyAction, hotkeyPriority, hotkeyLabelOnly, title, ...props }: IconButtonProps,
|
|
||||||
ref,
|
|
||||||
) {
|
|
||||||
const hotkeyTrigger = useFormattedHotkey(hotkeyAction ?? null)?.join("");
|
|
||||||
const fullTitle = hotkeyTrigger ? `${title ?? ""} ${hotkeyTrigger}`.trim() : title;
|
|
||||||
|
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
||||||
useImperativeHandle<HTMLButtonElement | null, HTMLButtonElement | null>(
|
|
||||||
ref,
|
|
||||||
() => buttonRef.current,
|
|
||||||
);
|
|
||||||
|
|
||||||
useHotKey(
|
|
||||||
hotkeyAction ?? null,
|
|
||||||
() => {
|
|
||||||
buttonRef.current?.click();
|
|
||||||
},
|
|
||||||
{ priority: hotkeyPriority, enable: !hotkeyLabelOnly },
|
|
||||||
);
|
|
||||||
|
|
||||||
return <BaseIconButton ref={buttonRef} title={fullTitle} {...props} />;
|
|
||||||
});
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
import classNames from "classnames";
|
|
||||||
import type { HTMLAttributes, ReactElement, ReactNode } from "react";
|
|
||||||
import { CopyIconButton } from "../CopyIconButton";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
children:
|
|
||||||
| ReactElement<HTMLAttributes<HTMLTableColElement>>
|
|
||||||
| (ReactElement<HTMLAttributes<HTMLTableColElement>> | null)[];
|
|
||||||
selectable?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function KeyValueRows({ children, selectable }: Props) {
|
|
||||||
const childArray = Array.isArray(children) ? children.filter(Boolean) : [children];
|
|
||||||
return (
|
|
||||||
<table
|
|
||||||
className={classNames(
|
|
||||||
"text-editor font-mono min-w-0 w-full mb-auto",
|
|
||||||
selectable &&
|
|
||||||
"[&_td]:select-auto [&_td]:cursor-auto [&_td_*]:select-auto [&_td_*]:cursor-auto",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<tbody className="divide-y divide-surface-highlight">
|
|
||||||
{childArray.map((child, i) => (
|
|
||||||
// oxlint-disable-next-line react/no-array-index-key
|
|
||||||
<tr key={i}>{child}</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface KeyValueRowProps {
|
|
||||||
label: ReactNode;
|
|
||||||
children: ReactNode;
|
|
||||||
rightSlot?: ReactNode;
|
|
||||||
leftSlot?: ReactNode;
|
|
||||||
align?: "top" | "middle";
|
|
||||||
labelClassName?: string;
|
|
||||||
labelColor?: "secondary" | "primary" | "info";
|
|
||||||
enableCopy?: boolean;
|
|
||||||
copyText?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function KeyValueRow({
|
|
||||||
label,
|
|
||||||
children,
|
|
||||||
rightSlot,
|
|
||||||
leftSlot,
|
|
||||||
align = "top",
|
|
||||||
labelColor = "secondary",
|
|
||||||
labelClassName,
|
|
||||||
enableCopy,
|
|
||||||
copyText,
|
|
||||||
}: KeyValueRowProps) {
|
|
||||||
const textToCopy =
|
|
||||||
copyText ??
|
|
||||||
(typeof children === "string" || typeof children === "number" ? `${children}` : null);
|
|
||||||
const resolvedRightSlot =
|
|
||||||
rightSlot ??
|
|
||||||
(enableCopy && textToCopy != null ? (
|
|
||||||
<CopyIconButton
|
|
||||||
text={textToCopy}
|
|
||||||
className="text-text-subtle"
|
|
||||||
size="2xs"
|
|
||||||
title={`Copy ${label}`}
|
|
||||||
iconSize="sm"
|
|
||||||
/>
|
|
||||||
) : null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<td
|
|
||||||
className={classNames(
|
|
||||||
"select-none py-0.5 pr-2 h-full max-w-[10rem]",
|
|
||||||
align === "top" && "align-top",
|
|
||||||
align === "middle" && "align-middle",
|
|
||||||
labelClassName,
|
|
||||||
labelColor === "primary" && "text-primary",
|
|
||||||
labelColor === "secondary" && "text-text-subtle",
|
|
||||||
labelColor === "info" && "text-info",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="select-text cursor-text">{label}</span>
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
className={classNames(
|
|
||||||
"select-none py-0.5 break-all max-w-[15rem]",
|
|
||||||
align === "top" && "align-top",
|
|
||||||
align === "middle" && "align-middle",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="select-text cursor-text max-h-[12rem] overflow-y-auto grid grid-cols-[auto_minmax(0,1fr)_auto]">
|
|
||||||
{leftSlot ?? <span aria-hidden />}
|
|
||||||
{children}
|
|
||||||
{resolvedRightSlot ? (
|
|
||||||
<div className="ml-1.5">{resolvedRightSlot}</div>
|
|
||||||
) : (
|
|
||||||
<span aria-hidden />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { generateId } from "../../lib/generateId";
|
|
||||||
import type { Pair, PairWithId } from "./PairEditor";
|
|
||||||
|
|
||||||
export function ensurePairId(p: Pair): PairWithId {
|
|
||||||
if (typeof p.id === "string") {
|
|
||||||
return p as PairWithId;
|
|
||||||
}
|
|
||||||
return { ...p, id: p.id ?? generateId() };
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import classNames from "classnames";
|
|
||||||
import type { ButtonProps } from "./Button";
|
|
||||||
import { Button } from "./Button";
|
|
||||||
|
|
||||||
export function PillButton({ className, ...props }: ButtonProps) {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
size="2xs"
|
|
||||||
variant="border"
|
|
||||||
className={classNames(className, "!rounded-full mx-1 !px-3")}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import type { FormInput, JsonPrimitive } from "@yaakapp-internal/plugins";
|
|
||||||
import { HStack } from "@yaakapp-internal/ui";
|
|
||||||
import type { FormEvent } from "react";
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { generateId } from "../../lib/generateId";
|
|
||||||
import { DynamicForm } from "../DynamicForm";
|
|
||||||
import { Button } from "./Button";
|
|
||||||
|
|
||||||
export interface PromptProps {
|
|
||||||
inputs: FormInput[];
|
|
||||||
onCancel: () => void;
|
|
||||||
onResult: (value: Record<string, JsonPrimitive> | null) => void;
|
|
||||||
confirmText?: string;
|
|
||||||
cancelText?: string;
|
|
||||||
onValuesChange?: (values: Record<string, JsonPrimitive>) => void;
|
|
||||||
onInputsUpdated?: (cb: (inputs: FormInput[]) => void) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Prompt({
|
|
||||||
onCancel,
|
|
||||||
inputs: initialInputs,
|
|
||||||
onResult,
|
|
||||||
confirmText = "Confirm",
|
|
||||||
cancelText = "Cancel",
|
|
||||||
onValuesChange,
|
|
||||||
onInputsUpdated,
|
|
||||||
}: PromptProps) {
|
|
||||||
const [value, setValue] = useState<Record<string, JsonPrimitive>>({});
|
|
||||||
const [inputs, setInputs] = useState<FormInput[]>(initialInputs);
|
|
||||||
const handleSubmit = useCallback(
|
|
||||||
(e: FormEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onResult(value);
|
|
||||||
},
|
|
||||||
[onResult, value],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Register callback for external input updates (from plugin dynamic resolution)
|
|
||||||
useEffect(() => {
|
|
||||||
onInputsUpdated?.(setInputs);
|
|
||||||
}, [onInputsUpdated]);
|
|
||||||
|
|
||||||
// Notify of value changes for dynamic resolution
|
|
||||||
useEffect(() => {
|
|
||||||
onValuesChange?.(value);
|
|
||||||
}, [value, onValuesChange]);
|
|
||||||
|
|
||||||
const id = `prompt.form.${useRef(generateId()).current}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
className="grid grid-rows-[auto_auto] grid-cols-[minmax(0,1fr)] gap-4 mb-4"
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
>
|
|
||||||
<DynamicForm inputs={inputs} onChange={setValue} data={value} stateKey={id} />
|
|
||||||
<HStack space={2} justifyContent="end">
|
|
||||||
<Button onClick={onCancel} variant="border" color="secondary">
|
|
||||||
{cancelText || "Cancel"}
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" color="primary">
|
|
||||||
{confirmText || "Done"}
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,64 +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,43 +0,0 @@
|
|||||||
import type { Color } from "@yaakapp-internal/plugins";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
orientation?: "horizontal" | "vertical";
|
|
||||||
dashed?: boolean;
|
|
||||||
className?: string;
|
|
||||||
children?: ReactNode;
|
|
||||||
color?: Color;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Separator({
|
|
||||||
color,
|
|
||||||
className,
|
|
||||||
dashed,
|
|
||||||
orientation = "horizontal",
|
|
||||||
children,
|
|
||||||
}: Props) {
|
|
||||||
return (
|
|
||||||
<div role="presentation" className={classNames(className, "flex items-center w-full")}>
|
|
||||||
{children && (
|
|
||||||
<div className="text-sm text-text-subtlest mr-2 whitespace-nowrap">{children}</div>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
"opacity-60",
|
|
||||||
color == null && "border-border",
|
|
||||||
color === "primary" && "border-primary",
|
|
||||||
color === "secondary" && "border-secondary",
|
|
||||||
color === "success" && "border-success",
|
|
||||||
color === "notice" && "border-notice",
|
|
||||||
color === "warning" && "border-warning",
|
|
||||||
color === "danger" && "border-danger",
|
|
||||||
color === "info" && "border-info",
|
|
||||||
dashed && "border-dashed",
|
|
||||||
orientation === "horizontal" && "w-full h-0 border-t",
|
|
||||||
orientation === "vertical" && "h-full w-0 border-l",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,522 +0,0 @@
|
|||||||
import type { AnyModel } from "@yaakapp-internal/models";
|
|
||||||
import { patchModel } from "@yaakapp-internal/models";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { CopyIconButton } from "../CopyIconButton";
|
|
||||||
import { Checkbox } from "./Checkbox";
|
|
||||||
import { IconButton, type IconButtonProps } from "./IconButton";
|
|
||||||
import { PlainInput } from "./PlainInput";
|
|
||||||
import type { RadioDropdownItem } from "./RadioDropdown";
|
|
||||||
import { Select } from "./Select";
|
|
||||||
import { SelectFile } from "../SelectFile";
|
|
||||||
|
|
||||||
type ModelKeyOfValue<T, V> = {
|
|
||||||
[K in keyof T]-?: T[K] extends V ? K : never;
|
|
||||||
}[keyof T];
|
|
||||||
|
|
||||||
type SettingRowBaseProps = {
|
|
||||||
className?: string;
|
|
||||||
controlClassName?: string;
|
|
||||||
description?: ReactNode;
|
|
||||||
disabled?: boolean;
|
|
||||||
title: ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function SettingsList({ children, className }: { children: ReactNode; className?: string }) {
|
|
||||||
return <div className={classNames("w-full", className)}>{children}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingsSection({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
description,
|
|
||||||
title,
|
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
description?: ReactNode;
|
|
||||||
title: ReactNode | null;
|
|
||||||
}) {
|
|
||||||
const showHeader = title != null || description != null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className={classNames(className, "w-full")}>
|
|
||||||
{showHeader && (
|
|
||||||
<div className="border-b border-border-subtle pb-2">
|
|
||||||
{title != null && <div className="text-text-subtle">{title}</div>}
|
|
||||||
{description != null && <p className="mt-1 text-sm text-text-subtlest">{description}</p>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="[&>*:last-child]:border-b-0">{children}</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingRow({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
controlClassName,
|
|
||||||
description,
|
|
||||||
disabled,
|
|
||||||
title,
|
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
|
||||||
} & SettingRowBaseProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
aria-disabled={disabled || undefined}
|
|
||||||
className={classNames(
|
|
||||||
className,
|
|
||||||
"@container border-b border-border-subtle py-4",
|
|
||||||
disabled && "opacity-disabled",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
"grid grid-cols-1 gap-2",
|
|
||||||
"@[30rem]:grid-cols-[minmax(0,1fr)_auto] items-center",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="text text-text">{title}</div>
|
|
||||||
{description != null && (
|
|
||||||
<div className="mt-1 max-w-2xl text-sm text-text-subtle">{description}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
"flex min-w-0 items-center justify-start @[40rem]:justify-end",
|
|
||||||
controlClassName,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingValue({
|
|
||||||
actions,
|
|
||||||
className,
|
|
||||||
copyText,
|
|
||||||
enableCopy = true,
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
actions?: SettingValueAction[];
|
|
||||||
className?: string;
|
|
||||||
copyText?: string;
|
|
||||||
enableCopy?: boolean;
|
|
||||||
value: ReactNode;
|
|
||||||
}) {
|
|
||||||
const textValue = typeof value === "string" || typeof value === "number" ? `${value}` : null;
|
|
||||||
const textToCopy = copyText ?? textValue;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<span
|
|
||||||
className={classNames(
|
|
||||||
className,
|
|
||||||
"cursor-text select-text truncate font-mono text-editor text-text-subtle pr-1.5",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</span>
|
|
||||||
{actions?.map((action) => (
|
|
||||||
<IconButton
|
|
||||||
key={action.title}
|
|
||||||
icon={action.icon}
|
|
||||||
title={action.title}
|
|
||||||
size="2xs"
|
|
||||||
iconSize="sm"
|
|
||||||
onClick={action.onClick}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{enableCopy && textToCopy != null && (
|
|
||||||
<CopyIconButton size="2xs" text={textToCopy} title="Copy value" />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type SettingValueAction = {
|
|
||||||
icon: IconButtonProps["icon"];
|
|
||||||
onClick: () => void;
|
|
||||||
title: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function SettingRowBoolean({
|
|
||||||
checked,
|
|
||||||
checkboxSize = "md",
|
|
||||||
onChange,
|
|
||||||
title,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
checked: boolean;
|
|
||||||
checkboxSize?: "sm" | "md";
|
|
||||||
onChange: (checked: boolean) => void;
|
|
||||||
} & SettingRowBaseProps) {
|
|
||||||
return (
|
|
||||||
<SettingRow title={title} {...props}>
|
|
||||||
<Checkbox
|
|
||||||
hideLabel
|
|
||||||
size={checkboxSize}
|
|
||||||
checked={checked}
|
|
||||||
disabled={props.disabled}
|
|
||||||
title={title}
|
|
||||||
onChange={onChange}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ModelSettingRowBoolean<M extends AnyModel, K extends ModelKeyOfValue<M, boolean>>({
|
|
||||||
model,
|
|
||||||
modelKey,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
model: M;
|
|
||||||
modelKey: K;
|
|
||||||
} & Omit<Parameters<typeof SettingRowBoolean>[0], "checked" | "onChange">) {
|
|
||||||
return (
|
|
||||||
<SettingRowBoolean
|
|
||||||
checked={model[modelKey] as boolean}
|
|
||||||
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingRowNumber({
|
|
||||||
inputClassName,
|
|
||||||
inputWidthClassName = "!w-48",
|
|
||||||
name,
|
|
||||||
onChange,
|
|
||||||
placeholder,
|
|
||||||
required,
|
|
||||||
title,
|
|
||||||
type = "number",
|
|
||||||
validate,
|
|
||||||
value,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
inputClassName?: string;
|
|
||||||
inputWidthClassName?: string;
|
|
||||||
name: string;
|
|
||||||
onChange: (value: number) => void;
|
|
||||||
placeholder?: string;
|
|
||||||
required?: boolean;
|
|
||||||
type?: "number";
|
|
||||||
validate?: (value: string) => boolean;
|
|
||||||
value: number;
|
|
||||||
} & SettingRowBaseProps) {
|
|
||||||
return (
|
|
||||||
<SettingRow title={title} {...props}>
|
|
||||||
<PlainInput
|
|
||||||
required={required}
|
|
||||||
hideLabel
|
|
||||||
size="sm"
|
|
||||||
name={name}
|
|
||||||
label={typeof title === "string" ? title : name}
|
|
||||||
placeholder={placeholder}
|
|
||||||
defaultValue={`${value}`}
|
|
||||||
validate={validate}
|
|
||||||
onChange={(value) => onChange(Number.parseInt(value, 10) || 0)}
|
|
||||||
type={type}
|
|
||||||
className={inputClassName}
|
|
||||||
containerClassName={inputWidthClassName}
|
|
||||||
disabled={props.disabled}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ModelSettingRowNumber<M extends AnyModel, K extends ModelKeyOfValue<M, number>>({
|
|
||||||
model,
|
|
||||||
modelKey,
|
|
||||||
name = String(modelKey),
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
model: M;
|
|
||||||
modelKey: K;
|
|
||||||
name?: string;
|
|
||||||
} & Omit<Parameters<typeof SettingRowNumber>[0], "name" | "onChange" | "value">) {
|
|
||||||
return (
|
|
||||||
<SettingRowNumber
|
|
||||||
name={name}
|
|
||||||
value={model[modelKey] as number}
|
|
||||||
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingRowText({
|
|
||||||
inputClassName,
|
|
||||||
inputWidthClassName = "!w-80",
|
|
||||||
name,
|
|
||||||
onChange,
|
|
||||||
placeholder,
|
|
||||||
required,
|
|
||||||
title,
|
|
||||||
type = "text",
|
|
||||||
value,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
inputClassName?: string;
|
|
||||||
inputWidthClassName?: string;
|
|
||||||
name: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
placeholder?: string;
|
|
||||||
required?: boolean;
|
|
||||||
type?: "text" | "password";
|
|
||||||
value: string;
|
|
||||||
} & SettingRowBaseProps) {
|
|
||||||
return (
|
|
||||||
<SettingRow title={title} {...props}>
|
|
||||||
<PlainInput
|
|
||||||
required={required}
|
|
||||||
hideLabel
|
|
||||||
size="sm"
|
|
||||||
name={name}
|
|
||||||
label={typeof title === "string" ? title : name}
|
|
||||||
placeholder={placeholder}
|
|
||||||
defaultValue={value}
|
|
||||||
onChange={onChange}
|
|
||||||
type={type}
|
|
||||||
className={inputClassName}
|
|
||||||
containerClassName={inputWidthClassName}
|
|
||||||
disabled={props.disabled}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ModelSettingRowText<M extends AnyModel, K extends ModelKeyOfValue<M, string>>({
|
|
||||||
model,
|
|
||||||
modelKey,
|
|
||||||
name = String(modelKey),
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
model: M;
|
|
||||||
modelKey: K;
|
|
||||||
name?: string;
|
|
||||||
} & Omit<Parameters<typeof SettingRowText>[0], "name" | "onChange" | "value">) {
|
|
||||||
return (
|
|
||||||
<SettingRowText
|
|
||||||
name={name}
|
|
||||||
value={model[modelKey] as string}
|
|
||||||
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingRowFile({
|
|
||||||
buttonClassName,
|
|
||||||
controlClassName = "min-w-0 max-w-[min(32rem,45vw)]",
|
|
||||||
directory,
|
|
||||||
filePath,
|
|
||||||
nameOverride,
|
|
||||||
noun,
|
|
||||||
onChange,
|
|
||||||
size = "xs",
|
|
||||||
title,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
buttonClassName?: string;
|
|
||||||
directory?: boolean;
|
|
||||||
filePath: string | null;
|
|
||||||
nameOverride?: string | null;
|
|
||||||
noun?: string;
|
|
||||||
onChange: (filePath: string | null) => void | Promise<void>;
|
|
||||||
size?: Parameters<typeof SelectFile>[0]["size"];
|
|
||||||
} & SettingRowBaseProps) {
|
|
||||||
return (
|
|
||||||
<SettingRow title={title} controlClassName={controlClassName} {...props}>
|
|
||||||
<SelectFile
|
|
||||||
directory={directory}
|
|
||||||
inline
|
|
||||||
hideLabel
|
|
||||||
label={typeof title === "string" ? title : noun}
|
|
||||||
size={size}
|
|
||||||
noun={noun}
|
|
||||||
nameOverride={nameOverride}
|
|
||||||
filePath={filePath}
|
|
||||||
className={buttonClassName}
|
|
||||||
onChange={({ filePath }) => onChange(filePath)}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingRowDirectory({
|
|
||||||
noun = "Directory",
|
|
||||||
...props
|
|
||||||
}: Omit<Parameters<typeof SettingRowFile>[0], "directory">) {
|
|
||||||
return <SettingRowFile directory noun={noun} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingRowSelect<T extends string>({
|
|
||||||
defaultValue,
|
|
||||||
name,
|
|
||||||
onChange,
|
|
||||||
options,
|
|
||||||
selectClassName = "!w-48",
|
|
||||||
title,
|
|
||||||
value,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
defaultValue?: T;
|
|
||||||
name: string;
|
|
||||||
onChange: (value: T) => void;
|
|
||||||
options: RadioDropdownItem<T>[];
|
|
||||||
selectClassName?: string;
|
|
||||||
value: T;
|
|
||||||
} & SettingRowBaseProps) {
|
|
||||||
return (
|
|
||||||
<SettingRow title={title} {...props}>
|
|
||||||
<SettingSelectControl
|
|
||||||
name={name}
|
|
||||||
label={typeof title === "string" ? title : name}
|
|
||||||
value={value}
|
|
||||||
defaultValue={defaultValue}
|
|
||||||
selectClassName={selectClassName}
|
|
||||||
disabled={props.disabled}
|
|
||||||
onChange={onChange}
|
|
||||||
options={options}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingSelectControl<T extends string>({
|
|
||||||
defaultValue,
|
|
||||||
disabled,
|
|
||||||
label,
|
|
||||||
name,
|
|
||||||
onChange,
|
|
||||||
options,
|
|
||||||
selectClassName = "!w-48",
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
defaultValue?: T;
|
|
||||||
disabled?: boolean;
|
|
||||||
label: string;
|
|
||||||
name: string;
|
|
||||||
onChange: (value: T) => void;
|
|
||||||
options: RadioDropdownItem<T>[];
|
|
||||||
selectClassName?: string;
|
|
||||||
value: T;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Select
|
|
||||||
hideLabel
|
|
||||||
name={name}
|
|
||||||
value={value}
|
|
||||||
defaultValue={defaultValue}
|
|
||||||
label={label}
|
|
||||||
size="sm"
|
|
||||||
className={selectClassName}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={onChange}
|
|
||||||
options={options}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ModelSettingSelectControl<
|
|
||||||
M extends AnyModel,
|
|
||||||
K extends ModelKeyOfValue<M, string>,
|
|
||||||
V extends M[K] & string,
|
|
||||||
>({
|
|
||||||
model,
|
|
||||||
modelKey,
|
|
||||||
name = String(modelKey),
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
model: M;
|
|
||||||
modelKey: K;
|
|
||||||
name?: string;
|
|
||||||
} & Omit<Parameters<typeof SettingSelectControl<V>>[0], "name" | "onChange" | "value">) {
|
|
||||||
return (
|
|
||||||
<SettingSelectControl
|
|
||||||
name={name}
|
|
||||||
value={model[modelKey] as V}
|
|
||||||
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ModelSettingRowSelect<
|
|
||||||
M extends AnyModel,
|
|
||||||
K extends ModelKeyOfValue<M, string>,
|
|
||||||
V extends M[K] & string,
|
|
||||||
>({
|
|
||||||
model,
|
|
||||||
modelKey,
|
|
||||||
name = String(modelKey),
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
model: M;
|
|
||||||
modelKey: K;
|
|
||||||
name?: string;
|
|
||||||
} & Omit<Parameters<typeof SettingRowSelect<V>>[0], "name" | "onChange" | "value">) {
|
|
||||||
return (
|
|
||||||
<SettingRowSelect
|
|
||||||
name={name}
|
|
||||||
value={model[modelKey] as V}
|
|
||||||
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingOverrideRow({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
controlClassName,
|
|
||||||
description,
|
|
||||||
disabled,
|
|
||||||
onResetOverride,
|
|
||||||
overridden,
|
|
||||||
resetTitle = "Reset override",
|
|
||||||
title,
|
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
controlClassName?: string;
|
|
||||||
description?: ReactNode;
|
|
||||||
disabled?: boolean;
|
|
||||||
onResetOverride: () => void;
|
|
||||||
overridden: boolean;
|
|
||||||
resetTitle?: string;
|
|
||||||
title: ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<SettingRow
|
|
||||||
className={className}
|
|
||||||
controlClassName={controlClassName}
|
|
||||||
description={description}
|
|
||||||
disabled={disabled}
|
|
||||||
title={
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
{title}
|
|
||||||
{overridden && (
|
|
||||||
<IconButton
|
|
||||||
icon="undo_2"
|
|
||||||
size="2xs"
|
|
||||||
iconSize="sm"
|
|
||||||
title={resetTitle}
|
|
||||||
className="text-text-subtle"
|
|
||||||
onClick={onResetOverride}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</SettingRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
import classNames from "classnames";
|
|
||||||
import type { CSSProperties, KeyboardEvent, ReactNode } from "react";
|
|
||||||
import { useRef, useState } from "react";
|
|
||||||
import { generateId } from "../../lib/generateId";
|
|
||||||
import { Portal } from "@yaakapp-internal/ui";
|
|
||||||
|
|
||||||
export interface TooltipProps {
|
|
||||||
children: ReactNode;
|
|
||||||
content: ReactNode;
|
|
||||||
tabIndex?: number;
|
|
||||||
size?: "md" | "lg";
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hiddenStyles: CSSProperties = {
|
|
||||||
left: -99999,
|
|
||||||
top: -99999,
|
|
||||||
visibility: "hidden",
|
|
||||||
pointerEvents: "none",
|
|
||||||
opacity: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
type TooltipPosition = "top" | "bottom";
|
|
||||||
|
|
||||||
interface TooltipOpenState {
|
|
||||||
styles: CSSProperties;
|
|
||||||
position: TooltipPosition;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Tooltip({ children, className, content, tabIndex, size = "md" }: TooltipProps) {
|
|
||||||
const [openState, setOpenState] = useState<TooltipOpenState | null>(null);
|
|
||||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
||||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
|
||||||
const showTimeout = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
||||||
|
|
||||||
const handleOpenImmediate = () => {
|
|
||||||
if (triggerRef.current == null || tooltipRef.current == null) return;
|
|
||||||
clearTimeout(showTimeout.current);
|
|
||||||
const triggerRect = triggerRef.current.getBoundingClientRect();
|
|
||||||
const tooltipRect = tooltipRef.current.getBoundingClientRect();
|
|
||||||
const viewportHeight = document.documentElement.clientHeight;
|
|
||||||
|
|
||||||
const margin = 8;
|
|
||||||
const spaceAbove = Math.max(0, triggerRect.top - margin);
|
|
||||||
const spaceBelow = Math.max(0, viewportHeight - triggerRect.bottom - margin);
|
|
||||||
const preferBottom = spaceAbove < tooltipRect.height + margin && spaceBelow > spaceAbove;
|
|
||||||
const position: TooltipPosition = preferBottom ? "bottom" : "top";
|
|
||||||
|
|
||||||
const styles: CSSProperties = {
|
|
||||||
left: Math.max(0, triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2),
|
|
||||||
maxHeight: position === "top" ? spaceAbove : spaceBelow,
|
|
||||||
...(position === "top"
|
|
||||||
? { bottom: viewportHeight - triggerRect.top }
|
|
||||||
: { top: triggerRect.bottom }),
|
|
||||||
};
|
|
||||||
|
|
||||||
setOpenState({ styles, position });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpen = () => {
|
|
||||||
clearTimeout(showTimeout.current);
|
|
||||||
showTimeout.current = setTimeout(handleOpenImmediate, 500);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
clearTimeout(showTimeout.current);
|
|
||||||
setOpenState(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleImmediate = () => {
|
|
||||||
if (openState) handleClose();
|
|
||||||
else handleOpenImmediate();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
|
|
||||||
if (openState && e.key === "Escape") {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
handleClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const id = useRef(`tooltip-${generateId()}`);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Portal name="tooltip">
|
|
||||||
<div
|
|
||||||
ref={tooltipRef}
|
|
||||||
style={openState?.styles ?? hiddenStyles}
|
|
||||||
id={id.current}
|
|
||||||
role="tooltip"
|
|
||||||
aria-hidden={openState == null}
|
|
||||||
onMouseEnter={handleOpenImmediate}
|
|
||||||
onMouseLeave={handleClose}
|
|
||||||
className="p-2 fixed z-50 text-sm transition-opacity grid grid-rows-[minmax(0,1fr)]"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
"bg-surface-highlight rounded-md px-3 py-2 z-50 border border-border overflow-auto",
|
|
||||||
size === "md" && "max-w-sm",
|
|
||||||
size === "lg" && "max-w-md",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{content}
|
|
||||||
</div>
|
|
||||||
<Triangle
|
|
||||||
className="text-border"
|
|
||||||
position={openState?.position === "bottom" ? "top" : "bottom"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Portal>
|
|
||||||
{/* oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- Needs to be usable in other buttons */}
|
|
||||||
<span
|
|
||||||
ref={triggerRef}
|
|
||||||
role="button"
|
|
||||||
aria-describedby={openState ? id.current : undefined}
|
|
||||||
tabIndex={tabIndex ?? -1}
|
|
||||||
className={classNames(className, "flex-grow-0 flex items-center")}
|
|
||||||
onClick={handleToggleImmediate}
|
|
||||||
onMouseEnter={handleOpen}
|
|
||||||
onMouseLeave={handleClose}
|
|
||||||
onFocus={handleOpenImmediate}
|
|
||||||
onBlur={handleClose}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Triangle({ className, position }: { className?: string; position: "top" | "bottom" }) {
|
|
||||||
const isBottom = position === "bottom";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
aria-hidden
|
|
||||||
viewBox="0 0 30 10"
|
|
||||||
preserveAspectRatio="none"
|
|
||||||
shapeRendering="crispEdges"
|
|
||||||
className={classNames(
|
|
||||||
className,
|
|
||||||
"absolute z-50 left-[calc(50%-0.4rem)] h-[0.5rem] w-[0.8rem]",
|
|
||||||
isBottom
|
|
||||||
? "border-t-[2px] border-surface-highlight -bottom-[calc(0.5rem-3px)] mb-2"
|
|
||||||
: "border-b-[2px] border-surface-highlight -top-[calc(0.5rem-3px)] mt-2",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<title>Triangle</title>
|
|
||||||
<polygon
|
|
||||||
className="fill-surface-highlight"
|
|
||||||
points={isBottom ? "0,0 30,0 15,10" : "0,10 30,10 15,0"}
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d={isBottom ? "M0 0 L15 9 L30 0" : "M0 10 L15 1 L30 10"}
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1"
|
|
||||||
strokeLinejoin="miter"
|
|
||||||
vectorEffect="non-scaling-stroke"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import type { WebsocketConnection } from "@yaakapp-internal/models";
|
|
||||||
import classNames from "classnames";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
connection: WebsocketConnection;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WebsocketStatusTag({ connection, className }: Props) {
|
|
||||||
const { state, error } = connection;
|
|
||||||
|
|
||||||
let label: string;
|
|
||||||
let colorClass = "text-text-subtle";
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
label = "ERROR";
|
|
||||||
colorClass = "text-danger";
|
|
||||||
} else if (state === "connected") {
|
|
||||||
label = "CONNECTED";
|
|
||||||
colorClass = "text-success";
|
|
||||||
} else if (state === "closing") {
|
|
||||||
label = "CLOSING";
|
|
||||||
} else if (state === "closed") {
|
|
||||||
label = "CLOSED";
|
|
||||||
colorClass = "text-warning";
|
|
||||||
} else {
|
|
||||||
label = "CONNECTING";
|
|
||||||
}
|
|
||||||
|
|
||||||
return <span className={classNames(className, "font-mono", colorClass)}>{label}</span>;
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
import { useGitFileDiffForCommit, useGitLog, useGitMutations } from "@yaakapp-internal/git";
|
|
||||||
import type { GitCommit } from "@yaakapp-internal/git";
|
|
||||||
import { InlineCode, SplitLayout } from "@yaakapp-internal/ui";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { formatDistanceToNowStrict } from "date-fns";
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { sync } from "../../init/sync";
|
|
||||||
import { showConfirm } from "../../lib/confirm";
|
|
||||||
import { EmptyStateText } from "../EmptyStateText";
|
|
||||||
import { Button } from "../core/Button";
|
|
||||||
import { DiffViewer } from "../core/Editor/DiffViewer";
|
|
||||||
import { useGitCallbacks } from "./callbacks";
|
|
||||||
|
|
||||||
export function FileHistoryDialog({ dir, relaPath }: { dir: string; relaPath: string }) {
|
|
||||||
const callbacks = useGitCallbacks(dir);
|
|
||||||
const { restoreFileFromCommit } = useGitMutations(dir, callbacks);
|
|
||||||
const log = useGitLog(dir, undefined, relaPath);
|
|
||||||
const commits = log.data ?? [];
|
|
||||||
const [selectedOid, setSelectedOid] = useState<string | null>(null);
|
|
||||||
const selectedCommit = useMemo(
|
|
||||||
() => commits.find((commit) => commit.oid === selectedOid) ?? null,
|
|
||||||
[commits, selectedOid],
|
|
||||||
);
|
|
||||||
const diff = useGitFileDiffForCommit(dir, relaPath, selectedCommit?.oid);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (commits.length === 0) {
|
|
||||||
setSelectedOid(null);
|
|
||||||
} else if (selectedOid == null || !commits.some((commit) => commit.oid === selectedOid)) {
|
|
||||||
setSelectedOid(commits[0]?.oid ?? null);
|
|
||||||
}
|
|
||||||
}, [commits, selectedOid]);
|
|
||||||
|
|
||||||
const handleRestoreCommit = useCallback(
|
|
||||||
async (commit: GitCommit) => {
|
|
||||||
const confirmed = await showConfirm({
|
|
||||||
id: "git-restore-file-history-entry",
|
|
||||||
title: "Restore File",
|
|
||||||
description: "This will restore the file to the selected commit.",
|
|
||||||
confirmText: "Restore",
|
|
||||||
color: "warning",
|
|
||||||
});
|
|
||||||
if (!confirmed) return;
|
|
||||||
|
|
||||||
await restoreFileFromCommit.mutateAsync({ commitOid: commit.oid, relaPath });
|
|
||||||
await sync({ force: true });
|
|
||||||
},
|
|
||||||
[relaPath, restoreFileFromCommit],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (commits.length === 0 && !log.isLoading) {
|
|
||||||
return <EmptyStateText>No history for this file</EmptyStateText>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full px-2 pb-4">
|
|
||||||
<SplitLayout
|
|
||||||
storageKey="git-file-history-horizontal"
|
|
||||||
layout="horizontal"
|
|
||||||
defaultRatio={0.6}
|
|
||||||
firstSlot={({ style }) => (
|
|
||||||
<div style={style} className="h-full overflow-y-auto px-4 pb-2 transform-cpu">
|
|
||||||
<div className="flex flex-col pt-1.5">
|
|
||||||
{commits.map((commit) => (
|
|
||||||
<CommitListItem
|
|
||||||
key={commit.oid}
|
|
||||||
commit={commit}
|
|
||||||
selected={commit.oid === selectedCommit?.oid}
|
|
||||||
onSelect={() => setSelectedOid(commit.oid)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
secondSlot={({ style }) => (
|
|
||||||
<div style={style} className="h-full min-w-0 border-l border-l-border-subtle px-4">
|
|
||||||
{selectedCommit == null ? (
|
|
||||||
<EmptyStateText>Select a commit to view diff</EmptyStateText>
|
|
||||||
) : (
|
|
||||||
<div className="h-full flex flex-col">
|
|
||||||
<div className="mb-2 min-w-0 text-text-subtle grid items-center gap-2 grid-cols-[minmax(0,1fr)_auto]">
|
|
||||||
<div className="min-w-0 truncate">{selectedCommit.message || "No message"}</div>
|
|
||||||
<Button
|
|
||||||
className="ml-auto"
|
|
||||||
color="warning"
|
|
||||||
size="2xs"
|
|
||||||
variant="border"
|
|
||||||
onClick={() => handleRestoreCommit(selectedCommit)}
|
|
||||||
>
|
|
||||||
Restore File
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<DiffViewer
|
|
||||||
original={diff.data?.original ?? ""}
|
|
||||||
modified={diff.data?.modified ?? ""}
|
|
||||||
className="flex-1 min-h-0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CommitListItem({
|
|
||||||
commit,
|
|
||||||
selected,
|
|
||||||
onSelect,
|
|
||||||
}: {
|
|
||||||
commit: GitCommit;
|
|
||||||
selected: boolean;
|
|
||||||
onSelect: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={classNames(
|
|
||||||
"w-full min-w-0 text-left rounded px-2 py-1.5",
|
|
||||||
selected && "bg-surface-active",
|
|
||||||
)}
|
|
||||||
onClick={onSelect}
|
|
||||||
>
|
|
||||||
<div className="truncate flex-1">{commit.message || "No message"}</div>
|
|
||||||
<div className="text-text-subtle text-sm truncate">
|
|
||||||
{commit.author.name || "Unknown"} - {formatDistanceToNowStrict(commit.when)} ago - <span className="shrink-0 text-2xs text-text-subtle font-mono">{commit.oid.slice(0, 7)}</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,521 +0,0 @@
|
|||||||
import type { GitStatusEntry } from "@yaakapp-internal/git";
|
|
||||||
import { useGit } from "@yaakapp-internal/git";
|
|
||||||
import type {
|
|
||||||
Environment,
|
|
||||||
Folder,
|
|
||||||
GrpcRequest,
|
|
||||||
HttpRequest,
|
|
||||||
WebsocketRequest,
|
|
||||||
Workspace,
|
|
||||||
} from "@yaakapp-internal/models";
|
|
||||||
import { Banner, HStack, Icon, IconButton, InlineCode, SplitLayout } from "@yaakapp-internal/ui";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
|
||||||
import { modelToYaml } from "../../lib/diffYaml";
|
|
||||||
import { resolvedModelName } from "../../lib/resolvedModelName";
|
|
||||||
import { showConfirm } from "../../lib/confirm";
|
|
||||||
import { showErrorToast } from "../../lib/toast";
|
|
||||||
import { sync } from "../../init/sync";
|
|
||||||
import { Button } from "../core/Button";
|
|
||||||
import type { CheckboxProps } from "../core/Checkbox";
|
|
||||||
import { Checkbox } from "../core/Checkbox";
|
|
||||||
import { DiffViewer } from "../core/Editor/DiffViewer";
|
|
||||||
import { Input } from "../core/Input";
|
|
||||||
import { Separator } from "../core/Separator";
|
|
||||||
import { EmptyStateText } from "../EmptyStateText";
|
|
||||||
import { useGitCallbacks } from "./callbacks";
|
|
||||||
import { handlePushResult } from "./git-util";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
syncDir: string;
|
|
||||||
onDone: () => void;
|
|
||||||
workspace: Workspace;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CommitTreeNode {
|
|
||||||
model: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Environment | Workspace;
|
|
||||||
status: GitStatusEntry;
|
|
||||||
children: CommitTreeNode[];
|
|
||||||
ancestors: CommitTreeNode[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
|
||||||
const callbacks = useGitCallbacks(syncDir);
|
|
||||||
const [{ status }, { commit, commitAndPush, add, unstage, restore }] = useGit(
|
|
||||||
syncDir,
|
|
||||||
callbacks,
|
|
||||||
);
|
|
||||||
const [isPushing, setIsPushing] = useState(false);
|
|
||||||
const [commitError, setCommitError] = useState<string | null>(null);
|
|
||||||
const [message, setMessage] = useState<string>("");
|
|
||||||
const [selectedEntry, setSelectedEntry] = useState<GitStatusEntry | null>(null);
|
|
||||||
|
|
||||||
const handleCreateCommit = async () => {
|
|
||||||
setCommitError(null);
|
|
||||||
try {
|
|
||||||
await commit.mutateAsync({ message });
|
|
||||||
onDone();
|
|
||||||
} catch (err) {
|
|
||||||
setCommitError(String(err));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateCommitAndPush = async () => {
|
|
||||||
setIsPushing(true);
|
|
||||||
try {
|
|
||||||
const r = await commitAndPush.mutateAsync({ message });
|
|
||||||
handlePushResult(r);
|
|
||||||
onDone();
|
|
||||||
} catch (err) {
|
|
||||||
showErrorToast({
|
|
||||||
id: "git-commit-and-push-error",
|
|
||||||
title: "Error committing and pushing",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsPushing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const { internalEntries, externalEntries, allEntries } = useMemo(() => {
|
|
||||||
const allEntries = [];
|
|
||||||
const yaakEntries = [];
|
|
||||||
const externalEntries = [];
|
|
||||||
|
|
||||||
for (const entry of status.data?.entries ?? []) {
|
|
||||||
allEntries.push(entry);
|
|
||||||
if (entry.next == null && entry.prev == null) {
|
|
||||||
externalEntries.push(entry);
|
|
||||||
} else {
|
|
||||||
yaakEntries.push(entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { internalEntries: yaakEntries, externalEntries, allEntries };
|
|
||||||
}, [status.data?.entries]);
|
|
||||||
|
|
||||||
const hasAddedAnything = allEntries.find((e) => e.staged) != null;
|
|
||||||
const hasAnythingToAdd = allEntries.find((e) => e.status !== "current") != null;
|
|
||||||
|
|
||||||
const tree: CommitTreeNode | null = useMemo(() => {
|
|
||||||
const next = (
|
|
||||||
model: CommitTreeNode["model"],
|
|
||||||
ancestors: CommitTreeNode[],
|
|
||||||
): CommitTreeNode | null => {
|
|
||||||
const statusEntry = internalEntries?.find((s) => s.relaPath.includes(model.id));
|
|
||||||
if (statusEntry == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const node: CommitTreeNode = {
|
|
||||||
model,
|
|
||||||
status: statusEntry,
|
|
||||||
children: [],
|
|
||||||
ancestors,
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const entry of internalEntries) {
|
|
||||||
const childModel = entry.next ?? entry.prev;
|
|
||||||
|
|
||||||
// Should never happen because we're iterating internalEntries
|
|
||||||
if (childModel == null) continue;
|
|
||||||
|
|
||||||
// TODO: Figure out why not all of these show up
|
|
||||||
if ("folderId" in childModel && childModel.folderId != null) {
|
|
||||||
if (childModel.folderId === model.id) {
|
|
||||||
const c = next(childModel, [...ancestors, node]);
|
|
||||||
if (c != null) node.children.push(c);
|
|
||||||
}
|
|
||||||
} else if ("workspaceId" in childModel && childModel.workspaceId === model.id) {
|
|
||||||
const c = next(childModel, [...ancestors, node]);
|
|
||||||
if (c != null) node.children.push(c);
|
|
||||||
} else {
|
|
||||||
// Do nothing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return node;
|
|
||||||
};
|
|
||||||
|
|
||||||
return next(workspace, []);
|
|
||||||
}, [workspace, internalEntries]);
|
|
||||||
|
|
||||||
const checkNode = useCallback(
|
|
||||||
(treeNode: CommitTreeNode) => {
|
|
||||||
const checked = nodeCheckedStatus(treeNode);
|
|
||||||
const newChecked = checked === "indeterminate" ? true : !checked;
|
|
||||||
setCheckedAndChildren(treeNode, newChecked, unstage.mutate, add.mutate);
|
|
||||||
// TODO: Also ensure parents are added properly
|
|
||||||
},
|
|
||||||
[add.mutate, unstage.mutate],
|
|
||||||
);
|
|
||||||
|
|
||||||
const checkEntry = useCallback(
|
|
||||||
(entry: GitStatusEntry) => {
|
|
||||||
if (entry.staged) unstage.mutate({ relaPaths: [entry.relaPath] });
|
|
||||||
else add.mutate({ relaPaths: [entry.relaPath] });
|
|
||||||
},
|
|
||||||
[add.mutate, unstage.mutate],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSelectChild = useCallback(
|
|
||||||
(entry: GitStatusEntry) => {
|
|
||||||
if (entry === selectedEntry) {
|
|
||||||
setSelectedEntry(null);
|
|
||||||
} else {
|
|
||||||
setSelectedEntry(entry);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[selectedEntry],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDiscardChanges = useCallback(
|
|
||||||
async (entry: GitStatusEntry) => {
|
|
||||||
const confirmed = await showConfirm({
|
|
||||||
id: "git-restore-commit-entry",
|
|
||||||
title: "Discard Changes",
|
|
||||||
description: "Do you really want to discard uncommitted changes for the selected item?",
|
|
||||||
confirmText: "Discard",
|
|
||||||
color: "danger",
|
|
||||||
});
|
|
||||||
if (!confirmed) return;
|
|
||||||
|
|
||||||
await restore.mutateAsync({ relaPaths: [entry.relaPath] });
|
|
||||||
await sync({ force: true });
|
|
||||||
setSelectedEntry(null);
|
|
||||||
},
|
|
||||||
[restore],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (tree == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasAnythingToAdd) {
|
|
||||||
return (
|
|
||||||
<div className="h-full px-6 pb-4">
|
|
||||||
<EmptyStateText>No changes since last commit</EmptyStateText>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full px-2 pb-4">
|
|
||||||
<SplitLayout
|
|
||||||
storageKey="commit-horizontal"
|
|
||||||
layout="horizontal"
|
|
||||||
defaultRatio={0.6}
|
|
||||||
firstSlot={({ style }) => (
|
|
||||||
<div style={style} className="h-full px-4">
|
|
||||||
<SplitLayout
|
|
||||||
storageKey="commit-vertical"
|
|
||||||
layout="vertical"
|
|
||||||
defaultRatio={0.35}
|
|
||||||
firstSlot={({ style: innerStyle }) => (
|
|
||||||
<div
|
|
||||||
style={innerStyle}
|
|
||||||
className="h-full overflow-y-auto pb-3 pr-0.5 transform-cpu"
|
|
||||||
>
|
|
||||||
<TreeNodeChildren
|
|
||||||
node={tree}
|
|
||||||
depth={0}
|
|
||||||
onCheck={checkNode}
|
|
||||||
onSelect={handleSelectChild}
|
|
||||||
selectedPath={selectedEntry?.relaPath ?? null}
|
|
||||||
/>
|
|
||||||
{externalEntries.find((e) => e.status !== "current") && (
|
|
||||||
<>
|
|
||||||
<Separator className="mt-3 mb-1">External file changes</Separator>
|
|
||||||
{externalEntries.map((entry) => (
|
|
||||||
<ExternalTreeNode
|
|
||||||
key={entry.relaPath + entry.status}
|
|
||||||
entry={entry}
|
|
||||||
onCheck={checkEntry}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
secondSlot={({ style: innerStyle }) => (
|
|
||||||
<div style={innerStyle} className="grid grid-rows-[minmax(0,1fr)_auto] gap-3 pb-2">
|
|
||||||
<Input
|
|
||||||
className="!text-base font-sans rounded-md"
|
|
||||||
placeholder="Commit message..."
|
|
||||||
onChange={setMessage}
|
|
||||||
stateKey={null}
|
|
||||||
label="Commit message"
|
|
||||||
fullHeight
|
|
||||||
multiLine
|
|
||||||
hideLabel
|
|
||||||
/>
|
|
||||||
{commitError && <Banner color="danger">{commitError}</Banner>}
|
|
||||||
<HStack alignItems="center" space={2}>
|
|
||||||
<InlineCode>{status.data?.headRefShorthand}</InlineCode>
|
|
||||||
<HStack space={2} className="ml-auto">
|
|
||||||
<Button
|
|
||||||
color="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleCreateCommit}
|
|
||||||
disabled={!hasAddedAnything || message.trim().length === 0}
|
|
||||||
isLoading={isPushing}
|
|
||||||
>
|
|
||||||
Commit
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="primary"
|
|
||||||
size="sm"
|
|
||||||
disabled={!hasAddedAnything || message.trim().length === 0}
|
|
||||||
onClick={handleCreateCommitAndPush}
|
|
||||||
isLoading={isPushing}
|
|
||||||
>
|
|
||||||
Commit and Push
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
</HStack>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
secondSlot={({ style }) => (
|
|
||||||
<div style={style} className="h-full px-4 border-l border-l-border-subtle">
|
|
||||||
{selectedEntry ? (
|
|
||||||
<DiffPanel entry={selectedEntry} onDiscardChanges={handleDiscardChanges} />
|
|
||||||
) : (
|
|
||||||
<EmptyStateText>Select a change to view diff</EmptyStateText>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TreeNodeChildren({
|
|
||||||
node,
|
|
||||||
depth,
|
|
||||||
onCheck,
|
|
||||||
onSelect,
|
|
||||||
selectedPath,
|
|
||||||
}: {
|
|
||||||
node: CommitTreeNode | null;
|
|
||||||
depth: number;
|
|
||||||
onCheck: (node: CommitTreeNode, checked: boolean) => void;
|
|
||||||
onSelect: (entry: GitStatusEntry) => void;
|
|
||||||
selectedPath: string | null;
|
|
||||||
}) {
|
|
||||||
if (node === null) return null;
|
|
||||||
if (!isNodeRelevant(node)) return null;
|
|
||||||
|
|
||||||
const checked = nodeCheckedStatus(node);
|
|
||||||
const isSelected = selectedPath === node.status.relaPath;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
depth > 0 && "pl-4 ml-2 border-l border-dashed border-border-subtle relative",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
"relative flex gap-1 w-full h-xs items-center",
|
|
||||||
isSelected ? "text-text" : "text-text-subtle",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isSelected && (
|
|
||||||
<div className="absolute -left-[100vw] right-0 top-0 bottom-0 bg-surface-active opacity-30 -z-10" />
|
|
||||||
)}
|
|
||||||
<Checkbox
|
|
||||||
checked={checked}
|
|
||||||
title={checked ? "Unstage change" : "Stage change"}
|
|
||||||
hideLabel
|
|
||||||
onChange={(checked) => onCheck(node, checked)}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={classNames("flex-1 min-w-0 flex items-center gap-1 px-1 py-0.5 text-left")}
|
|
||||||
onClick={() => node.status.status !== "current" && onSelect(node.status)}
|
|
||||||
>
|
|
||||||
{node.model.model !== "http_request" &&
|
|
||||||
node.model.model !== "grpc_request" &&
|
|
||||||
node.model.model !== "websocket_request" ? (
|
|
||||||
<Icon
|
|
||||||
color="secondary"
|
|
||||||
icon={
|
|
||||||
node.model.model === "folder"
|
|
||||||
? "folder"
|
|
||||||
: node.model.model === "environment"
|
|
||||||
? "variable"
|
|
||||||
: "house"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span aria-hidden className="w-4" />
|
|
||||||
)}
|
|
||||||
<div className="truncate flex-1">{resolvedModelName(node.model)}</div>
|
|
||||||
{node.status.status !== "current" && (
|
|
||||||
<InlineCode
|
|
||||||
className={classNames(
|
|
||||||
"py-0 bg-transparent w-[6rem] text-center shrink-0",
|
|
||||||
node.status.status === "modified" && "text-info",
|
|
||||||
node.status.status === "untracked" && "text-success",
|
|
||||||
node.status.status === "removed" && "text-danger",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{node.status.status}
|
|
||||||
</InlineCode>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{node.children.map((childNode) => {
|
|
||||||
return (
|
|
||||||
<TreeNodeChildren
|
|
||||||
key={childNode.status.relaPath + childNode.status.status + childNode.status.staged}
|
|
||||||
node={childNode}
|
|
||||||
depth={depth + 1}
|
|
||||||
onCheck={onCheck}
|
|
||||||
onSelect={onSelect}
|
|
||||||
selectedPath={selectedPath}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ExternalTreeNode({
|
|
||||||
entry,
|
|
||||||
onCheck,
|
|
||||||
}: {
|
|
||||||
entry: GitStatusEntry;
|
|
||||||
onCheck: (entry: GitStatusEntry) => void;
|
|
||||||
}) {
|
|
||||||
if (entry.status === "current") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Checkbox
|
|
||||||
fullWidth
|
|
||||||
className="h-xs w-full hover:bg-surface-highlight rounded px-1 group"
|
|
||||||
checked={entry.staged}
|
|
||||||
onChange={() => onCheck(entry)}
|
|
||||||
title={
|
|
||||||
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] gap-1 w-full items-center">
|
|
||||||
<Icon color="secondary" icon="file_code" />
|
|
||||||
<div className="truncate">{entry.relaPath}</div>
|
|
||||||
<InlineCode
|
|
||||||
className={classNames(
|
|
||||||
"py-0 ml-auto bg-transparent w-[6rem] text-center",
|
|
||||||
entry.status === "modified" && "text-info",
|
|
||||||
entry.status === "untracked" && "text-success",
|
|
||||||
entry.status === "removed" && "text-danger",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{entry.status}
|
|
||||||
</InlineCode>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function nodeCheckedStatus(root: CommitTreeNode): CheckboxProps["checked"] {
|
|
||||||
let numVisited = 0;
|
|
||||||
let numChecked = 0;
|
|
||||||
let numCurrent = 0;
|
|
||||||
|
|
||||||
const visitChildren = (n: CommitTreeNode) => {
|
|
||||||
numVisited += 1;
|
|
||||||
if (n.status.status === "current") {
|
|
||||||
numCurrent += 1;
|
|
||||||
} else if (n.status.staged) {
|
|
||||||
numChecked += 1;
|
|
||||||
}
|
|
||||||
for (const child of n.children) {
|
|
||||||
visitChildren(child);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
visitChildren(root);
|
|
||||||
|
|
||||||
if (numVisited === numChecked + numCurrent) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (numChecked === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return "indeterminate";
|
|
||||||
}
|
|
||||||
|
|
||||||
function setCheckedAndChildren(
|
|
||||||
node: CommitTreeNode,
|
|
||||||
checked: boolean,
|
|
||||||
unstage: (args: { relaPaths: string[] }) => void,
|
|
||||||
add: (args: { relaPaths: string[] }) => void,
|
|
||||||
) {
|
|
||||||
const toAdd: string[] = [];
|
|
||||||
const toUnstage: string[] = [];
|
|
||||||
|
|
||||||
const next = (node: CommitTreeNode) => {
|
|
||||||
for (const child of node.children) {
|
|
||||||
next(child);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.status.status === "current") {
|
|
||||||
// Nothing required
|
|
||||||
} else if (checked && !node.status.staged) {
|
|
||||||
toAdd.push(node.status.relaPath);
|
|
||||||
} else if (!checked && node.status.staged) {
|
|
||||||
toUnstage.push(node.status.relaPath);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
next(node);
|
|
||||||
|
|
||||||
if (toAdd.length > 0) add({ relaPaths: toAdd });
|
|
||||||
if (toUnstage.length > 0) unstage({ relaPaths: toUnstage });
|
|
||||||
}
|
|
||||||
|
|
||||||
function isNodeRelevant(node: CommitTreeNode): boolean {
|
|
||||||
if (node.status.status !== "current") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recursively check children
|
|
||||||
return node.children.some((c) => isNodeRelevant(c));
|
|
||||||
}
|
|
||||||
|
|
||||||
function DiffPanel({
|
|
||||||
entry,
|
|
||||||
onDiscardChanges,
|
|
||||||
}: {
|
|
||||||
entry: GitStatusEntry;
|
|
||||||
onDiscardChanges: (entry: GitStatusEntry) => void | Promise<void>;
|
|
||||||
}) {
|
|
||||||
const prevYaml = modelToYaml(entry.prev);
|
|
||||||
const nextYaml = modelToYaml(entry.next);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full flex flex-col">
|
|
||||||
<div className="text-text-subtle mb-2 px-1 grid items-center gap-2 grid-cols-[minmax(0,1fr)_auto]">
|
|
||||||
<div className="min-w-0 truncate">
|
|
||||||
{resolvedModelName(entry.next ?? entry.prev)} ({entry.status})
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
className="ml-auto"
|
|
||||||
color="warning"
|
|
||||||
size="2xs"
|
|
||||||
variant="border"
|
|
||||||
onClick={() => onDiscardChanges(entry)}
|
|
||||||
>Discard Changes</Button>
|
|
||||||
</div>
|
|
||||||
<DiffViewer
|
|
||||||
original={prevYaml ?? ""}
|
|
||||||
modified={nextYaml ?? ""}
|
|
||||||
className="flex-1 min-h-0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,681 +0,0 @@
|
|||||||
import { useGitBranchInfo, useGitMutations } from "@yaakapp-internal/git";
|
|
||||||
import type { WorkspaceMeta } from "@yaakapp-internal/models";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import type { HTMLAttributes } from "react";
|
|
||||||
import { forwardRef, useCallback, useMemo } from "react";
|
|
||||||
import { openWorkspaceSettings } from "../../commands/openWorkspaceSettings";
|
|
||||||
import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from "../../hooks/useActiveWorkspace";
|
|
||||||
import { useKeyValue } from "../../hooks/useKeyValue";
|
|
||||||
import { useRandomKey } from "../../hooks/useRandomKey";
|
|
||||||
import { sync } from "../../init/sync";
|
|
||||||
import { showConfirm, showConfirmDelete } from "../../lib/confirm";
|
|
||||||
import { fireAndForget } from "../../lib/fireAndForget";
|
|
||||||
import { showDialog } from "../../lib/dialog";
|
|
||||||
import { gitWorktreeStatusAtom } from "../../lib/gitWorktreeStatus";
|
|
||||||
import { showPrompt } from "../../lib/prompt";
|
|
||||||
import { showErrorToast, showToast } from "../../lib/toast";
|
|
||||||
import type { DropdownItem } from "../core/Dropdown";
|
|
||||||
import { Dropdown } from "../core/Dropdown";
|
|
||||||
import { Banner, Icon, InlineCode } from "@yaakapp-internal/ui";
|
|
||||||
import { useGitCallbacks } from "./callbacks";
|
|
||||||
import { GitCommitDialog } from "./GitCommitDialog";
|
|
||||||
import { GitRemotesDialog } from "./GitRemotesDialog";
|
|
||||||
import { handlePullResult, handlePushResult } from "./git-util";
|
|
||||||
import { HistoryDialog } from "./HistoryDialog";
|
|
||||||
|
|
||||||
const EMPTY_BRANCHES: string[] = [];
|
|
||||||
|
|
||||||
export function GitDropdown() {
|
|
||||||
const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);
|
|
||||||
if (workspaceMeta == null) return null;
|
|
||||||
|
|
||||||
if (workspaceMeta.settingSyncDir == null) {
|
|
||||||
return <SetupSyncDropdown workspaceMeta={workspaceMeta} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <SyncDropdownWithSyncDir syncDir={workspaceMeta.settingSyncDir} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
|
||||||
const workspace = useAtomValue(activeWorkspaceAtom);
|
|
||||||
const worktreeStatus = useAtomValue(gitWorktreeStatusAtom);
|
|
||||||
const [refreshKey, regenerateKey] = useRandomKey();
|
|
||||||
const branchInfo = useGitBranchInfo(syncDir, refreshKey);
|
|
||||||
const callbacks = useGitCallbacks(syncDir);
|
|
||||||
const {
|
|
||||||
createBranch,
|
|
||||||
deleteBranch,
|
|
||||||
deleteRemoteBranch,
|
|
||||||
renameBranch,
|
|
||||||
mergeBranch,
|
|
||||||
push,
|
|
||||||
pull,
|
|
||||||
checkout,
|
|
||||||
resetChanges,
|
|
||||||
init,
|
|
||||||
} = useGitMutations(syncDir, callbacks);
|
|
||||||
|
|
||||||
const localBranches = branchInfo.data?.localBranches ?? EMPTY_BRANCHES;
|
|
||||||
const remoteBranches = branchInfo.data?.remoteBranches ?? EMPTY_BRANCHES;
|
|
||||||
const remoteOnlyBranches = useMemo(
|
|
||||||
() => remoteBranches.filter((b) => !localBranches.includes(b.replace(/^origin\//, ""))),
|
|
||||||
[localBranches, remoteBranches],
|
|
||||||
);
|
|
||||||
const currentBranch = branchInfo.data?.headRefShorthand;
|
|
||||||
const hasChanges = worktreeStatus?.entries.some((e) => e.status !== "current") ?? false;
|
|
||||||
const ahead = branchInfo.data?.ahead ?? 0;
|
|
||||||
const behind = branchInfo.data?.behind ?? 0;
|
|
||||||
const initRepo = useCallback(() => {
|
|
||||||
init.mutate();
|
|
||||||
}, [init]);
|
|
||||||
|
|
||||||
const items: DropdownItem[] = useMemo(() => {
|
|
||||||
if (workspace == null || branchInfo.data == null) return [];
|
|
||||||
|
|
||||||
const tryCheckout = (branch: string, force: boolean) => {
|
|
||||||
checkout.mutate(
|
|
||||||
{ branch, force },
|
|
||||||
{
|
|
||||||
disableToastError: true,
|
|
||||||
async onError(err) {
|
|
||||||
if (!force) {
|
|
||||||
// Checkout failed so ask user if they want to force it
|
|
||||||
const forceCheckout = await showConfirm({
|
|
||||||
id: "git-force-checkout",
|
|
||||||
title: "Conflicts Detected",
|
|
||||||
description:
|
|
||||||
"Your branch has conflicts. Either make a commit or force checkout to discard changes.",
|
|
||||||
confirmText: "Force Checkout",
|
|
||||||
color: "warning",
|
|
||||||
});
|
|
||||||
if (forceCheckout) {
|
|
||||||
tryCheckout(branch, true);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Checkout failed
|
|
||||||
showErrorToast({
|
|
||||||
id: "git-checkout-error",
|
|
||||||
title: "Error checking out branch",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async onSuccess(branchName) {
|
|
||||||
showToast({
|
|
||||||
id: "git-checkout-success",
|
|
||||||
message: (
|
|
||||||
<>
|
|
||||||
Switched branch <InlineCode>{branchName}</InlineCode>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
color: "success",
|
|
||||||
});
|
|
||||||
await sync({ force: true });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: "View History...",
|
|
||||||
leftSlot: <Icon icon="history" />,
|
|
||||||
onSelect: async () => {
|
|
||||||
showDialog({
|
|
||||||
id: "git-history",
|
|
||||||
size: "md",
|
|
||||||
title: "Commit History",
|
|
||||||
noPadding: true,
|
|
||||||
render: () => <HistoryDialog dir={syncDir} />,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Manage Remotes...",
|
|
||||||
leftSlot: <Icon icon="hard_drive_download" />,
|
|
||||||
onSelect: () => GitRemotesDialog.show(syncDir),
|
|
||||||
},
|
|
||||||
{ type: "separator" },
|
|
||||||
{
|
|
||||||
label: "New Branch...",
|
|
||||||
leftSlot: <Icon icon="git_branch_plus" />,
|
|
||||||
async onSelect() {
|
|
||||||
const name = await showPrompt({
|
|
||||||
id: "git-branch-name",
|
|
||||||
title: "Create Branch",
|
|
||||||
label: "Branch Name",
|
|
||||||
});
|
|
||||||
if (!name) return;
|
|
||||||
|
|
||||||
await createBranch.mutateAsync(
|
|
||||||
{ branch: name },
|
|
||||||
{
|
|
||||||
disableToastError: true,
|
|
||||||
onError: (err) => {
|
|
||||||
showErrorToast({
|
|
||||||
id: "git-branch-error",
|
|
||||||
title: "Error creating branch",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
tryCheckout(name, false);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ type: "separator" },
|
|
||||||
{
|
|
||||||
label: "Push",
|
|
||||||
leftSlot: <Icon icon="arrow_up_from_line" />,
|
|
||||||
waitForOnSelect: true,
|
|
||||||
async onSelect() {
|
|
||||||
await push.mutateAsync(undefined, {
|
|
||||||
disableToastError: true,
|
|
||||||
onSuccess: handlePushResult,
|
|
||||||
onError(err) {
|
|
||||||
showErrorToast({
|
|
||||||
id: "git-push-error",
|
|
||||||
title: "Error pushing changes",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Pull",
|
|
||||||
leftSlot: <Icon icon="arrow_down_to_line" />,
|
|
||||||
waitForOnSelect: true,
|
|
||||||
async onSelect() {
|
|
||||||
await pull.mutateAsync(undefined, {
|
|
||||||
disableToastError: true,
|
|
||||||
onSuccess: handlePullResult,
|
|
||||||
onError(err) {
|
|
||||||
showErrorToast({
|
|
||||||
id: "git-pull-error",
|
|
||||||
title: "Error pulling changes",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Commit...",
|
|
||||||
|
|
||||||
leftSlot: <Icon icon="git_commit_vertical" />,
|
|
||||||
onSelect() {
|
|
||||||
showDialog({
|
|
||||||
id: "commit",
|
|
||||||
title: "Commit Changes",
|
|
||||||
size: "full",
|
|
||||||
noPadding: true,
|
|
||||||
render: ({ hide }) => (
|
|
||||||
<GitCommitDialog syncDir={syncDir} onDone={hide} workspace={workspace} />
|
|
||||||
),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Reset Changes",
|
|
||||||
hidden: !hasChanges,
|
|
||||||
leftSlot: <Icon icon="rotate_ccw" />,
|
|
||||||
color: "danger",
|
|
||||||
async onSelect() {
|
|
||||||
const confirmed = await showConfirm({
|
|
||||||
id: "git-reset-changes",
|
|
||||||
title: "Reset Changes",
|
|
||||||
description: "This will discard all uncommitted changes. This cannot be undone.",
|
|
||||||
confirmText: "Reset",
|
|
||||||
color: "danger",
|
|
||||||
});
|
|
||||||
if (!confirmed) return;
|
|
||||||
|
|
||||||
await resetChanges.mutateAsync(undefined, {
|
|
||||||
disableToastError: true,
|
|
||||||
onSuccess() {
|
|
||||||
showToast({
|
|
||||||
id: "git-reset-success",
|
|
||||||
message: "Changes have been reset",
|
|
||||||
color: "success",
|
|
||||||
});
|
|
||||||
fireAndForget(sync({ force: true }));
|
|
||||||
},
|
|
||||||
onError(err) {
|
|
||||||
showErrorToast({
|
|
||||||
id: "git-reset-error",
|
|
||||||
title: "Error resetting changes",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ type: "separator", label: "Branches", hidden: localBranches.length < 1 },
|
|
||||||
...localBranches.map((branch) => {
|
|
||||||
const isCurrent = currentBranch === branch;
|
|
||||||
return {
|
|
||||||
label: branch,
|
|
||||||
leftSlot: <Icon icon={isCurrent ? "check" : "empty"} />,
|
|
||||||
submenuOpenOnClick: true,
|
|
||||||
submenu: [
|
|
||||||
{
|
|
||||||
label: "Checkout",
|
|
||||||
hidden: isCurrent,
|
|
||||||
onSelect: () => tryCheckout(branch, false),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: (
|
|
||||||
<>
|
|
||||||
Merge into <InlineCode>{currentBranch}</InlineCode>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
hidden: isCurrent,
|
|
||||||
async onSelect() {
|
|
||||||
await mergeBranch.mutateAsync(
|
|
||||||
{ branch },
|
|
||||||
{
|
|
||||||
disableToastError: true,
|
|
||||||
onSuccess() {
|
|
||||||
showToast({
|
|
||||||
id: "git-merged-branch",
|
|
||||||
message: (
|
|
||||||
<>
|
|
||||||
Merged <InlineCode>{branch}</InlineCode> into{" "}
|
|
||||||
<InlineCode>{currentBranch}</InlineCode>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
fireAndForget(sync({ force: true }));
|
|
||||||
},
|
|
||||||
onError(err) {
|
|
||||||
showErrorToast({
|
|
||||||
id: "git-merged-branch-error",
|
|
||||||
title: "Error merging branch",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "New Branch...",
|
|
||||||
async onSelect() {
|
|
||||||
const name = await showPrompt({
|
|
||||||
id: "git-new-branch-from",
|
|
||||||
title: "New Branch",
|
|
||||||
description: (
|
|
||||||
<>
|
|
||||||
Create a new branch from <InlineCode>{branch}</InlineCode>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
label: "Branch Name",
|
|
||||||
});
|
|
||||||
if (!name) return;
|
|
||||||
|
|
||||||
await createBranch.mutateAsync(
|
|
||||||
{ branch: name, base: branch },
|
|
||||||
{
|
|
||||||
disableToastError: true,
|
|
||||||
onError: (err) => {
|
|
||||||
showErrorToast({
|
|
||||||
id: "git-branch-error",
|
|
||||||
title: "Error creating branch",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
tryCheckout(name, false);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Rename...",
|
|
||||||
async onSelect() {
|
|
||||||
const newName = await showPrompt({
|
|
||||||
id: "git-rename-branch",
|
|
||||||
title: "Rename Branch",
|
|
||||||
label: "New Branch Name",
|
|
||||||
defaultValue: branch,
|
|
||||||
});
|
|
||||||
if (!newName || newName === branch) return;
|
|
||||||
|
|
||||||
await renameBranch.mutateAsync(
|
|
||||||
{ oldName: branch, newName },
|
|
||||||
{
|
|
||||||
disableToastError: true,
|
|
||||||
onSuccess() {
|
|
||||||
showToast({
|
|
||||||
id: "git-rename-branch-success",
|
|
||||||
message: (
|
|
||||||
<>
|
|
||||||
Renamed <InlineCode>{branch}</InlineCode> to{" "}
|
|
||||||
<InlineCode>{newName}</InlineCode>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
color: "success",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError(err) {
|
|
||||||
showErrorToast({
|
|
||||||
id: "git-rename-branch-error",
|
|
||||||
title: "Error renaming branch",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ type: "separator", hidden: isCurrent },
|
|
||||||
{
|
|
||||||
label: "Delete",
|
|
||||||
color: "danger",
|
|
||||||
hidden: isCurrent,
|
|
||||||
onSelect: async () => {
|
|
||||||
const confirmed = await showConfirmDelete({
|
|
||||||
id: "git-delete-branch",
|
|
||||||
title: "Delete Branch",
|
|
||||||
description: (
|
|
||||||
<>
|
|
||||||
Permanently delete <InlineCode>{branch}</InlineCode>?
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
if (!confirmed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await deleteBranch.mutateAsync(
|
|
||||||
{ branch },
|
|
||||||
{
|
|
||||||
disableToastError: true,
|
|
||||||
onError(err) {
|
|
||||||
showErrorToast({
|
|
||||||
id: "git-delete-branch-error",
|
|
||||||
title: "Error deleting branch",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.type === "not_fully_merged") {
|
|
||||||
const confirmed = await showConfirm({
|
|
||||||
id: "force-branch-delete",
|
|
||||||
title: "Branch not fully merged",
|
|
||||||
description: (
|
|
||||||
<>
|
|
||||||
<p>
|
|
||||||
Branch <InlineCode>{branch}</InlineCode> is not fully merged.
|
|
||||||
</p>
|
|
||||||
<p>Do you want to delete it anyway?</p>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
if (confirmed) {
|
|
||||||
await deleteBranch.mutateAsync(
|
|
||||||
{ branch, force: true },
|
|
||||||
{
|
|
||||||
disableToastError: true,
|
|
||||||
onError(err) {
|
|
||||||
showErrorToast({
|
|
||||||
id: "git-force-delete-branch-error",
|
|
||||||
title: "Error force deleting branch",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} satisfies DropdownItem;
|
|
||||||
}),
|
|
||||||
...remoteOnlyBranches.map((branch) => {
|
|
||||||
const isCurrent = currentBranch === branch;
|
|
||||||
return {
|
|
||||||
label: branch,
|
|
||||||
leftSlot: <Icon icon={isCurrent ? "check" : "empty"} />,
|
|
||||||
submenuOpenOnClick: true,
|
|
||||||
submenu: [
|
|
||||||
{
|
|
||||||
label: "Checkout",
|
|
||||||
hidden: isCurrent,
|
|
||||||
onSelect: () => tryCheckout(branch, false),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Delete",
|
|
||||||
color: "danger",
|
|
||||||
async onSelect() {
|
|
||||||
const confirmed = await showConfirmDelete({
|
|
||||||
id: "git-delete-remote-branch",
|
|
||||||
title: "Delete Remote Branch",
|
|
||||||
description: (
|
|
||||||
<>
|
|
||||||
Permanently delete <InlineCode>{branch}</InlineCode> from the remote?
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
if (!confirmed) return;
|
|
||||||
|
|
||||||
await deleteRemoteBranch.mutateAsync(
|
|
||||||
{ branch },
|
|
||||||
{
|
|
||||||
disableToastError: true,
|
|
||||||
onSuccess() {
|
|
||||||
showToast({
|
|
||||||
id: "git-delete-remote-branch-success",
|
|
||||||
message: (
|
|
||||||
<>
|
|
||||||
Deleted remote branch <InlineCode>{branch}</InlineCode>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
color: "success",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError(err) {
|
|
||||||
showErrorToast({
|
|
||||||
id: "git-delete-remote-branch-error",
|
|
||||||
title: "Error deleting remote branch",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} satisfies DropdownItem;
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
}, [
|
|
||||||
branchInfo.data,
|
|
||||||
checkout,
|
|
||||||
createBranch,
|
|
||||||
currentBranch,
|
|
||||||
deleteBranch,
|
|
||||||
deleteRemoteBranch,
|
|
||||||
hasChanges,
|
|
||||||
localBranches,
|
|
||||||
mergeBranch,
|
|
||||||
pull,
|
|
||||||
push,
|
|
||||||
remoteOnlyBranches,
|
|
||||||
renameBranch,
|
|
||||||
resetChanges,
|
|
||||||
syncDir,
|
|
||||||
workspace,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (workspace == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const noRepo = branchInfo.error?.includes("not found");
|
|
||||||
if (noRepo) {
|
|
||||||
return <SetupGitDropdown workspaceId={workspace.id} initRepo={initRepo} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Still loading
|
|
||||||
if (branchInfo.data == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dropdown fullWidth items={items} onOpen={regenerateKey}>
|
|
||||||
<GitMenuButton>
|
|
||||||
<InlineCode className="flex items-center gap-1">
|
|
||||||
<Icon icon="git_branch" size="xs" className="opacity-50" />
|
|
||||||
{currentBranch}
|
|
||||||
</InlineCode>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{ahead > 0 && (
|
|
||||||
<span className="text-xs flex items-center gap-0.5">
|
|
||||||
<span className="text-primary">↗</span>
|
|
||||||
{ahead}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{behind > 0 && (
|
|
||||||
<span className="text-xs flex items-center gap-0.5">
|
|
||||||
<span className="text-info">↙</span>
|
|
||||||
{behind}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</GitMenuButton>
|
|
||||||
</Dropdown>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const GitMenuButton = forwardRef<HTMLButtonElement, HTMLAttributes<HTMLButtonElement>>(
|
|
||||||
function GitMenuButton({ className, ...props }: HTMLAttributes<HTMLButtonElement>, ref) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
ref={ref}
|
|
||||||
className={classNames(
|
|
||||||
className,
|
|
||||||
"px-3 h-md border-t border-border flex items-center justify-between text-text-subtle outline-none focus-visible:bg-surface-highlight",
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
function SetupSyncDropdown({ workspaceMeta }: { workspaceMeta: WorkspaceMeta }) {
|
|
||||||
const { value: hidden, set: setHidden } = useKeyValue<Record<string, boolean>>({
|
|
||||||
key: "setup_sync",
|
|
||||||
fallback: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hidden == null || hidden[workspaceMeta.workspaceId]) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const banner = (
|
|
||||||
<Banner color="info">
|
|
||||||
When enabled, workspace data syncs to the chosen folder as text files, ideal for backup and
|
|
||||||
Git collaboration.
|
|
||||||
</Banner>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dropdown
|
|
||||||
fullWidth
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
type: "content",
|
|
||||||
label: banner,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
color: "success",
|
|
||||||
label: "Open Workspace Settings",
|
|
||||||
leftSlot: <Icon icon="settings" />,
|
|
||||||
onSelect: () => openWorkspaceSettings("settings"),
|
|
||||||
},
|
|
||||||
{ type: "separator" },
|
|
||||||
{
|
|
||||||
label: "Hide This Message",
|
|
||||||
leftSlot: <Icon icon="eye_closed" />,
|
|
||||||
async onSelect() {
|
|
||||||
const confirmed = await showConfirm({
|
|
||||||
id: "hide-sync-menu-prompt",
|
|
||||||
title: "Hide Setup Message",
|
|
||||||
description: "You can configure filesystem sync or Git it in the workspace settings",
|
|
||||||
});
|
|
||||||
if (confirmed) {
|
|
||||||
await setHidden((prev) => ({ ...prev, [workspaceMeta.workspaceId]: true }));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<GitMenuButton>
|
|
||||||
<div className="text-sm text-text-subtle grid grid-cols-[auto_minmax(0,1fr)] items-center gap-2">
|
|
||||||
<Icon icon="wrench" />
|
|
||||||
<div className="truncate">Setup FS Sync or Git</div>
|
|
||||||
</div>
|
|
||||||
</GitMenuButton>
|
|
||||||
</Dropdown>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SetupGitDropdown({
|
|
||||||
workspaceId,
|
|
||||||
initRepo,
|
|
||||||
}: {
|
|
||||||
workspaceId: string;
|
|
||||||
initRepo: () => void;
|
|
||||||
}) {
|
|
||||||
const { value: hidden, set: setHidden } = useKeyValue<Record<string, boolean>>({
|
|
||||||
key: "setup_git_repo",
|
|
||||||
fallback: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hidden == null || hidden[workspaceId]) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const banner = <Banner color="info">Initialize local repo to start versioning with Git</Banner>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dropdown
|
|
||||||
fullWidth
|
|
||||||
items={[
|
|
||||||
{ type: "content", label: banner },
|
|
||||||
{
|
|
||||||
label: "Initialize Git Repo",
|
|
||||||
leftSlot: <Icon icon="magic_wand" />,
|
|
||||||
onSelect: initRepo,
|
|
||||||
},
|
|
||||||
{ type: "separator" },
|
|
||||||
{
|
|
||||||
label: "Hide This Message",
|
|
||||||
leftSlot: <Icon icon="eye_closed" />,
|
|
||||||
async onSelect() {
|
|
||||||
const confirmed = await showConfirm({
|
|
||||||
id: "hide-git-init-prompt",
|
|
||||||
title: "Hide Git Setup",
|
|
||||||
description: "You can initialize a git repo outside of Yaak to bring this back",
|
|
||||||
});
|
|
||||||
if (confirmed) {
|
|
||||||
await setHidden((prev) => ({ ...prev, [workspaceId]: true }));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<GitMenuButton>
|
|
||||||
<div className="text-sm text-text-subtle grid grid-cols-[auto_minmax(0,1fr)] items-center gap-2">
|
|
||||||
<Icon icon="folder_git" />
|
|
||||||
<div className="truncate">Setup Git</div>
|
|
||||||
</div>
|
|
||||||
</GitMenuButton>
|
|
||||||
</Dropdown>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import type { GitCallbacks } from "@yaakapp-internal/git";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { sync } from "../../init/sync";
|
|
||||||
import { promptCredentials } from "./credentials";
|
|
||||||
import { promptDivergedStrategy } from "./diverged";
|
|
||||||
import { addGitRemote } from "./showAddRemoteDialog";
|
|
||||||
import { promptUncommittedChangesStrategy } from "./uncommitted";
|
|
||||||
|
|
||||||
export function gitCallbacks(dir: string): GitCallbacks {
|
|
||||||
return {
|
|
||||||
addRemote: async () => {
|
|
||||||
return addGitRemote(dir, "origin");
|
|
||||||
},
|
|
||||||
promptCredentials: async ({ url, error }) => {
|
|
||||||
const creds = await promptCredentials({ url, error });
|
|
||||||
if (creds == null) throw new Error("Cancelled credentials prompt");
|
|
||||||
return creds;
|
|
||||||
},
|
|
||||||
promptDiverged: async ({ remote, branch }) => {
|
|
||||||
return promptDivergedStrategy({ remote, branch });
|
|
||||||
},
|
|
||||||
promptUncommittedChanges: async () => {
|
|
||||||
return promptUncommittedChangesStrategy();
|
|
||||||
},
|
|
||||||
forceSync: () => sync({ force: true }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useGitCallbacks(dir: string): GitCallbacks {
|
|
||||||
return useMemo(() => gitCallbacks(dir), [dir]);
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { showPromptForm } from "../../lib/prompt-form";
|
|
||||||
import { Banner, InlineCode } from "@yaakapp-internal/ui";
|
|
||||||
|
|
||||||
export interface GitCredentials {
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function promptCredentials({
|
|
||||||
url: remoteUrl,
|
|
||||||
error,
|
|
||||||
}: {
|
|
||||||
url: string;
|
|
||||||
error: string | null;
|
|
||||||
}): Promise<GitCredentials | null> {
|
|
||||||
const isGitHub = /github\.com/i.test(remoteUrl);
|
|
||||||
const userLabel = isGitHub ? "GitHub Username" : "Username";
|
|
||||||
const passLabel = isGitHub ? "GitHub Personal Access Token" : "Password / Token";
|
|
||||||
const userDescription = isGitHub ? "Use your GitHub username (not your email)." : undefined;
|
|
||||||
const passDescription = isGitHub
|
|
||||||
? "GitHub requires a Personal Access Token (PAT) for write operations over HTTPS. Passwords are not supported."
|
|
||||||
: "Enter your password or access token for this Git server.";
|
|
||||||
const r = await showPromptForm({
|
|
||||||
id: "git-credentials",
|
|
||||||
title: "Credentials Required",
|
|
||||||
description: error ? (
|
|
||||||
<Banner color="danger">{error}</Banner>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
Enter credentials for <InlineCode>{remoteUrl}</InlineCode>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
inputs: [
|
|
||||||
{ type: "text", name: "username", label: userLabel, description: userDescription },
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
name: "password",
|
|
||||||
label: passLabel,
|
|
||||||
description: passDescription,
|
|
||||||
password: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
if (r == null) return null;
|
|
||||||
|
|
||||||
const username = String(r.username || "");
|
|
||||||
const password = String(r.password || "");
|
|
||||||
return { username, password };
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
import type { DivergedStrategy } from "@yaakapp-internal/git";
|
|
||||||
import { HStack, InlineCode } from "@yaakapp-internal/ui";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { showDialog } from "../../lib/dialog";
|
|
||||||
import { Button } from "../core/Button";
|
|
||||||
import { RadioCards } from "../core/RadioCards";
|
|
||||||
|
|
||||||
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,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user