mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-05-16 04:37:34 +02:00
Compare commits
1 Commits
codex-revi
...
v2025.2.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64f5d973eb |
@@ -1,83 +0,0 @@
|
|||||||
# Claude Context: Detaching Tauri from Yaak
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Make Yaak runnable as a standalone CLI without Tauri as a dependency. The core Rust crates in `crates/` should be usable independently, while Tauri-specific code lives in `crates-tauri/`.
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
crates/ # Core crates - should NOT depend on Tauri
|
|
||||||
crates-tauri/ # Tauri-specific crates (yaak-app-client, yaak-tauri-utils, etc.)
|
|
||||||
crates-cli/ # CLI crate (yaak-cli)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Completed Work
|
|
||||||
|
|
||||||
### 1. Folder Restructure
|
|
||||||
|
|
||||||
- Moved Tauri-dependent app code to `crates-tauri/yaak-app-client/`
|
|
||||||
- Created `crates-tauri/yaak-tauri-utils/` for shared Tauri utilities (window traits, api_client, error handling)
|
|
||||||
- Created `crates-cli/yaak-cli/` for the standalone CLI
|
|
||||||
|
|
||||||
### 2. Decoupled Crates (no longer depend on Tauri)
|
|
||||||
|
|
||||||
- **yaak-models**: Uses `init_standalone()` pattern for CLI database access
|
|
||||||
- **yaak-http**: Removed Tauri plugin, HttpConnectionManager initialized in yaak-app setup
|
|
||||||
- **yaak-common**: Only contains Tauri-free utilities (serde, platform)
|
|
||||||
- **yaak-crypto**: Removed Tauri plugin, EncryptionManager initialized in yaak-app setup, commands moved to yaak-app
|
|
||||||
- **yaak-grpc**: Replaced AppHandle with GrpcConfig struct, uses tokio::process::Command instead of Tauri sidecar
|
|
||||||
|
|
||||||
### 3. CLI Implementation
|
|
||||||
|
|
||||||
- Basic CLI at `crates-cli/yaak-cli/src/main.rs`
|
|
||||||
- Commands: workspaces, requests, send (by ID), get (ad-hoc URL), create
|
|
||||||
- Uses same database as Tauri app via `yaak_models::init_standalone()`
|
|
||||||
|
|
||||||
## Remaining Work
|
|
||||||
|
|
||||||
### Crates Still Depending on Tauri (in `crates/`)
|
|
||||||
|
|
||||||
1. **yaak-git** (3 files) - Moderate complexity
|
|
||||||
2. **yaak-plugins** (13 files) - **Hardest** - deeply integrated with Tauri for plugin-to-window communication
|
|
||||||
3. **yaak-sync** (4 files) - Moderate complexity
|
|
||||||
4. **yaak-ws** (5 files) - Moderate complexity
|
|
||||||
|
|
||||||
### Pattern for Decoupling
|
|
||||||
|
|
||||||
1. Remove Tauri plugin `init()` function from the crate
|
|
||||||
2. Move commands to `yaak-app/src/commands.rs` or keep inline in `lib.rs`
|
|
||||||
3. Move extension traits (e.g., `SomethingManagerExt`) to yaak-app or yaak-tauri-utils
|
|
||||||
4. Initialize managers in yaak-app's `.setup()` block
|
|
||||||
5. Remove `tauri` from Cargo.toml dependencies
|
|
||||||
6. Update `crates-tauri/yaak-app-client/capabilities/default.json` to remove the plugin permission
|
|
||||||
7. Replace `tauri::async_runtime::block_on` with `tokio::runtime::Handle::current().block_on()`
|
|
||||||
|
|
||||||
## Key Files
|
|
||||||
|
|
||||||
- `crates-tauri/yaak-app-client/src/lib.rs` - Main Tauri app, setup block initializes managers
|
|
||||||
- `crates-tauri/yaak-app-client/src/commands.rs` - Migrated Tauri commands
|
|
||||||
- `crates-tauri/yaak-app-client/src/models_ext.rs` - Database plugin and extension traits
|
|
||||||
- `crates-tauri/yaak-tauri-utils/src/window.rs` - WorkspaceWindowTrait for window state
|
|
||||||
- `crates/yaak-models/src/lib.rs` - Contains `init_standalone()` for CLI usage
|
|
||||||
|
|
||||||
## Git Branch
|
|
||||||
|
|
||||||
Working on `detach-tauri` branch.
|
|
||||||
|
|
||||||
## Recent Commits
|
|
||||||
|
|
||||||
```
|
|
||||||
c40cff40 Remove Tauri dependencies from yaak-crypto and yaak-grpc
|
|
||||||
df495f1d Move Tauri utilities from yaak-common to yaak-tauri-utils
|
|
||||||
481e0273 Remove Tauri dependencies from yaak-http and yaak-common
|
|
||||||
10568ac3 Add HTTP request sending to yaak-cli
|
|
||||||
bcb7d600 Add yaak-cli stub with basic database access
|
|
||||||
e718a5f1 Refactor models_ext to use init_standalone from yaak-models
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
- Run `cargo check -p <crate>` to verify a crate builds without Tauri
|
|
||||||
- Run `npm run client:dev` to test the Tauri app still works
|
|
||||||
- Run `cargo run -p yaak-cli -- --help` to test the CLI
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
---
|
|
||||||
description: Generate formatted release notes for Yaak releases
|
|
||||||
allowed-tools: Bash(git tag:*)
|
|
||||||
---
|
|
||||||
|
|
||||||
Generate formatted release notes for Yaak releases by analyzing git history and pull request descriptions.
|
|
||||||
|
|
||||||
## What to do
|
|
||||||
|
|
||||||
1. Identifies the version tag and previous version
|
|
||||||
2. Retrieves all commits between versions
|
|
||||||
- If the version is a beta version, it retrieves commits between the beta version and previous beta version
|
|
||||||
- If the version is a stable version, it retrieves commits between the stable version and the previous stable version
|
|
||||||
3. Fetches PR descriptions for linked issues to find:
|
|
||||||
- Feedback URLs (feedback.yaak.app)
|
|
||||||
- Additional context and descriptions
|
|
||||||
- Installation links for plugins
|
|
||||||
4. Formats the release notes using the standard Yaak format:
|
|
||||||
- Changelog badge at the top
|
|
||||||
- Bulleted list of changes with PR links
|
|
||||||
- Feedback links where available
|
|
||||||
- Full changelog comparison link at the bottom
|
|
||||||
|
|
||||||
## Output Format
|
|
||||||
|
|
||||||
The skill generates markdown-formatted release notes following this structure:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
[](https://yaak.app/changelog/VERSION)
|
|
||||||
|
|
||||||
- Feature/fix description in by @username [#123](https://github.com/mountain-loop/yaak/pull/123)
|
|
||||||
- [Linked feedback item](https://feedback.yaak.app/p/item) by @username in [#456](https://github.com/mountain-loop/yaak/pull/456)
|
|
||||||
- A simple item that doesn't have a feedback or PR link
|
|
||||||
|
|
||||||
**Full Changelog**: https://github.com/mountain-loop/yaak/compare/vPREV...vCURRENT
|
|
||||||
```
|
|
||||||
|
|
||||||
**IMPORTANT**: Always add a blank lines around the markdown code fence and output the markdown code block last
|
|
||||||
**IMPORTANT**: PRs by `@gschier` should not mention the @username
|
|
||||||
**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".
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# Project Rules
|
|
||||||
|
|
||||||
## General Development
|
|
||||||
|
|
||||||
- **NEVER** commit or push without explicit confirmation
|
|
||||||
|
|
||||||
## Build and Lint
|
|
||||||
|
|
||||||
- **ALWAYS** run `npm run lint` after modifying TypeScript or JavaScript files
|
|
||||||
- Run `npm run bootstrap` after changing plugin runtime or MCP server code
|
|
||||||
|
|
||||||
## Plugin System
|
|
||||||
|
|
||||||
### Backend Constraints
|
|
||||||
|
|
||||||
- Always use `UpdateSource::Plugin` when calling database methods from plugin events
|
|
||||||
- Never send timestamps (`createdAt`, `updatedAt`) from TypeScript - Rust backend controls these
|
|
||||||
- Backend uses `NaiveDateTime` (no timezone) so avoid sending ISO timestamp strings
|
|
||||||
|
|
||||||
### MCP Server
|
|
||||||
|
|
||||||
- MCP server has **no active window context** - cannot call `window.workspaceId()`
|
|
||||||
- Get workspace ID from `workspaceCtx.yaak.workspace.list()` instead
|
|
||||||
|
|
||||||
## Rust Type Generation
|
|
||||||
|
|
||||||
- Run `cargo test --package yaak-plugins` (and for other crates) to regenerate TypeScript bindings after modifying Rust event types
|
|
||||||
@@ -1,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`.
|
|
||||||
6
.eslintignore
Normal file
6
.eslintignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.eslintrc.cjs
|
||||||
|
.prettierrc.cjs
|
||||||
|
src-web/postcss.config.cjs
|
||||||
|
src-web/vite.config.ts
|
||||||
49
.eslintrc.cjs
Normal file
49
.eslintrc.cjs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
module.exports = {
|
||||||
|
extends: [
|
||||||
|
'eslint:recommended',
|
||||||
|
'plugin:react/recommended',
|
||||||
|
'plugin:react-hooks/recommended',
|
||||||
|
'plugin:import/recommended',
|
||||||
|
'plugin:jsx-a11y/recommended',
|
||||||
|
'plugin:@typescript-eslint/recommended',
|
||||||
|
'eslint-config-prettier',
|
||||||
|
],
|
||||||
|
plugins: ['react-refresh'],
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.json'],
|
||||||
|
},
|
||||||
|
ignorePatterns: [
|
||||||
|
'scripts/**/*',
|
||||||
|
'packages/plugin-runtime/**/*',
|
||||||
|
'packages/plugin-runtime-types/**/*',
|
||||||
|
'src-tauri/**/*',
|
||||||
|
'src-web/tailwind.config.cjs',
|
||||||
|
'src-web/vite.config.ts',
|
||||||
|
],
|
||||||
|
settings: {
|
||||||
|
react: {
|
||||||
|
version: 'detect',
|
||||||
|
},
|
||||||
|
'import/resolver': {
|
||||||
|
node: {
|
||||||
|
paths: ['src-web'],
|
||||||
|
extensions: ['.ts', '.tsx'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'react-refresh/only-export-components': 'error',
|
||||||
|
'jsx-a11y/no-autofocus': 'off',
|
||||||
|
'react/react-in-jsx-scope': 'off',
|
||||||
|
'import/no-unresolved': 'off',
|
||||||
|
'@typescript-eslint/consistent-type-imports': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
prefer: 'type-imports',
|
||||||
|
disallowTypeAnnotations: true,
|
||||||
|
fixStyle: 'separate-type-imports',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
9
.gitattributes
vendored
9
.gitattributes
vendored
@@ -1,7 +1,2 @@
|
|||||||
crates-tauri/yaak-app-client/vendored/**/* linguist-generated=true
|
src-tauri/vendored/**/* linguist-generated=true
|
||||||
crates-tauri/yaak-app-client/gen/schemas/**/* linguist-generated=true
|
src-tauri/gen/schemas/**/* linguist-generated=true
|
||||||
**/bindings/* linguist-generated=true
|
|
||||||
crates/yaak-templates/pkg/* linguist-generated=true
|
|
||||||
|
|
||||||
# Ensure consistent line endings for test files that check exact content
|
|
||||||
crates/yaak-http/tests/test.txt text eol=lf
|
|
||||||
|
|||||||
12
.github/FUNDING.yml
vendored
12
.github/FUNDING.yml
vendored
@@ -1,3 +1,15 @@
|
|||||||
# These are supported funding model platforms
|
# These are supported funding model platforms
|
||||||
|
|
||||||
github: gschier
|
github: gschier
|
||||||
|
patreon: # Replace with a single Patreon username
|
||||||
|
open_collective: # Replace with a single Open Collective username
|
||||||
|
ko_fi: # Replace with a single Ko-fi username
|
||||||
|
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||||
|
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||||
|
liberapay: # Replace with a single Liberapay username
|
||||||
|
issuehunt: # Replace with a single IssueHunt username
|
||||||
|
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||||
|
polar: # Replace with a single Polar username
|
||||||
|
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||||
|
thanks_dev: # Replace with a single thanks.dev username
|
||||||
|
custom: https://yaak.app/pricing
|
||||||
|
|||||||
24
.github/ISSUE_TEMPLATE/bug_report.md
vendored
24
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -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.
|
||||||
|
|||||||
19
.github/pull_request_template.md
vendored
19
.github/pull_request_template.md
vendored
@@ -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. -->
|
|
||||||
18
.github/workflows/ci-js.yml
vendored
Normal file
18
.github/workflows/ci-js.yml
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [develop]
|
||||||
|
|
||||||
|
name: CI (JS)
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: Lint/Test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run lint
|
||||||
|
- run: npm test
|
||||||
36
.github/workflows/ci-rust.yml
vendored
Normal file
36
.github/workflows/ci-rust.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [develop]
|
||||||
|
paths:
|
||||||
|
- src-tauri/**
|
||||||
|
- .github/workflows/**
|
||||||
|
|
||||||
|
name: CI (Rust)
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: src-tauri
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: Check/Test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libwebkit2gtk-4.1-dev
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- uses: actions/cache@v3
|
||||||
|
continue-on-error: false
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/bin/
|
||||||
|
~/.cargo/registry/index/
|
||||||
|
~/.cargo/registry/cache/
|
||||||
|
~/.cargo/git/db/
|
||||||
|
target/
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: ${{ runner.os }}-cargo-
|
||||||
|
- run: cargo check --all
|
||||||
|
- run: cargo test --all
|
||||||
33
.github/workflows/ci.yml
vendored
33
.github/workflows/ci.yml
vendored
@@ -1,33 +0,0 @@
|
|||||||
on:
|
|
||||||
pull_request:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
name: Lint and Test
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
name: Lint/Test
|
|
||||||
runs-on: macos-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: voidzero-dev/setup-vp@v1
|
|
||||||
with:
|
|
||||||
node-version: "24"
|
|
||||||
cache: true
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
|
||||||
with:
|
|
||||||
shared-key: ci
|
|
||||||
cache-on-failure: true
|
|
||||||
|
|
||||||
- run: vp install
|
|
||||||
- run: npm run bootstrap
|
|
||||||
- run: npm run lint
|
|
||||||
- name: Run JS Tests
|
|
||||||
run: vp test
|
|
||||||
- name: Run Rust Tests
|
|
||||||
run: cargo test --all
|
|
||||||
49
.github/workflows/claude.yml
vendored
49
.github/workflows/claude.yml
vendored
@@ -1,49 +0,0 @@
|
|||||||
name: Claude Code
|
|
||||||
|
|
||||||
on:
|
|
||||||
issue_comment:
|
|
||||||
types: [created]
|
|
||||||
pull_request_review_comment:
|
|
||||||
types: [created]
|
|
||||||
issues:
|
|
||||||
types: [opened, assigned]
|
|
||||||
pull_request_review:
|
|
||||||
types: [submitted]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
claude:
|
|
||||||
if: |
|
|
||||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
|
||||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
|
||||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
|
||||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: read
|
|
||||||
issues: read
|
|
||||||
id-token: write
|
|
||||||
actions: read # Required for Claude to read CI results on PRs
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 1
|
|
||||||
|
|
||||||
- name: Run Claude Code
|
|
||||||
id: claude
|
|
||||||
uses: anthropics/claude-code-action@v1
|
|
||||||
with:
|
|
||||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|
||||||
|
|
||||||
# This is an optional setting that allows Claude to read CI results on PRs
|
|
||||||
additional_permissions: |
|
|
||||||
actions: read
|
|
||||||
|
|
||||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
|
||||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
|
||||||
|
|
||||||
# Optional: Add claude_args to customize behavior and configuration
|
|
||||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
|
||||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
|
||||||
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
|
||||||
52
.github/workflows/flathub.yml
vendored
52
.github/workflows/flathub.yml
vendored
@@ -1,52 +0,0 @@
|
|||||||
name: Update Flathub
|
|
||||||
on:
|
|
||||||
release:
|
|
||||||
types: [published]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
update-flathub:
|
|
||||||
name: Update Flathub manifest
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
# Only run for stable releases (skip betas/pre-releases)
|
|
||||||
if: ${{ !github.event.release.prerelease }}
|
|
||||||
steps:
|
|
||||||
- name: Checkout app repo
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Checkout Flathub repo
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
repository: flathub/app.yaak.Yaak
|
|
||||||
token: ${{ secrets.FLATHUB_TOKEN }}
|
|
||||||
path: flathub-repo
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
|
|
||||||
- name: Set up Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "22"
|
|
||||||
|
|
||||||
- name: Install source generators
|
|
||||||
run: |
|
|
||||||
pip install flatpak-node-generator tomlkit aiohttp
|
|
||||||
git clone --depth 1 https://github.com/flatpak/flatpak-builder-tools flatpak/flatpak-builder-tools
|
|
||||||
|
|
||||||
- name: Run update-manifest.sh
|
|
||||||
run: bash flatpak/update-manifest.sh "${{ github.event.release.tag_name }}" flathub-repo
|
|
||||||
|
|
||||||
- name: Commit and push to Flathub
|
|
||||||
working-directory: flathub-repo
|
|
||||||
run: |
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
||||||
git add -A
|
|
||||||
git diff --cached --quiet && echo "No changes to commit" && exit 0
|
|
||||||
git commit -m "Update to ${{ github.event.release.tag_name }}"
|
|
||||||
git push
|
|
||||||
59
.github/workflows/release-api-npm.yml
vendored
59
.github/workflows/release-api-npm.yml
vendored
@@ -1,59 +0,0 @@
|
|||||||
name: Release API to NPM
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags: [yaak-api-*]
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
description: API version to publish (for example 0.9.0 or v0.9.0)
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
publish-npm:
|
|
||||||
name: Publish @yaakapp/api
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: lts/*
|
|
||||||
registry-url: https://registry.npmjs.org
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Set @yaakapp/api version
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
WORKFLOW_VERSION: ${{ inputs.version }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
|
||||||
VERSION="$WORKFLOW_VERSION"
|
|
||||||
else
|
|
||||||
VERSION="${GITHUB_REF_NAME#yaak-api-}"
|
|
||||||
fi
|
|
||||||
VERSION="${VERSION#v}"
|
|
||||||
echo "Preparing @yaakapp/api version: $VERSION"
|
|
||||||
cd packages/plugin-runtime-types
|
|
||||||
npm version "$VERSION" --no-git-tag-version --allow-same-version
|
|
||||||
|
|
||||||
- name: Build @yaakapp/api
|
|
||||||
working-directory: packages/plugin-runtime-types
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
- name: Publish @yaakapp/api
|
|
||||||
working-directory: packages/plugin-runtime-types
|
|
||||||
run: npm publish --provenance --access public
|
|
||||||
185
.github/workflows/release-app.yml
vendored
185
.github/workflows/release-app.yml
vendored
@@ -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
|
|
||||||
218
.github/workflows/release-cli-npm.yml
vendored
218
.github/workflows/release-cli-npm.yml
vendored
@@ -1,218 +0,0 @@
|
|||||||
name: Release CLI to NPM
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags: [yaak-cli-*]
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
description: CLI version to publish (for example 0.4.0 or v0.4.0)
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
prepare-vendored-assets:
|
|
||||||
name: Prepare vendored plugin assets
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: lts/*
|
|
||||||
|
|
||||||
- name: Install Rust stable
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Build plugin assets
|
|
||||||
env:
|
|
||||||
SKIP_WASM_BUILD: "1"
|
|
||||||
run: |
|
|
||||||
npm run build
|
|
||||||
npm run vendor:vendor-plugins
|
|
||||||
|
|
||||||
- name: Upload vendored assets
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: vendored-assets
|
|
||||||
path: |
|
|
||||||
crates-tauri/yaak-app-client/vendored/plugin-runtime/index.cjs
|
|
||||||
crates-tauri/yaak-app-client/vendored/plugins
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
build-binaries:
|
|
||||||
name: Build ${{ matrix.pkg }}
|
|
||||||
needs: prepare-vendored-assets
|
|
||||||
runs-on: ${{ matrix.runner }}
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- pkg: cli-darwin-arm64
|
|
||||||
runner: macos-latest
|
|
||||||
target: aarch64-apple-darwin
|
|
||||||
binary: yaak
|
|
||||||
- pkg: cli-darwin-x64
|
|
||||||
runner: macos-latest
|
|
||||||
target: x86_64-apple-darwin
|
|
||||||
binary: yaak
|
|
||||||
- pkg: cli-linux-arm64
|
|
||||||
runner: ubuntu-22.04-arm
|
|
||||||
target: aarch64-unknown-linux-gnu
|
|
||||||
binary: yaak
|
|
||||||
- pkg: cli-linux-x64
|
|
||||||
runner: ubuntu-22.04
|
|
||||||
target: x86_64-unknown-linux-gnu
|
|
||||||
binary: yaak
|
|
||||||
- pkg: cli-win32-arm64
|
|
||||||
runner: windows-latest
|
|
||||||
target: aarch64-pc-windows-msvc
|
|
||||||
binary: yaak.exe
|
|
||||||
- pkg: cli-win32-x64
|
|
||||||
runner: windows-latest
|
|
||||||
target: x86_64-pc-windows-msvc
|
|
||||||
binary: yaak.exe
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install Rust stable
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
with:
|
|
||||||
targets: ${{ matrix.target }}
|
|
||||||
|
|
||||||
- name: Restore Rust cache
|
|
||||||
uses: Swatinem/rust-cache@v2
|
|
||||||
with:
|
|
||||||
shared-key: release-cli-npm
|
|
||||||
cache-on-failure: true
|
|
||||||
|
|
||||||
- name: Install Linux build dependencies
|
|
||||||
if: startsWith(matrix.runner, 'ubuntu')
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y pkg-config libdbus-1-dev
|
|
||||||
|
|
||||||
- name: Download vendored assets
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: vendored-assets
|
|
||||||
path: crates-tauri/yaak-app-client/vendored
|
|
||||||
|
|
||||||
- name: Set CLI build version
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
WORKFLOW_VERSION: ${{ inputs.version }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
|
||||||
VERSION="$WORKFLOW_VERSION"
|
|
||||||
else
|
|
||||||
VERSION="${GITHUB_REF_NAME#yaak-cli-}"
|
|
||||||
fi
|
|
||||||
VERSION="${VERSION#v}"
|
|
||||||
echo "Building yaak version: $VERSION"
|
|
||||||
echo "YAAK_CLI_VERSION=$VERSION" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Build yaak
|
|
||||||
run: cargo build --locked --release -p yaak-cli --bin yaak --target ${{ matrix.target }}
|
|
||||||
|
|
||||||
- name: Stage binary artifact
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
mkdir -p "npm/dist/${{ matrix.pkg }}"
|
|
||||||
cp "target/${{ matrix.target }}/release/${{ matrix.binary }}" "npm/dist/${{ matrix.pkg }}/${{ matrix.binary }}"
|
|
||||||
|
|
||||||
- name: Upload binary artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: ${{ matrix.pkg }}
|
|
||||||
path: npm/dist/${{ matrix.pkg }}/${{ matrix.binary }}
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
publish-npm:
|
|
||||||
name: Publish @yaakapp/cli packages
|
|
||||||
needs: build-binaries
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: lts/*
|
|
||||||
registry-url: https://registry.npmjs.org
|
|
||||||
|
|
||||||
- name: Download binary artifacts
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
pattern: cli-*
|
|
||||||
path: npm/dist
|
|
||||||
merge-multiple: false
|
|
||||||
|
|
||||||
- name: Prepare npm packages
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
WORKFLOW_VERSION: ${{ inputs.version }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
|
||||||
VERSION="$WORKFLOW_VERSION"
|
|
||||||
else
|
|
||||||
VERSION="${GITHUB_REF_NAME#yaak-cli-}"
|
|
||||||
fi
|
|
||||||
VERSION="${VERSION#v}"
|
|
||||||
if [[ "$VERSION" == *-* ]]; then
|
|
||||||
PRERELEASE="${VERSION#*-}"
|
|
||||||
NPM_TAG="${PRERELEASE%%.*}"
|
|
||||||
else
|
|
||||||
NPM_TAG="latest"
|
|
||||||
fi
|
|
||||||
echo "Preparing CLI npm packages for version: $VERSION"
|
|
||||||
echo "Publishing with npm dist-tag: $NPM_TAG"
|
|
||||||
echo "NPM_TAG=$NPM_TAG" >> "$GITHUB_ENV"
|
|
||||||
YAAK_CLI_VERSION="$VERSION" node npm/prepare-publish.js
|
|
||||||
|
|
||||||
- name: Publish @yaakapp/cli-darwin-arm64
|
|
||||||
run: npm publish --provenance --access public --tag "$NPM_TAG"
|
|
||||||
working-directory: npm/cli-darwin-arm64
|
|
||||||
|
|
||||||
- name: Publish @yaakapp/cli-darwin-x64
|
|
||||||
run: npm publish --provenance --access public --tag "$NPM_TAG"
|
|
||||||
working-directory: npm/cli-darwin-x64
|
|
||||||
|
|
||||||
- name: Publish @yaakapp/cli-linux-arm64
|
|
||||||
run: npm publish --provenance --access public --tag "$NPM_TAG"
|
|
||||||
working-directory: npm/cli-linux-arm64
|
|
||||||
|
|
||||||
- name: Publish @yaakapp/cli-linux-x64
|
|
||||||
run: npm publish --provenance --access public --tag "$NPM_TAG"
|
|
||||||
working-directory: npm/cli-linux-x64
|
|
||||||
|
|
||||||
- name: Publish @yaakapp/cli-win32-arm64
|
|
||||||
run: npm publish --provenance --access public --tag "$NPM_TAG"
|
|
||||||
working-directory: npm/cli-win32-arm64
|
|
||||||
|
|
||||||
- name: Publish @yaakapp/cli-win32-x64
|
|
||||||
run: npm publish --provenance --access public --tag "$NPM_TAG"
|
|
||||||
working-directory: npm/cli-win32-x64
|
|
||||||
|
|
||||||
- name: Publish @yaakapp/cli
|
|
||||||
run: npm publish --provenance --access public --tag "$NPM_TAG"
|
|
||||||
working-directory: npm/cli
|
|
||||||
125
.github/workflows/release.yml
vendored
Normal file
125
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
name: Generate Artifacts
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: [ v* ]
|
||||||
|
|
||||||
|
env:
|
||||||
|
YAAK_PLUGINS_DIR: checkout/plugins
|
||||||
|
|
||||||
|
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'
|
||||||
|
- platform: 'macos-latest' # for Intel-based Macs.
|
||||||
|
args: '--target x86_64-apple-darwin'
|
||||||
|
yaak_arch: 'x64'
|
||||||
|
- platform: 'ubuntu-22.04'
|
||||||
|
args: ''
|
||||||
|
yaak_arch: 'x64'
|
||||||
|
- platform: 'windows-latest'
|
||||||
|
args: ''
|
||||||
|
yaak_arch: 'x64'
|
||||||
|
runs-on: ${{ matrix.platform }}
|
||||||
|
timeout-minutes: 40
|
||||||
|
steps:
|
||||||
|
- name: Checkout yaakapp/app
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: install Rust stable
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
# Those targets are only used on macos runners so it's in an `if` to slightly speed up windows and linux builds.
|
||||||
|
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||||
|
|
||||||
|
- uses: actions/cache@v3
|
||||||
|
continue-on-error: false
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/bin/
|
||||||
|
~/.cargo/registry/index/
|
||||||
|
~/.cargo/registry/cache/
|
||||||
|
~/.cargo/git/db/
|
||||||
|
src-tauri/target/
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: ${{ runner.os }}-cargo-
|
||||||
|
|
||||||
|
- name: install dependencies (ubuntu only)
|
||||||
|
if: matrix.platform == 'ubuntu-22.04' # This must match the platform value defined above.
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||||
|
|
||||||
|
- name: install dependencies (windows only)
|
||||||
|
if: matrix.platform == 'windows-latest'
|
||||||
|
run: cargo install --force trusted-signing-cli --version 0.5.0
|
||||||
|
|
||||||
|
- name: Install NPM Dependencies
|
||||||
|
run: |
|
||||||
|
npm ci
|
||||||
|
npm install @yaakapp/cli
|
||||||
|
|
||||||
|
- name: Install Protoc for plugin-runtime
|
||||||
|
uses: arduino/setup-protoc@v3
|
||||||
|
with:
|
||||||
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Run JS build
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Run lint
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Checkout yaakapp/plugins
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: yaakapp/plugins
|
||||||
|
path: ${{ env.YAAK_PLUGINS_DIR }}
|
||||||
|
|
||||||
|
- name: Set version
|
||||||
|
run: npm run replace-version
|
||||||
|
env:
|
||||||
|
YAAK_VERSION: ${{ github.ref_name }}
|
||||||
|
|
||||||
|
- uses: tauri-apps/tauri-action@v0
|
||||||
|
env:
|
||||||
|
YAAK_PLUGINS_DIR: ${{ env.YAAK_PLUGINS_DIR }}
|
||||||
|
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.platform == 'macos-latest' && secrets.APPLE_CERTIFICATE }}
|
||||||
|
APPLE_CERTIFICATE_PASSWORD: ${{ matrix.platform == 'macos-latest' && secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||||
|
APPLE_ID: ${{ matrix.platform == 'macos-latest' && secrets.APPLE_ID }}
|
||||||
|
APPLE_PASSWORD: ${{ matrix.platform == 'macos-latest' && secrets.APPLE_PASSWORD }}
|
||||||
|
APPLE_SIGNING_IDENTITY: ${{ matrix.platform == 'macos-latest' && secrets.APPLE_SIGNING_IDENTITY }}
|
||||||
|
APPLE_TEAM_ID: ${{ matrix.platform == 'macos-latest' && secrets.APPLE_TEAM_ID }}
|
||||||
|
|
||||||
|
# Windows signing stuff
|
||||||
|
AZURE_CLIENT_ID: ${{ matrix.platform == 'windows-latest' && secrets.AZURE_CLIENT_ID }}
|
||||||
|
AZURE_CLIENT_SECRET: ${{ matrix.platform == 'windows-latest' && secrets.AZURE_CLIENT_SECRET }}
|
||||||
|
AZURE_TENANT_ID: ${{ matrix.platform == 'windows-latest' && secrets.AZURE_TENANT_ID }}
|
||||||
|
with:
|
||||||
|
tagName: 'v__VERSION__'
|
||||||
|
releaseName: 'Release __VERSION__'
|
||||||
|
releaseBody: '[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)'
|
||||||
|
releaseDraft: true
|
||||||
|
prerelease: false
|
||||||
|
args: ${{ matrix.args }}
|
||||||
44
.github/workflows/sponsors.yml
vendored
44
.github/workflows/sponsors.yml
vendored
@@ -1,44 +0,0 @@
|
|||||||
name: Generate Sponsors README
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
schedule:
|
|
||||||
- cron: 30 15 * * 0-6
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout 🛎️
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
|
|
||||||
- name: Generate Sponsors
|
|
||||||
uses: JamesIves/github-sponsors-readme-action@v1
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.SPONSORS_PAT }}
|
|
||||||
file: "README.md"
|
|
||||||
maximum: 1999
|
|
||||||
template: '<a href="https://github.com/{{{ login }}}"><img src="{{{ avatarUrl }}}" width="50px" alt="User avatar: {{{ login }}}" /></a> '
|
|
||||||
active-only: false
|
|
||||||
include-private: true
|
|
||||||
marker: "sponsors-base"
|
|
||||||
|
|
||||||
- name: Generate Sponsors
|
|
||||||
uses: JamesIves/github-sponsors-readme-action@v1
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.SPONSORS_PAT }}
|
|
||||||
file: "README.md"
|
|
||||||
minimum: 2000
|
|
||||||
template: '<a href="https://github.com/{{{ login }}}"><img src="{{{ avatarUrl }}}" width="80px" alt="User avatar: {{{ login }}}" /></a> '
|
|
||||||
active-only: false
|
|
||||||
include-private: true
|
|
||||||
marker: "sponsors-premium"
|
|
||||||
|
|
||||||
# ⚠️ Note: You can use any deployment step here to automatically push the README
|
|
||||||
# changes back to your branch.
|
|
||||||
- name: Commit Changes
|
|
||||||
uses: JamesIves/github-pages-deploy-action@v4
|
|
||||||
with:
|
|
||||||
branch: main
|
|
||||||
force: false
|
|
||||||
folder: "."
|
|
||||||
27
.gitignore
vendored
27
.gitignore
vendored
@@ -15,8 +15,6 @@ dist-ssr
|
|||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
!.vscode/settings.json
|
|
||||||
!.vscode/launch.json
|
|
||||||
.idea
|
.idea
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.suo
|
*.suo
|
||||||
@@ -25,7 +23,6 @@ dist-ssr
|
|||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
.eslintcache
|
.eslintcache
|
||||||
out
|
|
||||||
|
|
||||||
*.sqlite
|
*.sqlite
|
||||||
*.sqlite-*
|
*.sqlite-*
|
||||||
@@ -34,27 +31,3 @@ out
|
|||||||
|
|
||||||
.tmp
|
.tmp
|
||||||
tmp
|
tmp
|
||||||
.zed
|
|
||||||
codebook.toml
|
|
||||||
target
|
|
||||||
|
|
||||||
# Per-worktree Tauri config (generated by post-checkout hook)
|
|
||||||
crates-tauri/yaak-app-client/tauri.worktree.conf.json
|
|
||||||
crates-tauri/yaak-app-proxy/tauri.worktree.conf.json
|
|
||||||
|
|
||||||
# Tauri auto-generated permission files
|
|
||||||
**/permissions/autogenerated
|
|
||||||
**/permissions/schemas
|
|
||||||
|
|
||||||
# Flatpak build artifacts
|
|
||||||
flatpak-repo/
|
|
||||||
.flatpak-builder/
|
|
||||||
flatpak/flatpak-builder-tools/
|
|
||||||
flatpak/cargo-sources.json
|
|
||||||
flatpak/node-sources.json
|
|
||||||
|
|
||||||
# Local Codex desktop env state
|
|
||||||
.codex/environments/environment.toml
|
|
||||||
|
|
||||||
# Claude Code local settings
|
|
||||||
.claude/settings.local.json
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
24.14.0
|
|
||||||
2
.npmrc
2
.npmrc
@@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
4
.prettierignore
Normal file
4
.prettierignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
out/
|
||||||
|
.prettierrc.cjs
|
||||||
8
.prettierrc.js
Normal file
8
.prettierrc.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export default {
|
||||||
|
"trailingComma": "all",
|
||||||
|
"tabWidth": 2,
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 100,
|
||||||
|
"bracketSpacing": true
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
node scripts/git-hooks/post-checkout.mjs "$@"
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
vp lint
|
|
||||||
vp staged
|
|
||||||
7
.vscode/extensions.json
vendored
7
.vscode/extensions.json
vendored
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"recommendations": [
|
|
||||||
"rust-lang.rust-analyzer",
|
|
||||||
"bradlc.vscode-tailwindcss",
|
|
||||||
"VoidZero.vite-plus-extension-pack"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
26
.vscode/launch.json
vendored
26
.vscode/launch.json
vendored
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "0.2.0",
|
|
||||||
"configurations": [
|
|
||||||
{
|
|
||||||
"type": "node",
|
|
||||||
"request": "launch",
|
|
||||||
"name": "Dev App",
|
|
||||||
"runtimeExecutable": "npm",
|
|
||||||
"runtimeArgs": ["run", "start"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "node",
|
|
||||||
"request": "launch",
|
|
||||||
"name": "Build App",
|
|
||||||
"runtimeExecutable": "npm",
|
|
||||||
"runtimeArgs": ["run", "start"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "node",
|
|
||||||
"request": "launch",
|
|
||||||
"name": "Bootstrap",
|
|
||||||
"runtimeExecutable": "npm",
|
|
||||||
"runtimeArgs": ["run", "bootstrap"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
8
.vscode/settings.json
vendored
8
.vscode/settings.json
vendored
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"editor.defaultFormatter": "oxc.oxc-vscode",
|
|
||||||
"editor.formatOnSave": true,
|
|
||||||
"editor.formatOnSaveMode": "file",
|
|
||||||
"editor.codeActionsOnSave": {
|
|
||||||
"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).
|
|
||||||
91
Cargo.toml
91
Cargo.toml
@@ -1,91 +0,0 @@
|
|||||||
[workspace]
|
|
||||||
resolver = "2"
|
|
||||||
members = [
|
|
||||||
"crates/yaak",
|
|
||||||
# Common/foundation crates
|
|
||||||
"crates/common/yaak-database",
|
|
||||||
"crates/common/yaak-rpc",
|
|
||||||
# Shared crates (no Tauri dependency)
|
|
||||||
"crates/yaak-core",
|
|
||||||
"crates/yaak-common",
|
|
||||||
"crates/yaak-crypto",
|
|
||||||
"crates/yaak-git",
|
|
||||||
"crates/yaak-grpc",
|
|
||||||
"crates/yaak-http",
|
|
||||||
"crates/yaak-models",
|
|
||||||
"crates/yaak-plugins",
|
|
||||||
"crates/yaak-sse",
|
|
||||||
"crates/yaak-sync",
|
|
||||||
"crates/yaak-templates",
|
|
||||||
"crates/yaak-tls",
|
|
||||||
"crates/yaak-ws",
|
|
||||||
"crates/yaak-api",
|
|
||||||
"crates/yaak-proxy",
|
|
||||||
# Proxy-specific crates
|
|
||||||
"crates-proxy/yaak-proxy-lib",
|
|
||||||
# CLI crates
|
|
||||||
"crates-cli/yaak-cli",
|
|
||||||
# Tauri-specific crates
|
|
||||||
"crates-tauri/yaak-app-client",
|
|
||||||
"crates-tauri/yaak-app-proxy",
|
|
||||||
"crates-tauri/yaak-fonts",
|
|
||||||
"crates-tauri/yaak-license",
|
|
||||||
"crates-tauri/yaak-mac-window",
|
|
||||||
"crates-tauri/yaak-tauri-utils",
|
|
||||||
"crates-tauri/yaak-window",
|
|
||||||
]
|
|
||||||
|
|
||||||
[workspace.dependencies]
|
|
||||||
chrono = "0.4.42"
|
|
||||||
hex = "0.4.3"
|
|
||||||
keyring = "3.6.3"
|
|
||||||
log = "0.4.29"
|
|
||||||
reqwest = "0.12.20"
|
|
||||||
rustls = { version = "0.23.34", default-features = false }
|
|
||||||
rustls-platform-verifier = "0.6.2"
|
|
||||||
schemars = { version = "0.8.22", features = ["chrono"] }
|
|
||||||
serde = "1.0.228"
|
|
||||||
serde_json = "1.0.145"
|
|
||||||
sha2 = "0.10.9"
|
|
||||||
tauri = "2.11.1"
|
|
||||||
tauri-plugin = "2.6.1"
|
|
||||||
tauri-plugin-dialog = "2.7.1"
|
|
||||||
tauri-plugin-shell = "2.3.5"
|
|
||||||
thiserror = "2.0.17"
|
|
||||||
tokio = "1.48.0"
|
|
||||||
ts-rs = "11.1.0"
|
|
||||||
|
|
||||||
# Internal crates - common/foundation
|
|
||||||
yaak-database = { path = "crates/common/yaak-database" }
|
|
||||||
yaak-rpc = { path = "crates/common/yaak-rpc" }
|
|
||||||
|
|
||||||
# Internal crates - shared
|
|
||||||
yaak-core = { path = "crates/yaak-core" }
|
|
||||||
yaak = { path = "crates/yaak" }
|
|
||||||
yaak-common = { path = "crates/yaak-common" }
|
|
||||||
yaak-crypto = { path = "crates/yaak-crypto" }
|
|
||||||
yaak-git = { path = "crates/yaak-git" }
|
|
||||||
yaak-grpc = { path = "crates/yaak-grpc" }
|
|
||||||
yaak-http = { path = "crates/yaak-http" }
|
|
||||||
yaak-models = { path = "crates/yaak-models" }
|
|
||||||
yaak-plugins = { path = "crates/yaak-plugins" }
|
|
||||||
yaak-sse = { path = "crates/yaak-sse" }
|
|
||||||
yaak-sync = { path = "crates/yaak-sync" }
|
|
||||||
yaak-templates = { path = "crates/yaak-templates" }
|
|
||||||
yaak-tls = { path = "crates/yaak-tls" }
|
|
||||||
yaak-ws = { path = "crates/yaak-ws" }
|
|
||||||
yaak-api = { path = "crates/yaak-api" }
|
|
||||||
yaak-proxy = { path = "crates/yaak-proxy" }
|
|
||||||
|
|
||||||
# Internal crates - proxy
|
|
||||||
yaak-proxy-lib = { path = "crates-proxy/yaak-proxy-lib" }
|
|
||||||
|
|
||||||
# Internal crates - Tauri-specific
|
|
||||||
yaak-fonts = { path = "crates-tauri/yaak-fonts" }
|
|
||||||
yaak-license = { path = "crates-tauri/yaak-license" }
|
|
||||||
yaak-mac-window = { path = "crates-tauri/yaak-mac-window" }
|
|
||||||
yaak-tauri-utils = { path = "crates-tauri/yaak-tauri-utils" }
|
|
||||||
yaak-window = { path = "crates-tauri/yaak-window" }
|
|
||||||
|
|
||||||
[profile.release]
|
|
||||||
strip = false
|
|
||||||
@@ -1,26 +1,24 @@
|
|||||||
# Developer Setup
|
# Developer Setup
|
||||||
|
|
||||||
Yaak is a combined Node.js and Rust monorepo. It is a [Tauri](https://tauri.app) project, so
|
Yaak is a combined Node.js and Rust monorepo. It is a [Tauri](https://tauri.app) project, so
|
||||||
uses Rust and HTML/CSS/JS for the main application but there is also a plugin system powered
|
uses Rust and HTML/CSS/JS for the main application but there is also a plugin system powered
|
||||||
by a Node.js sidecar that communicates to the app over gRPC.
|
by a Node.js sidecar that communicates to the app over gRPC.
|
||||||
|
|
||||||
Because of the moving parts, there are a few setup steps required before development can
|
Because of the moving parts, there are a few setup steps required before development can
|
||||||
begin.
|
begin.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
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
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -36,6 +34,8 @@ Run the `bootstrap` command to do some initial setup:
|
|||||||
npm run bootstrap
|
npm run bootstrap
|
||||||
```
|
```
|
||||||
|
|
||||||
|
_NOTE: Run with `YAAK_PLUGINS_DIR=<Path to yaakapp/plugins>` to re-build bundled plugins_
|
||||||
|
|
||||||
## Run the App
|
## Run the App
|
||||||
|
|
||||||
After bootstrapping, start the app in development mode:
|
After bootstrapping, start the app in development mode:
|
||||||
@@ -44,43 +44,26 @@ After bootstrapping, start the app in development mode:
|
|||||||
npm start
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
|
_NOTE: If working on bundled plugins, run with `YAAK_PLUGINS_DIR=<Path to yaakapp/plugins>`_
|
||||||
|
|
||||||
## SQLite Migrations
|
## SQLite Migrations
|
||||||
|
|
||||||
New migrations can be created from the `src-tauri/` directory:
|
New migrations can be created from the `src-tauri/` directory:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
npm run migration
|
cd src-tauri
|
||||||
|
sqlx migrate add migration-name
|
||||||
```
|
```
|
||||||
|
|
||||||
Rerun the app to apply the migrations.
|
Run the app to apply the migrations.
|
||||||
|
|
||||||
_Note: For safety, development builds use a separate database location from production builds._
|
If nothing happens, try `cargo clean` and run the app again.
|
||||||
|
|
||||||
## Lezer Grammar Generation
|
_Note: Development builds use a separate database location from production builds._
|
||||||
|
|
||||||
|
## Lezer Grammer Generation
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# Example
|
# Example
|
||||||
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
|
|
||||||
|
|
||||||
This repo uses [Vite+](https://vite.dev/guide/vite-plus) for linting (oxlint) and formatting (oxfmt).
|
|
||||||
|
|
||||||
- Lint the entire repo:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm run lint
|
|
||||||
```
|
|
||||||
|
|
||||||
- Format code:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm run format
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
|
|
||||||
- A pre-commit hook runs `vp lint` automatically on commit.
|
|
||||||
- Some workspace packages also run `tsc --noEmit` for type-checking.
|
|
||||||
- VS Code users should install the recommended extensions for format-on-save support.
|
|
||||||
|
|||||||
86
README.md
86
README.md
@@ -1,70 +1,34 @@
|
|||||||
<p align="center">
|
# Yaak API Client
|
||||||
<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">
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h1 align="center">
|
Yaak is a desktop API client for interacting with REST, GraphQL, Server Sent Events (SSE), WebSocket, and gRPC
|
||||||
💫 Yaak ➟ Desktop API Client 💫
|
APIs. It's built using [Tauri](https://tauri.app), Rust, and ReactJS.
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p align="center">
|

|
||||||
A fast, privacy-first API client for REST, GraphQL, SSE, WebSocket, and gRPC – built with Tauri, Rust, and React.
|
|
||||||
</p>
|
|
||||||
<p align="center">
|
|
||||||
Development is funded by community-purchased <a href="https://yaak.app/pricing">licenses</a>. You can also <a href="https://github.com/sponsors/gschier">become a sponsor</a> to have your logo appear below. 💖
|
|
||||||
</p>
|
|
||||||
<br>
|
|
||||||
|
|
||||||
<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 -->
|
|
||||||
</p>
|
|
||||||
<p align="center">
|
|
||||||
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https://github.com/seanwash.png" width="50px" alt="User avatar: seanwash" /></a> <a href="https://github.com/jerath"><img src="https://github.com/jerath.png" width="50px" alt="User avatar: jerath" /></a> <a href="https://github.com/itsa-sh"><img src="https://github.com/itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a> <a href="https://github.com/dmmulroy"><img src="https://github.com/dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a> <a href="https://github.com/timcole"><img src="https://github.com/timcole.png" width="50px" alt="User avatar: timcole" /></a> <a href="https://github.com/VLZH"><img src="https://github.com/VLZH.png" width="50px" alt="User avatar: VLZH" /></a> <a href="https://github.com/terasaka2k"><img src="https://github.com/terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a> <a href="https://github.com/andriyor"><img src="https://github.com/andriyor.png" width="50px" alt="User avatar: andriyor" /></a> <a href="https://github.com/majudhu"><img src="https://github.com/majudhu.png" width="50px" alt="User avatar: majudhu" /></a> <a href="https://github.com/axelrindle"><img src="https://github.com/axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a> <a href="https://github.com/jirizverina"><img src="https://github.com/jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a> <a href="https://github.com/chip-well"><img src="https://github.com/chip-well.png" width="50px" alt="User avatar: chip-well" /></a> <a href="https://github.com/GRAYAH"><img src="https://github.com/GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a> <a href="https://github.com/flashblaze"><img src="https://github.com/flashblaze.png" width="50px" alt="User avatar: flashblaze" /></a> <a href="https://github.com/Frostist"><img src="https://github.com/Frostist.png" width="50px" alt="User avatar: Frostist" /></a> <!-- sponsors-base -->
|
|
||||||
</p>
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
Yaak is an offline-first API client designed to stay out of your way while giving you everything you need when you need it.
|
|
||||||
Built with [Tauri](https://tauri.app), Rust, and React, it’s fast, lightweight, and private. No telemetry, no VC funding, and no cloud lock-in.
|
|
||||||
|
|
||||||
### 🌐 Work with any API
|
|
||||||
|
|
||||||
- Import collections from Postman, Insomnia, OpenAPI, Swagger, or Curl.
|
|
||||||
- Send requests via REST, GraphQL, gRPC, WebSocket, or Server-Sent Events.
|
|
||||||
- Filter and inspect responses with JSONPath or XPath.
|
|
||||||
|
|
||||||
### 🔐 Stay secure
|
|
||||||
|
|
||||||
- Use OAuth 2.0, JWT, Basic Auth, or custom plugins for authentication.
|
|
||||||
- Secure sensitive values with encrypted secrets.
|
|
||||||
- Store secrets in your OS keychain.
|
|
||||||
|
|
||||||
### ☁️ Organize & collaborate
|
|
||||||
|
|
||||||
- Group requests into workspaces and nested folders.
|
|
||||||
- Use environment variables to switch between dev, staging, and prod.
|
|
||||||
- Mirror workspaces to your filesystem for versioning in Git or syncing with Dropbox.
|
|
||||||
|
|
||||||
### 🧩 Extend & customize
|
|
||||||
|
|
||||||
- Insert dynamic values like UUIDs or timestamps with template tags.
|
|
||||||
- Pick from built-in themes or build your own.
|
|
||||||
- Create plugins to extend authentication, template tags, or the UI.
|
|
||||||
|
|
||||||
## Contribution Policy
|
## 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.
|
## Feature Overview
|
||||||
|
|
||||||
|
- 🪂 Import data from Postman, Insomnia, OpenAPI, Swagger, or Curl.<br/>
|
||||||
|
- 📤 Send requests via REST, GraphQL, Server Sent Events (SSE), WebSockets, or gRPC.<br/>
|
||||||
|
- 🔐 Automatically authorize requests with OAuth 2.0, JWT tokens, Basic Auth, and more.<br/>
|
||||||
|
- 🔎 Filter response bodies using JSONPath or XPath queries.<br/>
|
||||||
|
- ⛓️ Chain together multiple requests to dynamically reference values.<br/>
|
||||||
|
- 📂 Organize requests into workspaces and nested folders.<br/>
|
||||||
|
- 🧮 Use environment variables to easily switch between Prod and Dev.<br/>
|
||||||
|
- 🛡️ Secure arbitrary text values with end-to-end encryption<br/>
|
||||||
|
- 🏷️ Send dynamic values like UUIDs or timestamps using template tags.<br/>
|
||||||
|
- 🎨 Choose from many of the included themes, or make your own.<br/>
|
||||||
|
- 💽 Mirror workspace data to a directory for integration with Git or Dropbox.<br/>
|
||||||
|
- 📜 View response history for each request.<br/>
|
||||||
|
- 🔌 Create your own plugins for authentication, template tags, and more!<br/>
|
||||||
|
- 🛜 Configure a proxy to access firewall-blocked APIs
|
||||||
|
|
||||||
## 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/blog/postman-alternative)
|
||||||
- [Yaak vs Bruno](https://yaak.app/alternatives/bruno)
|
|
||||||
- [Yaak vs Insomnia](https://yaak.app/alternatives/insomnia)
|
|
||||||
|
|||||||
@@ -1,170 +0,0 @@
|
|||||||
import { createWorkspaceModel, type Folder, modelTypeLabel } from "@yaakapp-internal/models";
|
|
||||||
import { applySync, calculateSync } from "@yaakapp-internal/sync";
|
|
||||||
import { Button } from "../components/core/Button";
|
|
||||||
import {
|
|
||||||
Banner,
|
|
||||||
InlineCode,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeaderCell,
|
|
||||||
TableRow,
|
|
||||||
TruncatedWideTableCell,
|
|
||||||
} from "@yaakapp-internal/ui";
|
|
||||||
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
|
||||||
import { createFastMutation } from "../hooks/useFastMutation";
|
|
||||||
import { showDialog } from "../lib/dialog";
|
|
||||||
import { jotaiStore } from "../lib/jotai";
|
|
||||||
import { pluralizeCount } from "../lib/pluralize";
|
|
||||||
import { showPrompt } from "../lib/prompt";
|
|
||||||
import { resolvedModelNameWithFolders } from "../lib/resolvedModelName";
|
|
||||||
|
|
||||||
export const createFolder = createFastMutation<
|
|
||||||
string | null,
|
|
||||||
void,
|
|
||||||
Partial<Pick<Folder, "name" | "sortPriority" | "folderId">>
|
|
||||||
>({
|
|
||||||
mutationKey: ["create_folder"],
|
|
||||||
mutationFn: async (patch) => {
|
|
||||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
|
||||||
if (workspaceId == null) {
|
|
||||||
throw new Error("Cannot create folder when there's no active workspace");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!patch.name) {
|
|
||||||
const name = await showPrompt({
|
|
||||||
id: "new-folder",
|
|
||||||
label: "Name",
|
|
||||||
defaultValue: "Folder",
|
|
||||||
title: "New Folder",
|
|
||||||
confirmText: "Create",
|
|
||||||
placeholder: "Name",
|
|
||||||
});
|
|
||||||
if (name == null) return null;
|
|
||||||
|
|
||||||
patch.name = name;
|
|
||||||
}
|
|
||||||
|
|
||||||
patch.sortPriority = patch.sortPriority || -Date.now();
|
|
||||||
const id = await createWorkspaceModel({ model: "folder", workspaceId, ...patch });
|
|
||||||
return id;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const syncWorkspace = createFastMutation<
|
|
||||||
void,
|
|
||||||
void,
|
|
||||||
{ workspaceId: string; syncDir: string; force?: boolean }
|
|
||||||
>({
|
|
||||||
mutationKey: [],
|
|
||||||
mutationFn: async ({ workspaceId, syncDir, force }) => {
|
|
||||||
const ops = (await calculateSync(workspaceId, syncDir)) ?? [];
|
|
||||||
if (ops.length === 0) {
|
|
||||||
console.log("Nothing to sync", workspaceId, syncDir);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log("Syncing workspace", workspaceId, syncDir, ops);
|
|
||||||
|
|
||||||
const dbOps = ops.filter((o) => o.type.startsWith("db"));
|
|
||||||
|
|
||||||
if (dbOps.length === 0) {
|
|
||||||
await applySync(workspaceId, syncDir, ops);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDeletingWorkspace = ops.some(
|
|
||||||
(o) => o.type === "dbDelete" && o.model.model === "workspace",
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log("Directory changes detected", { dbOps, ops });
|
|
||||||
|
|
||||||
if (force) {
|
|
||||||
await applySync(workspaceId, syncDir, ops);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
showDialog({
|
|
||||||
id: "commit-sync",
|
|
||||||
title: "Changes Detected",
|
|
||||||
size: "md",
|
|
||||||
render: ({ hide }) => (
|
|
||||||
<form
|
|
||||||
className="h-full grid grid-rows-[auto_auto_minmax(0,1fr)_auto] gap-3"
|
|
||||||
onSubmit={async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
await applySync(workspaceId, syncDir, ops);
|
|
||||||
hide();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isDeletingWorkspace ? (
|
|
||||||
<Banner color="danger">
|
|
||||||
🚨 <strong>Changes contain a workspace deletion!</strong>
|
|
||||||
</Banner>
|
|
||||||
) : (
|
|
||||||
<span />
|
|
||||||
)}
|
|
||||||
<p>
|
|
||||||
{pluralizeCount("file", dbOps.length)} in the directory{" "}
|
|
||||||
{dbOps.length === 1 ? "has" : "have"} changed. Do you want to update your workspace?
|
|
||||||
</p>
|
|
||||||
<Table scrollable className="my-4">
|
|
||||||
<TableHead>
|
|
||||||
<TableRow>
|
|
||||||
<TableHeaderCell>Type</TableHeaderCell>
|
|
||||||
<TableHeaderCell>Name</TableHeaderCell>
|
|
||||||
<TableHeaderCell>Operation</TableHeaderCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<TableBody>
|
|
||||||
{dbOps.map((op, i) => {
|
|
||||||
let name: string;
|
|
||||||
let label: string;
|
|
||||||
let color: string;
|
|
||||||
let model: string;
|
|
||||||
|
|
||||||
if (op.type === "dbCreate") {
|
|
||||||
label = "create";
|
|
||||||
name = resolvedModelNameWithFolders(op.fs.model);
|
|
||||||
color = "text-success";
|
|
||||||
model = modelTypeLabel(op.fs.model);
|
|
||||||
} else if (op.type === "dbUpdate") {
|
|
||||||
label = "update";
|
|
||||||
name = resolvedModelNameWithFolders(op.fs.model);
|
|
||||||
color = "text-info";
|
|
||||||
model = modelTypeLabel(op.fs.model);
|
|
||||||
} else if (op.type === "dbDelete") {
|
|
||||||
label = "delete";
|
|
||||||
name = resolvedModelNameWithFolders(op.model);
|
|
||||||
color = "text-danger";
|
|
||||||
model = modelTypeLabel(op.model);
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
// oxlint-disable-next-line react/no-array-index-key
|
|
||||||
<TableRow key={i}>
|
|
||||||
<TableCell className="text-text-subtle">{model}</TableCell>
|
|
||||||
<TruncatedWideTableCell>{name}</TruncatedWideTableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<InlineCode className={color}>{label}</InlineCode>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
<footer className="py-3 flex flex-row-reverse items-center gap-3">
|
|
||||||
<Button type="submit" color="primary">
|
|
||||||
Apply Changes
|
|
||||||
</Button>
|
|
||||||
<Button onClick={hide} color="secondary">
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</footer>
|
|
||||||
</form>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import type { Environment } from "@yaakapp-internal/models";
|
|
||||||
import { CreateEnvironmentDialog } from "../components/CreateEnvironmentDialog";
|
|
||||||
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
|
||||||
import { createFastMutation } from "../hooks/useFastMutation";
|
|
||||||
import { showDialog } from "../lib/dialog";
|
|
||||||
import { jotaiStore } from "../lib/jotai";
|
|
||||||
import { setWorkspaceSearchParams } from "../lib/setWorkspaceSearchParams";
|
|
||||||
|
|
||||||
export const createSubEnvironmentAndActivate = createFastMutation<
|
|
||||||
string | null,
|
|
||||||
unknown,
|
|
||||||
Environment | null
|
|
||||||
>({
|
|
||||||
mutationKey: ["create_environment"],
|
|
||||||
mutationFn: async (baseEnvironment) => {
|
|
||||||
if (baseEnvironment == null) {
|
|
||||||
throw new Error("No base environment passed");
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
|
||||||
if (workspaceId == null) {
|
|
||||||
throw new Error("Cannot create environment when no active workspace");
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise<string | null>((resolve) => {
|
|
||||||
showDialog({
|
|
||||||
id: "new-environment",
|
|
||||||
title: "New Environment",
|
|
||||||
description: "Create multiple environments with different sets of variables",
|
|
||||||
size: "sm",
|
|
||||||
onClose: () => resolve(null),
|
|
||||||
render: ({ hide }) => (
|
|
||||||
<CreateEnvironmentDialog
|
|
||||||
workspaceId={workspaceId}
|
|
||||||
hide={hide}
|
|
||||||
onCreate={(id: string) => {
|
|
||||||
resolve(id);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSuccess: async (environmentId) => {
|
|
||||||
if (environmentId == null) {
|
|
||||||
return; // Was not created
|
|
||||||
}
|
|
||||||
|
|
||||||
setWorkspaceSearchParams({ environment_id: environmentId });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import type { WebsocketRequest } from "@yaakapp-internal/models";
|
|
||||||
import { deleteWebsocketConnections as cmdDeleteWebsocketConnections } from "@yaakapp-internal/ws";
|
|
||||||
import { createFastMutation } from "../hooks/useFastMutation";
|
|
||||||
|
|
||||||
export const deleteWebsocketConnections = createFastMutation({
|
|
||||||
mutationKey: ["delete_websocket_connections"],
|
|
||||||
mutationFn: async (request: WebsocketRequest) => cmdDeleteWebsocketConnections(request.id),
|
|
||||||
});
|
|
||||||
@@ -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,30 +0,0 @@
|
|||||||
import type { SettingsTab } from "../components/Settings/Settings";
|
|
||||||
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
|
||||||
import { createFastMutation } from "../hooks/useFastMutation";
|
|
||||||
import { jotaiStore } from "../lib/jotai";
|
|
||||||
import { router } from "../lib/router";
|
|
||||||
import { invokeCmd } from "../lib/tauri";
|
|
||||||
|
|
||||||
// Allow tab with optional subtab (e.g., "plugins:installed")
|
|
||||||
type SettingsTabWithSubtab = SettingsTab | `${SettingsTab}:${string}` | null;
|
|
||||||
|
|
||||||
export const openSettings = createFastMutation<void, string, SettingsTabWithSubtab>({
|
|
||||||
mutationKey: ["open_settings"],
|
|
||||||
mutationFn: async (tab) => {
|
|
||||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
|
||||||
if (workspaceId == null) return;
|
|
||||||
|
|
||||||
const location = router.buildLocation({
|
|
||||||
to: "/workspaces/$workspaceId/settings",
|
|
||||||
params: { workspaceId },
|
|
||||||
search: { tab: (tab ?? undefined) as SettingsTab | undefined },
|
|
||||||
});
|
|
||||||
|
|
||||||
await invokeCmd("cmd_new_child_window", {
|
|
||||||
url: location.href,
|
|
||||||
label: "settings",
|
|
||||||
title: "Yaak Settings",
|
|
||||||
innerSize: [750, 600],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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,19 +0,0 @@
|
|||||||
import type { WorkspaceSettingsTab } from "../components/WorkspaceSettingsDialog";
|
|
||||||
import { WorkspaceSettingsDialog } from "../components/WorkspaceSettingsDialog";
|
|
||||||
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
|
||||||
import { showDialog } from "../lib/dialog";
|
|
||||||
import { jotaiStore } from "../lib/jotai";
|
|
||||||
|
|
||||||
export function openWorkspaceSettings(tab?: WorkspaceSettingsTab) {
|
|
||||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
|
||||||
if (workspaceId == null) return;
|
|
||||||
showDialog({
|
|
||||||
id: "workspace-settings",
|
|
||||||
size: "md",
|
|
||||||
className: "h-[calc(100vh-5rem)] !max-h-[40rem]",
|
|
||||||
noPadding: true,
|
|
||||||
render: ({ hide }) => (
|
|
||||||
<WorkspaceSettingsDialog workspaceId={workspaceId} hide={hide} tab={tab} />
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import type { ReactNode } from "react";
|
|
||||||
import { appInfo } from "../lib/appInfo";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
children: ReactNode;
|
|
||||||
feature: "updater" | "license";
|
|
||||||
}
|
|
||||||
|
|
||||||
const featureMap: Record<Props["feature"], boolean> = {
|
|
||||||
updater: appInfo.featureUpdater,
|
|
||||||
license: appInfo.featureLicense,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function CargoFeature({ children, feature }: Props) {
|
|
||||||
if (featureMap[feature]) {
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -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,28 +0,0 @@
|
|||||||
import classNames from "classnames";
|
|
||||||
import type { CSSProperties } from "react";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
color: string | null;
|
|
||||||
onClick?: () => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ColorIndicator({ color, onClick, className }: Props) {
|
|
||||||
const style: CSSProperties = { backgroundColor: color ?? undefined };
|
|
||||||
const finalClassName = classNames(
|
|
||||||
className,
|
|
||||||
"inline-block w-[0.75em] h-[0.75em] rounded-full mr-1.5 border border-transparent flex-shrink-0",
|
|
||||||
);
|
|
||||||
|
|
||||||
if (onClick) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
style={style}
|
|
||||||
className={classNames(finalClassName, "hover:border-text")}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <span style={style} className={finalClassName} />;
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
|
||||||
import { patchModel } from "@yaakapp-internal/models";
|
|
||||||
import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { useToggle } from "../hooks/useToggle";
|
|
||||||
import { showConfirm } from "../lib/confirm";
|
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import { Link } from "./core/Link";
|
|
||||||
import { SizeTag } from "./core/SizeTag";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
children: ReactNode;
|
|
||||||
request: HttpRequest;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LARGE_TEXT_BYTES = 2 * 1000 * 1000;
|
|
||||||
|
|
||||||
export function ConfirmLargeRequestBody({ children, request }: Props) {
|
|
||||||
const [showLargeResponse, toggleShowLargeResponse] = useToggle();
|
|
||||||
|
|
||||||
if (request.body?.text == null) {
|
|
||||||
return children;
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentLength = request.body.text.length ?? 0;
|
|
||||||
const tooLargeBytes = LARGE_TEXT_BYTES;
|
|
||||||
const isLarge = contentLength > tooLargeBytes;
|
|
||||||
if (!showLargeResponse && isLarge) {
|
|
||||||
return (
|
|
||||||
<Banner color="primary" className="flex flex-col gap-3">
|
|
||||||
<p>
|
|
||||||
Rendering content over{" "}
|
|
||||||
<InlineCode>
|
|
||||||
<SizeTag contentLength={tooLargeBytes} />
|
|
||||||
</InlineCode>{" "}
|
|
||||||
may impact performance.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
See{" "}
|
|
||||||
<Link href="https://feedback.yaak.app/en/help/articles/1198684-working-with-large-values">
|
|
||||||
Working With Large Values
|
|
||||||
</Link>{" "}
|
|
||||||
for tips.
|
|
||||||
</p>
|
|
||||||
<HStack wrap space={2}>
|
|
||||||
<Button color="primary" size="xs" onClick={toggleShowLargeResponse}>
|
|
||||||
Reveal Body
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="danger"
|
|
||||||
size="xs"
|
|
||||||
variant="border"
|
|
||||||
onClick={async () => {
|
|
||||||
const confirm = await showConfirm({
|
|
||||||
id: `delete-body-${request.id}`,
|
|
||||||
confirmText: "Delete Body",
|
|
||||||
title: "Delete Body Text",
|
|
||||||
description: "Are you sure you want to delete the request body text?",
|
|
||||||
color: "danger",
|
|
||||||
});
|
|
||||||
if (confirm) {
|
|
||||||
await patchModel(request, { body: { ...request.body, text: "" } });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete Body
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
</Banner>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
|
||||||
import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui";
|
|
||||||
import { type ReactNode, useMemo } from "react";
|
|
||||||
import { getRequestBodyText as getHttpResponseRequestBodyText } from "../hooks/useHttpRequestBody";
|
|
||||||
import { useToggle } from "../hooks/useToggle";
|
|
||||||
import { isProbablyTextContentType } from "../lib/contentType";
|
|
||||||
import { getContentTypeFromHeaders } from "../lib/model_util";
|
|
||||||
import { CopyButton } from "./CopyButton";
|
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import { SizeTag } from "./core/SizeTag";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
children: ReactNode;
|
|
||||||
response: HttpResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LARGE_BYTES = 2 * 1000 * 1000;
|
|
||||||
|
|
||||||
export function ConfirmLargeResponseRequest({ children, response }: Props) {
|
|
||||||
const [showLargeResponse, toggleShowLargeResponse] = useToggle();
|
|
||||||
const isProbablyText = useMemo(() => {
|
|
||||||
const contentType = getContentTypeFromHeaders(response.headers);
|
|
||||||
return isProbablyTextContentType(contentType);
|
|
||||||
}, [response.headers]);
|
|
||||||
|
|
||||||
const contentLength = response.requestContentLength ?? 0;
|
|
||||||
const isLarge = contentLength > LARGE_BYTES;
|
|
||||||
if (!showLargeResponse && isLarge) {
|
|
||||||
return (
|
|
||||||
<Banner color="primary" className="flex flex-col gap-3">
|
|
||||||
<p>
|
|
||||||
Showing content over{" "}
|
|
||||||
<InlineCode>
|
|
||||||
<SizeTag contentLength={LARGE_BYTES} />
|
|
||||||
</InlineCode>{" "}
|
|
||||||
may impact performance
|
|
||||||
</p>
|
|
||||||
<HStack wrap space={2}>
|
|
||||||
<Button color="primary" size="xs" onClick={toggleShowLargeResponse}>
|
|
||||||
Reveal Request Body
|
|
||||||
</Button>
|
|
||||||
{isProbablyText && (
|
|
||||||
<CopyButton
|
|
||||||
color="secondary"
|
|
||||||
variant="border"
|
|
||||||
size="xs"
|
|
||||||
text={() => getHttpResponseRequestBodyText(response).then((d) => d?.bodyText ?? "")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
</Banner>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
import type { Cookie, CookieDomain, CookieJar } from "@yaakapp-internal/models";
|
|
||||||
import { cookieJarsAtom, patchModelById } from "@yaakapp-internal/models";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { cookieDomain } from "../lib/model_util";
|
|
||||||
import { showPromptForm } from "../lib/prompt-form";
|
|
||||||
import { Banner, InlineCode } from "@yaakapp-internal/ui";
|
|
||||||
import { IconButton } from "./core/IconButton";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
cookieJarId: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function showAddCookieForm(cookieJarId: string): Promise<void> {
|
|
||||||
const result = await showPromptForm({
|
|
||||||
id: "add-cookie",
|
|
||||||
title: "Add Cookie",
|
|
||||||
size: "md",
|
|
||||||
inputs: [
|
|
||||||
{
|
|
||||||
name: "cookie_pairs",
|
|
||||||
label: "Cookie Attributes",
|
|
||||||
type: "key_value",
|
|
||||||
description:
|
|
||||||
"Add key-value pairs for the cookie. These will be combined into the cookie string.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "domain_value",
|
|
||||||
label: "Domain",
|
|
||||||
type: "text",
|
|
||||||
placeholder: "example.com",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "hostOnly",
|
|
||||||
label: "Host Only",
|
|
||||||
type: "checkbox",
|
|
||||||
defaultValue: "true",
|
|
||||||
description:
|
|
||||||
"If enabled, cookie is restricted to the exact host. Otherwise, it applies to the domain and its subdomains.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "path",
|
|
||||||
label: "Path",
|
|
||||||
type: "text",
|
|
||||||
placeholder: "/",
|
|
||||||
defaultValue: "/",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "secure",
|
|
||||||
label: "Secure",
|
|
||||||
type: "checkbox",
|
|
||||||
defaultValue: "true",
|
|
||||||
description: "If enabled, cookie will only be sent over HTTPS connections.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result == null) return;
|
|
||||||
|
|
||||||
// Parse the form results
|
|
||||||
const cookie_pairs_raw = result.cookie_pairs;
|
|
||||||
const domain_value = (result.domain_value as string) ?? "";
|
|
||||||
const path = (result.path as string) ?? "/";
|
|
||||||
const hostOnly = (result.hostOnly as string) === "true";
|
|
||||||
const secure = (result.secure as string) === "true";
|
|
||||||
|
|
||||||
// Convert key-value pairs to raw_cookie string format: key1=value1;key2=value2
|
|
||||||
// Parse cookie_pairs - it comes as a JSON string from the key_value input
|
|
||||||
let parsedPairs: Array<{ name: string; value: string }> = [];
|
|
||||||
try {
|
|
||||||
// Handle null, undefined, or string value
|
|
||||||
const pairsStr =
|
|
||||||
typeof cookie_pairs_raw === "string"
|
|
||||||
? cookie_pairs_raw
|
|
||||||
: cookie_pairs_raw != null
|
|
||||||
? JSON.stringify(cookie_pairs_raw)
|
|
||||||
: "[]";
|
|
||||||
if (pairsStr && pairsStr !== "") {
|
|
||||||
parsedPairs = JSON.parse(pairsStr);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
parsedPairs = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const validPairs = parsedPairs.filter((p) => p?.name?.trim());
|
|
||||||
// Ensure at least one valid pair exists
|
|
||||||
if (validPairs.length === 0) {
|
|
||||||
console.log("No valid cookie pairs provided");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const raw_cookie = validPairs.map((p) => `${p.name}=${p.value}`).join(";");
|
|
||||||
|
|
||||||
const domain: CookieDomain = hostOnly
|
|
||||||
? { HostOnly: domain_value ?? "" }
|
|
||||||
: { Suffix: domain_value ?? "" };
|
|
||||||
|
|
||||||
// Build the new cookie with explicit tuple type for path
|
|
||||||
const newCookie: Cookie = {
|
|
||||||
raw_cookie,
|
|
||||||
domain,
|
|
||||||
expires: "SessionEnd",
|
|
||||||
path: [path, secure] as [string, boolean],
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
await patchModelById<"cookie_jar", CookieJar>("cookie_jar", cookieJarId, (prev) => ({
|
|
||||||
...prev,
|
|
||||||
cookies: [...prev.cookies, newCookie],
|
|
||||||
}));
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to add cookie:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CookieDialog = ({ cookieJarId }: Props) => {
|
|
||||||
const cookieJars = useAtomValue(cookieJarsAtom);
|
|
||||||
const cookieJar = cookieJars?.find((c) => c.id === cookieJarId);
|
|
||||||
|
|
||||||
if (cookieJar == null) {
|
|
||||||
return <div>No cookie jar selected</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const onAddCookie = () => showAddCookieForm(cookieJar.id);
|
|
||||||
|
|
||||||
let tableBody;
|
|
||||||
if (cookieJar.cookies.length === 0) {
|
|
||||||
tableBody = (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={3}>
|
|
||||||
<Banner>
|
|
||||||
Cookies will appear when a response contains the <InlineCode>Set-Cookie</InlineCode>{" "}
|
|
||||||
header
|
|
||||||
</Banner>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
// );
|
|
||||||
} else {
|
|
||||||
tableBody = cookieJar?.cookies.map((c: Cookie) => (
|
|
||||||
<tr key={JSON.stringify(c)}>
|
|
||||||
<td className="py-2 select-text cursor-text font-mono font-semibold max-w-0">
|
|
||||||
{cookieDomain(c)}
|
|
||||||
</td>
|
|
||||||
<td className="py-2 pl-4 select-text cursor-text font-mono text-text-subtle whitespace-nowrap overflow-x-auto max-w-[200px] hide-scrollbars">
|
|
||||||
{c.raw_cookie}
|
|
||||||
</td>
|
|
||||||
<td className="max-w-0 w-10">
|
|
||||||
<IconButton
|
|
||||||
icon="trash"
|
|
||||||
size="xs"
|
|
||||||
iconSize="sm"
|
|
||||||
title="Delete"
|
|
||||||
className="ml-auto"
|
|
||||||
onClick={async () =>
|
|
||||||
await patchModelById<"cookie_jar", CookieJar>("cookie_jar", cookieJar.id, (prev) => ({
|
|
||||||
...prev,
|
|
||||||
cookies: prev.cookies.filter((c2: Cookie) => c2 !== c),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="pb-2">
|
|
||||||
<table className="w-full text-sm mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th className="py-2 text-left">Domain</th>
|
|
||||||
<th className="py-2 text-left pl-4">Cookie</th>
|
|
||||||
<th className="py-2 pl-4 w-10">
|
|
||||||
<IconButton
|
|
||||||
icon="plus"
|
|
||||||
size="xs"
|
|
||||||
iconSize="sm"
|
|
||||||
title="Add Cookie"
|
|
||||||
className="ml-auto"
|
|
||||||
onClick={onAddCookie}
|
|
||||||
/>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-surface-highlight">{tableBody}</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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,68 +0,0 @@
|
|||||||
import { createWorkspaceModel } from "@yaakapp-internal/models";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useToggle } from "../hooks/useToggle";
|
|
||||||
import { ColorIndicator } from "./ColorIndicator";
|
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import { Checkbox } from "./core/Checkbox";
|
|
||||||
import { ColorPickerWithThemeColors } from "./core/ColorPicker";
|
|
||||||
import { Label } from "./core/Label";
|
|
||||||
import { PlainInput } from "./core/PlainInput";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onCreate: (id: string) => void;
|
|
||||||
hide: () => void;
|
|
||||||
workspaceId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CreateEnvironmentDialog({ workspaceId, hide, onCreate }: Props) {
|
|
||||||
const [name, setName] = useState<string>("");
|
|
||||||
const [color, setColor] = useState<string | null>(null);
|
|
||||||
const [sharable, toggleSharable] = useToggle(false);
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
className="pb-3 flex flex-col gap-3"
|
|
||||||
onSubmit={async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const id = await createWorkspaceModel({
|
|
||||||
model: "environment",
|
|
||||||
name,
|
|
||||||
color,
|
|
||||||
variables: [],
|
|
||||||
public: sharable,
|
|
||||||
workspaceId,
|
|
||||||
parentModel: "environment",
|
|
||||||
});
|
|
||||||
hide();
|
|
||||||
onCreate(id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PlainInput
|
|
||||||
label="Name"
|
|
||||||
required
|
|
||||||
defaultValue={name}
|
|
||||||
onChange={setName}
|
|
||||||
placeholder="Production"
|
|
||||||
/>
|
|
||||||
<Checkbox
|
|
||||||
checked={sharable}
|
|
||||||
title="Share this environment"
|
|
||||||
help="Sharable environments are included in data export and directory sync."
|
|
||||||
onChange={toggleSharable}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<Label
|
|
||||||
htmlFor="color"
|
|
||||||
className="mb-1.5"
|
|
||||||
help="Select a color to be displayed when this environment is active, to help identify it."
|
|
||||||
>
|
|
||||||
Color
|
|
||||||
</Label>
|
|
||||||
<ColorPickerWithThemeColors onChange={setColor} color={color} />
|
|
||||||
</div>
|
|
||||||
<Button type="submit" color="secondary" className="mt-3">
|
|
||||||
{color != null && <ColorIndicator color={color} />}
|
|
||||||
Create Environment
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { useAtomValue } from "jotai";
|
|
||||||
import type { ComponentType } from "react";
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import { dialogsAtom, hideDialog } from "../lib/dialog";
|
|
||||||
import { Dialog, type DialogProps } from "./core/Dialog";
|
|
||||||
import { ErrorBoundary } from "./ErrorBoundary";
|
|
||||||
|
|
||||||
export type DialogInstance = {
|
|
||||||
id: string;
|
|
||||||
render: ComponentType<{ hide: () => void }>;
|
|
||||||
} & Omit<DialogProps, "open" | "children">;
|
|
||||||
|
|
||||||
export function Dialogs() {
|
|
||||||
const dialogs = useAtomValue(dialogsAtom);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{dialogs.map(({ id, ...props }) => (
|
|
||||||
<DialogInstance key={id} id={id} {...props} />
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogInstance({ render: Component, onClose, id, ...props }: DialogInstance) {
|
|
||||||
const hide = useCallback(() => {
|
|
||||||
hideDialog(id);
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
|
||||||
onClose?.();
|
|
||||||
hideDialog(id);
|
|
||||||
}, [id, onClose]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open onClose={handleClose} {...props}>
|
|
||||||
<ErrorBoundary name={`Dialog ${id}`}>
|
|
||||||
<Component hide={hide} {...props} />
|
|
||||||
</ErrorBoundary>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,628 +0,0 @@
|
|||||||
import type { Folder, HttpRequest } from "@yaakapp-internal/models";
|
|
||||||
import { foldersAtom, httpRequestsAtom } from "@yaakapp-internal/models";
|
|
||||||
import type {
|
|
||||||
FormInput,
|
|
||||||
FormInputCheckbox,
|
|
||||||
FormInputEditor,
|
|
||||||
FormInputFile,
|
|
||||||
FormInputHttpRequest,
|
|
||||||
FormInputKeyValue,
|
|
||||||
FormInputSelect,
|
|
||||||
FormInputText,
|
|
||||||
JsonPrimitive,
|
|
||||||
} from "@yaakapp-internal/plugins";
|
|
||||||
import { Banner, VStack } from "@yaakapp-internal/ui";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useCallback, useEffect, useMemo } from "react";
|
|
||||||
import { useActiveRequest } from "../hooks/useActiveRequest";
|
|
||||||
import { useRandomKey } from "../hooks/useRandomKey";
|
|
||||||
import { capitalize } from "../lib/capitalize";
|
|
||||||
import { showDialog } from "../lib/dialog";
|
|
||||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
|
||||||
import { Checkbox } from "./core/Checkbox";
|
|
||||||
import { DetailsBanner } from "./core/DetailsBanner";
|
|
||||||
import { Editor } from "./core/Editor/LazyEditor";
|
|
||||||
import { IconButton } from "./core/IconButton";
|
|
||||||
import type { InputProps } from "./core/Input";
|
|
||||||
import { Input } from "./core/Input";
|
|
||||||
import { Label } from "./core/Label";
|
|
||||||
import type { Pair } from "./core/PairEditor";
|
|
||||||
import { PairEditor } from "./core/PairEditor";
|
|
||||||
import { PlainInput } from "./core/PlainInput";
|
|
||||||
import { Select } from "./core/Select";
|
|
||||||
import { Markdown } from "./Markdown";
|
|
||||||
import { SelectFile } from "./SelectFile";
|
|
||||||
|
|
||||||
export const DYNAMIC_FORM_NULL_ARG = "__NULL__";
|
|
||||||
const INPUT_SIZE = "sm";
|
|
||||||
|
|
||||||
interface Props<T> {
|
|
||||||
inputs: FormInput[] | undefined | null;
|
|
||||||
onChange: (value: T) => void;
|
|
||||||
data: T;
|
|
||||||
autocompleteFunctions?: boolean;
|
|
||||||
autocompleteVariables?: boolean;
|
|
||||||
stateKey: string;
|
|
||||||
className?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DynamicForm<T extends Record<string, JsonPrimitive>>({
|
|
||||||
inputs,
|
|
||||||
data,
|
|
||||||
onChange,
|
|
||||||
autocompleteVariables,
|
|
||||||
autocompleteFunctions,
|
|
||||||
stateKey,
|
|
||||||
className,
|
|
||||||
disabled,
|
|
||||||
}: Props<T>) {
|
|
||||||
const setDataAttr = useCallback(
|
|
||||||
(name: string, value: JsonPrimitive) => {
|
|
||||||
onChange({ ...data, [name]: value === DYNAMIC_FORM_NULL_ARG ? undefined : value });
|
|
||||||
},
|
|
||||||
[data, onChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FormInputsStack
|
|
||||||
disabled={disabled}
|
|
||||||
inputs={inputs}
|
|
||||||
setDataAttr={setDataAttr}
|
|
||||||
stateKey={stateKey}
|
|
||||||
autocompleteFunctions={autocompleteFunctions}
|
|
||||||
autocompleteVariables={autocompleteVariables}
|
|
||||||
data={data}
|
|
||||||
className={classNames(className, "pb-4")} // Pad the bottom to look nice
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FormInputsStack<T extends Record<string, JsonPrimitive>>({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: FormInputsProps<T> & { className?: string }) {
|
|
||||||
return (
|
|
||||||
<VStack
|
|
||||||
space={3}
|
|
||||||
className={classNames(
|
|
||||||
className,
|
|
||||||
"h-full overflow-auto",
|
|
||||||
"pr-1", // A bit of space between inputs and scrollbar
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<FormInputs {...props} />
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type FormInputsProps<T> = Pick<
|
|
||||||
Props<T>,
|
|
||||||
"inputs" | "autocompleteFunctions" | "autocompleteVariables" | "stateKey" | "data"
|
|
||||||
> & {
|
|
||||||
setDataAttr: (name: string, value: JsonPrimitive) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
function FormInputs<T extends Record<string, JsonPrimitive>>({
|
|
||||||
inputs,
|
|
||||||
autocompleteFunctions,
|
|
||||||
autocompleteVariables,
|
|
||||||
stateKey,
|
|
||||||
setDataAttr,
|
|
||||||
data,
|
|
||||||
disabled,
|
|
||||||
}: FormInputsProps<T>) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{inputs?.map((input, i) => {
|
|
||||||
if ("hidden" in input && input.hidden) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ("disabled" in input && disabled != null) {
|
|
||||||
input.disabled = disabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (input.type) {
|
|
||||||
case "select":
|
|
||||||
return (
|
|
||||||
<SelectArg
|
|
||||||
key={i + stateKey}
|
|
||||||
arg={input}
|
|
||||||
onChange={(v) => setDataAttr(input.name, v)}
|
|
||||||
value={
|
|
||||||
data[input.name]
|
|
||||||
? String(data[input.name])
|
|
||||||
: (input.defaultValue ?? DYNAMIC_FORM_NULL_ARG)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case "text":
|
|
||||||
return (
|
|
||||||
<TextArg
|
|
||||||
key={i + stateKey}
|
|
||||||
stateKey={stateKey}
|
|
||||||
arg={input}
|
|
||||||
autocompleteFunctions={autocompleteFunctions || false}
|
|
||||||
autocompleteVariables={autocompleteVariables || false}
|
|
||||||
onChange={(v) => setDataAttr(input.name, v)}
|
|
||||||
value={
|
|
||||||
data[input.name] != null ? String(data[input.name]) : (input.defaultValue ?? "")
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case "editor":
|
|
||||||
return (
|
|
||||||
<EditorArg
|
|
||||||
key={i + stateKey}
|
|
||||||
stateKey={stateKey}
|
|
||||||
arg={input}
|
|
||||||
autocompleteFunctions={autocompleteFunctions || false}
|
|
||||||
autocompleteVariables={autocompleteVariables || false}
|
|
||||||
onChange={(v) => setDataAttr(input.name, v)}
|
|
||||||
value={
|
|
||||||
data[input.name] != null ? String(data[input.name]) : (input.defaultValue ?? "")
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case "checkbox":
|
|
||||||
return (
|
|
||||||
<CheckboxArg
|
|
||||||
key={i + stateKey}
|
|
||||||
arg={input}
|
|
||||||
onChange={(v) => setDataAttr(input.name, v)}
|
|
||||||
value={data[input.name] != null ? data[input.name] === true : false}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case "http_request":
|
|
||||||
return (
|
|
||||||
<HttpRequestArg
|
|
||||||
key={i + stateKey}
|
|
||||||
arg={input}
|
|
||||||
onChange={(v) => setDataAttr(input.name, v)}
|
|
||||||
value={data[input.name] != null ? String(data[input.name]) : DYNAMIC_FORM_NULL_ARG}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case "file":
|
|
||||||
return (
|
|
||||||
<FileArg
|
|
||||||
key={i + stateKey}
|
|
||||||
arg={input}
|
|
||||||
onChange={(v) => setDataAttr(input.name, v)}
|
|
||||||
filePath={
|
|
||||||
data[input.name] != null ? String(data[input.name]) : DYNAMIC_FORM_NULL_ARG
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case "accordion":
|
|
||||||
if (!hasVisibleInputs(input.inputs)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div key={i + stateKey}>
|
|
||||||
<DetailsBanner
|
|
||||||
summary={input.label}
|
|
||||||
className={classNames("!mb-auto", disabled && "opacity-disabled")}
|
|
||||||
>
|
|
||||||
<div className="mt-3">
|
|
||||||
<FormInputsStack
|
|
||||||
data={data}
|
|
||||||
disabled={disabled}
|
|
||||||
inputs={input.inputs}
|
|
||||||
setDataAttr={setDataAttr}
|
|
||||||
stateKey={stateKey}
|
|
||||||
autocompleteFunctions={autocompleteFunctions || false}
|
|
||||||
autocompleteVariables={autocompleteVariables}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</DetailsBanner>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case "h_stack":
|
|
||||||
if (!hasVisibleInputs(input.inputs)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap sm:flex-nowrap gap-3 items-end" key={i + stateKey}>
|
|
||||||
<FormInputs
|
|
||||||
data={data}
|
|
||||||
disabled={disabled}
|
|
||||||
inputs={input.inputs}
|
|
||||||
setDataAttr={setDataAttr}
|
|
||||||
stateKey={stateKey}
|
|
||||||
autocompleteFunctions={autocompleteFunctions || false}
|
|
||||||
autocompleteVariables={autocompleteVariables}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case "banner":
|
|
||||||
if (!hasVisibleInputs(input.inputs)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Banner
|
|
||||||
key={i + stateKey}
|
|
||||||
color={input.color}
|
|
||||||
className={classNames(disabled && "opacity-disabled")}
|
|
||||||
>
|
|
||||||
<FormInputsStack
|
|
||||||
data={data}
|
|
||||||
disabled={disabled}
|
|
||||||
inputs={input.inputs}
|
|
||||||
setDataAttr={setDataAttr}
|
|
||||||
stateKey={stateKey}
|
|
||||||
autocompleteFunctions={autocompleteFunctions || false}
|
|
||||||
autocompleteVariables={autocompleteVariables}
|
|
||||||
/>
|
|
||||||
</Banner>
|
|
||||||
);
|
|
||||||
case "markdown":
|
|
||||||
return <Markdown key={i + stateKey}>{input.content}</Markdown>;
|
|
||||||
case "key_value":
|
|
||||||
return (
|
|
||||||
<KeyValueArg
|
|
||||||
key={i + stateKey}
|
|
||||||
arg={input}
|
|
||||||
stateKey={stateKey}
|
|
||||||
onChange={(v) => setDataAttr(input.name, v)}
|
|
||||||
value={
|
|
||||||
data[input.name] != null ? String(data[input.name]) : (input.defaultValue ?? "[]")
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
// @ts-expect-error
|
|
||||||
throw new Error(`Invalid input type: ${input.type}`);
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TextArg({
|
|
||||||
arg,
|
|
||||||
onChange,
|
|
||||||
value,
|
|
||||||
autocompleteFunctions,
|
|
||||||
autocompleteVariables,
|
|
||||||
stateKey,
|
|
||||||
}: {
|
|
||||||
arg: FormInputText;
|
|
||||||
value: string;
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
autocompleteFunctions: boolean;
|
|
||||||
autocompleteVariables: boolean;
|
|
||||||
stateKey: string;
|
|
||||||
}) {
|
|
||||||
const props: InputProps = {
|
|
||||||
onChange,
|
|
||||||
name: arg.name,
|
|
||||||
multiLine: arg.multiLine,
|
|
||||||
className: arg.multiLine ? "min-h-[4rem]" : undefined,
|
|
||||||
defaultValue: value === DYNAMIC_FORM_NULL_ARG ? arg.defaultValue : value,
|
|
||||||
required: !arg.optional,
|
|
||||||
disabled: arg.disabled,
|
|
||||||
help: arg.description,
|
|
||||||
type: arg.password ? "password" : "text",
|
|
||||||
label: arg.label ?? arg.name,
|
|
||||||
size: INPUT_SIZE,
|
|
||||||
hideLabel: arg.hideLabel ?? arg.label == null,
|
|
||||||
placeholder: arg.placeholder ?? undefined,
|
|
||||||
forceUpdateKey: stateKey,
|
|
||||||
autocomplete: arg.completionOptions ? { options: arg.completionOptions } : undefined,
|
|
||||||
stateKey,
|
|
||||||
autocompleteFunctions,
|
|
||||||
autocompleteVariables,
|
|
||||||
};
|
|
||||||
if (autocompleteVariables || autocompleteFunctions || arg.completionOptions) {
|
|
||||||
return <Input {...props} />;
|
|
||||||
}
|
|
||||||
return <PlainInput {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function EditorArg({
|
|
||||||
arg,
|
|
||||||
onChange,
|
|
||||||
value,
|
|
||||||
autocompleteFunctions,
|
|
||||||
autocompleteVariables,
|
|
||||||
stateKey,
|
|
||||||
}: {
|
|
||||||
arg: FormInputEditor;
|
|
||||||
value: string;
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
autocompleteFunctions: boolean;
|
|
||||||
autocompleteVariables: boolean;
|
|
||||||
stateKey: string;
|
|
||||||
}) {
|
|
||||||
const id = `input-${arg.name}`;
|
|
||||||
|
|
||||||
// Read-only editor force refresh for every defaultValue change
|
|
||||||
// Should this be built into the <Editor/> component?
|
|
||||||
const [popoutKey, regeneratePopoutKey] = useRandomKey();
|
|
||||||
const forceUpdateKey = popoutKey + (arg.readOnly ? arg.defaultValue + stateKey : stateKey);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full grid grid-cols-1 grid-rows-[auto_minmax(0,1fr)]">
|
|
||||||
<Label
|
|
||||||
htmlFor={id}
|
|
||||||
required={!arg.optional}
|
|
||||||
visuallyHidden={arg.hideLabel}
|
|
||||||
help={arg.description}
|
|
||||||
tags={arg.language ? [capitalize(arg.language)] : undefined}
|
|
||||||
>
|
|
||||||
{arg.label}
|
|
||||||
</Label>
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
"border border-border rounded-md overflow-hidden px-2 py-1",
|
|
||||||
"focus-within:border-border-focus",
|
|
||||||
!arg.rows && "max-h-[10rem]", // So it doesn't take up too much space
|
|
||||||
)}
|
|
||||||
style={arg.rows ? { height: `${arg.rows * 1.4 + 0.75}rem` } : undefined}
|
|
||||||
>
|
|
||||||
<Editor
|
|
||||||
id={id}
|
|
||||||
autocomplete={arg.completionOptions ? { options: arg.completionOptions } : undefined}
|
|
||||||
disabled={arg.disabled}
|
|
||||||
language={arg.language}
|
|
||||||
readOnly={arg.readOnly}
|
|
||||||
onChange={onChange}
|
|
||||||
hideGutter
|
|
||||||
heightMode="auto"
|
|
||||||
className="min-h-[3rem]"
|
|
||||||
defaultValue={value === DYNAMIC_FORM_NULL_ARG ? arg.defaultValue : value}
|
|
||||||
placeholder={arg.placeholder ?? undefined}
|
|
||||||
autocompleteFunctions={autocompleteFunctions}
|
|
||||||
autocompleteVariables={autocompleteVariables}
|
|
||||||
stateKey={stateKey}
|
|
||||||
forceUpdateKey={forceUpdateKey}
|
|
||||||
actions={
|
|
||||||
<div>
|
|
||||||
<IconButton
|
|
||||||
variant="border"
|
|
||||||
size="sm"
|
|
||||||
className="my-0.5 opacity-60 group-hover:opacity-100"
|
|
||||||
icon="expand"
|
|
||||||
title="Pop out to large editor"
|
|
||||||
onClick={() => {
|
|
||||||
showDialog({
|
|
||||||
id: "id",
|
|
||||||
size: "full",
|
|
||||||
title: arg.readOnly ? "View Value" : "Edit Value",
|
|
||||||
className: "!max-w-[50rem] !max-h-[60rem]",
|
|
||||||
description: arg.label && (
|
|
||||||
<Label
|
|
||||||
htmlFor={id}
|
|
||||||
required={!arg.optional}
|
|
||||||
visuallyHidden={arg.hideLabel}
|
|
||||||
help={arg.description}
|
|
||||||
tags={arg.language ? [capitalize(arg.language)] : undefined}
|
|
||||||
>
|
|
||||||
{arg.label}
|
|
||||||
</Label>
|
|
||||||
),
|
|
||||||
onClose() {
|
|
||||||
// Force the main editor to update on close
|
|
||||||
regeneratePopoutKey();
|
|
||||||
},
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<Editor
|
|
||||||
id={id}
|
|
||||||
autocomplete={
|
|
||||||
arg.completionOptions ? { options: arg.completionOptions } : undefined
|
|
||||||
}
|
|
||||||
disabled={arg.disabled}
|
|
||||||
language={arg.language}
|
|
||||||
readOnly={arg.readOnly}
|
|
||||||
onChange={onChange}
|
|
||||||
defaultValue={value === DYNAMIC_FORM_NULL_ARG ? arg.defaultValue : value}
|
|
||||||
placeholder={arg.placeholder ?? undefined}
|
|
||||||
autocompleteFunctions={autocompleteFunctions}
|
|
||||||
autocompleteVariables={autocompleteVariables}
|
|
||||||
stateKey={stateKey}
|
|
||||||
forceUpdateKey={forceUpdateKey}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectArg({
|
|
||||||
arg,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
arg: FormInputSelect;
|
|
||||||
value: string;
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Select
|
|
||||||
label={arg.label ?? arg.name}
|
|
||||||
name={arg.name}
|
|
||||||
help={arg.description}
|
|
||||||
onChange={onChange}
|
|
||||||
defaultValue={arg.defaultValue}
|
|
||||||
hideLabel={arg.hideLabel}
|
|
||||||
value={value}
|
|
||||||
size={INPUT_SIZE}
|
|
||||||
disabled={arg.disabled}
|
|
||||||
options={arg.options}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FileArg({
|
|
||||||
arg,
|
|
||||||
filePath,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
arg: FormInputFile;
|
|
||||||
filePath: string;
|
|
||||||
onChange: (v: string | null) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<SelectFile
|
|
||||||
disabled={arg.disabled}
|
|
||||||
help={arg.description}
|
|
||||||
onChange={({ filePath }) => onChange(filePath)}
|
|
||||||
filePath={filePath === DYNAMIC_FORM_NULL_ARG ? null : filePath}
|
|
||||||
directory={!!arg.directory}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function HttpRequestArg({
|
|
||||||
arg,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
arg: FormInputHttpRequest;
|
|
||||||
value: string;
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
}) {
|
|
||||||
const folders = useAtomValue(foldersAtom);
|
|
||||||
const httpRequests = useAtomValue(httpRequestsAtom);
|
|
||||||
const activeHttpRequest = useActiveRequest("http_request");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (value === DYNAMIC_FORM_NULL_ARG && activeHttpRequest) {
|
|
||||||
onChange(activeHttpRequest.id);
|
|
||||||
}
|
|
||||||
}, [activeHttpRequest, onChange, value]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Select
|
|
||||||
label={arg.label ?? arg.name}
|
|
||||||
name={arg.name}
|
|
||||||
onChange={onChange}
|
|
||||||
help={arg.description}
|
|
||||||
value={value}
|
|
||||||
disabled={arg.disabled}
|
|
||||||
options={httpRequests.map((r) => {
|
|
||||||
return {
|
|
||||||
label:
|
|
||||||
buildRequestBreadcrumbs(r, folders).join(" / ") +
|
|
||||||
(r.id === activeHttpRequest?.id ? " (current)" : ""),
|
|
||||||
value: r.id,
|
|
||||||
};
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRequestBreadcrumbs(request: HttpRequest, folders: Folder[]): string[] {
|
|
||||||
const ancestors: (HttpRequest | Folder)[] = [request];
|
|
||||||
|
|
||||||
const next = () => {
|
|
||||||
const latest = ancestors[0];
|
|
||||||
if (latest == null) return [];
|
|
||||||
|
|
||||||
const parent = folders.find((f) => f.id === latest.folderId);
|
|
||||||
if (parent == null) return;
|
|
||||||
|
|
||||||
ancestors.unshift(parent);
|
|
||||||
next();
|
|
||||||
};
|
|
||||||
next();
|
|
||||||
|
|
||||||
return ancestors.map((a) => (a.model === "folder" ? a.name : resolvedModelName(a)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function CheckboxArg({
|
|
||||||
arg,
|
|
||||||
onChange,
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
arg: FormInputCheckbox;
|
|
||||||
value: boolean;
|
|
||||||
onChange: (v: boolean) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Checkbox
|
|
||||||
onChange={onChange}
|
|
||||||
checked={value}
|
|
||||||
help={arg.description}
|
|
||||||
disabled={arg.disabled}
|
|
||||||
title={arg.label ?? arg.name}
|
|
||||||
hideLabel={arg.label == null}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function KeyValueArg({
|
|
||||||
arg,
|
|
||||||
onChange,
|
|
||||||
value,
|
|
||||||
stateKey,
|
|
||||||
}: {
|
|
||||||
arg: FormInputKeyValue;
|
|
||||||
value: string;
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
stateKey: string;
|
|
||||||
}) {
|
|
||||||
const pairs: Pair[] = useMemo(() => {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(value);
|
|
||||||
return Array.isArray(parsed) ? parsed : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
const handleChange = useCallback(
|
|
||||||
(newPairs: Pair[]) => {
|
|
||||||
onChange(JSON.stringify(newPairs));
|
|
||||||
},
|
|
||||||
[onChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full grid grid-cols-1 grid-rows-[auto_minmax(0,1fr)] overflow-hidden">
|
|
||||||
<Label
|
|
||||||
htmlFor={`input-${arg.name}`}
|
|
||||||
required={!arg.optional}
|
|
||||||
visuallyHidden={arg.hideLabel}
|
|
||||||
help={arg.description}
|
|
||||||
>
|
|
||||||
{arg.label ?? arg.name}
|
|
||||||
</Label>
|
|
||||||
<PairEditor
|
|
||||||
pairs={pairs}
|
|
||||||
onChange={handleChange}
|
|
||||||
stateKey={stateKey}
|
|
||||||
namePlaceholder="name"
|
|
||||||
valuePlaceholder="value"
|
|
||||||
noScroll
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasVisibleInputs(inputs: FormInput[] | undefined): boolean {
|
|
||||||
if (!inputs) return false;
|
|
||||||
|
|
||||||
for (const input of inputs) {
|
|
||||||
if ("inputs" in input && !hasVisibleInputs(input.inputs)) {
|
|
||||||
// Has children, but none are visible
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!input.hidden) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
import classNames from "classnames";
|
|
||||||
import { memo, useMemo } from "react";
|
|
||||||
import { useActiveEnvironment } from "../hooks/useActiveEnvironment";
|
|
||||||
import { useEnvironmentsBreakdown } from "../hooks/useEnvironmentsBreakdown";
|
|
||||||
import { editEnvironment } from "../lib/editEnvironment";
|
|
||||||
import { setWorkspaceSearchParams } from "../lib/setWorkspaceSearchParams";
|
|
||||||
import type { ButtonProps } from "./core/Button";
|
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import type { DropdownItem } from "./core/Dropdown";
|
|
||||||
import { Dropdown } from "./core/Dropdown";
|
|
||||||
import { Icon } from "@yaakapp-internal/ui";
|
|
||||||
import { EnvironmentColorIndicator } from "./EnvironmentColorIndicator";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
className?: string;
|
|
||||||
} & Pick<ButtonProps, "forDropdown" | "leftSlot">;
|
|
||||||
|
|
||||||
export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdown({
|
|
||||||
className,
|
|
||||||
...buttonProps
|
|
||||||
}: Props) {
|
|
||||||
const { subEnvironments, baseEnvironment } = useEnvironmentsBreakdown();
|
|
||||||
const activeEnvironment = useActiveEnvironment();
|
|
||||||
|
|
||||||
const items: DropdownItem[] = useMemo(
|
|
||||||
() => [
|
|
||||||
...subEnvironments.map(
|
|
||||||
(e) => ({
|
|
||||||
key: e.id,
|
|
||||||
label: e.name,
|
|
||||||
rightSlot: <EnvironmentColorIndicator environment={e} />,
|
|
||||||
leftSlot: e.id === activeEnvironment?.id ? <Icon icon="check" /> : <Icon icon="empty" />,
|
|
||||||
onSelect: async () => {
|
|
||||||
if (e.id !== activeEnvironment?.id) {
|
|
||||||
setWorkspaceSearchParams({ environment_id: e.id });
|
|
||||||
} else {
|
|
||||||
setWorkspaceSearchParams({ environment_id: null });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[activeEnvironment?.id],
|
|
||||||
),
|
|
||||||
...((subEnvironments.length > 0
|
|
||||||
? [{ type: "separator", label: "Environments" }]
|
|
||||||
: []) as DropdownItem[]),
|
|
||||||
{
|
|
||||||
label: "Manage Environments",
|
|
||||||
hotKeyAction: "environment_editor.toggle",
|
|
||||||
leftSlot: <Icon icon="box" />,
|
|
||||||
onSelect: () => editEnvironment(activeEnvironment),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[subEnvironments, activeEnvironment],
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasBaseVars =
|
|
||||||
(baseEnvironment?.variables ?? []).filter((v) => v.enabled && (v.name || v.value)).length > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dropdown items={items}>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
className={classNames(
|
|
||||||
className,
|
|
||||||
"text !px-2 truncate",
|
|
||||||
!activeEnvironment && !hasBaseVars && "text-text-subtlest italic",
|
|
||||||
)}
|
|
||||||
// If no environments, the button simply opens the dialog.
|
|
||||||
// NOTE: We don't create a new button because we want to reuse the hotkey from the menu items
|
|
||||||
onClick={subEnvironments.length === 0 ? () => editEnvironment(null) : undefined}
|
|
||||||
{...buttonProps}
|
|
||||||
>
|
|
||||||
<EnvironmentColorIndicator environment={activeEnvironment ?? null} />
|
|
||||||
{activeEnvironment?.name ?? (hasBaseVars ? "Environment" : "No Environment")}
|
|
||||||
</Button>
|
|
||||||
</Dropdown>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import type { Environment } from "@yaakapp-internal/models";
|
|
||||||
import { showColorPicker } from "../lib/showColorPicker";
|
|
||||||
import { ColorIndicator } from "./ColorIndicator";
|
|
||||||
|
|
||||||
export function EnvironmentColorIndicator({
|
|
||||||
environment,
|
|
||||||
clickToEdit,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
environment: Environment | null;
|
|
||||||
clickToEdit?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
if (environment?.color == null) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ColorIndicator
|
|
||||||
className={className}
|
|
||||||
color={environment?.color ?? null}
|
|
||||||
onClick={clickToEdit ? () => showColorPicker(environment) : undefined}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { ColorIndicator } from "./ColorIndicator";
|
|
||||||
import { Banner } from "@yaakapp-internal/ui";
|
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import { ColorPickerWithThemeColors } from "./core/ColorPicker";
|
|
||||||
|
|
||||||
export function EnvironmentColorPicker({
|
|
||||||
color: defaultColor,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
color: string | null;
|
|
||||||
onChange: (color: string | null) => void;
|
|
||||||
}) {
|
|
||||||
const [color, setColor] = useState<string | null>(defaultColor);
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
className="flex flex-col items-stretch gap-5 pb-2 w-full"
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onChange(color);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Banner color="secondary">
|
|
||||||
This color will be used to color the interface when this environment is active
|
|
||||||
</Banner>
|
|
||||||
<ColorPickerWithThemeColors color={color} onChange={setColor} />
|
|
||||||
<Button type="submit" color="secondary">
|
|
||||||
{color != null && <ColorIndicator color={color} />}
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,444 +0,0 @@
|
|||||||
import type { Environment, Workspace } from "@yaakapp-internal/models";
|
|
||||||
import { duplicateModel, patchModel } from "@yaakapp-internal/models";
|
|
||||||
import type { TreeHandle, TreeNode, TreeProps } from "@yaakapp-internal/ui";
|
|
||||||
import { Banner, Icon, InlineCode, SplitLayout, Tree } from "@yaakapp-internal/ui";
|
|
||||||
import { atom, useAtomValue } from "jotai";
|
|
||||||
import { atomFamily } from "jotai-family";
|
|
||||||
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
|
||||||
import { createSubEnvironmentAndActivate } from "../commands/createEnvironment";
|
|
||||||
import { activeWorkspaceAtom, activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
|
||||||
import {
|
|
||||||
environmentsBreakdownAtom,
|
|
||||||
useEnvironmentsBreakdown,
|
|
||||||
} from "../hooks/useEnvironmentsBreakdown";
|
|
||||||
import { useHotKey } from "../hooks/useHotKey";
|
|
||||||
import { atomWithKVStorage } from "../lib/atoms/atomWithKVStorage";
|
|
||||||
import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
|
|
||||||
import { fireAndForget } from "../lib/fireAndForget";
|
|
||||||
import { jotaiStore } from "../lib/jotai";
|
|
||||||
import { isBaseEnvironment, isSubEnvironment } from "../lib/model_util";
|
|
||||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
|
||||||
import { showColorPicker } from "../lib/showColorPicker";
|
|
||||||
import type { ContextMenuProps, DropdownItem } from "./core/Dropdown";
|
|
||||||
import { ContextMenu } from "./core/Dropdown";
|
|
||||||
import { IconButton } from "./core/IconButton";
|
|
||||||
import { IconTooltip } from "./core/IconTooltip";
|
|
||||||
import type { PairEditorHandle } from "./core/PairEditor";
|
|
||||||
import { EnvironmentColorIndicator } from "./EnvironmentColorIndicator";
|
|
||||||
import { EnvironmentEditor } from "./EnvironmentEditor";
|
|
||||||
import { EnvironmentSharableTooltip } from "./EnvironmentSharableTooltip";
|
|
||||||
|
|
||||||
const collapsedFamily = atomFamily((treeId: string) => {
|
|
||||||
const key = ["env_collapsed", treeId ?? "n/a"];
|
|
||||||
return atomWithKVStorage<Record<string, boolean>>(key, {});
|
|
||||||
});
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
initialEnvironmentId: string | null;
|
|
||||||
setRef?: (ref: PairEditorHandle | null) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
type TreeModel = Environment | Workspace;
|
|
||||||
|
|
||||||
export function EnvironmentEditDialog({ initialEnvironmentId, setRef }: Props) {
|
|
||||||
const { allEnvironments, baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown();
|
|
||||||
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<string | null>(
|
|
||||||
initialEnvironmentId ?? null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectedEnvironment =
|
|
||||||
selectedEnvironmentId != null
|
|
||||||
? allEnvironments.find((e) => e.id === selectedEnvironmentId)
|
|
||||||
: baseEnvironment;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SplitLayout
|
|
||||||
storageKey="env_editor"
|
|
||||||
defaultRatio={0.75}
|
|
||||||
layout="horizontal"
|
|
||||||
className="gap-0"
|
|
||||||
resizeHandleClassName="-translate-x-[1px]"
|
|
||||||
firstSlot={() => (
|
|
||||||
<EnvironmentEditDialogSidebar
|
|
||||||
selectedEnvironmentId={selectedEnvironment?.id ?? null}
|
|
||||||
setSelectedEnvironmentId={setSelectedEnvironmentId}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
secondSlot={() => (
|
|
||||||
<div className="grid grid-rows-[auto_minmax(0,1fr)]">
|
|
||||||
{baseEnvironments.length > 1 ? (
|
|
||||||
<div className="p-3">
|
|
||||||
<Banner color="notice">
|
|
||||||
There are multiple base environments for this workspace. Please delete the
|
|
||||||
environments you no longer need.
|
|
||||||
</Banner>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span />
|
|
||||||
)}
|
|
||||||
{selectedEnvironment == null ? (
|
|
||||||
<div className="p-3 mt-10">
|
|
||||||
<Banner color="danger">
|
|
||||||
Failed to find selected environment <InlineCode>{selectedEnvironmentId}</InlineCode>
|
|
||||||
</Banner>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<EnvironmentEditor
|
|
||||||
key={selectedEnvironment.id}
|
|
||||||
setRef={setRef}
|
|
||||||
className="pl-4 pt-3"
|
|
||||||
environment={selectedEnvironment}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sharableTooltip = (
|
|
||||||
<IconTooltip
|
|
||||||
tabIndex={-1}
|
|
||||||
icon="eye"
|
|
||||||
iconSize="sm"
|
|
||||||
content="This environment will be included in Directory Sync and data exports"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
function EnvironmentEditDialogSidebar({
|
|
||||||
selectedEnvironmentId,
|
|
||||||
setSelectedEnvironmentId,
|
|
||||||
}: {
|
|
||||||
selectedEnvironmentId: string | null;
|
|
||||||
setSelectedEnvironmentId: (id: string | null) => void;
|
|
||||||
}) {
|
|
||||||
const activeWorkspaceId = useAtomValue(activeWorkspaceIdAtom) ?? "";
|
|
||||||
const treeId = `environment.${activeWorkspaceId}.sidebar`;
|
|
||||||
const treeRef = useRef<TreeHandle>(null);
|
|
||||||
const { baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown();
|
|
||||||
|
|
||||||
// oxlint-disable-next-line react-hooks/exhaustive-deps -- none
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (selectedEnvironmentId == null) return;
|
|
||||||
treeRef.current?.selectItem(selectedEnvironmentId);
|
|
||||||
treeRef.current?.focus();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleDeleteEnvironment = useCallback(
|
|
||||||
async (environment: Environment) => {
|
|
||||||
await deleteModelWithConfirm(environment);
|
|
||||||
if (selectedEnvironmentId === environment.id) {
|
|
||||||
setSelectedEnvironmentId(baseEnvironment?.id ?? null);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[baseEnvironment?.id, selectedEnvironmentId, setSelectedEnvironmentId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const treeHasFocus = useCallback(() => treeRef.current?.hasFocus() ?? false, []);
|
|
||||||
|
|
||||||
const getSelectedTreeModels = useCallback(
|
|
||||||
() => treeRef.current?.getSelectedItems() as TreeModel[] | undefined,
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleRenameSelected = useCallback(() => {
|
|
||||||
const items = getSelectedTreeModels();
|
|
||||||
if (items?.length === 1 && items[0] != null) {
|
|
||||||
treeRef.current?.renameItem(items[0].id);
|
|
||||||
}
|
|
||||||
}, [getSelectedTreeModels]);
|
|
||||||
|
|
||||||
const handleDeleteSelected = useCallback(
|
|
||||||
(items: TreeModel[]) => deleteModelWithConfirm(items),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDuplicateSelected = useCallback(
|
|
||||||
async (items: TreeModel[]) => {
|
|
||||||
if (items.length === 1 && items[0]) {
|
|
||||||
const newId = await duplicateModel(items[0]);
|
|
||||||
setSelectedEnvironmentId(newId);
|
|
||||||
} else {
|
|
||||||
await Promise.all(items.map(duplicateModel));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[setSelectedEnvironmentId],
|
|
||||||
);
|
|
||||||
|
|
||||||
useHotKey("sidebar.selected.rename", handleRenameSelected, {
|
|
||||||
enable: treeHasFocus,
|
|
||||||
allowDefault: true,
|
|
||||||
priority: 100,
|
|
||||||
});
|
|
||||||
useHotKey(
|
|
||||||
"sidebar.selected.delete",
|
|
||||||
useCallback(() => {
|
|
||||||
const items = getSelectedTreeModels();
|
|
||||||
if (items) {
|
|
||||||
fireAndForget(handleDeleteSelected(items));
|
|
||||||
}
|
|
||||||
}, [getSelectedTreeModels, handleDeleteSelected]),
|
|
||||||
{ enable: treeHasFocus, priority: 100 },
|
|
||||||
);
|
|
||||||
useHotKey(
|
|
||||||
"sidebar.selected.duplicate",
|
|
||||||
useCallback(async () => {
|
|
||||||
const items = getSelectedTreeModels();
|
|
||||||
if (items) await handleDuplicateSelected(items);
|
|
||||||
}, [getSelectedTreeModels, handleDuplicateSelected]),
|
|
||||||
{ enable: treeHasFocus, priority: 100 },
|
|
||||||
);
|
|
||||||
|
|
||||||
const getContextMenu = useCallback(
|
|
||||||
(items: TreeModel[]): ContextMenuProps["items"] => {
|
|
||||||
const environment = items[0];
|
|
||||||
const addEnvironmentItem: DropdownItem = {
|
|
||||||
label: "Create Sub Environment",
|
|
||||||
leftSlot: <Icon icon="plus" />,
|
|
||||||
onSelect: async () => {
|
|
||||||
await createSubEnvironment();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if (environment == null || environment.model !== "environment") {
|
|
||||||
return [addEnvironmentItem];
|
|
||||||
}
|
|
||||||
|
|
||||||
const singleEnvironment = items.length === 1;
|
|
||||||
const canDeleteEnvironment =
|
|
||||||
isSubEnvironment(environment) ||
|
|
||||||
(isBaseEnvironment(environment) && baseEnvironments.length > 1);
|
|
||||||
|
|
||||||
const menuItems: DropdownItem[] = [
|
|
||||||
{
|
|
||||||
label: "Rename",
|
|
||||||
leftSlot: <Icon icon="pencil" />,
|
|
||||||
hidden: isBaseEnvironment(environment) || !singleEnvironment,
|
|
||||||
hotKeyAction: "sidebar.selected.rename",
|
|
||||||
hotKeyLabelOnly: true,
|
|
||||||
onSelect: () => {
|
|
||||||
// Not sure why this is needed, but without it the
|
|
||||||
// edit input blurs immediately after opening.
|
|
||||||
requestAnimationFrame(() => handleRenameSelected());
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Duplicate",
|
|
||||||
leftSlot: <Icon icon="copy" />,
|
|
||||||
hidden: isBaseEnvironment(environment),
|
|
||||||
hotKeyAction: "sidebar.selected.duplicate",
|
|
||||||
hotKeyLabelOnly: true,
|
|
||||||
onSelect: () => handleDuplicateSelected(items),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: environment.color ? "Change Color" : "Assign Color",
|
|
||||||
leftSlot: <Icon icon="palette" />,
|
|
||||||
hidden: isBaseEnvironment(environment) || !singleEnvironment,
|
|
||||||
onSelect: async () => showColorPicker(environment),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: `Make ${environment.public ? "Private" : "Sharable"}`,
|
|
||||||
leftSlot: <Icon icon={environment.public ? "eye_closed" : "eye"} />,
|
|
||||||
rightSlot: <EnvironmentSharableTooltip />,
|
|
||||||
hidden: items.length > 1,
|
|
||||||
onSelect: async () => {
|
|
||||||
await patchModel(environment, { public: !environment.public });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
color: "danger",
|
|
||||||
label: "Delete",
|
|
||||||
hotKeyAction: "sidebar.selected.delete",
|
|
||||||
hotKeyLabelOnly: true,
|
|
||||||
hidden: !canDeleteEnvironment,
|
|
||||||
leftSlot: <Icon icon="trash" />,
|
|
||||||
onSelect: () => handleDeleteEnvironment(environment),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Add sub environment to base environment
|
|
||||||
if (isBaseEnvironment(environment) && singleEnvironment) {
|
|
||||||
menuItems.push({ type: "separator" });
|
|
||||||
menuItems.push(addEnvironmentItem);
|
|
||||||
}
|
|
||||||
|
|
||||||
return menuItems;
|
|
||||||
},
|
|
||||||
[
|
|
||||||
baseEnvironments.length,
|
|
||||||
handleDeleteEnvironment,
|
|
||||||
handleDuplicateSelected,
|
|
||||||
handleRenameSelected,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDragEnd = useCallback(async function handleDragEnd({
|
|
||||||
items,
|
|
||||||
children,
|
|
||||||
insertAt,
|
|
||||||
}: {
|
|
||||||
items: TreeModel[];
|
|
||||||
children: TreeModel[];
|
|
||||||
insertAt: number;
|
|
||||||
}) {
|
|
||||||
const prev = children[insertAt - 1] as Exclude<TreeModel, Workspace>;
|
|
||||||
const next = children[insertAt] as Exclude<TreeModel, Workspace>;
|
|
||||||
|
|
||||||
const beforePriority = prev?.sortPriority ?? 0;
|
|
||||||
const afterPriority = next?.sortPriority ?? 0;
|
|
||||||
const shouldUpdateAll = afterPriority - beforePriority < 1;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (shouldUpdateAll) {
|
|
||||||
// Add items to children at insertAt
|
|
||||||
children.splice(insertAt, 0, ...items);
|
|
||||||
await Promise.all(children.map((m, i) => patchModel(m, { sortPriority: i * 1000 })));
|
|
||||||
} else {
|
|
||||||
const range = afterPriority - beforePriority;
|
|
||||||
const increment = range / (items.length + 2);
|
|
||||||
await Promise.all(
|
|
||||||
items.map((m, i) => {
|
|
||||||
const sortPriority = beforePriority + (i + 1) * increment;
|
|
||||||
// Spread item sortPriority out over before/after range
|
|
||||||
return patchModel(m, { sortPriority });
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleActivate = useCallback(
|
|
||||||
(item: TreeModel) => {
|
|
||||||
setSelectedEnvironmentId(item.id);
|
|
||||||
},
|
|
||||||
[setSelectedEnvironmentId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderContextMenuFn = useCallback<NonNullable<TreeProps<TreeModel>["renderContextMenu"]>>(
|
|
||||||
({ items, position, onClose }) => (
|
|
||||||
<ContextMenu items={items as DropdownItem[]} triggerPosition={position} onClose={onClose} />
|
|
||||||
),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const tree = useAtomValue(treeAtom);
|
|
||||||
return (
|
|
||||||
<aside className="x-theme-sidebar h-full w-full min-w-0 grid overflow-y-auto border-r border-border-subtle ">
|
|
||||||
{tree != null && (
|
|
||||||
<div className="pt-2">
|
|
||||||
<Tree
|
|
||||||
ref={treeRef}
|
|
||||||
treeId={treeId}
|
|
||||||
collapsedAtom={collapsedFamily(treeId)}
|
|
||||||
className="px-2 pb-10"
|
|
||||||
root={tree}
|
|
||||||
getContextMenu={getContextMenu}
|
|
||||||
renderContextMenu={renderContextMenuFn}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
getItemKey={(i) => `${i.id}::${i.name}`}
|
|
||||||
ItemLeftSlotInner={ItemLeftSlotInner}
|
|
||||||
ItemRightSlot={ItemRightSlot}
|
|
||||||
ItemInner={ItemInner}
|
|
||||||
onActivate={handleActivate}
|
|
||||||
getEditOptions={getEditOptions}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const treeAtom = atom<TreeNode<TreeModel> | null>((get) => {
|
|
||||||
const activeWorkspace = get(activeWorkspaceAtom);
|
|
||||||
const { baseEnvironment, baseEnvironments, subEnvironments } = get(environmentsBreakdownAtom);
|
|
||||||
if (activeWorkspace == null || baseEnvironment == null) return null;
|
|
||||||
|
|
||||||
const root: TreeNode<TreeModel> = {
|
|
||||||
item: activeWorkspace,
|
|
||||||
parent: null,
|
|
||||||
children: [],
|
|
||||||
depth: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const item of baseEnvironments) {
|
|
||||||
root.children?.push({
|
|
||||||
item,
|
|
||||||
parent: root,
|
|
||||||
depth: 0,
|
|
||||||
draggable: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const parent = root.children?.[0];
|
|
||||||
if (baseEnvironments.length <= 1 && parent != null) {
|
|
||||||
parent.children = subEnvironments.map((item) => ({
|
|
||||||
item,
|
|
||||||
parent,
|
|
||||||
depth: 1,
|
|
||||||
localDrag: true,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
return root;
|
|
||||||
});
|
|
||||||
|
|
||||||
function ItemLeftSlotInner({ item }: { item: TreeModel }) {
|
|
||||||
const { baseEnvironments } = useEnvironmentsBreakdown();
|
|
||||||
return baseEnvironments.length > 1 ? (
|
|
||||||
<Icon icon="alert_triangle" color="notice" />
|
|
||||||
) : (
|
|
||||||
item.model === "environment" && item.color && <EnvironmentColorIndicator environment={item} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ItemRightSlot({ item }: { item: TreeModel }) {
|
|
||||||
const { baseEnvironments } = useEnvironmentsBreakdown();
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{item.model === "environment" && baseEnvironments.length <= 1 && isBaseEnvironment(item) && (
|
|
||||||
<IconButton
|
|
||||||
size="sm"
|
|
||||||
color="custom"
|
|
||||||
iconSize="sm"
|
|
||||||
icon="plus_circle"
|
|
||||||
className="opacity-50 hover:opacity-100"
|
|
||||||
title="Add Sub-Environment"
|
|
||||||
onClick={createSubEnvironment}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ItemInner({ item }: { item: TreeModel }) {
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-[auto_minmax(0,1fr)] w-full items-center">
|
|
||||||
{item.model === "environment" && item.public ? (
|
|
||||||
<div className="mr-2 flex items-center">{sharableTooltip}</div>
|
|
||||||
) : (
|
|
||||||
<span aria-hidden />
|
|
||||||
)}
|
|
||||||
<div className="truncate min-w-0 text-left">{resolvedModelName(item)}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createSubEnvironment() {
|
|
||||||
const { baseEnvironment } = jotaiStore.get(environmentsBreakdownAtom);
|
|
||||||
if (baseEnvironment == null) return;
|
|
||||||
const id = await createSubEnvironmentAndActivate.mutateAsync(baseEnvironment);
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEditOptions(item: TreeModel) {
|
|
||||||
const options: ReturnType<NonNullable<TreeProps<TreeModel>["getEditOptions"]>> = {
|
|
||||||
defaultValue: item.name,
|
|
||||||
placeholder: "Name",
|
|
||||||
async onChange(item, name) {
|
|
||||||
await patchModel(item, { name });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return options;
|
|
||||||
}
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
import type { Environment } from "@yaakapp-internal/models";
|
|
||||||
import { patchModel } from "@yaakapp-internal/models";
|
|
||||||
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
|
||||||
import { Heading } from "@yaakapp-internal/ui";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { useCallback, useMemo } from "react";
|
|
||||||
import { useEnvironmentsBreakdown } from "../hooks/useEnvironmentsBreakdown";
|
|
||||||
import { useIsEncryptionEnabled } from "../hooks/useIsEncryptionEnabled";
|
|
||||||
import { useKeyValue } from "../hooks/useKeyValue";
|
|
||||||
import { useRandomKey } from "../hooks/useRandomKey";
|
|
||||||
import { analyzeTemplate, convertTemplateToSecure } from "../lib/encryption";
|
|
||||||
import { isBaseEnvironment } from "../lib/model_util";
|
|
||||||
import {
|
|
||||||
setupOrConfigureEncryption,
|
|
||||||
withEncryptionEnabled,
|
|
||||||
} from "../lib/setupOrConfigureEncryption";
|
|
||||||
import { DismissibleBanner } from "./core/DismissibleBanner";
|
|
||||||
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
|
||||||
import type { PairEditorHandle, PairWithId } from "./core/PairEditor";
|
|
||||||
import { ensurePairId } from "./core/PairEditor.util";
|
|
||||||
import { PairOrBulkEditor } from "./core/PairOrBulkEditor";
|
|
||||||
import { PillButton } from "./core/PillButton";
|
|
||||||
import { EnvironmentColorIndicator } from "./EnvironmentColorIndicator";
|
|
||||||
import { EnvironmentSharableTooltip } from "./EnvironmentSharableTooltip";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
environment: Environment;
|
|
||||||
hideName?: boolean;
|
|
||||||
className?: string;
|
|
||||||
setRef?: (n: PairEditorHandle | null) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EnvironmentEditor({ environment, hideName, className, setRef }: Props) {
|
|
||||||
const workspaceId = environment.workspaceId;
|
|
||||||
const isEncryptionEnabled = useIsEncryptionEnabled();
|
|
||||||
const valueVisibility = useKeyValue<boolean>({
|
|
||||||
namespace: "global",
|
|
||||||
key: ["environmentValueVisibility", workspaceId],
|
|
||||||
fallback: false,
|
|
||||||
});
|
|
||||||
const { allEnvironments } = useEnvironmentsBreakdown();
|
|
||||||
const handleChange = useCallback(
|
|
||||||
(variables: PairWithId[]) => patchModel(environment, { variables }),
|
|
||||||
[environment],
|
|
||||||
);
|
|
||||||
const [forceUpdateKey, regenerateForceUpdateKey] = useRandomKey();
|
|
||||||
|
|
||||||
// Gather a list of env names from other environments to help the user get them aligned
|
|
||||||
const nameAutocomplete = useMemo<GenericCompletionConfig>(() => {
|
|
||||||
const options: GenericCompletionOption[] = [];
|
|
||||||
if (isBaseEnvironment(environment)) {
|
|
||||||
return { options };
|
|
||||||
}
|
|
||||||
|
|
||||||
const allVariables = allEnvironments.flatMap((e) => e?.variables);
|
|
||||||
const allVariableNames = new Set(allVariables.map((v) => v?.name));
|
|
||||||
for (const name of allVariableNames) {
|
|
||||||
const containingEnvs = allEnvironments.filter((e) =>
|
|
||||||
e.variables.some((v) => v.name === name),
|
|
||||||
);
|
|
||||||
const isAlreadyInActive = containingEnvs.find((e) => e.id === environment.id);
|
|
||||||
if (isAlreadyInActive) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
options.push({
|
|
||||||
label: name,
|
|
||||||
type: "constant",
|
|
||||||
detail: containingEnvs.map((e) => e.name).join(", "),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { options };
|
|
||||||
}, [environment, allEnvironments]);
|
|
||||||
|
|
||||||
const validateName = useCallback((name: string) => {
|
|
||||||
// Empty just means the variable doesn't have a name yet and is unusable
|
|
||||||
if (name === "") return true;
|
|
||||||
return name.match(/^[a-z_][a-z0-9_.-]*$/i) != null;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const valueType = !isEncryptionEnabled && valueVisibility.value ? "text" : "password";
|
|
||||||
const allVariableAreEncrypted = useMemo(
|
|
||||||
() =>
|
|
||||||
environment.variables.every((v) => v.value === "" || analyzeTemplate(v.value) !== "insecure"),
|
|
||||||
[environment.variables],
|
|
||||||
);
|
|
||||||
|
|
||||||
const encryptEnvironment = (environment: Environment) => {
|
|
||||||
withEncryptionEnabled(async () => {
|
|
||||||
const encryptedVariables: PairWithId[] = [];
|
|
||||||
for (const variable of environment.variables) {
|
|
||||||
const value = variable.value ? await convertTemplateToSecure(variable.value) : "";
|
|
||||||
encryptedVariables.push(ensurePairId({ ...variable, value }));
|
|
||||||
}
|
|
||||||
await handleChange(encryptedVariables);
|
|
||||||
regenerateForceUpdateKey();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
className,
|
|
||||||
"h-full grid grid-rows-[auto_minmax(0,1fr)] gap-2 pr-3 pb-3",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<Heading className="w-full flex items-center gap-0.5">
|
|
||||||
<EnvironmentColorIndicator
|
|
||||||
className="mr-2"
|
|
||||||
clickToEdit
|
|
||||||
environment={environment ?? null}
|
|
||||||
/>
|
|
||||||
{!hideName && <div className="mr-2">{environment?.name}</div>}
|
|
||||||
{isEncryptionEnabled ? (
|
|
||||||
!allVariableAreEncrypted ? (
|
|
||||||
<PillButton color="notice" onClick={() => encryptEnvironment(environment)}>
|
|
||||||
Encrypt All Variables
|
|
||||||
</PillButton>
|
|
||||||
) : (
|
|
||||||
<PillButton color="secondary" onClick={setupOrConfigureEncryption}>
|
|
||||||
Encryption Settings
|
|
||||||
</PillButton>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<PillButton color="secondary" onClick={() => valueVisibility.set((v) => !v)}>
|
|
||||||
{valueVisibility.value ? "Hide Values" : "Show Values"}
|
|
||||||
</PillButton>
|
|
||||||
)}
|
|
||||||
<PillButton
|
|
||||||
color="secondary"
|
|
||||||
rightSlot={<EnvironmentSharableTooltip />}
|
|
||||||
onClick={async () => {
|
|
||||||
await patchModel(environment, { public: !environment.public });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{environment.public ? "Sharable" : "Private"}
|
|
||||||
</PillButton>
|
|
||||||
</Heading>
|
|
||||||
{environment.public && (!isEncryptionEnabled || !allVariableAreEncrypted) && (
|
|
||||||
<DismissibleBanner
|
|
||||||
id={`warn-unencrypted-${environment.id}`}
|
|
||||||
color="notice"
|
|
||||||
className="mr-3"
|
|
||||||
actions={[
|
|
||||||
{
|
|
||||||
label: "Encrypt Variables",
|
|
||||||
onClick: () => encryptEnvironment(environment),
|
|
||||||
color: "success",
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
This sharable environment contains plain-text secrets
|
|
||||||
</DismissibleBanner>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<PairOrBulkEditor
|
|
||||||
setRef={setRef}
|
|
||||||
className="h-full"
|
|
||||||
allowMultilineValues
|
|
||||||
preferenceName="environment"
|
|
||||||
nameAutocomplete={nameAutocomplete}
|
|
||||||
namePlaceholder="VAR_NAME"
|
|
||||||
nameValidate={validateName}
|
|
||||||
valueType={valueType}
|
|
||||||
valueAutocompleteVariables="environment"
|
|
||||||
valueAutocompleteFunctions
|
|
||||||
forceUpdateKey={`${environment.id}::${forceUpdateKey}`}
|
|
||||||
pairs={environment.variables}
|
|
||||||
onChange={handleChange}
|
|
||||||
stateKey={`environment.${environment.id}`}
|
|
||||||
forcedEnvironmentId={environment.id}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { IconTooltip } from "./core/IconTooltip";
|
|
||||||
|
|
||||||
export function EnvironmentSharableTooltip() {
|
|
||||||
return (
|
|
||||||
<IconTooltip content="Sharable environments are included in Directory Sync and data export." />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
import { save } from "@tauri-apps/plugin-dialog";
|
|
||||||
import type { Workspace } from "@yaakapp-internal/models";
|
|
||||||
import { workspacesAtom } from "@yaakapp-internal/models";
|
|
||||||
import { HStack, VStack } from "@yaakapp-internal/ui";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
|
||||||
import slugify from "slugify";
|
|
||||||
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
|
|
||||||
import { pluralizeCount } from "../lib/pluralize";
|
|
||||||
import { invokeCmd } from "../lib/tauri";
|
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import { Checkbox } from "./core/Checkbox";
|
|
||||||
import { DetailsBanner } from "./core/DetailsBanner";
|
|
||||||
import { Link } from "./core/Link";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onHide: () => void;
|
|
||||||
onSuccess: (path: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ExportDataDialog({ onHide, onSuccess }: Props) {
|
|
||||||
const allWorkspaces = useAtomValue(workspacesAtom);
|
|
||||||
const activeWorkspace = useAtomValue(activeWorkspaceAtom);
|
|
||||||
if (activeWorkspace == null || allWorkspaces.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ExportDataDialogContent
|
|
||||||
onHide={onHide}
|
|
||||||
onSuccess={onSuccess}
|
|
||||||
allWorkspaces={allWorkspaces}
|
|
||||||
activeWorkspace={activeWorkspace}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ExportDataDialogContent({
|
|
||||||
onHide,
|
|
||||||
onSuccess,
|
|
||||||
activeWorkspace,
|
|
||||||
allWorkspaces,
|
|
||||||
}: Props & {
|
|
||||||
allWorkspaces: Workspace[];
|
|
||||||
activeWorkspace: Workspace;
|
|
||||||
}) {
|
|
||||||
const [includePrivateEnvironments, setIncludePrivateEnvironments] = useState<boolean>(false);
|
|
||||||
const [selectedWorkspaces, setSelectedWorkspaces] = useState<Record<string, boolean>>({
|
|
||||||
[activeWorkspace.id]: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Put the active workspace first
|
|
||||||
const workspaces = useMemo(
|
|
||||||
() => [activeWorkspace, ...allWorkspaces.filter((w) => w.id !== activeWorkspace.id)],
|
|
||||||
[activeWorkspace, allWorkspaces],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleToggleAll = () => {
|
|
||||||
setSelectedWorkspaces(
|
|
||||||
// oxlint-disable-next-line no-accumulating-spread
|
|
||||||
allSelected ? {} : workspaces.reduce((acc, w) => ({ ...acc, [w.id]: true }), {}),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExport = useCallback(async () => {
|
|
||||||
const ids = Object.keys(selectedWorkspaces).filter((k) => selectedWorkspaces[k]);
|
|
||||||
const workspace = ids.length === 1 ? workspaces.find((w) => w.id === ids[0]) : undefined;
|
|
||||||
const slug = workspace ? slugify(workspace.name, { lower: true }) : "workspaces";
|
|
||||||
const exportPath = await save({
|
|
||||||
title: "Export Data",
|
|
||||||
defaultPath: `yaak.${slug}.json`,
|
|
||||||
});
|
|
||||||
if (exportPath == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await invokeCmd("cmd_export_data", {
|
|
||||||
workspaceIds: ids,
|
|
||||||
exportPath,
|
|
||||||
includePrivateEnvironments: includePrivateEnvironments,
|
|
||||||
});
|
|
||||||
onHide();
|
|
||||||
onSuccess(exportPath);
|
|
||||||
}, [includePrivateEnvironments, onHide, onSuccess, selectedWorkspaces, workspaces]);
|
|
||||||
|
|
||||||
const allSelected = workspaces.every((w) => selectedWorkspaces[w.id]);
|
|
||||||
const numSelected = Object.values(selectedWorkspaces).filter(Boolean).length;
|
|
||||||
const noneSelected = numSelected === 0;
|
|
||||||
return (
|
|
||||||
<div className="w-full grid grid-rows-[minmax(0,1fr)_auto]">
|
|
||||||
<VStack space={3} className="overflow-auto px-5 pb-6">
|
|
||||||
<table className="w-full mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th className="w-6 min-w-0 py-2 text-left pl-1">
|
|
||||||
<Checkbox
|
|
||||||
checked={!allSelected && !noneSelected ? "indeterminate" : allSelected}
|
|
||||||
hideLabel
|
|
||||||
title="All workspaces"
|
|
||||||
onChange={handleToggleAll}
|
|
||||||
/>
|
|
||||||
</th>
|
|
||||||
<th className="py-2 text-left pl-4" onClick={handleToggleAll}>
|
|
||||||
Workspace
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-surface-highlight">
|
|
||||||
{workspaces.map((w) => (
|
|
||||||
<tr key={w.id}>
|
|
||||||
<td className="min-w-0 py-1 pl-1">
|
|
||||||
<Checkbox
|
|
||||||
checked={selectedWorkspaces[w.id] ?? false}
|
|
||||||
title={w.name}
|
|
||||||
hideLabel
|
|
||||||
onChange={() =>
|
|
||||||
setSelectedWorkspaces((prev) => ({ ...prev, [w.id]: !prev[w.id] }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
className="py-1 pl-4 text whitespace-nowrap overflow-x-auto hide-scrollbars"
|
|
||||||
onClick={() =>
|
|
||||||
setSelectedWorkspaces((prev) => ({ ...prev, [w.id]: !prev[w.id] }))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{w.name} {w.id === activeWorkspace.id ? "(current workspace)" : ""}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<DetailsBanner color="secondary" defaultOpen summary="Extra Settings">
|
|
||||||
<Checkbox
|
|
||||||
checked={includePrivateEnvironments}
|
|
||||||
onChange={setIncludePrivateEnvironments}
|
|
||||||
title="Include private environments"
|
|
||||||
help='Environments marked as "sharable" will be exported by default'
|
|
||||||
/>
|
|
||||||
</DetailsBanner>
|
|
||||||
</VStack>
|
|
||||||
<footer className="px-5 grid grid-cols-[1fr_auto] items-center bg-surface-highlight py-2 border-t border-border-subtle">
|
|
||||||
<div>
|
|
||||||
<Link href="https://yaak.app/button/new" noUnderline className="text-text-subtle">
|
|
||||||
Create Run Button
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<HStack space={2} justifyContent="end">
|
|
||||||
<Button size="sm" className="focus" variant="border" onClick={onHide}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
type="submit"
|
|
||||||
className="focus"
|
|
||||||
color="primary"
|
|
||||||
disabled={noneSelected}
|
|
||||||
onClick={() => handleExport()}
|
|
||||||
>
|
|
||||||
Export{" "}
|
|
||||||
{pluralizeCount("Workspace", numSelected, { omitSingle: true, noneWord: "Nothing" })}
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
import type { Folder, GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-internal/models";
|
|
||||||
import { foldersAtom } from "@yaakapp-internal/models";
|
|
||||||
import { Heading, HStack, Icon, LoadingIcon } from "@yaakapp-internal/ui";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import type { CSSProperties, ReactNode } from "react";
|
|
||||||
import { useCallback, useMemo } from "react";
|
|
||||||
import { allRequestsAtom } from "../hooks/useAllRequests";
|
|
||||||
import { useFolderActions } from "../hooks/useFolderActions";
|
|
||||||
import { useLatestHttpResponse } from "../hooks/useLatestHttpResponse";
|
|
||||||
import { sendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
|
|
||||||
import { showDialog } from "../lib/dialog";
|
|
||||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
|
||||||
import { router } from "../lib/router";
|
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import { HttpResponseDurationTag } from "./core/HttpResponseDurationTag";
|
|
||||||
import { HttpStatusTag } from "./core/HttpStatusTag";
|
|
||||||
import { IconButton } from "./core/IconButton";
|
|
||||||
import { Separator } from "./core/Separator";
|
|
||||||
import { SizeTag } from "./core/SizeTag";
|
|
||||||
import { HttpResponsePane } from "./HttpResponsePane";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
folder: Folder;
|
|
||||||
style: CSSProperties;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FolderLayout({ folder, style }: Props) {
|
|
||||||
const folders = useAtomValue(foldersAtom);
|
|
||||||
const requests = useAtomValue(allRequestsAtom);
|
|
||||||
const folderActions = useFolderActions();
|
|
||||||
const sendAllAction = useMemo(
|
|
||||||
() => folderActions.find((a) => a.label === "Send All"),
|
|
||||||
[folderActions],
|
|
||||||
);
|
|
||||||
|
|
||||||
const children = useMemo(() => {
|
|
||||||
return [
|
|
||||||
...folders.filter((f) => f.folderId === folder.id),
|
|
||||||
...requests.filter((r) => r.folderId === folder.id),
|
|
||||||
];
|
|
||||||
}, [folder.id, folders, requests]);
|
|
||||||
|
|
||||||
const handleSendAll = useCallback(() => {
|
|
||||||
void sendAllAction?.call(folder);
|
|
||||||
}, [sendAllAction, folder]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={style} className="p-6 pt-4 overflow-y-auto @container">
|
|
||||||
<HStack space={2} alignItems="center">
|
|
||||||
<Icon icon="folder" size="xl" color="secondary" />
|
|
||||||
<Heading level={1}>{resolvedModelName(folder)}</Heading>
|
|
||||||
<HStack className="ml-auto" alignItems="center">
|
|
||||||
<Button
|
|
||||||
rightSlot={<Icon icon="send_horizontal" />}
|
|
||||||
color="secondary"
|
|
||||||
size="sm"
|
|
||||||
variant="border"
|
|
||||||
onClick={handleSendAll}
|
|
||||||
disabled={sendAllAction == null}
|
|
||||||
>
|
|
||||||
Send All
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
</HStack>
|
|
||||||
<Separator className="mt-3 mb-8" />
|
|
||||||
<div className="grid grid-cols-1 @lg:grid-cols-2 @4xl:grid-cols-3 gap-4 min-w-0">
|
|
||||||
{children.map((child) => (
|
|
||||||
<ChildCard key={child.id} child={child} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChildCard({ child }: { child: Folder | HttpRequest | GrpcRequest | WebsocketRequest }) {
|
|
||||||
let card: ReactNode;
|
|
||||||
if (child.model === "folder") {
|
|
||||||
card = <FolderCard folder={child} />;
|
|
||||||
} else if (child.model === "http_request") {
|
|
||||||
card = <HttpRequestCard request={child} />;
|
|
||||||
} else if (child.model === "grpc_request") {
|
|
||||||
card = <RequestCard request={child} />;
|
|
||||||
} else if (child.model === "websocket_request") {
|
|
||||||
card = <RequestCard request={child} />;
|
|
||||||
} else {
|
|
||||||
card = <div>Unknown model</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const navigate = useCallback(async () => {
|
|
||||||
await router.navigate({
|
|
||||||
to: "/workspaces/$workspaceId",
|
|
||||||
params: { workspaceId: child.workspaceId },
|
|
||||||
search: (prev) => ({ ...prev, request_id: child.id }),
|
|
||||||
});
|
|
||||||
}, [child.id, child.workspaceId]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
"rounded-lg bg-surface-highlight p-3 pt-1 border border-border",
|
|
||||||
"flex flex-col gap-3",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<HStack space={2}>
|
|
||||||
{child.model === "folder" && <Icon icon="folder" size="lg" />}
|
|
||||||
<Heading className="truncate" level={2}>
|
|
||||||
{resolvedModelName(child)}
|
|
||||||
</Heading>
|
|
||||||
<HStack space={0.5} className="ml-auto -mr-1.5">
|
|
||||||
<IconButton
|
|
||||||
color="custom"
|
|
||||||
title="Send Request"
|
|
||||||
size="sm"
|
|
||||||
icon="external_link"
|
|
||||||
className="opacity-70 hover:opacity-100"
|
|
||||||
onClick={navigate}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
color="custom"
|
|
||||||
title="Send Request"
|
|
||||||
size="sm"
|
|
||||||
icon="send_horizontal"
|
|
||||||
className="opacity-70 hover:opacity-100"
|
|
||||||
onClick={() => {
|
|
||||||
sendAnyHttpRequest.mutate(child.id);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
</HStack>
|
|
||||||
<div className="text-text-subtle">{card}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FolderCard({ folder }: { folder: Folder }) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
color="primary"
|
|
||||||
onClick={async () => {
|
|
||||||
await router.navigate({
|
|
||||||
to: "/workspaces/$workspaceId",
|
|
||||||
params: { workspaceId: folder.workspaceId },
|
|
||||||
search: (prev) => {
|
|
||||||
return { ...prev, request_id: null, folder_id: folder.id };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Open
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RequestCard({ request }: { request: HttpRequest | GrpcRequest | WebsocketRequest }) {
|
|
||||||
return <div>TODO {request.id}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function HttpRequestCard({ request }: { request: HttpRequest }) {
|
|
||||||
const latestResponse = useLatestHttpResponse(request.id);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-rows-2 grid-cols-[minmax(0,1fr)] gap-2 overflow-hidden">
|
|
||||||
<code className="font-mono text-editor text-info border border-info rounded px-2.5 py-0.5 truncate w-full min-w-0">
|
|
||||||
{request.method} {request.url}
|
|
||||||
</code>
|
|
||||||
{latestResponse ? (
|
|
||||||
<button
|
|
||||||
className="block mr-auto"
|
|
||||||
type="button"
|
|
||||||
tabIndex={-1}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
showDialog({
|
|
||||||
id: "response-preview",
|
|
||||||
title: "Response Preview",
|
|
||||||
size: "md",
|
|
||||||
className: "h-full",
|
|
||||||
render: () => {
|
|
||||||
return <HttpResponsePane activeRequestId={request.id} />;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<HStack
|
|
||||||
space={2}
|
|
||||||
alignItems="center"
|
|
||||||
className={classNames(
|
|
||||||
"cursor-default select-none",
|
|
||||||
"whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars",
|
|
||||||
"font-mono text-editor border rounded px-1.5 py-0.5 truncate w-full",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{latestResponse.state !== "closed" && <LoadingIcon size="sm" />}
|
|
||||||
<HttpStatusTag showReason response={latestResponse} />
|
|
||||||
<span>•</span>
|
|
||||||
<HttpResponseDurationTag response={latestResponse} />
|
|
||||||
<span>•</span>
|
|
||||||
<SizeTag
|
|
||||||
contentLength={latestResponse.contentLength ?? 0}
|
|
||||||
contentLengthCompressed={latestResponse.contentLength}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<div>No Responses</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,197 +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";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
folderId: string | null;
|
|
||||||
tab?: FolderSettingsTab;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TAB_AUTH = "auth";
|
|
||||||
const TAB_HEADERS = "headers";
|
|
||||||
const TAB_VARIABLES = "variables";
|
|
||||||
const TAB_GENERAL = "general";
|
|
||||||
|
|
||||||
export type FolderSettingsTab =
|
|
||||||
| typeof TAB_AUTH
|
|
||||||
| typeof TAB_HEADERS
|
|
||||||
| typeof TAB_GENERAL
|
|
||||||
| typeof TAB_VARIABLES;
|
|
||||||
|
|
||||||
export function FolderSettingsDialog({ folderId, tab }: Props) {
|
|
||||||
const folders = useAtomValue(foldersAtom);
|
|
||||||
const folder = folders.find((f) => f.id === folderId) ?? null;
|
|
||||||
const ancestors = useModelAncestors(folder);
|
|
||||||
const breadcrumbs = useMemo(() => ancestors.toReversed(), [ancestors]);
|
|
||||||
const authTab = useAuthTab(TAB_AUTH, folder);
|
|
||||||
const headersTab = useHeadersTab(TAB_HEADERS, folder);
|
|
||||||
const inheritedHeaders = useInheritedHeaders(folder);
|
|
||||||
const environments = useEnvironmentsBreakdown();
|
|
||||||
const folderEnvironment = environments.allEnvironments.find(
|
|
||||||
(e) => e.parentModel === "folder" && e.parentId === folderId,
|
|
||||||
);
|
|
||||||
const numVars = (folderEnvironment?.variables ?? []).filter((v) => v.name).length;
|
|
||||||
|
|
||||||
const tabs = useMemo<TabItem[]>(() => {
|
|
||||||
if (folder == null) return [];
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
value: TAB_GENERAL,
|
|
||||||
label: "General",
|
|
||||||
},
|
|
||||||
...headersTab,
|
|
||||||
...authTab,
|
|
||||||
{
|
|
||||||
value: TAB_VARIABLES,
|
|
||||||
label: "Variables",
|
|
||||||
rightSlot: numVars > 0 ? <CountBadge count={numVars} /> : null,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}, [authTab, folder, headersTab, numVars]);
|
|
||||||
|
|
||||||
if (folder == null) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full flex flex-col">
|
|
||||||
<div className="flex items-center gap-3 px-6 pr-10 mt-4 mb-2 min-w-0 text-xl">
|
|
||||||
<Icon icon="folder_cog" size="lg" color="secondary" className="flex-shrink-0" />
|
|
||||||
<div className="flex items-center gap-1.5 font-semibold text-text min-w-0 overflow-hidden flex-1">
|
|
||||||
{breadcrumbs.map((item, index) => (
|
|
||||||
<Fragment key={item.id}>
|
|
||||||
{index > 0 && (
|
|
||||||
<Icon icon="chevron_right" size="lg" className="opacity-50 flex-shrink-0" />
|
|
||||||
)}
|
|
||||||
<span className="text-text-subtle truncate min-w-0" title={item.name}>
|
|
||||||
{item.name}
|
|
||||||
</span>
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
{breadcrumbs.length > 0 && (
|
|
||||||
<Icon icon="chevron_right" size="lg" className="opacity-50 flex-shrink-0" />
|
|
||||||
)}
|
|
||||||
<span className="whitespace-nowrap" title={folder.name}>
|
|
||||||
{folder.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tabs
|
|
||||||
defaultValue={tab ?? TAB_GENERAL}
|
|
||||||
label="Folder Settings"
|
|
||||||
className="pt-2 pb-2 pl-3 pr-1 flex-1"
|
|
||||||
layout="horizontal"
|
|
||||||
addBorders
|
|
||||||
tabs={tabs}
|
|
||||||
>
|
|
||||||
<TabContent value={TAB_AUTH} className="overflow-y-auto h-full px-4">
|
|
||||||
<HttpAuthenticationEditor model={folder} />
|
|
||||||
</TabContent>
|
|
||||||
<TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-4">
|
|
||||||
<div className="grid grid-rows-[auto_minmax(0,1fr)_auto] gap-3 pb-3 h-full">
|
|
||||||
<Input
|
|
||||||
label="Folder Name"
|
|
||||||
defaultValue={folder.name}
|
|
||||||
onChange={(name) => patchModel(folder, { name })}
|
|
||||||
stateKey={`name.${folder.id}`}
|
|
||||||
/>
|
|
||||||
<MarkdownEditor
|
|
||||||
name="folder-description"
|
|
||||||
placeholder="Folder description"
|
|
||||||
className="border border-border px-2"
|
|
||||||
defaultValue={folder.description}
|
|
||||||
stateKey={`description.${folder.id}`}
|
|
||||||
onChange={(description) => patchModel(folder, { description })}
|
|
||||||
/>
|
|
||||||
<HStack alignItems="center" justifyContent="between" className="w-full">
|
|
||||||
<Button
|
|
||||||
onClick={async () => {
|
|
||||||
const didDelete = await deleteModelWithConfirm(folder);
|
|
||||||
if (didDelete) {
|
|
||||||
hideDialog("folder-settings");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
color="danger"
|
|
||||||
variant="border"
|
|
||||||
size="xs"
|
|
||||||
>
|
|
||||||
Delete Folder
|
|
||||||
</Button>
|
|
||||||
<InlineCode className="flex gap-1 items-center text-primary pl-2.5">
|
|
||||||
{folder.id}
|
|
||||||
<CopyIconButton
|
|
||||||
className="opacity-70 !text-primary"
|
|
||||||
size="2xs"
|
|
||||||
iconSize="sm"
|
|
||||||
title="Copy folder ID"
|
|
||||||
text={folder.id}
|
|
||||||
/>
|
|
||||||
</InlineCode>
|
|
||||||
</HStack>
|
|
||||||
</div>
|
|
||||||
</TabContent>
|
|
||||||
<TabContent value={TAB_HEADERS} className="overflow-y-auto h-full px-4">
|
|
||||||
<HeadersEditor
|
|
||||||
inheritedHeaders={inheritedHeaders}
|
|
||||||
forceUpdateKey={folder.id}
|
|
||||||
headers={folder.headers}
|
|
||||||
onChange={(headers) => patchModel(folder, { headers })}
|
|
||||||
stateKey={`headers.${folder.id}`}
|
|
||||||
/>
|
|
||||||
</TabContent>
|
|
||||||
<TabContent value={TAB_VARIABLES} className="overflow-y-auto h-full px-4">
|
|
||||||
{folderEnvironment == null ? (
|
|
||||||
<EmptyStateText>
|
|
||||||
<VStack alignItems="center" space={1.5}>
|
|
||||||
<p>
|
|
||||||
Override{" "}
|
|
||||||
<Link href="https://yaak.app/docs/using-yaak/environments-and-variables">
|
|
||||||
Variables
|
|
||||||
</Link>{" "}
|
|
||||||
for requests within this folder.
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
variant="border"
|
|
||||||
size="sm"
|
|
||||||
onClick={async () => {
|
|
||||||
await createWorkspaceModel({
|
|
||||||
workspaceId: folder.workspaceId,
|
|
||||||
parentModel: "folder",
|
|
||||||
parentId: folder.id,
|
|
||||||
model: "environment",
|
|
||||||
name: "Folder Environment",
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Create Folder Environment
|
|
||||||
</Button>
|
|
||||||
</VStack>
|
|
||||||
</EmptyStateText>
|
|
||||||
) : (
|
|
||||||
<EnvironmentEditor hideName environment={folderEnvironment} />
|
|
||||||
)}
|
|
||||||
</TabContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,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,168 +0,0 @@
|
|||||||
import type { HttpRequestHeader } from "@yaakapp-internal/models";
|
|
||||||
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
|
||||||
import { HStack } from "@yaakapp-internal/ui";
|
|
||||||
import { charsets } from "../lib/data/charsets";
|
|
||||||
import { connections } from "../lib/data/connections";
|
|
||||||
import { encodings } from "../lib/data/encodings";
|
|
||||||
import { headerNames } from "../lib/data/headerNames";
|
|
||||||
import { mimeTypes } from "../lib/data/mimetypes";
|
|
||||||
import { CountBadge } from "./core/CountBadge";
|
|
||||||
import { DetailsBanner } from "./core/DetailsBanner";
|
|
||||||
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
|
||||||
import type { InputProps } from "./core/Input";
|
|
||||||
import type { Pair, PairEditorProps } from "./core/PairEditor";
|
|
||||||
import { PairEditorRow } from "./core/PairEditor";
|
|
||||||
import { ensurePairId } from "./core/PairEditor.util";
|
|
||||||
import { PairOrBulkEditor } from "./core/PairOrBulkEditor";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
forceUpdateKey: string;
|
|
||||||
headers: HttpRequestHeader[];
|
|
||||||
inheritedHeaders?: HttpRequestHeader[];
|
|
||||||
inheritedHeadersLabel?: string;
|
|
||||||
stateKey: string;
|
|
||||||
onChange: (headers: HttpRequestHeader[]) => void;
|
|
||||||
label?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function HeadersEditor({
|
|
||||||
stateKey,
|
|
||||||
headers,
|
|
||||||
inheritedHeaders,
|
|
||||||
inheritedHeadersLabel = "Inherited",
|
|
||||||
onChange,
|
|
||||||
forceUpdateKey,
|
|
||||||
}: Props) {
|
|
||||||
// Get header names defined at current level (case-insensitive)
|
|
||||||
const currentHeaderNames = new Set(
|
|
||||||
headers.filter((h) => h.name).map((h) => h.name.toLowerCase()),
|
|
||||||
);
|
|
||||||
// Filter inherited headers: must be enabled, have content, and not be overridden by current level
|
|
||||||
const validInheritedHeaders =
|
|
||||||
inheritedHeaders?.filter(
|
|
||||||
(pair) =>
|
|
||||||
pair.enabled &&
|
|
||||||
(pair.name || pair.value) &&
|
|
||||||
!currentHeaderNames.has(pair.name.toLowerCase()),
|
|
||||||
) ?? [];
|
|
||||||
const hasInheritedHeaders = validInheritedHeaders.length > 0;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
hasInheritedHeaders
|
|
||||||
? "@container w-full h-full grid grid-rows-[auto_minmax(0,1fr)] gap-y-1.5"
|
|
||||||
: "@container w-full h-full"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{hasInheritedHeaders && (
|
|
||||||
<DetailsBanner
|
|
||||||
color="secondary"
|
|
||||||
className="text-sm"
|
|
||||||
summary={
|
|
||||||
<HStack>
|
|
||||||
{inheritedHeadersLabel} <CountBadge count={validInheritedHeaders.length} />
|
|
||||||
</HStack>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="pb-2">
|
|
||||||
{validInheritedHeaders?.map((pair, i) => (
|
|
||||||
<PairEditorRow
|
|
||||||
key={`${pair.id}.${i}`}
|
|
||||||
index={i}
|
|
||||||
disabled
|
|
||||||
disableDrag
|
|
||||||
className="py-1"
|
|
||||||
pair={ensurePairId(pair)}
|
|
||||||
stateKey={null}
|
|
||||||
nameAutocompleteFunctions
|
|
||||||
nameAutocompleteVariables
|
|
||||||
valueAutocompleteFunctions
|
|
||||||
valueAutocompleteVariables
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</DetailsBanner>
|
|
||||||
)}
|
|
||||||
<PairOrBulkEditor
|
|
||||||
forceUpdateKey={forceUpdateKey}
|
|
||||||
nameAutocomplete={nameAutocomplete}
|
|
||||||
nameAutocompleteFunctions
|
|
||||||
nameAutocompleteVariables
|
|
||||||
namePlaceholder="Header-Name"
|
|
||||||
nameValidate={validateHttpHeader}
|
|
||||||
onChange={onChange}
|
|
||||||
pairs={headers}
|
|
||||||
preferenceName="headers"
|
|
||||||
stateKey={stateKey}
|
|
||||||
valueType={valueType}
|
|
||||||
valueAutocomplete={valueAutocomplete}
|
|
||||||
valueAutocompleteFunctions
|
|
||||||
valueAutocompleteVariables
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const MIN_MATCH = 3;
|
|
||||||
|
|
||||||
const headerOptionsMap: Record<string, string[]> = {
|
|
||||||
"content-type": mimeTypes,
|
|
||||||
accept: ["*/*", ...mimeTypes],
|
|
||||||
"accept-encoding": encodings,
|
|
||||||
connection: connections,
|
|
||||||
"accept-charset": charsets,
|
|
||||||
};
|
|
||||||
|
|
||||||
const valueType = (pair: Pair): InputProps["type"] => {
|
|
||||||
const name = pair.name.toLowerCase().trim();
|
|
||||||
if (
|
|
||||||
name.includes("authorization") ||
|
|
||||||
name.includes("api-key") ||
|
|
||||||
name.includes("access-token") ||
|
|
||||||
name.includes("auth") ||
|
|
||||||
name.includes("secret") ||
|
|
||||||
name.includes("token") ||
|
|
||||||
name === "cookie" ||
|
|
||||||
name === "set-cookie"
|
|
||||||
) {
|
|
||||||
return "password";
|
|
||||||
}
|
|
||||||
return "text";
|
|
||||||
};
|
|
||||||
|
|
||||||
const valueAutocomplete = (headerName: string): GenericCompletionConfig | undefined => {
|
|
||||||
const name = headerName.toLowerCase().trim();
|
|
||||||
const options: GenericCompletionOption[] =
|
|
||||||
headerOptionsMap[name]?.map((o) => ({
|
|
||||||
label: o,
|
|
||||||
type: "constant",
|
|
||||||
boost: 1, // Put above other completions
|
|
||||||
})) ?? [];
|
|
||||||
return { minMatch: MIN_MATCH, options };
|
|
||||||
};
|
|
||||||
|
|
||||||
const nameAutocomplete: PairEditorProps["nameAutocomplete"] = {
|
|
||||||
minMatch: MIN_MATCH,
|
|
||||||
options: headerNames.map((t) =>
|
|
||||||
typeof t === "string"
|
|
||||||
? {
|
|
||||||
label: t,
|
|
||||||
type: "constant",
|
|
||||||
boost: 1, // Put above other completions
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
...t,
|
|
||||||
boost: 1, // Put above other completions
|
|
||||||
},
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateHttpHeader = (v: string) => {
|
|
||||||
if (v === "") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Template strings are not allowed so we replace them with a valid example string
|
|
||||||
const withoutTemplateStrings = v.replace(/\$\{\[\s*[^\]\s]+\s*]}/gi, "123");
|
|
||||||
return withoutTemplateStrings.match(/^[a-zA-Z0-9-_]+$/) !== null;
|
|
||||||
};
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
import type {
|
|
||||||
Folder,
|
|
||||||
GrpcRequest,
|
|
||||||
HttpRequest,
|
|
||||||
WebsocketRequest,
|
|
||||||
Workspace,
|
|
||||||
} from "@yaakapp-internal/models";
|
|
||||||
import { patchModel } from "@yaakapp-internal/models";
|
|
||||||
import { HStack, Icon, InlineCode } from "@yaakapp-internal/ui";
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import { openFolderSettings } from "../commands/openFolderSettings";
|
|
||||||
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
|
|
||||||
import { useHttpAuthenticationConfig } from "../hooks/useHttpAuthenticationConfig";
|
|
||||||
import { useInheritedAuthentication } from "../hooks/useInheritedAuthentication";
|
|
||||||
import { useRenderTemplate } from "../hooks/useRenderTemplate";
|
|
||||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
|
||||||
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
|
||||||
import { IconButton } from "./core/IconButton";
|
|
||||||
import { Input, type InputProps } from "./core/Input";
|
|
||||||
import { Link } from "./core/Link";
|
|
||||||
import { SegmentedControl } from "./core/SegmentedControl";
|
|
||||||
import { DynamicForm } from "./DynamicForm";
|
|
||||||
import { EmptyStateText } from "./EmptyStateText";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
model: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HttpAuthenticationEditor({ model }: Props) {
|
|
||||||
const inheritedAuth = useInheritedAuthentication(model);
|
|
||||||
const authConfig = useHttpAuthenticationConfig(
|
|
||||||
model.authenticationType,
|
|
||||||
model.authentication,
|
|
||||||
model,
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleChange = useCallback(
|
|
||||||
async (authentication: Record<string, unknown>) => await patchModel(model, { authentication }),
|
|
||||||
[model],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (model.authenticationType === "none") {
|
|
||||||
return <EmptyStateText>No authentication</EmptyStateText>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (model.authenticationType != null && authConfig.data == null) {
|
|
||||||
return (
|
|
||||||
<EmptyStateText>
|
|
||||||
<p>
|
|
||||||
Auth plugin not found for <InlineCode>{model.authenticationType}</InlineCode>
|
|
||||||
</p>
|
|
||||||
</EmptyStateText>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inheritedAuth == null) {
|
|
||||||
if (model.model === "workspace" || model.model === "folder") {
|
|
||||||
return (
|
|
||||||
<EmptyStateText className="flex-col gap-1">
|
|
||||||
<p>
|
|
||||||
Apply auth to all requests in <strong>{resolvedModelName(model)}</strong>
|
|
||||||
</p>
|
|
||||||
<Link href="https://yaak.app/docs/using-yaak/request-inheritance">Documentation</Link>
|
|
||||||
</EmptyStateText>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <EmptyStateText>No authentication</EmptyStateText>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inheritedAuth.authenticationType === "none") {
|
|
||||||
return <EmptyStateText>No authentication</EmptyStateText>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wasAuthInherited = inheritedAuth?.id !== model.id;
|
|
||||||
if (wasAuthInherited) {
|
|
||||||
const name = resolvedModelName(inheritedAuth);
|
|
||||||
const cta = inheritedAuth.model === "workspace" ? "Workspace" : name;
|
|
||||||
return (
|
|
||||||
<EmptyStateText>
|
|
||||||
<p>
|
|
||||||
Inherited from{" "}
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="underline hover:text-text"
|
|
||||||
onClick={() => {
|
|
||||||
if (inheritedAuth.model === "folder") openFolderSettings(inheritedAuth.id, "auth");
|
|
||||||
else openWorkspaceSettings("auth");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{cta}
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
</EmptyStateText>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)] gap-y-3">
|
|
||||||
<div>
|
|
||||||
<HStack space={2} alignItems="start">
|
|
||||||
<SegmentedControl
|
|
||||||
label="Enabled"
|
|
||||||
hideLabel
|
|
||||||
name="enabled"
|
|
||||||
value={
|
|
||||||
model.authentication.disabled === false || model.authentication.disabled == null
|
|
||||||
? "__TRUE__"
|
|
||||||
: model.authentication.disabled === true
|
|
||||||
? "__FALSE__"
|
|
||||||
: "__DYNAMIC__"
|
|
||||||
}
|
|
||||||
options={[
|
|
||||||
{ label: "Enabled", value: "__TRUE__" },
|
|
||||||
{ label: "Disabled", value: "__FALSE__" },
|
|
||||||
{ label: "Enabled when...", value: "__DYNAMIC__" },
|
|
||||||
]}
|
|
||||||
onChange={async (enabled) => {
|
|
||||||
let disabled: boolean | string;
|
|
||||||
if (enabled === "__TRUE__") {
|
|
||||||
disabled = false;
|
|
||||||
} else if (enabled === "__FALSE__") {
|
|
||||||
disabled = true;
|
|
||||||
} else {
|
|
||||||
disabled = "";
|
|
||||||
}
|
|
||||||
await handleChange({ ...model.authentication, disabled });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{authConfig.data?.actions && authConfig.data.actions.length > 0 && (
|
|
||||||
<Dropdown
|
|
||||||
items={authConfig.data.actions.map(
|
|
||||||
(a): DropdownItem => ({
|
|
||||||
label: a.label,
|
|
||||||
leftSlot: a.icon ? <Icon icon={a.icon} /> : null,
|
|
||||||
onSelect: () => a.call(model),
|
|
||||||
}),
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
title="Authentication Actions"
|
|
||||||
icon="settings"
|
|
||||||
size="xs"
|
|
||||||
className="!text-secondary"
|
|
||||||
/>
|
|
||||||
</Dropdown>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
{typeof model.authentication.disabled === "string" && (
|
|
||||||
<div className="mt-3">
|
|
||||||
<AuthenticationDisabledInput
|
|
||||||
className="w-full"
|
|
||||||
stateKey={`auth.${model.id}.dynamic`}
|
|
||||||
value={model.authentication.disabled}
|
|
||||||
onChange={(v) => handleChange({ ...model.authentication, disabled: v })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<DynamicForm
|
|
||||||
disabled={model.authentication.disabled === true}
|
|
||||||
autocompleteVariables
|
|
||||||
autocompleteFunctions
|
|
||||||
stateKey={`auth.${model.id}.${model.authenticationType}`}
|
|
||||||
inputs={authConfig.data?.args ?? []}
|
|
||||||
data={model.authentication}
|
|
||||||
onChange={handleChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AuthenticationDisabledInput({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
stateKey,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
value: string;
|
|
||||||
onChange: InputProps["onChange"];
|
|
||||||
stateKey: string;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
const rendered = useRenderTemplate({
|
|
||||||
template: value,
|
|
||||||
enabled: true,
|
|
||||||
purpose: "preview",
|
|
||||||
refreshKey: value,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Input
|
|
||||||
size="sm"
|
|
||||||
className={className}
|
|
||||||
label="Dynamic Disabled"
|
|
||||||
hideLabel
|
|
||||||
defaultValue={value}
|
|
||||||
placeholder="Enabled when this renders a non-empty value"
|
|
||||||
rightSlot={
|
|
||||||
<div className="px-1 flex items-center">
|
|
||||||
<div className="rounded-full bg-surface-highlight text-xs px-1.5 py-0.5 text-text-subtle whitespace-nowrap">
|
|
||||||
{rendered.isPending ? "loading" : rendered.data ? "enabled" : "disabled"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
autocompleteFunctions
|
|
||||||
autocompleteVariables
|
|
||||||
onChange={onChange}
|
|
||||||
stateKey={stateKey}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
|
||||||
import type { SlotProps } from "@yaakapp-internal/ui";
|
|
||||||
import { SplitLayout } from "@yaakapp-internal/ui";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import type { CSSProperties } from "react";
|
|
||||||
import { useCurrentGraphQLSchema } from "../hooks/useIntrospectGraphQL";
|
|
||||||
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
|
|
||||||
import { workspaceLayoutAtom } from "../lib/atoms";
|
|
||||||
import { GraphQLDocsExplorer } from "./graphql/GraphQLDocsExplorer";
|
|
||||||
import { showGraphQLDocExplorerAtom } from "./graphql/graphqlAtoms";
|
|
||||||
import { HttpRequestPane } from "./HttpRequestPane";
|
|
||||||
import { HttpResponsePane } from "./HttpResponsePane";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
activeRequest: HttpRequest;
|
|
||||||
style: CSSProperties;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HttpRequestLayout({ activeRequest, style }: Props) {
|
|
||||||
const showGraphQLDocExplorer = useAtomValue(showGraphQLDocExplorerAtom);
|
|
||||||
const graphQLSchema = useCurrentGraphQLSchema(activeRequest);
|
|
||||||
const workspaceLayout = useAtomValue(workspaceLayoutAtom);
|
|
||||||
const activeWorkspace = useAtomValue(activeWorkspaceAtom);
|
|
||||||
const wsId = activeWorkspace?.id ?? "n/a";
|
|
||||||
|
|
||||||
const requestResponseSplit = ({ style }: Pick<SlotProps, "style">) => (
|
|
||||||
<SplitLayout
|
|
||||||
storageKey={`http_layout::${wsId}`}
|
|
||||||
className="p-3 gap-1.5"
|
|
||||||
style={style}
|
|
||||||
layout={workspaceLayout}
|
|
||||||
firstSlot={({ orientation, style }) => (
|
|
||||||
<HttpRequestPane
|
|
||||||
style={style}
|
|
||||||
activeRequest={activeRequest}
|
|
||||||
fullHeight={orientation === "horizontal"}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
secondSlot={({ style }) => (
|
|
||||||
<HttpResponsePane activeRequestId={activeRequest.id} style={style} />
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
activeRequest.bodyType === "graphql" &&
|
|
||||||
showGraphQLDocExplorer[activeRequest.id] !== undefined &&
|
|
||||||
graphQLSchema != null
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<SplitLayout
|
|
||||||
storageKey={`graphql_layout::${wsId}`}
|
|
||||||
defaultRatio={1 / 3}
|
|
||||||
firstSlot={requestResponseSplit}
|
|
||||||
secondSlot={({ style, orientation }) => (
|
|
||||||
<GraphQLDocsExplorer
|
|
||||||
requestId={activeRequest.id}
|
|
||||||
schema={graphQLSchema}
|
|
||||||
className={classNames(orientation === "horizontal" && "!ml-0")}
|
|
||||||
style={style}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return requestResponseSplit({ style });
|
|
||||||
}
|
|
||||||
@@ -1,476 +0,0 @@
|
|||||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
|
||||||
import { patchModel } from "@yaakapp-internal/models";
|
|
||||||
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { atom, useAtomValue } from "jotai";
|
|
||||||
import type { CSSProperties } from "react";
|
|
||||||
import { lazy, Suspense, useCallback, useMemo, useRef, useState } from "react";
|
|
||||||
import { activeRequestIdAtom } from "../hooks/useActiveRequestId";
|
|
||||||
import { allRequestsAtom } from "../hooks/useAllRequests";
|
|
||||||
import { useAuthTab } from "../hooks/useAuthTab";
|
|
||||||
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
|
|
||||||
import { useHeadersTab } from "../hooks/useHeadersTab";
|
|
||||||
import { useImportCurl } from "../hooks/useImportCurl";
|
|
||||||
import { useInheritedHeaders } from "../hooks/useInheritedHeaders";
|
|
||||||
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
|
|
||||||
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
|
|
||||||
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
|
|
||||||
import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
|
|
||||||
import { deepEqualAtom } from "../lib/atoms";
|
|
||||||
import { languageFromContentType } from "../lib/contentType";
|
|
||||||
import { generateId } from "../lib/generateId";
|
|
||||||
import {
|
|
||||||
BODY_TYPE_BINARY,
|
|
||||||
BODY_TYPE_FORM_MULTIPART,
|
|
||||||
BODY_TYPE_FORM_URLENCODED,
|
|
||||||
BODY_TYPE_GRAPHQL,
|
|
||||||
BODY_TYPE_JSON,
|
|
||||||
BODY_TYPE_NONE,
|
|
||||||
BODY_TYPE_OTHER,
|
|
||||||
BODY_TYPE_XML,
|
|
||||||
getContentTypeFromHeaders,
|
|
||||||
} from "../lib/model_util";
|
|
||||||
import { prepareImportQuerystring } from "../lib/prepareImportQuerystring";
|
|
||||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
|
||||||
import { showToast } from "../lib/toast";
|
|
||||||
import { BinaryFileEditor } from "./BinaryFileEditor";
|
|
||||||
import { ConfirmLargeRequestBody } from "./ConfirmLargeRequestBody";
|
|
||||||
import { CountBadge } from "./core/CountBadge";
|
|
||||||
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
|
||||||
import { Editor } from "./core/Editor/LazyEditor";
|
|
||||||
import { InlineCode } from "@yaakapp-internal/ui";
|
|
||||||
import type { Pair } from "./core/PairEditor";
|
|
||||||
import { PlainInput } from "./core/PlainInput";
|
|
||||||
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
|
|
||||||
import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs";
|
|
||||||
import { EmptyStateText } from "./EmptyStateText";
|
|
||||||
import { FormMultipartEditor } from "./FormMultipartEditor";
|
|
||||||
import { FormUrlencodedEditor } from "./FormUrlencodedEditor";
|
|
||||||
import { HeadersEditor } from "./HeadersEditor";
|
|
||||||
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
|
|
||||||
import { JsonBodyEditor } from "./JsonBodyEditor";
|
|
||||||
import { MarkdownEditor } from "./MarkdownEditor";
|
|
||||||
import { RequestMethodDropdown } from "./RequestMethodDropdown";
|
|
||||||
import { UrlBar } from "./UrlBar";
|
|
||||||
import { UrlParametersEditor } from "./UrlParameterEditor";
|
|
||||||
|
|
||||||
const GraphQLEditor = lazy(() =>
|
|
||||||
import("./graphql/GraphQLEditor").then((m) => ({ default: m.GraphQLEditor })),
|
|
||||||
);
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
style: CSSProperties;
|
|
||||||
fullHeight: boolean;
|
|
||||||
className?: string;
|
|
||||||
activeRequest: HttpRequest;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TAB_BODY = "body";
|
|
||||||
const TAB_PARAMS = "params";
|
|
||||||
const TAB_HEADERS = "headers";
|
|
||||||
const TAB_AUTH = "auth";
|
|
||||||
const TAB_DESCRIPTION = "description";
|
|
||||||
const TABS_STORAGE_KEY = "http_request_tabs";
|
|
||||||
|
|
||||||
const nonActiveRequestUrlsAtom = atom((get) => {
|
|
||||||
const activeRequestId = get(activeRequestIdAtom);
|
|
||||||
const requests = get(allRequestsAtom);
|
|
||||||
return requests
|
|
||||||
.filter((r) => r.id !== activeRequestId)
|
|
||||||
.map((r): GenericCompletionOption => ({ type: "constant", label: r.url }));
|
|
||||||
});
|
|
||||||
|
|
||||||
const memoNotActiveRequestUrlsAtom = deepEqualAtom(nonActiveRequestUrlsAtom);
|
|
||||||
|
|
||||||
export function HttpRequestPane({ style, fullHeight, className, activeRequest }: Props) {
|
|
||||||
const activeRequestId = activeRequest.id;
|
|
||||||
const tabsRef = useRef<TabsRef>(null);
|
|
||||||
const [forceUpdateHeaderEditorKey, setForceUpdateHeaderEditorKey] = useState<number>(0);
|
|
||||||
const forceUpdateKey = useRequestUpdateKey(activeRequest.id ?? null);
|
|
||||||
const [{ urlKey }, { forceUrlRefresh, forceParamsRefresh }] = useRequestEditor();
|
|
||||||
const contentType = getContentTypeFromHeaders(activeRequest.headers);
|
|
||||||
const authTab = useAuthTab(TAB_AUTH, activeRequest);
|
|
||||||
const headersTab = useHeadersTab(TAB_HEADERS, activeRequest);
|
|
||||||
const inheritedHeaders = useInheritedHeaders(activeRequest);
|
|
||||||
|
|
||||||
// Listen for event to focus the params tab (e.g., when clicking a :param in the URL)
|
|
||||||
useRequestEditorEvent(
|
|
||||||
"request_pane.focus_tab",
|
|
||||||
() => {
|
|
||||||
tabsRef.current?.setActiveTab(TAB_PARAMS);
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleContentTypeChange = useCallback(
|
|
||||||
async (contentType: string | null, patch: Partial<Omit<HttpRequest, "headers">> = {}) => {
|
|
||||||
if (activeRequest == null) {
|
|
||||||
console.error("Failed to get active request to update", activeRequest);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const headers = activeRequest.headers.filter((h) => h.name.toLowerCase() !== "content-type");
|
|
||||||
|
|
||||||
if (contentType != null) {
|
|
||||||
headers.push({
|
|
||||||
name: "Content-Type",
|
|
||||||
value: contentType,
|
|
||||||
enabled: true,
|
|
||||||
id: generateId(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await patchModel(activeRequest, { ...patch, headers });
|
|
||||||
|
|
||||||
// Force update header editor so any changed headers are reflected
|
|
||||||
setTimeout(() => setForceUpdateHeaderEditorKey((u) => u + 1), 100);
|
|
||||||
},
|
|
||||||
[activeRequest],
|
|
||||||
);
|
|
||||||
|
|
||||||
const { urlParameterPairs, urlParametersKey } = useMemo(() => {
|
|
||||||
const placeholderNames = Array.from(activeRequest.url.matchAll(/\/(:[^/]+)/g)).map(
|
|
||||||
(m) => m[1] ?? "",
|
|
||||||
);
|
|
||||||
const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);
|
|
||||||
const items: Pair[] = [...nonEmptyParameters];
|
|
||||||
for (const name of placeholderNames) {
|
|
||||||
const item = items.find((p) => p.name === name);
|
|
||||||
if (item) {
|
|
||||||
item.readOnlyName = true;
|
|
||||||
} else {
|
|
||||||
items.push({ name, value: "", enabled: true, readOnlyName: true, id: generateId() });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { urlParameterPairs: items, urlParametersKey: placeholderNames.join(",") };
|
|
||||||
}, [activeRequest.url, activeRequest.urlParameters]);
|
|
||||||
|
|
||||||
let numParams = 0;
|
|
||||||
if (
|
|
||||||
activeRequest.bodyType === BODY_TYPE_FORM_URLENCODED ||
|
|
||||||
activeRequest.bodyType === BODY_TYPE_FORM_MULTIPART
|
|
||||||
) {
|
|
||||||
numParams = Array.isArray(activeRequest.body?.form)
|
|
||||||
? activeRequest.body.form.filter((p) => p.name).length
|
|
||||||
: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tabs = useMemo<TabItem[]>(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
value: TAB_BODY,
|
|
||||||
rightSlot: numParams > 0 ? <CountBadge count={numParams} /> : null,
|
|
||||||
options: {
|
|
||||||
value: activeRequest.bodyType,
|
|
||||||
items: [
|
|
||||||
{ type: "separator", label: "Form Data" },
|
|
||||||
{ label: "Url Encoded", value: BODY_TYPE_FORM_URLENCODED },
|
|
||||||
{ label: "Multi-Part", value: BODY_TYPE_FORM_MULTIPART },
|
|
||||||
{ type: "separator", label: "Text Content" },
|
|
||||||
{ label: "GraphQL", value: BODY_TYPE_GRAPHQL },
|
|
||||||
{ label: "JSON", value: BODY_TYPE_JSON },
|
|
||||||
{ label: "XML", value: BODY_TYPE_XML },
|
|
||||||
{
|
|
||||||
label: "Other",
|
|
||||||
value: BODY_TYPE_OTHER,
|
|
||||||
shortLabel: nameOfContentTypeOr(contentType, "Other"),
|
|
||||||
},
|
|
||||||
{ type: "separator", label: "Other" },
|
|
||||||
{ label: "Binary File", value: BODY_TYPE_BINARY },
|
|
||||||
{ label: "No Body", shortLabel: "Body", value: BODY_TYPE_NONE },
|
|
||||||
],
|
|
||||||
onChange: async (bodyType) => {
|
|
||||||
if (bodyType === activeRequest.bodyType) return;
|
|
||||||
|
|
||||||
const showMethodToast = (newMethod: string) => {
|
|
||||||
if (activeRequest.method.toLowerCase() === newMethod.toLowerCase()) return;
|
|
||||||
showToast({
|
|
||||||
id: "switched-method",
|
|
||||||
message: (
|
|
||||||
<>
|
|
||||||
Request method switched to <InlineCode>POST</InlineCode>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const patch: Partial<HttpRequest> = { bodyType };
|
|
||||||
let newContentType: string | null | undefined;
|
|
||||||
if (bodyType === BODY_TYPE_NONE) {
|
|
||||||
newContentType = null;
|
|
||||||
} else if (
|
|
||||||
bodyType === BODY_TYPE_FORM_URLENCODED ||
|
|
||||||
bodyType === BODY_TYPE_FORM_MULTIPART ||
|
|
||||||
bodyType === BODY_TYPE_JSON ||
|
|
||||||
bodyType === BODY_TYPE_OTHER ||
|
|
||||||
bodyType === BODY_TYPE_XML
|
|
||||||
) {
|
|
||||||
const isDefaultishRequest =
|
|
||||||
activeRequest.bodyType === BODY_TYPE_NONE &&
|
|
||||||
activeRequest.method.toLowerCase() === "get";
|
|
||||||
const requiresPost = bodyType === BODY_TYPE_FORM_MULTIPART;
|
|
||||||
if (isDefaultishRequest || requiresPost) {
|
|
||||||
patch.method = "POST";
|
|
||||||
showMethodToast(patch.method);
|
|
||||||
}
|
|
||||||
newContentType = bodyType === BODY_TYPE_OTHER ? "text/plain" : bodyType;
|
|
||||||
} else if (bodyType === BODY_TYPE_GRAPHQL) {
|
|
||||||
patch.method = "POST";
|
|
||||||
newContentType = "application/json";
|
|
||||||
showMethodToast(patch.method);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newContentType !== undefined) {
|
|
||||||
await handleContentTypeChange(newContentType, patch);
|
|
||||||
} else {
|
|
||||||
await patchModel(activeRequest, patch);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: TAB_PARAMS,
|
|
||||||
rightSlot: <CountBadge count={urlParameterPairs.length} />,
|
|
||||||
label: "Params",
|
|
||||||
},
|
|
||||||
...headersTab,
|
|
||||||
...authTab,
|
|
||||||
{
|
|
||||||
value: TAB_DESCRIPTION,
|
|
||||||
label: "Info",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
activeRequest,
|
|
||||||
authTab,
|
|
||||||
contentType,
|
|
||||||
handleContentTypeChange,
|
|
||||||
headersTab,
|
|
||||||
numParams,
|
|
||||||
urlParameterPairs.length,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const { mutate: sendRequest } = useSendAnyHttpRequest();
|
|
||||||
const { activeResponse } = usePinnedHttpResponse(activeRequestId);
|
|
||||||
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
|
|
||||||
const updateKey = useRequestUpdateKey(activeRequestId);
|
|
||||||
const { mutate: importCurl } = useImportCurl();
|
|
||||||
|
|
||||||
const handleBodyChange = useCallback(
|
|
||||||
(body: HttpRequest["body"]) => patchModel(activeRequest, { body }),
|
|
||||||
[activeRequest],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleBodyTextChange = useCallback(
|
|
||||||
(text: string) => patchModel(activeRequest, { body: { ...activeRequest.body, text } }),
|
|
||||||
[activeRequest],
|
|
||||||
);
|
|
||||||
|
|
||||||
const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom);
|
|
||||||
|
|
||||||
const autocomplete: GenericCompletionConfig = useMemo(
|
|
||||||
() => ({
|
|
||||||
minMatch: 3,
|
|
||||||
options:
|
|
||||||
autocompleteUrls.length > 0
|
|
||||||
? autocompleteUrls
|
|
||||||
: [
|
|
||||||
{ label: "http://", type: "constant" },
|
|
||||||
{ label: "https://", type: "constant" },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
[autocompleteUrls],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePaste = useCallback(
|
|
||||||
async (e: ClipboardEvent, text: string) => {
|
|
||||||
if (text.startsWith("curl ")) {
|
|
||||||
importCurl({ overwriteRequestId: activeRequestId, command: text });
|
|
||||||
} else {
|
|
||||||
const patch = prepareImportQuerystring(text);
|
|
||||||
if (patch != null) {
|
|
||||||
e.preventDefault(); // Prevent input onChange
|
|
||||||
|
|
||||||
await patchModel(activeRequest, patch);
|
|
||||||
await setActiveTab({
|
|
||||||
storageKey: TABS_STORAGE_KEY,
|
|
||||||
activeTabKey: activeRequestId,
|
|
||||||
value: TAB_PARAMS,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait for request to update, then refresh the UI
|
|
||||||
// TODO: Somehow make this deterministic
|
|
||||||
setTimeout(() => {
|
|
||||||
forceUrlRefresh();
|
|
||||||
forceParamsRefresh();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[activeRequest, activeRequestId, forceParamsRefresh, forceUrlRefresh, importCurl],
|
|
||||||
);
|
|
||||||
const handleSend = useCallback(
|
|
||||||
() => sendRequest(activeRequest.id ?? null),
|
|
||||||
[activeRequest.id, sendRequest],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleUrlChange = useCallback(
|
|
||||||
(url: string) => patchModel(activeRequest, { url }),
|
|
||||||
[activeRequest],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={style}
|
|
||||||
className={classNames(className, "h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1")}
|
|
||||||
>
|
|
||||||
{activeRequest && (
|
|
||||||
<>
|
|
||||||
<UrlBar
|
|
||||||
stateKey={`url.${activeRequest.id}`}
|
|
||||||
key={forceUpdateKey + urlKey}
|
|
||||||
url={activeRequest.url}
|
|
||||||
placeholder="https://example.com"
|
|
||||||
onPasteOverwrite={handlePaste}
|
|
||||||
autocomplete={autocomplete}
|
|
||||||
onSend={handleSend}
|
|
||||||
onCancel={cancelResponse}
|
|
||||||
onUrlChange={handleUrlChange}
|
|
||||||
leftSlot={
|
|
||||||
<div className="py-0.5">
|
|
||||||
<RequestMethodDropdown request={activeRequest} className="ml-0.5 !h-full" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
forceUpdateKey={updateKey}
|
|
||||||
isLoading={activeResponse != null && activeResponse.state !== "closed"}
|
|
||||||
/>
|
|
||||||
<Tabs
|
|
||||||
ref={tabsRef}
|
|
||||||
label="Request"
|
|
||||||
tabs={tabs}
|
|
||||||
tabListClassName="mt-1 -mb-1.5"
|
|
||||||
storageKey={TABS_STORAGE_KEY}
|
|
||||||
activeTabKey={activeRequestId}
|
|
||||||
>
|
|
||||||
<TabContent value={TAB_AUTH}>
|
|
||||||
<HttpAuthenticationEditor model={activeRequest} />
|
|
||||||
</TabContent>
|
|
||||||
<TabContent value={TAB_HEADERS}>
|
|
||||||
<HeadersEditor
|
|
||||||
inheritedHeaders={inheritedHeaders}
|
|
||||||
forceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`}
|
|
||||||
headers={activeRequest.headers}
|
|
||||||
stateKey={`headers.${activeRequest.id}`}
|
|
||||||
onChange={(headers) => patchModel(activeRequest, { headers })}
|
|
||||||
/>
|
|
||||||
</TabContent>
|
|
||||||
<TabContent value={TAB_PARAMS}>
|
|
||||||
<UrlParametersEditor
|
|
||||||
stateKey={`params.${activeRequest.id}`}
|
|
||||||
forceUpdateKey={forceUpdateKey + urlParametersKey}
|
|
||||||
pairs={urlParameterPairs}
|
|
||||||
onChange={(urlParameters) => patchModel(activeRequest, { urlParameters })}
|
|
||||||
/>
|
|
||||||
</TabContent>
|
|
||||||
<TabContent value={TAB_BODY}>
|
|
||||||
<ConfirmLargeRequestBody request={activeRequest}>
|
|
||||||
{activeRequest.bodyType === BODY_TYPE_JSON ? (
|
|
||||||
<JsonBodyEditor
|
|
||||||
forceUpdateKey={forceUpdateKey}
|
|
||||||
heightMode={fullHeight ? "full" : "auto"}
|
|
||||||
request={activeRequest}
|
|
||||||
/>
|
|
||||||
) : activeRequest.bodyType === BODY_TYPE_XML ? (
|
|
||||||
<Editor
|
|
||||||
forceUpdateKey={forceUpdateKey}
|
|
||||||
autocompleteFunctions
|
|
||||||
autocompleteVariables
|
|
||||||
placeholder="..."
|
|
||||||
heightMode={fullHeight ? "full" : "auto"}
|
|
||||||
defaultValue={`${activeRequest.body?.text ?? ""}`}
|
|
||||||
language="xml"
|
|
||||||
onChange={handleBodyTextChange}
|
|
||||||
stateKey={`xml.${activeRequest.id}`}
|
|
||||||
/>
|
|
||||||
) : activeRequest.bodyType === BODY_TYPE_GRAPHQL ? (
|
|
||||||
<Suspense>
|
|
||||||
<GraphQLEditor
|
|
||||||
forceUpdateKey={forceUpdateKey}
|
|
||||||
baseRequest={activeRequest}
|
|
||||||
request={activeRequest}
|
|
||||||
onChange={handleBodyChange}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
) : activeRequest.bodyType === BODY_TYPE_FORM_URLENCODED ? (
|
|
||||||
<FormUrlencodedEditor
|
|
||||||
forceUpdateKey={forceUpdateKey}
|
|
||||||
request={activeRequest}
|
|
||||||
onChange={handleBodyChange}
|
|
||||||
/>
|
|
||||||
) : activeRequest.bodyType === BODY_TYPE_FORM_MULTIPART ? (
|
|
||||||
<FormMultipartEditor
|
|
||||||
forceUpdateKey={forceUpdateKey}
|
|
||||||
request={activeRequest}
|
|
||||||
onChange={handleBodyChange}
|
|
||||||
/>
|
|
||||||
) : activeRequest.bodyType === BODY_TYPE_BINARY ? (
|
|
||||||
<BinaryFileEditor
|
|
||||||
requestId={activeRequest.id}
|
|
||||||
contentType={contentType}
|
|
||||||
body={activeRequest.body}
|
|
||||||
onChange={(body) => patchModel(activeRequest, { body })}
|
|
||||||
onChangeContentType={handleContentTypeChange}
|
|
||||||
/>
|
|
||||||
) : typeof activeRequest.bodyType === "string" ? (
|
|
||||||
<Editor
|
|
||||||
forceUpdateKey={forceUpdateKey}
|
|
||||||
autocompleteFunctions
|
|
||||||
autocompleteVariables
|
|
||||||
language={languageFromContentType(contentType)}
|
|
||||||
placeholder="..."
|
|
||||||
heightMode={fullHeight ? "full" : "auto"}
|
|
||||||
defaultValue={`${activeRequest.body?.text ?? ""}`}
|
|
||||||
onChange={handleBodyTextChange}
|
|
||||||
stateKey={`other.${activeRequest.id}`}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<EmptyStateText>No Body</EmptyStateText>
|
|
||||||
)}
|
|
||||||
</ConfirmLargeRequestBody>
|
|
||||||
</TabContent>
|
|
||||||
<TabContent value={TAB_DESCRIPTION}>
|
|
||||||
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full">
|
|
||||||
<PlainInput
|
|
||||||
label="Request Name"
|
|
||||||
hideLabel
|
|
||||||
forceUpdateKey={updateKey}
|
|
||||||
defaultValue={activeRequest.name}
|
|
||||||
className="font-sans !text-xl !px-0"
|
|
||||||
containerClassName="border-0"
|
|
||||||
placeholder={resolvedModelName(activeRequest)}
|
|
||||||
onChange={(name) => patchModel(activeRequest, { name })}
|
|
||||||
/>
|
|
||||||
<MarkdownEditor
|
|
||||||
name="request-description"
|
|
||||||
placeholder="Request description"
|
|
||||||
defaultValue={activeRequest.description}
|
|
||||||
stateKey={`description.${activeRequest.id}`}
|
|
||||||
forceUpdateKey={updateKey}
|
|
||||||
onChange={(description) => patchModel(activeRequest, { description })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TabContent>
|
|
||||||
</Tabs>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function nameOfContentTypeOr(contentType: string | null, fallback: string) {
|
|
||||||
const language = languageFromContentType(contentType);
|
|
||||||
if (language === "markdown") {
|
|
||||||
return "Markdown";
|
|
||||||
}
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
@@ -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,418 +0,0 @@
|
|||||||
import type {
|
|
||||||
HttpResponse,
|
|
||||||
HttpResponseEvent,
|
|
||||||
HttpResponseEventData,
|
|
||||||
} from "@yaakapp-internal/models";
|
|
||||||
import { type ReactNode, useMemo, useState } from "react";
|
|
||||||
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
|
|
||||||
import { Editor } from "./core/Editor/LazyEditor";
|
|
||||||
import { type EventDetailAction, EventDetailHeader, EventViewer } from "./core/EventViewer";
|
|
||||||
import { EventViewerRow } from "./core/EventViewerRow";
|
|
||||||
import { HttpStatusTagRaw } from "./core/HttpStatusTag";
|
|
||||||
import { Icon, type IconProps } from "@yaakapp-internal/ui";
|
|
||||||
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
|
|
||||||
import type { TimelineViewMode } from "./HttpResponsePane";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
response: HttpResponse;
|
|
||||||
viewMode: TimelineViewMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HttpResponseTimeline({ response, viewMode }: Props) {
|
|
||||||
return <Inner key={response.id} response={response} viewMode={viewMode} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Inner({ response, viewMode }: Props) {
|
|
||||||
const [showRaw, setShowRaw] = useState(false);
|
|
||||||
const { data: events, error, isLoading } = useHttpResponseEvents(response);
|
|
||||||
|
|
||||||
// Generate plain text representation of all events (with prefixes for timeline view)
|
|
||||||
const plainText = useMemo(() => {
|
|
||||||
if (!events || events.length === 0) return "";
|
|
||||||
return events.map((event) => formatEventText(event.event, true)).join("\n");
|
|
||||||
}, [events]);
|
|
||||||
|
|
||||||
// Plain text view - show all events as text in an editor
|
|
||||||
if (viewMode === "text") {
|
|
||||||
if (isLoading) {
|
|
||||||
return <div className="p-4 text-text-subtlest">Loading events...</div>;
|
|
||||||
} else if (error) {
|
|
||||||
return <div className="p-4 text-danger">{String(error)}</div>;
|
|
||||||
} else if (!events || events.length === 0) {
|
|
||||||
return <div className="p-4 text-text-subtlest">No events recorded</div>;
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<Editor language="timeline" defaultValue={plainText} readOnly stateKey={null} hideGutter />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<EventViewer
|
|
||||||
events={events ?? []}
|
|
||||||
getEventKey={(event) => event.id}
|
|
||||||
error={error ? String(error) : null}
|
|
||||||
isLoading={isLoading}
|
|
||||||
loadingMessage="Loading events..."
|
|
||||||
emptyMessage="No events recorded"
|
|
||||||
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 actions: EventDetailAction[] = [
|
|
||||||
{
|
|
||||||
key: "toggle-raw",
|
|
||||||
label: showRaw ? "Formatted" : "Text",
|
|
||||||
onClick: () => setShowRaw(!showRaw),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Determine the title based on event type
|
|
||||||
const title = (() => {
|
|
||||||
switch (e.type) {
|
|
||||||
case "header_up":
|
|
||||||
return "Header Sent";
|
|
||||||
case "header_down":
|
|
||||||
return "Header Received";
|
|
||||||
case "send_url":
|
|
||||||
return "Request";
|
|
||||||
case "receive_url":
|
|
||||||
return "Response";
|
|
||||||
case "redirect":
|
|
||||||
return "Redirect";
|
|
||||||
case "setting":
|
|
||||||
return "Apply Setting";
|
|
||||||
case "chunk_sent":
|
|
||||||
return "Data Sent";
|
|
||||||
case "chunk_received":
|
|
||||||
return "Data Received";
|
|
||||||
case "dns_resolved":
|
|
||||||
return e.overridden ? "DNS Override" : "DNS Resolution";
|
|
||||||
default:
|
|
||||||
return label;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
// Render content based on view mode and event type
|
|
||||||
const renderContent = () => {
|
|
||||||
// Raw view - show plaintext representation (without prefix)
|
|
||||||
if (showRaw) {
|
|
||||||
const rawText = formatEventText(event.event, false);
|
|
||||||
return <Editor language="text" defaultValue={rawText} readOnly stateKey={null} hideGutter />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Headers - show name and value
|
|
||||||
if (e.type === "header_up" || e.type === "header_down") {
|
|
||||||
return (
|
|
||||||
<KeyValueRows>
|
|
||||||
<KeyValueRow label="Header">{e.name}</KeyValueRow>
|
|
||||||
<KeyValueRow label="Value">{e.value}</KeyValueRow>
|
|
||||||
</KeyValueRows>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Request URL - show all URL parts separately
|
|
||||||
if (e.type === "send_url") {
|
|
||||||
const auth = e.username || e.password ? `${e.username}:${e.password}@` : "";
|
|
||||||
const isDefaultPort =
|
|
||||||
(e.scheme === "http" && e.port === 80) || (e.scheme === "https" && e.port === 443);
|
|
||||||
const portStr = isDefaultPort ? "" : `:${e.port}`;
|
|
||||||
const query = e.query ? `?${e.query}` : "";
|
|
||||||
const fragment = e.fragment ? `#${e.fragment}` : "";
|
|
||||||
const fullUrl = `${e.scheme}://${auth}${e.host}${portStr}${e.path}${query}${fragment}`;
|
|
||||||
return (
|
|
||||||
<KeyValueRows>
|
|
||||||
<KeyValueRow label="URL">{fullUrl}</KeyValueRow>
|
|
||||||
<KeyValueRow label="Method">{e.method}</KeyValueRow>
|
|
||||||
<KeyValueRow label="Scheme">{e.scheme}</KeyValueRow>
|
|
||||||
{e.username ? <KeyValueRow label="Username">{e.username}</KeyValueRow> : null}
|
|
||||||
{e.password ? <KeyValueRow label="Password">{e.password}</KeyValueRow> : null}
|
|
||||||
<KeyValueRow label="Host">{e.host}</KeyValueRow>
|
|
||||||
{!isDefaultPort ? <KeyValueRow label="Port">{e.port}</KeyValueRow> : null}
|
|
||||||
<KeyValueRow label="Path">{e.path}</KeyValueRow>
|
|
||||||
{e.query ? <KeyValueRow label="Query">{e.query}</KeyValueRow> : null}
|
|
||||||
{e.fragment ? <KeyValueRow label="Fragment">{e.fragment}</KeyValueRow> : null}
|
|
||||||
</KeyValueRows>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Response status - show version and status separately
|
|
||||||
if (e.type === "receive_url") {
|
|
||||||
return (
|
|
||||||
<KeyValueRows>
|
|
||||||
<KeyValueRow label="HTTP Version">{e.version}</KeyValueRow>
|
|
||||||
<KeyValueRow label="Status">
|
|
||||||
<HttpStatusTagRaw status={e.status} />
|
|
||||||
</KeyValueRow>
|
|
||||||
</KeyValueRows>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redirect - show status, URL, and behavior
|
|
||||||
if (e.type === "redirect") {
|
|
||||||
const droppedHeaders = e.dropped_headers ?? [];
|
|
||||||
return (
|
|
||||||
<KeyValueRows>
|
|
||||||
<KeyValueRow label="Status">
|
|
||||||
<HttpStatusTagRaw status={e.status} />
|
|
||||||
</KeyValueRow>
|
|
||||||
<KeyValueRow label="Location">{e.url}</KeyValueRow>
|
|
||||||
<KeyValueRow label="Behavior">
|
|
||||||
{e.behavior === "drop_body" ? "Drop body, change to GET" : "Preserve method and body"}
|
|
||||||
</KeyValueRow>
|
|
||||||
<KeyValueRow label="Body Dropped">{e.dropped_body ? "Yes" : "No"}</KeyValueRow>
|
|
||||||
<KeyValueRow label="Headers Dropped">
|
|
||||||
{droppedHeaders.length > 0 ? droppedHeaders.join(", ") : "--"}
|
|
||||||
</KeyValueRow>
|
|
||||||
</KeyValueRows>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Settings - show as key/value
|
|
||||||
if (e.type === "setting") {
|
|
||||||
return (
|
|
||||||
<KeyValueRows>
|
|
||||||
<KeyValueRow label="Setting">{e.name}</KeyValueRow>
|
|
||||||
<KeyValueRow label="Value">{e.value}</KeyValueRow>
|
|
||||||
</KeyValueRows>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chunks - show formatted bytes
|
|
||||||
if (e.type === "chunk_sent" || e.type === "chunk_received") {
|
|
||||||
return <div className="font-mono text-editor">{formatBytes(e.bytes)}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// DNS Resolution - show hostname, addresses, and timing
|
|
||||||
if (e.type === "dns_resolved") {
|
|
||||||
return (
|
|
||||||
<KeyValueRows>
|
|
||||||
<KeyValueRow label="Hostname">{e.hostname}</KeyValueRow>
|
|
||||||
<KeyValueRow label="Addresses">{e.addresses.join(", ")}</KeyValueRow>
|
|
||||||
<KeyValueRow label="Duration">
|
|
||||||
{e.overridden ? (
|
|
||||||
<span className="text-text-subtlest">--</span>
|
|
||||||
) : (
|
|
||||||
`${String(e.duration)}ms`
|
|
||||||
)}
|
|
||||||
</KeyValueRow>
|
|
||||||
{e.overridden ? <KeyValueRow label="Source">Workspace Override</KeyValueRow> : null}
|
|
||||||
</KeyValueRows>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default - use summary
|
|
||||||
const { summary } = getEventDisplay(event.event);
|
|
||||||
return <div className="font-mono text-editor">{summary}</div>;
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-2 h-full">
|
|
||||||
<EventDetailHeader
|
|
||||||
title={title}
|
|
||||||
timestamp={event.createdAt}
|
|
||||||
actions={actions}
|
|
||||||
onClose={onClose}
|
|
||||||
/>
|
|
||||||
{renderContent()}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type EventTextParts = { prefix: ">" | "<" | "*"; text: string };
|
|
||||||
|
|
||||||
/** Get the prefix and text for an event */
|
|
||||||
function getEventTextParts(event: HttpResponseEventData): EventTextParts {
|
|
||||||
switch (event.type) {
|
|
||||||
case "send_url":
|
|
||||||
return {
|
|
||||||
prefix: ">",
|
|
||||||
text: `${event.method} ${event.path}${event.query ? `?${event.query}` : ""}${event.fragment ? `#${event.fragment}` : ""}`,
|
|
||||||
};
|
|
||||||
case "receive_url":
|
|
||||||
return { prefix: "<", text: `${event.version} ${event.status}` };
|
|
||||||
case "header_up":
|
|
||||||
return { prefix: ">", text: `${event.name}: ${event.value}` };
|
|
||||||
case "header_down":
|
|
||||||
return { prefix: "<", text: `${event.name}: ${event.value}` };
|
|
||||||
case "redirect": {
|
|
||||||
const behavior = event.behavior === "drop_body" ? "drop body" : "preserve";
|
|
||||||
const droppedHeaders = event.dropped_headers ?? [];
|
|
||||||
const dropped = [
|
|
||||||
event.dropped_body ? "body dropped" : null,
|
|
||||||
droppedHeaders.length > 0 ? `headers dropped: ${droppedHeaders.join(", ")}` : null,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(", ");
|
|
||||||
return {
|
|
||||||
prefix: "*",
|
|
||||||
text: `Redirect ${event.status} -> ${event.url} (${behavior}${dropped ? `, ${dropped}` : ""})`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
case "setting":
|
|
||||||
return { prefix: "*", text: `Setting ${event.name}=${event.value}` };
|
|
||||||
case "info":
|
|
||||||
return { prefix: "*", text: event.message };
|
|
||||||
case "chunk_sent":
|
|
||||||
return { prefix: "*", text: `[${formatBytes(event.bytes)} sent]` };
|
|
||||||
case "chunk_received":
|
|
||||||
return { prefix: "*", text: `[${formatBytes(event.bytes)} received]` };
|
|
||||||
case "dns_resolved":
|
|
||||||
if (event.overridden) {
|
|
||||||
return {
|
|
||||||
prefix: "*",
|
|
||||||
text: `DNS override ${event.hostname} -> ${event.addresses.join(", ")}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
prefix: "*",
|
|
||||||
text: `DNS resolved ${event.hostname} to ${event.addresses.join(", ")} (${event.duration}ms)`,
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return { prefix: "*", text: "[unknown event]" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Format event as plaintext, optionally with curl-style prefix (> outgoing, < incoming, * info) */
|
|
||||||
function formatEventText(event: HttpResponseEventData, includePrefix: boolean): string {
|
|
||||||
const { prefix, text } = getEventTextParts(event);
|
|
||||||
return includePrefix ? `${prefix} ${text}` : text;
|
|
||||||
}
|
|
||||||
|
|
||||||
type EventDisplay = {
|
|
||||||
icon: IconProps["icon"];
|
|
||||||
color: IconProps["color"];
|
|
||||||
label: string;
|
|
||||||
summary: ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
function getEventDisplay(event: HttpResponseEventData): EventDisplay {
|
|
||||||
switch (event.type) {
|
|
||||||
case "setting":
|
|
||||||
return {
|
|
||||||
icon: "settings",
|
|
||||||
color: "secondary",
|
|
||||||
label: "Setting",
|
|
||||||
summary: `${event.name} = ${event.value}`,
|
|
||||||
};
|
|
||||||
case "info":
|
|
||||||
return {
|
|
||||||
icon: "info",
|
|
||||||
color: "secondary",
|
|
||||||
label: "Info",
|
|
||||||
summary: event.message,
|
|
||||||
};
|
|
||||||
case "redirect": {
|
|
||||||
const droppedHeaders = event.dropped_headers ?? [];
|
|
||||||
const dropped = [
|
|
||||||
event.dropped_body ? "drop body" : null,
|
|
||||||
droppedHeaders.length > 0
|
|
||||||
? `drop ${droppedHeaders.length} ${droppedHeaders.length === 1 ? "header" : "headers"}`
|
|
||||||
: null,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(", ");
|
|
||||||
return {
|
|
||||||
icon: "arrow_big_right_dash",
|
|
||||||
color: "success",
|
|
||||||
label: "Redirect",
|
|
||||||
summary: `Redirecting ${event.status} ${event.url}${dropped ? ` (${dropped})` : ""}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
case "send_url":
|
|
||||||
return {
|
|
||||||
icon: "arrow_big_up_dash",
|
|
||||||
color: "primary",
|
|
||||||
label: "Request",
|
|
||||||
summary: `${event.method} ${event.path}${event.query ? `?${event.query}` : ""}${event.fragment ? `#${event.fragment}` : ""}`,
|
|
||||||
};
|
|
||||||
case "receive_url":
|
|
||||||
return {
|
|
||||||
icon: "arrow_big_down_dash",
|
|
||||||
color: "info",
|
|
||||||
label: "Response",
|
|
||||||
summary: `${event.version} ${event.status}`,
|
|
||||||
};
|
|
||||||
case "header_up":
|
|
||||||
return {
|
|
||||||
icon: "arrow_big_up_dash",
|
|
||||||
color: "primary",
|
|
||||||
label: "Header",
|
|
||||||
summary: `${event.name}: ${event.value}`,
|
|
||||||
};
|
|
||||||
case "header_down":
|
|
||||||
return {
|
|
||||||
icon: "arrow_big_down_dash",
|
|
||||||
color: "info",
|
|
||||||
label: "Header",
|
|
||||||
summary: `${event.name}: ${event.value}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
case "chunk_sent":
|
|
||||||
return {
|
|
||||||
icon: "info",
|
|
||||||
color: "secondary",
|
|
||||||
label: "Chunk",
|
|
||||||
summary: `${formatBytes(event.bytes)} chunk sent`,
|
|
||||||
};
|
|
||||||
case "chunk_received":
|
|
||||||
return {
|
|
||||||
icon: "info",
|
|
||||||
color: "secondary",
|
|
||||||
label: "Chunk",
|
|
||||||
summary: `${formatBytes(event.bytes)} chunk received`,
|
|
||||||
};
|
|
||||||
case "dns_resolved":
|
|
||||||
return {
|
|
||||||
icon: "globe",
|
|
||||||
color: event.overridden ? "success" : "secondary",
|
|
||||||
label: event.overridden ? "DNS Override" : "DNS",
|
|
||||||
summary: event.overridden
|
|
||||||
? `${event.hostname} → ${event.addresses.join(", ")} (overridden)`
|
|
||||||
: `${event.hostname} → ${event.addresses.join(", ")} (${event.duration}ms)`,
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
icon: "info",
|
|
||||||
color: "secondary",
|
|
||||||
label: "Unknown",
|
|
||||||
summary: "Unknown event",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,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,10 +0,0 @@
|
|||||||
import { hotkeyActions } from "../hooks/useHotKey";
|
|
||||||
import { HotkeyList } from "./core/HotkeyList";
|
|
||||||
|
|
||||||
export function KeyboardShortcutsDialog() {
|
|
||||||
return (
|
|
||||||
<div className="grid h-full">
|
|
||||||
<HotkeyList hotkeys={hotkeyActions} className="pb-6" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
|
||||||
import type { LicenseCheckStatus } from "@yaakapp-internal/license";
|
|
||||||
import { useLicense } from "@yaakapp-internal/license";
|
|
||||||
import { settingsAtom } from "@yaakapp-internal/models";
|
|
||||||
import { differenceInCalendarDays } from "date-fns";
|
|
||||||
import { formatDate } from "date-fns/format";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { openSettings } from "../commands/openSettings";
|
|
||||||
import { atomWithKVStorage } from "../lib/atoms/atomWithKVStorage";
|
|
||||||
import { jotaiStore } from "../lib/jotai";
|
|
||||||
import { CargoFeature } from "./CargoFeature";
|
|
||||||
import type { ButtonProps } from "./core/Button";
|
|
||||||
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
|
||||||
import { Icon } from "@yaakapp-internal/ui";
|
|
||||||
import { PillButton } from "./core/PillButton";
|
|
||||||
|
|
||||||
const dismissedAtom = atomWithKVStorage<string | null>("dismissed_license_expired", null);
|
|
||||||
|
|
||||||
function getDetail(
|
|
||||||
data: LicenseCheckStatus,
|
|
||||||
dismissedExpired: string | null,
|
|
||||||
): { label: ReactNode; color: ButtonProps["color"]; options?: DropdownItem[] } | null | undefined {
|
|
||||||
const dismissedAt = dismissedExpired ? new Date(dismissedExpired).getTime() : null;
|
|
||||||
|
|
||||||
switch (data.status) {
|
|
||||||
case "active":
|
|
||||||
return null;
|
|
||||||
case "personal_use":
|
|
||||||
return { label: "Personal Use", color: "notice" };
|
|
||||||
case "trialing":
|
|
||||||
return { label: "Commercial Trial", color: "secondary" };
|
|
||||||
case "error":
|
|
||||||
return { label: "Error", color: "danger" };
|
|
||||||
case "inactive":
|
|
||||||
return { label: "Personal Use", color: "notice" };
|
|
||||||
case "past_due":
|
|
||||||
return { label: "Past Due", color: "danger" };
|
|
||||||
case "expired":
|
|
||||||
// Don't show the expired message if it's been less than 14 days since the last dismissal
|
|
||||||
if (dismissedAt && differenceInCalendarDays(new Date(), dismissedAt) < 14) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
color: "notice",
|
|
||||||
label: data.data.changes > 0 ? "Updates Paused" : "License Expired",
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
label: `${data.data.changes} New Updates`,
|
|
||||||
color: "success",
|
|
||||||
leftSlot: <Icon icon="gift" />,
|
|
||||||
rightSlot: <Icon icon="external_link" size="sm" className="opacity-disabled" />,
|
|
||||||
hidden: data.data.changes === 0 || data.data.changesUrl == null,
|
|
||||||
onSelect: () => openUrl(data.data.changesUrl ?? ""),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "separator",
|
|
||||||
label: `License expired ${formatDate(data.data.periodEnd, "MMM dd, yyyy")}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: <div className="min-w-[12rem]">Renew License</div>,
|
|
||||||
leftSlot: <Icon icon="refresh" />,
|
|
||||||
rightSlot: <Icon icon="external_link" size="sm" className="opacity-disabled" />,
|
|
||||||
hidden: data.data.changesUrl == null,
|
|
||||||
onSelect: () => openUrl(data.data.billingUrl),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Enter License Key",
|
|
||||||
leftSlot: <Icon icon="key_round" />,
|
|
||||||
hidden: data.data.changesUrl == null,
|
|
||||||
onSelect: openLicenseDialog,
|
|
||||||
},
|
|
||||||
{ type: "separator" },
|
|
||||||
{
|
|
||||||
label: <span className="text-text-subtle">Remind me Later</span>,
|
|
||||||
leftSlot: <Icon icon="alarm_clock" className="text-text-subtle" />,
|
|
||||||
onSelect: () => jotaiStore.set(dismissedAtom, new Date().toISOString()),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LicenseBadge() {
|
|
||||||
return (
|
|
||||||
<CargoFeature feature="license">
|
|
||||||
<LicenseBadgeCmp />
|
|
||||||
</CargoFeature>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function LicenseBadgeCmp() {
|
|
||||||
const { check } = useLicense();
|
|
||||||
const settings = useAtomValue(settingsAtom);
|
|
||||||
const dismissed = useAtomValue(dismissedAtom);
|
|
||||||
|
|
||||||
// Dismissed license badge
|
|
||||||
if (settings.hideLicenseBadge) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (check.error) {
|
|
||||||
// Failed to check for license. Probably a network or server error, so just don't show anything.
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hasn't loaded yet
|
|
||||||
if (check.data == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const detail = getDetail(check.data, dismissed);
|
|
||||||
if (detail == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (detail.options && detail.options.length > 0) {
|
|
||||||
return (
|
|
||||||
<Dropdown items={detail.options}>
|
|
||||||
<PillButton color={detail.color}>
|
|
||||||
<div className="flex items-center gap-0.5">
|
|
||||||
{detail.label} <Icon icon="chevron_down" className="opacity-60" />
|
|
||||||
</div>
|
|
||||||
</PillButton>
|
|
||||||
</Dropdown>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PillButton color={detail.color} onClick={openLicenseDialog}>
|
|
||||||
{detail.label}
|
|
||||||
</PillButton>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openLicenseDialog() {
|
|
||||||
openSettings.mutate("license");
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
|
||||||
import { resolveResource } from "@tauri-apps/api/path";
|
|
||||||
import classNames from "classnames";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
src: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LocalImage({ src: srcPath, className }: Props) {
|
|
||||||
const src = useQuery({
|
|
||||||
queryKey: ["local-image", srcPath],
|
|
||||||
queryFn: async () => {
|
|
||||||
const p = await resolveResource(srcPath);
|
|
||||||
return convertFileSrc(p);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
src={src.data}
|
|
||||||
alt="Response preview"
|
|
||||||
className={classNames(
|
|
||||||
className,
|
|
||||||
"transition-opacity",
|
|
||||||
src.data == null ? "opacity-0" : "opacity-100",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,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,102 +0,0 @@
|
|||||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
|
||||||
import { lazy, Suspense } from "react";
|
|
||||||
import { useHttpRequestBody } from "../hooks/useHttpRequestBody";
|
|
||||||
import { getMimeTypeFromContentType, languageFromContentType } from "../lib/contentType";
|
|
||||||
import { LoadingIcon } from "@yaakapp-internal/ui";
|
|
||||||
import { EmptyStateText } from "./EmptyStateText";
|
|
||||||
import { AudioViewer } from "./responseViewers/AudioViewer";
|
|
||||||
import { CsvViewer } from "./responseViewers/CsvViewer";
|
|
||||||
import { ImageViewer } from "./responseViewers/ImageViewer";
|
|
||||||
import { MultipartViewer } from "./responseViewers/MultipartViewer";
|
|
||||||
import { SvgViewer } from "./responseViewers/SvgViewer";
|
|
||||||
import { TextViewer } from "./responseViewers/TextViewer";
|
|
||||||
import { VideoViewer } from "./responseViewers/VideoViewer";
|
|
||||||
import { WebPageViewer } from "./responseViewers/WebPageViewer";
|
|
||||||
|
|
||||||
const PdfViewer = lazy(() =>
|
|
||||||
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
|
|
||||||
);
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
response: HttpResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RequestBodyViewer({ response }: Props) {
|
|
||||||
return <RequestBodyViewerInner key={response.id} response={response} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function RequestBodyViewerInner({ response }: Props) {
|
|
||||||
const { data, isLoading, error } = useHttpRequestBody(response);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<EmptyStateText>
|
|
||||||
<LoadingIcon />
|
|
||||||
</EmptyStateText>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return <EmptyStateText>Error loading request body: {error.message}</EmptyStateText>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data?.bodyText == null || data.bodyText.length === 0) {
|
|
||||||
return <EmptyStateText>No request body</EmptyStateText>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { bodyText, body } = data;
|
|
||||||
|
|
||||||
// Try to detect language from content-type header that was sent
|
|
||||||
const contentTypeHeader = response.requestHeaders.find(
|
|
||||||
(h) => h.name.toLowerCase() === "content-type",
|
|
||||||
);
|
|
||||||
const contentType = contentTypeHeader?.value ?? null;
|
|
||||||
const mimeType = contentType ? getMimeTypeFromContentType(contentType).essence : null;
|
|
||||||
const language = languageFromContentType(contentType, bodyText);
|
|
||||||
|
|
||||||
// Route to appropriate viewer based on content type
|
|
||||||
if (mimeType?.match(/^multipart/i)) {
|
|
||||||
const boundary = contentType?.split("boundary=")[1] ?? "unknown";
|
|
||||||
// Create a copy because parseMultipart may detach the buffer
|
|
||||||
const bodyCopy = new Uint8Array(body);
|
|
||||||
return (
|
|
||||||
<MultipartViewer data={bodyCopy} boundary={boundary} idPrefix={`request.${response.id}`} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mimeType?.match(/^image\/svg/i)) {
|
|
||||||
return <SvgViewer text={bodyText} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mimeType?.match(/^image/i)) {
|
|
||||||
return <ImageViewer data={body.buffer} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mimeType?.match(/^audio/i)) {
|
|
||||||
return <AudioViewer data={body} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mimeType?.match(/^video/i)) {
|
|
||||||
return <VideoViewer data={body} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mimeType?.match(/csv|tab-separated/i)) {
|
|
||||||
return <CsvViewer text={bodyText} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mimeType?.match(/^text\/html/i)) {
|
|
||||||
return <WebPageViewer html={bodyText} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mimeType?.match(/pdf/i)) {
|
|
||||||
return (
|
|
||||||
<Suspense fallback={<LoadingIcon />}>
|
|
||||||
<PdfViewer data={body} />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TextViewer text={bodyText} language={language} stateKey={`request.body.${response.id}`} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,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,225 +0,0 @@
|
|||||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import type { JSX } from "react/jsx-runtime";
|
|
||||||
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
|
|
||||||
import { CountBadge } from "./core/CountBadge";
|
|
||||||
import { DetailsBanner } from "./core/DetailsBanner";
|
|
||||||
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
response: HttpResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ParsedCookie {
|
|
||||||
name: string;
|
|
||||||
value: string;
|
|
||||||
domain?: string;
|
|
||||||
path?: string;
|
|
||||||
expires?: string;
|
|
||||||
maxAge?: string;
|
|
||||||
secure?: boolean;
|
|
||||||
httpOnly?: boolean;
|
|
||||||
sameSite?: string;
|
|
||||||
isDeleted?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseCookieHeader(cookieHeader: string): Array<{ name: string; value: string }> {
|
|
||||||
// Parse "Cookie: name=value; name2=value2" format
|
|
||||||
return cookieHeader.split(";").map((pair) => {
|
|
||||||
const [name = "", ...valueParts] = pair.split("=");
|
|
||||||
return {
|
|
||||||
name: name.trim(),
|
|
||||||
value: valueParts.join("=").trim(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseSetCookieHeader(setCookieHeader: string): ParsedCookie {
|
|
||||||
// Parse "Set-Cookie: name=value; Domain=...; Path=..." format
|
|
||||||
const parts = setCookieHeader.split(";").map((p) => p.trim());
|
|
||||||
const [nameValue = "", ...attributes] = parts;
|
|
||||||
const [name = "", ...valueParts] = nameValue.split("=");
|
|
||||||
|
|
||||||
const cookie: ParsedCookie = {
|
|
||||||
name: name.trim(),
|
|
||||||
value: valueParts.join("=").trim(),
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const attr of attributes) {
|
|
||||||
const [key = "", val] = attr.split("=").map((s) => s.trim());
|
|
||||||
const lowerKey = key.toLowerCase();
|
|
||||||
|
|
||||||
if (lowerKey === "domain") cookie.domain = val;
|
|
||||||
else if (lowerKey === "path") cookie.path = val;
|
|
||||||
else if (lowerKey === "expires") cookie.expires = val;
|
|
||||||
else if (lowerKey === "max-age") cookie.maxAge = val;
|
|
||||||
else if (lowerKey === "secure") cookie.secure = true;
|
|
||||||
else if (lowerKey === "httponly") cookie.httpOnly = true;
|
|
||||||
else if (lowerKey === "samesite") cookie.sameSite = val;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect if cookie is being deleted
|
|
||||||
if (cookie.maxAge !== undefined) {
|
|
||||||
const maxAgeNum = Number.parseInt(cookie.maxAge, 10);
|
|
||||||
if (!Number.isNaN(maxAgeNum) && maxAgeNum <= 0) {
|
|
||||||
cookie.isDeleted = true;
|
|
||||||
}
|
|
||||||
} else if (cookie.expires !== undefined) {
|
|
||||||
// Check if expires date is in the past
|
|
||||||
try {
|
|
||||||
const expiresDate = new Date(cookie.expires);
|
|
||||||
if (expiresDate.getTime() < Date.now()) {
|
|
||||||
cookie.isDeleted = true;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Invalid date, ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return cookie;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ResponseCookies({ response }: Props) {
|
|
||||||
const { data: events } = useHttpResponseEvents(response);
|
|
||||||
|
|
||||||
const { sentCookies, receivedCookies } = useMemo(() => {
|
|
||||||
if (!events) return { sentCookies: [], receivedCookies: [] };
|
|
||||||
|
|
||||||
// Use Maps to deduplicate by cookie name (latest value wins)
|
|
||||||
const sentMap = new Map<string, { name: string; value: string }>();
|
|
||||||
const receivedMap = new Map<string, ParsedCookie>();
|
|
||||||
|
|
||||||
for (const event of events) {
|
|
||||||
const e = event.event;
|
|
||||||
|
|
||||||
// Cookie headers sent (header_up with name=cookie)
|
|
||||||
if (e.type === "header_up" && e.name.toLowerCase() === "cookie") {
|
|
||||||
const cookies = parseCookieHeader(e.value);
|
|
||||||
for (const cookie of cookies) {
|
|
||||||
sentMap.set(cookie.name, cookie);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set-Cookie headers received (header_down with name=set-cookie)
|
|
||||||
if (e.type === "header_down" && e.name.toLowerCase() === "set-cookie") {
|
|
||||||
const cookie = parseSetCookieHeader(e.value);
|
|
||||||
receivedMap.set(cookie.name, cookie);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
sentCookies: Array.from(sentMap.values()),
|
|
||||||
receivedCookies: Array.from(receivedMap.values()),
|
|
||||||
};
|
|
||||||
}, [events]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5">
|
|
||||||
<DetailsBanner
|
|
||||||
defaultOpen
|
|
||||||
storageKey={`${response.requestId}.sent_cookies`}
|
|
||||||
summary={
|
|
||||||
<h2 className="flex items-center">
|
|
||||||
Sent Cookies <CountBadge showZero count={sentCookies.length} />
|
|
||||||
</h2>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{sentCookies.length === 0 ? (
|
|
||||||
<NoCookies />
|
|
||||||
) : (
|
|
||||||
<KeyValueRows>
|
|
||||||
{sentCookies.map((cookie, i) => (
|
|
||||||
// oxlint-disable-next-line react/no-array-index-key
|
|
||||||
<KeyValueRow labelColor="primary" key={i} label={cookie.name}>
|
|
||||||
{cookie.value}
|
|
||||||
</KeyValueRow>
|
|
||||||
))}
|
|
||||||
</KeyValueRows>
|
|
||||||
)}
|
|
||||||
</DetailsBanner>
|
|
||||||
|
|
||||||
<DetailsBanner
|
|
||||||
defaultOpen
|
|
||||||
storageKey={`${response.requestId}.received_cookies`}
|
|
||||||
summary={
|
|
||||||
<h2 className="flex items-center">
|
|
||||||
Received Cookies <CountBadge showZero count={receivedCookies.length} />
|
|
||||||
</h2>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{receivedCookies.length === 0 ? (
|
|
||||||
<NoCookies />
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{receivedCookies.map((cookie, i) => (
|
|
||||||
// oxlint-disable-next-line react/no-array-index-key
|
|
||||||
<div key={i} className="flex flex-col gap-1">
|
|
||||||
<div className="flex items-center gap-2 my-1">
|
|
||||||
<span
|
|
||||||
className={classNames(
|
|
||||||
"font-mono text-editor select-auto cursor-auto",
|
|
||||||
cookie.isDeleted ? "line-through opacity-60 text-text-subtle" : "text-text",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{cookie.name}
|
|
||||||
<span className="text-text-subtlest select-auto cursor-auto mx-0.5">=</span>
|
|
||||||
{cookie.value}
|
|
||||||
</span>
|
|
||||||
{cookie.isDeleted && (
|
|
||||||
<span className="text-xs font-sans text-danger bg-danger/10 px-1.5 py-0.5 rounded">
|
|
||||||
Deleted
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<KeyValueRows>
|
|
||||||
{[
|
|
||||||
cookie.domain && (
|
|
||||||
<KeyValueRow labelColor="info" label="Domain" key="domain">
|
|
||||||
{cookie.domain}
|
|
||||||
</KeyValueRow>
|
|
||||||
),
|
|
||||||
cookie.path && (
|
|
||||||
<KeyValueRow labelColor="info" label="Path" key="path">
|
|
||||||
{cookie.path}
|
|
||||||
</KeyValueRow>
|
|
||||||
),
|
|
||||||
cookie.expires && (
|
|
||||||
<KeyValueRow labelColor="info" label="Expires" key="expires">
|
|
||||||
{cookie.expires}
|
|
||||||
</KeyValueRow>
|
|
||||||
),
|
|
||||||
cookie.maxAge && (
|
|
||||||
<KeyValueRow labelColor="info" label="Max-Age" key="maxAge">
|
|
||||||
{cookie.maxAge}
|
|
||||||
</KeyValueRow>
|
|
||||||
),
|
|
||||||
cookie.secure && (
|
|
||||||
<KeyValueRow labelColor="info" label="Secure" key="secure">
|
|
||||||
true
|
|
||||||
</KeyValueRow>
|
|
||||||
),
|
|
||||||
cookie.httpOnly && (
|
|
||||||
<KeyValueRow labelColor="info" label="HttpOnly" key="httpOnly">
|
|
||||||
true
|
|
||||||
</KeyValueRow>
|
|
||||||
),
|
|
||||||
cookie.sameSite && (
|
|
||||||
<KeyValueRow labelColor="info" label="SameSite" key="sameSite">
|
|
||||||
{cookie.sameSite}
|
|
||||||
</KeyValueRow>
|
|
||||||
),
|
|
||||||
].filter((item): item is JSX.Element => Boolean(item))}
|
|
||||||
</KeyValueRows>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DetailsBanner>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NoCookies() {
|
|
||||||
return <span className="text-text-subtlest text-sm italic">No Cookies</span>;
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
|
||||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { CountBadge } from "./core/CountBadge";
|
|
||||||
import { DetailsBanner } from "./core/DetailsBanner";
|
|
||||||
import { IconButton } from "./core/IconButton";
|
|
||||||
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
response: HttpResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ResponseHeaders({ response }: Props) {
|
|
||||||
const responseHeaders = useMemo(
|
|
||||||
() =>
|
|
||||||
[...response.headers].sort((a, b) =>
|
|
||||||
a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()),
|
|
||||||
),
|
|
||||||
[response.headers],
|
|
||||||
);
|
|
||||||
const requestHeaders = useMemo(
|
|
||||||
() =>
|
|
||||||
[...response.requestHeaders].sort((a, b) =>
|
|
||||||
a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()),
|
|
||||||
),
|
|
||||||
[response.requestHeaders],
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5">
|
|
||||||
<DetailsBanner storageKey={`${response.requestId}.general`} summary={<h2>Info</h2>}>
|
|
||||||
<KeyValueRows>
|
|
||||||
<KeyValueRow labelColor="secondary" label="Request URL">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<span className="select-text cursor-text">{response.url}</span>
|
|
||||||
<IconButton
|
|
||||||
iconSize="sm"
|
|
||||||
className="inline-block w-auto !h-auto opacity-50 hover:opacity-100"
|
|
||||||
icon="external_link"
|
|
||||||
onClick={() => openUrl(response.url)}
|
|
||||||
title="Open in browser"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</KeyValueRow>
|
|
||||||
<KeyValueRow labelColor="secondary" label="Remote Address">
|
|
||||||
{response.remoteAddr ?? <span className="text-text-subtlest">--</span>}
|
|
||||||
</KeyValueRow>
|
|
||||||
<KeyValueRow labelColor="secondary" label="Version">
|
|
||||||
{response.version ?? <span className="text-text-subtlest">--</span>}
|
|
||||||
</KeyValueRow>
|
|
||||||
</KeyValueRows>
|
|
||||||
</DetailsBanner>
|
|
||||||
<DetailsBanner
|
|
||||||
storageKey={`${response.requestId}.request_headers`}
|
|
||||||
summary={
|
|
||||||
<h2 className="flex items-center">
|
|
||||||
Request Headers <CountBadge showZero count={requestHeaders.length} />
|
|
||||||
</h2>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{requestHeaders.length === 0 ? (
|
|
||||||
<NoHeaders />
|
|
||||||
) : (
|
|
||||||
<KeyValueRows>
|
|
||||||
{requestHeaders.map((h, i) => (
|
|
||||||
// oxlint-disable-next-line react/no-array-index-key
|
|
||||||
<KeyValueRow labelColor="primary" key={i} label={h.name}>
|
|
||||||
{h.value}
|
|
||||||
</KeyValueRow>
|
|
||||||
))}
|
|
||||||
</KeyValueRows>
|
|
||||||
)}
|
|
||||||
</DetailsBanner>
|
|
||||||
<DetailsBanner
|
|
||||||
defaultOpen
|
|
||||||
storageKey={`${response.requestId}.response_headers`}
|
|
||||||
summary={
|
|
||||||
<h2 className="flex items-center">
|
|
||||||
Response Headers <CountBadge showZero count={responseHeaders.length} />
|
|
||||||
</h2>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{responseHeaders.length === 0 ? (
|
|
||||||
<NoHeaders />
|
|
||||||
) : (
|
|
||||||
<KeyValueRows>
|
|
||||||
{responseHeaders.map((h, i) => (
|
|
||||||
// oxlint-disable-next-line react/no-array-index-key
|
|
||||||
<KeyValueRow labelColor="info" key={i} label={h.name}>
|
|
||||||
{h.value}
|
|
||||||
</KeyValueRow>
|
|
||||||
))}
|
|
||||||
</KeyValueRows>
|
|
||||||
)}
|
|
||||||
</DetailsBanner>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NoHeaders() {
|
|
||||||
return <span className="text-text-subtlest text-sm italic">No Headers</span>;
|
|
||||||
}
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
|
||||||
import { open } from "@tauri-apps/plugin-dialog";
|
|
||||||
import { HStack } from "@yaakapp-internal/ui";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import mime from "mime";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import type { ButtonProps } from "./core/Button";
|
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import { IconButton } from "./core/IconButton";
|
|
||||||
import { IconTooltip } from "./core/IconTooltip";
|
|
||||||
import { Label } from "./core/Label";
|
|
||||||
|
|
||||||
type Props = Omit<ButtonProps, "type"> & {
|
|
||||||
onChange: (value: { filePath: string | null; contentType: string | null }) => void;
|
|
||||||
filePath: string | null;
|
|
||||||
nameOverride?: string | null;
|
|
||||||
directory?: boolean;
|
|
||||||
inline?: boolean;
|
|
||||||
noun?: string;
|
|
||||||
help?: ReactNode;
|
|
||||||
label?: ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Special character to insert ltr text in rtl element
|
|
||||||
const rtlEscapeChar = <>‎</>;
|
|
||||||
|
|
||||||
export function SelectFile({
|
|
||||||
onChange,
|
|
||||||
filePath,
|
|
||||||
inline,
|
|
||||||
className,
|
|
||||||
directory,
|
|
||||||
noun,
|
|
||||||
nameOverride,
|
|
||||||
size = "sm",
|
|
||||||
label,
|
|
||||||
help,
|
|
||||||
...props
|
|
||||||
}: Props) {
|
|
||||||
const handleClick = async () => {
|
|
||||||
const filePath = await open({
|
|
||||||
title: directory ? "Select Folder" : "Select File",
|
|
||||||
multiple: false,
|
|
||||||
directory,
|
|
||||||
});
|
|
||||||
if (filePath == null) return;
|
|
||||||
const contentType = filePath ? mime.getType(filePath) : null;
|
|
||||||
onChange({ filePath, contentType });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClear = async () => {
|
|
||||||
onChange({ filePath: null, contentType: null });
|
|
||||||
};
|
|
||||||
|
|
||||||
const itemLabel = noun ?? (directory ? "Folder" : "File");
|
|
||||||
const selectOrChange = (filePath ? "Change " : "Select ") + itemLabel;
|
|
||||||
const [isHovering, setIsHovering] = useState(false);
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Listen for dropped files on the element
|
|
||||||
// NOTE: This doesn't work for Windows since native drag-n-drop can't work at the same tmie
|
|
||||||
// as browser drag-n-drop.
|
|
||||||
useEffect(() => {
|
|
||||||
let unlisten: (() => void) | undefined;
|
|
||||||
const setup = async () => {
|
|
||||||
const webview = getCurrentWebviewWindow();
|
|
||||||
unlisten = await webview.onDragDropEvent((event) => {
|
|
||||||
if (event.payload.type === "over") {
|
|
||||||
const p = event.payload.position;
|
|
||||||
const r = ref.current?.getBoundingClientRect();
|
|
||||||
if (r == null) return;
|
|
||||||
const isOver = p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom;
|
|
||||||
console.log("IS OVER", isOver);
|
|
||||||
setIsHovering(isOver);
|
|
||||||
} else if (event.payload.type === "drop" && isHovering) {
|
|
||||||
console.log("User dropped", event.payload.paths);
|
|
||||||
const p = event.payload.paths[0];
|
|
||||||
if (p) onChange({ filePath: p, contentType: null });
|
|
||||||
setIsHovering(false);
|
|
||||||
} else {
|
|
||||||
console.log("File drop cancelled");
|
|
||||||
setIsHovering(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
setup().catch(console.error);
|
|
||||||
return () => {
|
|
||||||
if (unlisten) unlisten();
|
|
||||||
};
|
|
||||||
}, [isHovering, onChange]);
|
|
||||||
|
|
||||||
const filePathWithNameOverride = nameOverride ? `${filePath} (${nameOverride})` : filePath;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={ref} className="w-full">
|
|
||||||
{label && (
|
|
||||||
<Label htmlFor={null} help={help}>
|
|
||||||
{label}
|
|
||||||
</Label>
|
|
||||||
)}
|
|
||||||
<HStack className="relative justify-stretch overflow-hidden">
|
|
||||||
<Button
|
|
||||||
className={classNames(
|
|
||||||
className,
|
|
||||||
"rtl mr-1.5",
|
|
||||||
inline && "w-full",
|
|
||||||
filePath && inline && "font-mono text-xs",
|
|
||||||
isHovering && "!border-notice",
|
|
||||||
)}
|
|
||||||
color={isHovering ? "primary" : "secondary"}
|
|
||||||
onClick={handleClick}
|
|
||||||
size={size}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{rtlEscapeChar}
|
|
||||||
{inline ? filePathWithNameOverride || selectOrChange : selectOrChange}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{!inline && (
|
|
||||||
<>
|
|
||||||
{filePath && (
|
|
||||||
<IconButton
|
|
||||||
size={size === "auto" ? "md" : size}
|
|
||||||
variant="border"
|
|
||||||
icon="x"
|
|
||||||
title={`Unset ${itemLabel}`}
|
|
||||||
onClick={handleClear}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
"truncate rtl pl-1.5 pr-3 text-text",
|
|
||||||
filePath && "font-mono",
|
|
||||||
size === "xs" && filePath && "text-xs",
|
|
||||||
size === "sm" && filePath && "text-sm",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{rtlEscapeChar}
|
|
||||||
{filePath ?? `No ${itemLabel.toLowerCase()} selected`}
|
|
||||||
</div>
|
|
||||||
{filePath == null && help && !label && <IconTooltip content={help} />}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
import { useSearch } from "@tanstack/react-router";
|
|
||||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
|
||||||
import { type } from "@tauri-apps/plugin-os";
|
|
||||||
import { useLicense } from "@yaakapp-internal/license";
|
|
||||||
import { pluginsAtom, settingsAtom } from "@yaakapp-internal/models";
|
|
||||||
import { HeaderSize, HStack, Icon } from "@yaakapp-internal/ui";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useKeyPressEvent } from "react-use";
|
|
||||||
import { appInfo } from "../../lib/appInfo";
|
|
||||||
import { capitalize } from "../../lib/capitalize";
|
|
||||||
import { CountBadge } from "../core/CountBadge";
|
|
||||||
import { TabContent, type TabItem, Tabs } from "../core/Tabs/Tabs";
|
|
||||||
import { SettingsCertificates } from "./SettingsCertificates";
|
|
||||||
import { SettingsGeneral } from "./SettingsGeneral";
|
|
||||||
import { SettingsHotkeys } from "./SettingsHotkeys";
|
|
||||||
import { SettingsInterface } from "./SettingsInterface";
|
|
||||||
import { SettingsLicense } from "./SettingsLicense";
|
|
||||||
import { SettingsPlugins } from "./SettingsPlugins";
|
|
||||||
import { SettingsProxy } from "./SettingsProxy";
|
|
||||||
import { SettingsTheme } from "./SettingsTheme";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
hide?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TAB_GENERAL = "general";
|
|
||||||
const TAB_INTERFACE = "interface";
|
|
||||||
const TAB_THEME = "theme";
|
|
||||||
const TAB_SHORTCUTS = "shortcuts";
|
|
||||||
const TAB_PROXY = "proxy";
|
|
||||||
const TAB_CERTIFICATES = "certificates";
|
|
||||||
const TAB_PLUGINS = "plugins";
|
|
||||||
const TAB_LICENSE = "license";
|
|
||||||
const tabs = [
|
|
||||||
TAB_GENERAL,
|
|
||||||
TAB_THEME,
|
|
||||||
TAB_INTERFACE,
|
|
||||||
TAB_SHORTCUTS,
|
|
||||||
TAB_PLUGINS,
|
|
||||||
TAB_CERTIFICATES,
|
|
||||||
TAB_PROXY,
|
|
||||||
TAB_LICENSE,
|
|
||||||
] as const;
|
|
||||||
export type SettingsTab = (typeof tabs)[number];
|
|
||||||
|
|
||||||
export default function Settings({ hide }: Props) {
|
|
||||||
const { tab: tabFromQuery } = useSearch({ from: "/workspaces/$workspaceId/settings" });
|
|
||||||
// Parse tab and subtab (e.g., "plugins:installed")
|
|
||||||
const [mainTab, subtab] = tabFromQuery?.split(":") ?? [];
|
|
||||||
const settings = useAtomValue(settingsAtom);
|
|
||||||
const plugins = useAtomValue(pluginsAtom);
|
|
||||||
const licenseCheck = useLicense();
|
|
||||||
|
|
||||||
// Close settings window on escape
|
|
||||||
// TODO: Could this be put in a better place? Eg. in Rust key listener when creating the window
|
|
||||||
useKeyPressEvent("Escape", async () => {
|
|
||||||
if (hide != null) {
|
|
||||||
// It's being shown in a dialog, so close the dialog
|
|
||||||
hide();
|
|
||||||
} else {
|
|
||||||
// It's being shown in a window, so close the window
|
|
||||||
await getCurrentWebviewWindow().close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={classNames("grid grid-rows-[auto_minmax(0,1fr)] h-full")}>
|
|
||||||
{hide ? (
|
|
||||||
<span />
|
|
||||||
) : (
|
|
||||||
<HeaderSize
|
|
||||||
data-tauri-drag-region
|
|
||||||
ignoreControlsSpacing
|
|
||||||
onlyXWindowControl
|
|
||||||
size="md"
|
|
||||||
className="x-theme-appHeader bg-surface text-text-subtle flex items-center justify-center border-b border-border-subtle text-sm font-semibold"
|
|
||||||
osType={type()}
|
|
||||||
hideWindowControls={settings.hideWindowControls}
|
|
||||||
useNativeTitlebar={settings.useNativeTitlebar}
|
|
||||||
interfaceScale={settings.interfaceScale}
|
|
||||||
>
|
|
||||||
<HStack
|
|
||||||
space={2}
|
|
||||||
justifyContent="center"
|
|
||||||
className="w-full h-full grid grid-cols-[1fr_auto] pointer-events-none"
|
|
||||||
>
|
|
||||||
<div className={classNames(type() === "macos" ? "text-center" : "pl-2")}>Settings</div>
|
|
||||||
</HStack>
|
|
||||||
</HeaderSize>
|
|
||||||
)}
|
|
||||||
<Tabs
|
|
||||||
layout="horizontal"
|
|
||||||
defaultValue={mainTab || tabFromQuery}
|
|
||||||
addBorders
|
|
||||||
tabListClassName="min-w-[10rem] bg-surface x-theme-sidebar border-r border-border pl-3"
|
|
||||||
label="Settings"
|
|
||||||
tabs={tabs.map(
|
|
||||||
(value): TabItem => ({
|
|
||||||
value,
|
|
||||||
label: capitalize(value),
|
|
||||||
hidden: !appInfo.featureLicense && value === TAB_LICENSE,
|
|
||||||
leftSlot:
|
|
||||||
value === TAB_GENERAL ? (
|
|
||||||
<Icon icon="settings" className="text-secondary" />
|
|
||||||
) : value === TAB_THEME ? (
|
|
||||||
<Icon icon="palette" className="text-secondary" />
|
|
||||||
) : value === TAB_INTERFACE ? (
|
|
||||||
<Icon icon="columns_2" className="text-secondary" />
|
|
||||||
) : value === TAB_SHORTCUTS ? (
|
|
||||||
<Icon icon="keyboard" className="text-secondary" />
|
|
||||||
) : value === TAB_CERTIFICATES ? (
|
|
||||||
<Icon icon="shield_check" className="text-secondary" />
|
|
||||||
) : value === TAB_PROXY ? (
|
|
||||||
<Icon icon="wifi" className="text-secondary" />
|
|
||||||
) : value === TAB_PLUGINS ? (
|
|
||||||
<Icon icon="puzzle" className="text-secondary" />
|
|
||||||
) : value === TAB_LICENSE ? (
|
|
||||||
<Icon icon="key_round" className="text-secondary" />
|
|
||||||
) : null,
|
|
||||||
rightSlot:
|
|
||||||
value === TAB_CERTIFICATES ? (
|
|
||||||
<CountBadge count={settings.clientCertificates.length} />
|
|
||||||
) : value === TAB_PLUGINS ? (
|
|
||||||
<CountBadge count={plugins.filter((p) => p.source !== "bundled").length} />
|
|
||||||
) : value === TAB_PROXY && settings.proxy?.type === "enabled" ? (
|
|
||||||
<CountBadge count />
|
|
||||||
) : value === TAB_LICENSE && licenseCheck.check.data?.status === "personal_use" ? (
|
|
||||||
<CountBadge count color="notice" />
|
|
||||||
) : null,
|
|
||||||
}),
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-6 !py-4">
|
|
||||||
<SettingsGeneral />
|
|
||||||
</TabContent>
|
|
||||||
<TabContent value={TAB_INTERFACE} className="overflow-y-auto h-full px-6 !py-4">
|
|
||||||
<SettingsInterface />
|
|
||||||
</TabContent>
|
|
||||||
<TabContent value={TAB_THEME} className="overflow-y-auto h-full px-6 !py-4">
|
|
||||||
<SettingsTheme />
|
|
||||||
</TabContent>
|
|
||||||
<TabContent value={TAB_SHORTCUTS} className="overflow-y-auto h-full px-6 !py-4">
|
|
||||||
<SettingsHotkeys />
|
|
||||||
</TabContent>
|
|
||||||
<TabContent value={TAB_PLUGINS} className="h-full grid grid-rows-1">
|
|
||||||
<SettingsPlugins defaultSubtab={mainTab === TAB_PLUGINS ? subtab : undefined} />
|
|
||||||
</TabContent>
|
|
||||||
<TabContent value={TAB_PROXY} className="overflow-y-auto h-full px-6 !py-4">
|
|
||||||
<SettingsProxy />
|
|
||||||
</TabContent>
|
|
||||||
<TabContent value={TAB_CERTIFICATES} className="overflow-y-auto h-full px-6 !py-4">
|
|
||||||
<SettingsCertificates />
|
|
||||||
</TabContent>
|
|
||||||
<TabContent value={TAB_LICENSE} className="overflow-y-auto h-full px-6 !py-4">
|
|
||||||
<SettingsLicense />
|
|
||||||
</TabContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
import type { ClientCertificate } from "@yaakapp-internal/models";
|
|
||||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
|
||||||
import { Heading, HStack, InlineCode, VStack } from "@yaakapp-internal/ui";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useRef } from "react";
|
|
||||||
import { showConfirmDelete } from "../../lib/confirm";
|
|
||||||
import { Button } from "../core/Button";
|
|
||||||
import { Checkbox } from "../core/Checkbox";
|
|
||||||
import { DetailsBanner } from "../core/DetailsBanner";
|
|
||||||
import { IconButton } from "../core/IconButton";
|
|
||||||
import { PlainInput } from "../core/PlainInput";
|
|
||||||
import { Separator } from "../core/Separator";
|
|
||||||
import { SelectFile } from "../SelectFile";
|
|
||||||
|
|
||||||
function createEmptyCertificate(): ClientCertificate {
|
|
||||||
return {
|
|
||||||
host: "",
|
|
||||||
port: null,
|
|
||||||
crtFile: null,
|
|
||||||
keyFile: null,
|
|
||||||
pfxFile: null,
|
|
||||||
passphrase: null,
|
|
||||||
enabled: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CertificateEditorProps {
|
|
||||||
certificate: ClientCertificate;
|
|
||||||
index: number;
|
|
||||||
onUpdate: (index: number, cert: ClientCertificate) => void;
|
|
||||||
onRemove: (index: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function CertificateEditor({ certificate, index, onUpdate, onRemove }: CertificateEditorProps) {
|
|
||||||
const updateField = <K extends keyof ClientCertificate>(
|
|
||||||
field: K,
|
|
||||||
value: ClientCertificate[K],
|
|
||||||
) => {
|
|
||||||
onUpdate(index, { ...certificate, [field]: value });
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasPfx = Boolean(certificate.pfxFile && certificate.pfxFile.length > 0);
|
|
||||||
const hasCrtKey = Boolean(
|
|
||||||
(certificate.crtFile && certificate.crtFile.length > 0) ||
|
|
||||||
(certificate.keyFile && certificate.keyFile.length > 0),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Determine certificate type for display
|
|
||||||
const certType = hasPfx ? "PFX" : hasCrtKey ? "CERT" : null;
|
|
||||||
const defaultOpen = useRef<boolean>(!certificate.host);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DetailsBanner
|
|
||||||
defaultOpen={defaultOpen.current}
|
|
||||||
summary={
|
|
||||||
<HStack alignItems="center" justifyContent="between" space={2} className="w-full">
|
|
||||||
<HStack space={1.5}>
|
|
||||||
<Checkbox
|
|
||||||
className="ml-1"
|
|
||||||
checked={certificate.enabled ?? true}
|
|
||||||
title={certificate.enabled ? "Disable certificate" : "Enable certificate"}
|
|
||||||
hideLabel
|
|
||||||
onChange={(enabled) => updateField("enabled", enabled)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{certificate.host ? (
|
|
||||||
<InlineCode>
|
|
||||||
{certificate.host || <> </>}
|
|
||||||
{certificate.port != null && `:${certificate.port}`}
|
|
||||||
</InlineCode>
|
|
||||||
) : (
|
|
||||||
<span className="italic text-sm text-text-subtlest">Configure Certificate</span>
|
|
||||||
)}
|
|
||||||
{certType && <InlineCode>{certType}</InlineCode>}
|
|
||||||
</HStack>
|
|
||||||
<IconButton
|
|
||||||
icon="trash"
|
|
||||||
size="sm"
|
|
||||||
title="Remove certificate"
|
|
||||||
className="text-text-subtlest -mr-2"
|
|
||||||
onClick={() => onRemove(index)}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<VStack space={3} className="mt-2">
|
|
||||||
<HStack space={2} alignItems="end">
|
|
||||||
<PlainInput
|
|
||||||
leftSlot={
|
|
||||||
<div className="bg-surface-highlight flex items-center text-editor font-mono px-2 text-text-subtle mr-1">
|
|
||||||
https://
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
validate={(value) => {
|
|
||||||
if (!value) return false;
|
|
||||||
if (!/^[a-zA-Z0-9_.-]+$/.test(value)) return false;
|
|
||||||
return true;
|
|
||||||
}}
|
|
||||||
label="Host"
|
|
||||||
placeholder="example.com"
|
|
||||||
size="sm"
|
|
||||||
required
|
|
||||||
defaultValue={certificate.host}
|
|
||||||
onChange={(host) => updateField("host", host)}
|
|
||||||
/>
|
|
||||||
<PlainInput
|
|
||||||
label="Port"
|
|
||||||
hideLabel
|
|
||||||
validate={(value) => {
|
|
||||||
if (!value) return true;
|
|
||||||
if (Number.isNaN(parseInt(value, 10))) return false;
|
|
||||||
return true;
|
|
||||||
}}
|
|
||||||
placeholder="443"
|
|
||||||
leftSlot={
|
|
||||||
<div className="bg-surface-highlight flex items-center text-editor font-mono px-2 text-text-subtle mr-1">
|
|
||||||
:
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
size="sm"
|
|
||||||
className="w-24"
|
|
||||||
defaultValue={certificate.port?.toString() ?? ""}
|
|
||||||
onChange={(port) => updateField("port", port ? parseInt(port, 10) : null)}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
<Separator className="my-3" />
|
|
||||||
|
|
||||||
<VStack space={2}>
|
|
||||||
<SelectFile
|
|
||||||
label="CRT File"
|
|
||||||
noun="Cert"
|
|
||||||
filePath={certificate.crtFile ?? null}
|
|
||||||
size="sm"
|
|
||||||
disabled={hasPfx}
|
|
||||||
onChange={({ filePath }) => updateField("crtFile", filePath)}
|
|
||||||
/>
|
|
||||||
<SelectFile
|
|
||||||
label="KEY File"
|
|
||||||
noun="Key"
|
|
||||||
filePath={certificate.keyFile ?? null}
|
|
||||||
size="sm"
|
|
||||||
disabled={hasPfx}
|
|
||||||
onChange={({ filePath }) => updateField("keyFile", filePath)}
|
|
||||||
/>
|
|
||||||
</VStack>
|
|
||||||
|
|
||||||
<Separator className="my-3" />
|
|
||||||
|
|
||||||
<SelectFile
|
|
||||||
label="PFX File"
|
|
||||||
noun="Key"
|
|
||||||
filePath={certificate.pfxFile ?? null}
|
|
||||||
size="sm"
|
|
||||||
disabled={hasCrtKey}
|
|
||||||
onChange={({ filePath }) => updateField("pfxFile", filePath)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<PlainInput
|
|
||||||
label="Passphrase"
|
|
||||||
size="sm"
|
|
||||||
type="password"
|
|
||||||
defaultValue={certificate.passphrase ?? ""}
|
|
||||||
onChange={(passphrase) => updateField("passphrase", passphrase || null)}
|
|
||||||
/>
|
|
||||||
</VStack>
|
|
||||||
</DetailsBanner>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingsCertificates() {
|
|
||||||
const settings = useAtomValue(settingsAtom);
|
|
||||||
const certificates = settings.clientCertificates ?? [];
|
|
||||||
|
|
||||||
const updateCertificates = async (newCertificates: ClientCertificate[]) => {
|
|
||||||
await patchModel(settings, { clientCertificates: newCertificates });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAdd = async () => {
|
|
||||||
const newCert = createEmptyCertificate();
|
|
||||||
await updateCertificates([...certificates, newCert]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdate = async (index: number, cert: ClientCertificate) => {
|
|
||||||
const newCertificates = [...certificates];
|
|
||||||
newCertificates[index] = cert;
|
|
||||||
await updateCertificates(newCertificates);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemove = async (index: number) => {
|
|
||||||
const cert = certificates[index];
|
|
||||||
if (cert == null) return;
|
|
||||||
|
|
||||||
const host = cert.host || "this certificate";
|
|
||||||
const port = cert.port != null ? `:${cert.port}` : "";
|
|
||||||
|
|
||||||
const confirmed = await showConfirmDelete({
|
|
||||||
id: "confirm-remove-certificate",
|
|
||||||
title: "Delete Certificate",
|
|
||||||
description: (
|
|
||||||
<>
|
|
||||||
Permanently delete certificate for{" "}
|
|
||||||
<InlineCode>
|
|
||||||
{host}
|
|
||||||
{port}
|
|
||||||
</InlineCode>
|
|
||||||
?
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!confirmed) return;
|
|
||||||
|
|
||||||
const newCertificates = certificates.filter((_, i) => i !== index);
|
|
||||||
|
|
||||||
await updateCertificates(newCertificates);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VStack space={3}>
|
|
||||||
<div className="mb-3">
|
|
||||||
<HStack justifyContent="between" alignItems="start">
|
|
||||||
<div>
|
|
||||||
<Heading>Client Certificates</Heading>
|
|
||||||
<p className="text-text-subtle">
|
|
||||||
Add and manage TLS certificates on a per domain basis
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button variant="border" size="sm" color="secondary" onClick={handleAdd}>
|
|
||||||
Add Certificate
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{certificates.length > 0 && (
|
|
||||||
<VStack space={3}>
|
|
||||||
{certificates.map((cert, index) => (
|
|
||||||
<CertificateEditor
|
|
||||||
// oxlint-disable-next-line react/no-array-index-key
|
|
||||||
key={index}
|
|
||||||
certificate={cert}
|
|
||||||
index={index}
|
|
||||||
onUpdate={handleUpdate}
|
|
||||||
onRemove={handleRemove}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</VStack>
|
|
||||||
)}
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,175 +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 { Checkbox } from "../core/Checkbox";
|
|
||||||
import { IconButton } from "../core/IconButton";
|
|
||||||
import { KeyValueRow, KeyValueRows } from "../core/KeyValueRow";
|
|
||||||
import { PlainInput } from "../core/PlainInput";
|
|
||||||
import { Select } from "../core/Select";
|
|
||||||
import { Separator } from "../core/Separator";
|
|
||||||
|
|
||||||
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>
|
|
||||||
<CargoFeature feature="updater">
|
|
||||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-1">
|
|
||||||
<Select
|
|
||||||
name="updateChannel"
|
|
||||||
label="Update Channel"
|
|
||||||
labelPosition="left"
|
|
||||||
labelClassName="w-[14rem]"
|
|
||||||
size="sm"
|
|
||||||
value={settings.updateChannel}
|
|
||||||
onChange={(updateChannel) => patchModel(settings, { updateChannel })}
|
|
||||||
options={[
|
|
||||||
{ label: "Stable", value: "stable" },
|
|
||||||
{ label: "Beta (more frequent)", value: "beta" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
variant="border"
|
|
||||||
size="sm"
|
|
||||||
title="Check for updates"
|
|
||||||
icon="refresh"
|
|
||||||
spin={checkForUpdates.isPending}
|
|
||||||
onClick={() => checkForUpdates.mutateAsync()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
name="autoupdate"
|
|
||||||
value={settings.autoupdate ? "auto" : "manual"}
|
|
||||||
label="Update Behavior"
|
|
||||||
labelPosition="left"
|
|
||||||
size="sm"
|
|
||||||
labelClassName="w-[14rem]"
|
|
||||||
onChange={(v) => patchModel(settings, { autoupdate: v === "auto" })}
|
|
||||||
options={[
|
|
||||||
{ label: "Automatic", value: "auto" },
|
|
||||||
{ label: "Manual", value: "manual" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Checkbox
|
|
||||||
className="pl-2 mt-1 ml-[14rem]"
|
|
||||||
checked={settings.autoDownloadUpdates}
|
|
||||||
disabled={!settings.autoupdate}
|
|
||||||
help="Automatically download Yaak updates (!50MB) in the background, so they will be immediately ready to install."
|
|
||||||
title="Automatically download updates"
|
|
||||||
onChange={(autoDownloadUpdates) => patchModel(settings, { autoDownloadUpdates })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Checkbox
|
|
||||||
className="pl-2 mt-1 ml-[14rem]"
|
|
||||||
checked={settings.checkNotifications}
|
|
||||||
title="Check for notifications"
|
|
||||||
help="Periodically ping Yaak servers to check for relevant notifications."
|
|
||||||
onChange={(checkNotifications) => patchModel(settings, { checkNotifications })}
|
|
||||||
/>
|
|
||||||
<Checkbox
|
|
||||||
disabled
|
|
||||||
className="pl-2 mt-1 ml-[14rem]"
|
|
||||||
checked={false}
|
|
||||||
title="Send anonymous usage statistics"
|
|
||||||
help="Yaak is local-first and does not collect analytics or usage data 🔐"
|
|
||||||
onChange={(checkNotifications) => patchModel(settings, { checkNotifications })}
|
|
||||||
/>
|
|
||||||
</CargoFeature>
|
|
||||||
|
|
||||||
<Separator className="my-4" />
|
|
||||||
|
|
||||||
<Heading level={2}>
|
|
||||||
Workspace{" "}
|
|
||||||
<div className="inline-block ml-1 bg-surface-highlight px-2 py-0.5 rounded text text-shrink">
|
|
||||||
{workspace.name}
|
|
||||||
</div>
|
|
||||||
</Heading>
|
|
||||||
<VStack className="mt-1 w-full" space={3}>
|
|
||||||
<PlainInput
|
|
||||||
required
|
|
||||||
size="sm"
|
|
||||||
name="requestTimeout"
|
|
||||||
label="Request Timeout (ms)"
|
|
||||||
labelClassName="w-[14rem]"
|
|
||||||
placeholder="0"
|
|
||||||
labelPosition="left"
|
|
||||||
defaultValue={`${workspace.settingRequestTimeout}`}
|
|
||||||
validate={(value) => Number.parseInt(value, 10) >= 0}
|
|
||||||
onChange={(v) =>
|
|
||||||
patchModel(workspace, { settingRequestTimeout: Number.parseInt(v, 10) || 0 })
|
|
||||||
}
|
|
||||||
type="number"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Checkbox
|
|
||||||
checked={workspace.settingValidateCertificates}
|
|
||||||
help="When disabled, skip validation of server certificates, useful when interacting with self-signed certs."
|
|
||||||
title="Validate TLS certificates"
|
|
||||||
onChange={(settingValidateCertificates) =>
|
|
||||||
patchModel(workspace, { settingValidateCertificates })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Checkbox
|
|
||||||
checked={workspace.settingFollowRedirects}
|
|
||||||
title="Follow redirects"
|
|
||||||
onChange={(settingFollowRedirects) =>
|
|
||||||
patchModel(workspace, {
|
|
||||||
settingFollowRedirects,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</VStack>
|
|
||||||
|
|
||||||
<Separator className="my-4" />
|
|
||||||
|
|
||||||
<Heading level={2}>App Info</Heading>
|
|
||||||
<KeyValueRows>
|
|
||||||
<KeyValueRow label="Version">{appInfo.version}</KeyValueRow>
|
|
||||||
<KeyValueRow
|
|
||||||
label="Data Directory"
|
|
||||||
rightSlot={
|
|
||||||
<IconButton
|
|
||||||
title={revealInFinderText}
|
|
||||||
icon="folder_open"
|
|
||||||
size="2xs"
|
|
||||||
onClick={() => revealItemInDir(appInfo.appDataDir)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{appInfo.appDataDir}
|
|
||||||
</KeyValueRow>
|
|
||||||
<KeyValueRow
|
|
||||||
label="Logs Directory"
|
|
||||||
rightSlot={
|
|
||||||
<IconButton
|
|
||||||
title={revealInFinderText}
|
|
||||||
icon="folder_open"
|
|
||||||
size="2xs"
|
|
||||||
onClick={() => revealItemInDir(appInfo.appLogDir)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{appInfo.appLogDir}
|
|
||||||
</KeyValueRow>
|
|
||||||
</KeyValueRows>
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,365 +0,0 @@
|
|||||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
|
||||||
import {
|
|
||||||
Heading,
|
|
||||||
HStack,
|
|
||||||
Icon,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeaderCell,
|
|
||||||
TableRow,
|
|
||||||
VStack,
|
|
||||||
} from "@yaakapp-internal/ui";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { fuzzyMatch } from "fuzzbunny";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
defaultHotkeys,
|
|
||||||
formatHotkeyString,
|
|
||||||
getHotkeyScope,
|
|
||||||
type HotkeyAction,
|
|
||||||
hotkeyActions,
|
|
||||||
hotkeysAtom,
|
|
||||||
useHotkeyLabel,
|
|
||||||
} from "../../hooks/useHotKey";
|
|
||||||
import { capitalize } from "../../lib/capitalize";
|
|
||||||
import { showDialog } from "../../lib/dialog";
|
|
||||||
import { Button } from "../core/Button";
|
|
||||||
import { Dropdown, type DropdownItem } from "../core/Dropdown";
|
|
||||||
import { HotkeyRaw } from "../core/Hotkey";
|
|
||||||
import { IconButton } from "../core/IconButton";
|
|
||||||
import { PlainInput } from "../core/PlainInput";
|
|
||||||
|
|
||||||
const HOLD_KEYS = ["Shift", "Control", "Alt", "Meta"];
|
|
||||||
const LAYOUT_INSENSITIVE_KEYS = [
|
|
||||||
"Equal",
|
|
||||||
"Minus",
|
|
||||||
"BracketLeft",
|
|
||||||
"BracketRight",
|
|
||||||
"Backquote",
|
|
||||||
"Space",
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Convert a KeyboardEvent to a hotkey string like "Meta+Shift+k" or "Control+Shift+k" */
|
|
||||||
function eventToHotkeyString(e: KeyboardEvent): string | null {
|
|
||||||
// Don't capture modifier-only key presses
|
|
||||||
if (HOLD_KEYS.includes(e.key)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parts: string[] = [];
|
|
||||||
|
|
||||||
// Add modifiers in consistent order (Meta, Control, Alt, Shift)
|
|
||||||
if (e.metaKey) {
|
|
||||||
parts.push("Meta");
|
|
||||||
}
|
|
||||||
if (e.ctrlKey) {
|
|
||||||
parts.push("Control");
|
|
||||||
}
|
|
||||||
if (e.altKey) {
|
|
||||||
parts.push("Alt");
|
|
||||||
}
|
|
||||||
if (e.shiftKey) {
|
|
||||||
parts.push("Shift");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the main key - use the same logic as useHotKey.ts
|
|
||||||
const key = LAYOUT_INSENSITIVE_KEYS.includes(e.code) ? e.code : e.key;
|
|
||||||
parts.push(key);
|
|
||||||
|
|
||||||
return parts.join("+");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingsHotkeys() {
|
|
||||||
const settings = useAtomValue(settingsAtom);
|
|
||||||
const hotkeys = useAtomValue(hotkeysAtom);
|
|
||||||
const [filter, setFilter] = useState("");
|
|
||||||
|
|
||||||
const filteredActions = useMemo(() => {
|
|
||||||
if (!filter.trim()) {
|
|
||||||
return hotkeyActions;
|
|
||||||
}
|
|
||||||
return hotkeyActions.filter((action) => {
|
|
||||||
const scope = getHotkeyScope(action).replace(/_/g, " ");
|
|
||||||
const label = action.replace(/[_.]/g, " ");
|
|
||||||
const searchText = `${scope} ${label}`;
|
|
||||||
return fuzzyMatch(searchText, filter) != null;
|
|
||||||
});
|
|
||||||
}, [filter]);
|
|
||||||
|
|
||||||
if (settings == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VStack space={3} className="mb-4">
|
|
||||||
<div className="mb-3">
|
|
||||||
<Heading>Keyboard Shortcuts</Heading>
|
|
||||||
<p className="text-text-subtle">
|
|
||||||
Click the menu button to add, remove, or reset keyboard shortcuts.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<PlainInput
|
|
||||||
label="Filter"
|
|
||||||
placeholder="Filter shortcuts..."
|
|
||||||
defaultValue={filter}
|
|
||||||
onChange={setFilter}
|
|
||||||
hideLabel
|
|
||||||
containerClassName="max-w-xs"
|
|
||||||
/>
|
|
||||||
<Table>
|
|
||||||
<TableHead>
|
|
||||||
<TableRow>
|
|
||||||
<TableHeaderCell>Scope</TableHeaderCell>
|
|
||||||
<TableHeaderCell>Action</TableHeaderCell>
|
|
||||||
<TableHeaderCell>Shortcut</TableHeaderCell>
|
|
||||||
<TableHeaderCell></TableHeaderCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
{/* key={filter} forces re-render on filter change to fix Safari table rendering bug */}
|
|
||||||
<TableBody key={filter}>
|
|
||||||
{filteredActions.map((action) => (
|
|
||||||
<HotkeyRow
|
|
||||||
key={action}
|
|
||||||
action={action}
|
|
||||||
currentKeys={hotkeys[action]}
|
|
||||||
defaultKeys={defaultHotkeys[action]}
|
|
||||||
onSave={async (keys) => {
|
|
||||||
const newHotkeys = { ...settings.hotkeys };
|
|
||||||
if (arraysEqual(keys, defaultHotkeys[action])) {
|
|
||||||
// Remove from settings if it matches default (use default)
|
|
||||||
delete newHotkeys[action];
|
|
||||||
} else {
|
|
||||||
// Store the keys (including empty array to disable)
|
|
||||||
newHotkeys[action] = keys;
|
|
||||||
}
|
|
||||||
await patchModel(settings, { hotkeys: newHotkeys });
|
|
||||||
}}
|
|
||||||
onReset={async () => {
|
|
||||||
const newHotkeys = { ...settings.hotkeys };
|
|
||||||
delete newHotkeys[action];
|
|
||||||
await patchModel(settings, { hotkeys: newHotkeys });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HotkeyRowProps {
|
|
||||||
action: HotkeyAction;
|
|
||||||
currentKeys: string[];
|
|
||||||
defaultKeys: string[];
|
|
||||||
onSave: (keys: string[]) => Promise<void>;
|
|
||||||
onReset: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function HotkeyRow({ action, currentKeys, defaultKeys, onSave, onReset }: HotkeyRowProps) {
|
|
||||||
const label = useHotkeyLabel(action);
|
|
||||||
const scope = capitalize(getHotkeyScope(action).replace(/_/g, " "));
|
|
||||||
const isCustomized = !arraysEqual(currentKeys, defaultKeys);
|
|
||||||
const isDisabled = currentKeys.length === 0;
|
|
||||||
|
|
||||||
const handleStartRecording = useCallback(() => {
|
|
||||||
showDialog({
|
|
||||||
id: `record-hotkey-${action}`,
|
|
||||||
title: label,
|
|
||||||
size: "sm",
|
|
||||||
render: ({ hide }) => (
|
|
||||||
<RecordHotkeyDialog
|
|
||||||
label={label}
|
|
||||||
onSave={async (key) => {
|
|
||||||
await onSave([...currentKeys, key]);
|
|
||||||
hide();
|
|
||||||
}}
|
|
||||||
onCancel={hide}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}, [action, label, currentKeys, onSave]);
|
|
||||||
|
|
||||||
const handleRemove = useCallback(
|
|
||||||
async (keyToRemove: string) => {
|
|
||||||
const newKeys = currentKeys.filter((k) => k !== keyToRemove);
|
|
||||||
await onSave(newKeys);
|
|
||||||
},
|
|
||||||
[currentKeys, onSave],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleClearAll = useCallback(async () => {
|
|
||||||
await onSave([]);
|
|
||||||
}, [onSave]);
|
|
||||||
|
|
||||||
// Build dropdown items dynamically
|
|
||||||
const dropdownItems: DropdownItem[] = [
|
|
||||||
{
|
|
||||||
label: "Add Keyboard Shortcut",
|
|
||||||
leftSlot: <Icon icon="plus" />,
|
|
||||||
onSelect: handleStartRecording,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Add remove options for each existing shortcut
|
|
||||||
if (!isDisabled) {
|
|
||||||
currentKeys.forEach((key) => {
|
|
||||||
dropdownItems.push({
|
|
||||||
label: (
|
|
||||||
<HStack space={1.5}>
|
|
||||||
<span>Remove</span>
|
|
||||||
<HotkeyRaw labelParts={formatHotkeyString(key)} variant="with-bg" className="text-xs" />
|
|
||||||
</HStack>
|
|
||||||
),
|
|
||||||
leftSlot: <Icon icon="trash" />,
|
|
||||||
onSelect: () => handleRemove(key),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (currentKeys.length > 1) {
|
|
||||||
dropdownItems.push(
|
|
||||||
{
|
|
||||||
type: "separator",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Remove All Shortcuts",
|
|
||||||
leftSlot: <Icon icon="trash" />,
|
|
||||||
onSelect: handleClearAll,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isCustomized) {
|
|
||||||
dropdownItems.push({
|
|
||||||
type: "separator",
|
|
||||||
});
|
|
||||||
dropdownItems.push({
|
|
||||||
label: "Reset to Default",
|
|
||||||
leftSlot: <Icon icon="refresh" />,
|
|
||||||
onSelect: onReset,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell>
|
|
||||||
<span className="text-sm text-text-subtlest">{scope}</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<span className="text-sm">{label}</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<HStack space={1.5} className="py-1">
|
|
||||||
{isDisabled ? (
|
|
||||||
<span className="text-text-subtlest">Disabled</span>
|
|
||||||
) : (
|
|
||||||
currentKeys.map((k) => (
|
|
||||||
<HotkeyRaw key={k} labelParts={formatHotkeyString(k)} variant="with-bg" />
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell align="right">
|
|
||||||
<Dropdown items={dropdownItems}>
|
|
||||||
<IconButton
|
|
||||||
icon="ellipsis_vertical"
|
|
||||||
size="sm"
|
|
||||||
title="Hotkey actions"
|
|
||||||
className="ml-auto text-text-subtlest"
|
|
||||||
/>
|
|
||||||
</Dropdown>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function arraysEqual(a: string[], b: string[]): boolean {
|
|
||||||
if (a.length !== b.length) return false;
|
|
||||||
const sortedA = [...a].sort();
|
|
||||||
const sortedB = [...b].sort();
|
|
||||||
return sortedA.every((v, i) => v === sortedB[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RecordHotkeyDialogProps {
|
|
||||||
label: string;
|
|
||||||
onSave: (key: string) => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function RecordHotkeyDialog({ label, onSave, onCancel }: RecordHotkeyDialogProps) {
|
|
||||||
const [recordedKey, setRecordedKey] = useState<string | null>(null);
|
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isFocused) return;
|
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
onCancel();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hotkeyString = eventToHotkeyString(e);
|
|
||||||
if (hotkeyString) {
|
|
||||||
setRecordedKey(hotkeyString);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown, { capture: true });
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("keydown", handleKeyDown, { capture: true });
|
|
||||||
};
|
|
||||||
}, [isFocused, onCancel]);
|
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
|
||||||
if (recordedKey) {
|
|
||||||
onSave(recordedKey);
|
|
||||||
}
|
|
||||||
}, [recordedKey, onSave]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VStack space={4}>
|
|
||||||
<div>
|
|
||||||
<p className="text-text-subtle mb-2">
|
|
||||||
Record a key combination for <span className="font-semibold">{label}</span>
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-disable-hotkey
|
|
||||||
aria-label="Keyboard shortcut input"
|
|
||||||
onFocus={() => setIsFocused(true)}
|
|
||||||
onBlur={() => setIsFocused(false)}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.currentTarget.focus();
|
|
||||||
}}
|
|
||||||
className={classNames(
|
|
||||||
"flex items-center justify-center",
|
|
||||||
"px-4 py-2 rounded-lg bg-surface-highlight border outline-none cursor-default w-full",
|
|
||||||
"border-border-subtle focus:border-border-focus",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{recordedKey ? (
|
|
||||||
<HotkeyRaw labelParts={formatHotkeyString(recordedKey)} />
|
|
||||||
) : (
|
|
||||||
<span className="text-text-subtlest">Press keys...</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<HStack space={2} justifyContent="end">
|
|
||||||
<Button color="secondary" onClick={onCancel}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button color="primary" onClick={handleSave} disabled={!recordedKey}>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,241 +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, HStack, Icon, 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 { Select } from "../core/Select";
|
|
||||||
|
|
||||||
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={3} className="mb-4">
|
|
||||||
<div className="mb-3">
|
|
||||||
<Heading>Interface</Heading>
|
|
||||||
<p className="text-text-subtle">Tweak settings related to the user interface.</p>
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
name="switchWorkspaceBehavior"
|
|
||||||
label="Open workspace behavior"
|
|
||||||
size="sm"
|
|
||||||
help="When opening a workspace, should it open in the current window or a new window?"
|
|
||||||
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" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<HStack space={2} alignItems="end">
|
|
||||||
{fonts.data && (
|
|
||||||
<Select
|
|
||||||
size="sm"
|
|
||||||
name="uiFont"
|
|
||||||
label="Interface font"
|
|
||||||
value={settings.interfaceFont ?? NULL_FONT_VALUE}
|
|
||||||
options={[
|
|
||||||
{ label: "System default", value: NULL_FONT_VALUE },
|
|
||||||
...(fonts.data.uiFonts.map((f) => ({
|
|
||||||
label: f,
|
|
||||||
value: f,
|
|
||||||
})) ?? []),
|
|
||||||
// Some people like monospace fonts for the UI
|
|
||||||
...(fonts.data.editorFonts.map((f) => ({
|
|
||||||
label: f,
|
|
||||||
value: f,
|
|
||||||
})) ?? []),
|
|
||||||
]}
|
|
||||||
onChange={async (v) => {
|
|
||||||
const interfaceFont = v === NULL_FONT_VALUE ? null : v;
|
|
||||||
await patchModel(settings, { interfaceFont });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Select
|
|
||||||
hideLabel
|
|
||||||
size="sm"
|
|
||||||
name="interfaceFontSize"
|
|
||||||
label="Interface Font Size"
|
|
||||||
defaultValue="14"
|
|
||||||
value={`${settings.interfaceFontSize}`}
|
|
||||||
options={fontSizeOptions}
|
|
||||||
onChange={(v) => patchModel(settings, { interfaceFontSize: Number.parseInt(v, 10) })}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
<HStack space={2} alignItems="end">
|
|
||||||
{fonts.data && (
|
|
||||||
<Select
|
|
||||||
size="sm"
|
|
||||||
name="editorFont"
|
|
||||||
label="Editor font"
|
|
||||||
value={settings.editorFont ?? 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 });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Select
|
|
||||||
hideLabel
|
|
||||||
size="sm"
|
|
||||||
name="editorFontSize"
|
|
||||||
label="Editor Font Size"
|
|
||||||
defaultValue="12"
|
|
||||||
value={`${settings.editorFontSize}`}
|
|
||||||
options={fontSizeOptions}
|
|
||||||
onChange={(v) =>
|
|
||||||
patchModel(settings, { editorFontSize: clamp(Number.parseInt(v, 10) || 14, 8, 30) })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
<Select
|
|
||||||
leftSlot={<Icon icon="keyboard" color="secondary" />}
|
|
||||||
size="sm"
|
|
||||||
name="editorKeymap"
|
|
||||||
label="Editor keymap"
|
|
||||||
value={`${settings.editorKeymap}`}
|
|
||||||
options={keymaps}
|
|
||||||
onChange={(v) => patchModel(settings, { editorKeymap: v })}
|
|
||||||
/>
|
|
||||||
<Checkbox
|
|
||||||
checked={settings.editorSoftWrap}
|
|
||||||
title="Wrap editor lines"
|
|
||||||
onChange={(editorSoftWrap) => patchModel(settings, { editorSoftWrap })}
|
|
||||||
/>
|
|
||||||
<Checkbox
|
|
||||||
checked={settings.coloredMethods}
|
|
||||||
title="Colorize request methods"
|
|
||||||
onChange={(coloredMethods) => patchModel(settings, { coloredMethods })}
|
|
||||||
/>
|
|
||||||
<CargoFeature feature="license">
|
|
||||||
<LicenseSettings settings={settings} />
|
|
||||||
</CargoFeature>
|
|
||||||
|
|
||||||
<NativeTitlebarSetting settings={settings} />
|
|
||||||
|
|
||||||
{type() !== "macos" && (
|
|
||||||
<Checkbox
|
|
||||||
checked={settings.hideWindowControls}
|
|
||||||
title="Hide window controls"
|
|
||||||
help="Hide the close/maximize/minimize controls on Windows or Linux"
|
|
||||||
onChange={(hideWindowControls) => patchModel(settings, { hideWindowControls })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NativeTitlebarSetting({ settings }: { settings: Settings }) {
|
|
||||||
const [nativeTitlebar, setNativeTitlebar] = useState(settings.useNativeTitlebar);
|
|
||||||
return (
|
|
||||||
<div className="flex gap-1 overflow-hidden h-2xs">
|
|
||||||
<Checkbox
|
|
||||||
checked={nativeTitlebar}
|
|
||||||
title="Native title bar"
|
|
||||||
help="Use the operating system's standard title bar and window controls"
|
|
||||||
onChange={setNativeTitlebar}
|
|
||||||
/>
|
|
||||||
{settings.useNativeTitlebar !== nativeTitlebar && (
|
|
||||||
<Button
|
|
||||||
color="primary"
|
|
||||||
size="2xs"
|
|
||||||
onClick={async () => {
|
|
||||||
await patchModel(settings, { useNativeTitlebar: nativeTitlebar });
|
|
||||||
await invokeCmd("cmd_restart");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Apply and Restart
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function LicenseSettings({ settings }: { settings: Settings }) {
|
|
||||||
const license = useLicense();
|
|
||||||
if (license.check.data?.status !== "personal_use") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Checkbox
|
|
||||||
checked={settings.hideLicenseBadge}
|
|
||||||
title="Hide personal use badge"
|
|
||||||
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; // Cancel
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await patchModel(settings, { hideLicenseBadge });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
|
||||||
import { useLicense } from "@yaakapp-internal/license";
|
|
||||||
import { Banner, HStack, Icon, VStack } from "@yaakapp-internal/ui";
|
|
||||||
import { differenceInDays } from "date-fns";
|
|
||||||
import { formatDate } from "date-fns/format";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useToggle } from "../../hooks/useToggle";
|
|
||||||
import { pluralizeCount } from "../../lib/pluralize";
|
|
||||||
import { CargoFeature } from "../CargoFeature";
|
|
||||||
import { Button } from "../core/Button";
|
|
||||||
import { Link } from "../core/Link";
|
|
||||||
import { PlainInput } from "../core/PlainInput";
|
|
||||||
import { Separator } from "../core/Separator";
|
|
||||||
|
|
||||||
export function SettingsLicense() {
|
|
||||||
return (
|
|
||||||
<CargoFeature feature="license">
|
|
||||||
<SettingsLicenseCmp />
|
|
||||||
</CargoFeature>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SettingsLicenseCmp() {
|
|
||||||
const { check, activate, deactivate } = useLicense();
|
|
||||||
const [key, setKey] = useState<string>("");
|
|
||||||
const [activateFormVisible, toggleActivateFormVisible] = useToggle(false);
|
|
||||||
|
|
||||||
if (check.isPending) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderBanner = () => {
|
|
||||||
if (!check.data) return null;
|
|
||||||
|
|
||||||
switch (check.data.status) {
|
|
||||||
case "active":
|
|
||||||
return <Banner color="success">Your license is active 🥳</Banner>;
|
|
||||||
|
|
||||||
case "trialing":
|
|
||||||
return (
|
|
||||||
<Banner color="info" className="max-w-lg">
|
|
||||||
<p className="w-full">
|
|
||||||
<strong>
|
|
||||||
{pluralizeCount("day", differenceInDays(check.data.data.end, new Date()))}
|
|
||||||
</strong>{" "}
|
|
||||||
left to evaluate Yaak for commercial use.
|
|
||||||
<br />
|
|
||||||
<span className="opacity-50">Personal use is always free, forever.</span>
|
|
||||||
<Separator className="my-2" />
|
|
||||||
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
|
|
||||||
<Link noUnderline href={`https://yaak.app/pricing?s=learn&t=${check.data.status}`}>
|
|
||||||
Learn More
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</p>
|
|
||||||
</Banner>
|
|
||||||
);
|
|
||||||
|
|
||||||
case "personal_use":
|
|
||||||
return (
|
|
||||||
<Banner color="notice" className="max-w-lg">
|
|
||||||
<p className="w-full">
|
|
||||||
Your commercial-use trial has ended.
|
|
||||||
<br />
|
|
||||||
<span className="opacity-50">
|
|
||||||
You may continue using Yaak for personal use only.
|
|
||||||
<br />A license is required for commercial use.
|
|
||||||
</span>
|
|
||||||
<Separator className="my-2" />
|
|
||||||
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
|
|
||||||
<Link noUnderline href={`https://yaak.app/pricing?s=learn&t=${check.data.status}`}>
|
|
||||||
Learn More
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</p>
|
|
||||||
</Banner>
|
|
||||||
);
|
|
||||||
|
|
||||||
case "inactive":
|
|
||||||
return (
|
|
||||||
<Banner color="danger">
|
|
||||||
Your license is invalid. Please <Link href="https://yaak.app/dashboard">Sign In</Link>{" "}
|
|
||||||
for more details
|
|
||||||
</Banner>
|
|
||||||
);
|
|
||||||
|
|
||||||
case "expired":
|
|
||||||
return (
|
|
||||||
<Banner color="notice">
|
|
||||||
Your license expired{" "}
|
|
||||||
<strong>{formatDate(check.data.data.periodEnd, "MMMM dd, yyyy")}</strong>. Please{" "}
|
|
||||||
<Link href="https://yaak.app/dashboard">Resubscribe</Link> to continue receiving
|
|
||||||
updates.
|
|
||||||
{check.data.data.changesUrl && (
|
|
||||||
<>
|
|
||||||
<br />
|
|
||||||
<Link href={check.data.data.changesUrl}>What's new in latest builds</Link>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Banner>
|
|
||||||
);
|
|
||||||
|
|
||||||
case "past_due":
|
|
||||||
return (
|
|
||||||
<Banner color="danger">
|
|
||||||
<strong>Your payment method needs attention.</strong>
|
|
||||||
<br />
|
|
||||||
To re-activate your license, please{" "}
|
|
||||||
<Link href={check.data.data.billingUrl}>update your billing info</Link>.
|
|
||||||
</Banner>
|
|
||||||
);
|
|
||||||
|
|
||||||
case "error":
|
|
||||||
return (
|
|
||||||
<Banner color="danger">
|
|
||||||
License check failed: {check.data.data.message} (Code: {check.data.data.code})
|
|
||||||
</Banner>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-6 max-w-xl">
|
|
||||||
{renderBanner()}
|
|
||||||
|
|
||||||
{check.error && <Banner color="danger">{check.error}</Banner>}
|
|
||||||
{activate.error && <Banner color="danger">{activate.error}</Banner>}
|
|
||||||
|
|
||||||
{check.data?.status === "active" ? (
|
|
||||||
<HStack space={2}>
|
|
||||||
<Button variant="border" color="secondary" size="sm" onClick={() => deactivate.mutate()}>
|
|
||||||
Deactivate License
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => openUrl("https://yaak.app/dashboard?s=support&ref=app.yaak.desktop")}
|
|
||||||
rightSlot={<Icon icon="external_link" />}
|
|
||||||
>
|
|
||||||
Direct Support
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
) : (
|
|
||||||
<HStack space={2}>
|
|
||||||
<Button variant="border" color="secondary" size="sm" onClick={toggleActivateFormVisible}>
|
|
||||||
Activate License
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
color="primary"
|
|
||||||
rightSlot={<Icon icon="external_link" />}
|
|
||||||
onClick={() =>
|
|
||||||
openUrl(
|
|
||||||
`https://yaak.app/pricing?s=purchase&ref=app.yaak.desktop&t=${check.data?.status ?? ""}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Purchase License
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activateFormVisible && (
|
|
||||||
<VStack
|
|
||||||
as="form"
|
|
||||||
space={3}
|
|
||||||
className="max-w-sm"
|
|
||||||
onSubmit={async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
await activate.mutateAsync({ licenseKey: key });
|
|
||||||
toggleActivateFormVisible();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PlainInput
|
|
||||||
autoFocus
|
|
||||||
label="License Key"
|
|
||||||
name="key"
|
|
||||||
onChange={setKey}
|
|
||||||
placeholder="YK1-XXXXX-XXXXX-XXXXX-XXXXX"
|
|
||||||
/>
|
|
||||||
<Button type="submit" color="primary" size="sm" isLoading={activate.isPending}>
|
|
||||||
Submit
|
|
||||||
</Button>
|
|
||||||
</VStack>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,426 +0,0 @@
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
|
||||||
import type { Plugin } from "@yaakapp-internal/models";
|
|
||||||
import { patchModel, pluginsAtom } from "@yaakapp-internal/models";
|
|
||||||
import type { PluginVersion } from "@yaakapp-internal/plugins";
|
|
||||||
import {
|
|
||||||
checkPluginUpdates,
|
|
||||||
installPlugin,
|
|
||||||
searchPlugins,
|
|
||||||
uninstallPlugin,
|
|
||||||
} from "@yaakapp-internal/plugins";
|
|
||||||
import {
|
|
||||||
HStack,
|
|
||||||
Icon,
|
|
||||||
InlineCode,
|
|
||||||
LoadingIcon,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeaderCell,
|
|
||||||
TableRow,
|
|
||||||
useDebouncedValue,
|
|
||||||
} from "@yaakapp-internal/ui";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useInstallPlugin } from "../../hooks/useInstallPlugin";
|
|
||||||
import { usePluginInfo } from "../../hooks/usePluginInfo";
|
|
||||||
import { usePluginsKey, useRefreshPlugins } from "../../hooks/usePlugins";
|
|
||||||
import { showConfirmDelete } from "../../lib/confirm";
|
|
||||||
import { minPromiseMillis } from "../../lib/minPromiseMillis";
|
|
||||||
import { Button } from "../core/Button";
|
|
||||||
import { Checkbox } from "../core/Checkbox";
|
|
||||||
import { CountBadge } from "../core/CountBadge";
|
|
||||||
import { IconButton } from "../core/IconButton";
|
|
||||||
import { Link } from "../core/Link";
|
|
||||||
import { PlainInput } from "../core/PlainInput";
|
|
||||||
import { TabContent, Tabs } from "../core/Tabs/Tabs";
|
|
||||||
import { EmptyStateText } from "../EmptyStateText";
|
|
||||||
import { SelectFile } from "../SelectFile";
|
|
||||||
|
|
||||||
interface SettingsPluginsProps {
|
|
||||||
defaultSubtab?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingsPlugins({ defaultSubtab }: SettingsPluginsProps) {
|
|
||||||
const [directory, setDirectory] = useState<string | null>(null);
|
|
||||||
const plugins = useAtomValue(pluginsAtom);
|
|
||||||
const bundledPlugins = plugins.filter((p) => p.source === "bundled");
|
|
||||||
const installedPlugins = plugins.filter((p) => p.source !== "bundled");
|
|
||||||
const createPlugin = useInstallPlugin();
|
|
||||||
const refreshPlugins = useRefreshPlugins();
|
|
||||||
return (
|
|
||||||
<div className="h-full">
|
|
||||||
<Tabs
|
|
||||||
defaultValue={defaultSubtab}
|
|
||||||
label="Plugins"
|
|
||||||
addBorders
|
|
||||||
tabListClassName="px-6 pt-2"
|
|
||||||
tabs={[
|
|
||||||
{ label: "Discover", value: "search" },
|
|
||||||
{
|
|
||||||
label: "Installed",
|
|
||||||
value: "installed",
|
|
||||||
rightSlot: <CountBadge count={installedPlugins.length} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Bundled",
|
|
||||||
value: "bundled",
|
|
||||||
rightSlot: <CountBadge count={bundledPlugins.length} />,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<TabContent value="search" className="px-6">
|
|
||||||
<PluginSearch />
|
|
||||||
</TabContent>
|
|
||||||
<TabContent value="installed" className="pb-0">
|
|
||||||
<div className="h-full grid grid-rows-[minmax(0,1fr)_auto]">
|
|
||||||
<InstalledPlugins plugins={installedPlugins} className="px-6" />
|
|
||||||
<footer className="grid grid-cols-[minmax(0,1fr)_auto] py-2 px-4 border-t bg-surface-highlight border-border-subtle min-w-0">
|
|
||||||
<SelectFile
|
|
||||||
size="xs"
|
|
||||||
noun="Plugin"
|
|
||||||
directory
|
|
||||||
onChange={({ filePath }) => setDirectory(filePath)}
|
|
||||||
filePath={directory}
|
|
||||||
/>
|
|
||||||
<HStack>
|
|
||||||
{directory && (
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
color="primary"
|
|
||||||
className="ml-auto"
|
|
||||||
onClick={() => {
|
|
||||||
if (directory == null) return;
|
|
||||||
createPlugin.mutate(directory);
|
|
||||||
setDirectory(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Add Plugin
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<IconButton
|
|
||||||
size="sm"
|
|
||||||
icon="refresh"
|
|
||||||
title="Reload plugins"
|
|
||||||
spin={refreshPlugins.isPending}
|
|
||||||
onClick={() => refreshPlugins.mutate()}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
size="sm"
|
|
||||||
icon="help"
|
|
||||||
title="View documentation"
|
|
||||||
onClick={() =>
|
|
||||||
openUrl("https://yaak.app/docs/plugin-development/plugins-quick-start")
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</TabContent>
|
|
||||||
<TabContent value="bundled" className="pb-0 px-6">
|
|
||||||
<BundledPlugins plugins={bundledPlugins} />
|
|
||||||
</TabContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PluginTableRowForInstalledPlugin({ plugin }: { plugin: Plugin }) {
|
|
||||||
const info = usePluginInfo(plugin.id).data;
|
|
||||||
if (info == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PluginTableRow
|
|
||||||
plugin={plugin}
|
|
||||||
version={info.version}
|
|
||||||
name={info.name}
|
|
||||||
displayName={info.displayName}
|
|
||||||
url={plugin.url}
|
|
||||||
showCheckbox={true}
|
|
||||||
showUninstall={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PluginTableRowForBundledPlugin({ plugin }: { plugin: Plugin }) {
|
|
||||||
const info = usePluginInfo(plugin.id).data;
|
|
||||||
if (info == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PluginTableRow
|
|
||||||
plugin={plugin}
|
|
||||||
version={info.version}
|
|
||||||
name={info.name}
|
|
||||||
displayName={info.displayName}
|
|
||||||
url={plugin.url}
|
|
||||||
showCheckbox={true}
|
|
||||||
showUninstall={false}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PluginTableRowForRemotePluginVersion({ pluginVersion }: { pluginVersion: PluginVersion }) {
|
|
||||||
const plugin = useAtomValue(pluginsAtom).find((p) => p.id === pluginVersion.id);
|
|
||||||
const pluginInfo = usePluginInfo(plugin?.id ?? null).data;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PluginTableRow
|
|
||||||
plugin={plugin ?? null}
|
|
||||||
version={pluginInfo?.version ?? pluginVersion.version}
|
|
||||||
name={pluginVersion.name}
|
|
||||||
displayName={pluginVersion.displayName}
|
|
||||||
url={pluginVersion.url}
|
|
||||||
showCheckbox={false}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PluginTableRow({
|
|
||||||
plugin,
|
|
||||||
name,
|
|
||||||
version,
|
|
||||||
displayName,
|
|
||||||
url,
|
|
||||||
showCheckbox = true,
|
|
||||||
showUninstall = true,
|
|
||||||
}: {
|
|
||||||
plugin: Plugin | null;
|
|
||||||
name: string;
|
|
||||||
version: string;
|
|
||||||
displayName: string;
|
|
||||||
url: string | null;
|
|
||||||
showCheckbox?: boolean;
|
|
||||||
showUninstall?: boolean;
|
|
||||||
}) {
|
|
||||||
const updates = usePluginUpdates();
|
|
||||||
const latestVersion = updates.data?.plugins.find((u) => u.name === name)?.version;
|
|
||||||
const installPluginMutation = useMutation({
|
|
||||||
mutationKey: ["install_plugin", name],
|
|
||||||
mutationFn: (name: string) => installPlugin(name, null),
|
|
||||||
});
|
|
||||||
const uninstall = usePromptUninstall(plugin?.id ?? null, displayName);
|
|
||||||
const refreshPlugins = useRefreshPlugins();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TableRow>
|
|
||||||
{showCheckbox && (
|
|
||||||
<TableCell className="!py-0">
|
|
||||||
<Checkbox
|
|
||||||
hideLabel
|
|
||||||
title={plugin?.enabled ? "Disable plugin" : "Enable plugin"}
|
|
||||||
checked={plugin?.enabled ?? false}
|
|
||||||
disabled={plugin == null}
|
|
||||||
onChange={async (enabled) => {
|
|
||||||
if (plugin) {
|
|
||||||
await patchModel(plugin, { enabled });
|
|
||||||
refreshPlugins.mutate();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
<TableCell className="font-semibold">
|
|
||||||
{url ? (
|
|
||||||
<Link noUnderline href={url}>
|
|
||||||
{displayName}
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
displayName
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<InlineCode>{name}</InlineCode>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<HStack space={1.5}>
|
|
||||||
<InlineCode>{version}</InlineCode>
|
|
||||||
{latestVersion != null && (
|
|
||||||
<InlineCode className="text-success flex items-center gap-1">
|
|
||||||
<Icon icon="arrow_up" size="sm" />
|
|
||||||
{latestVersion}
|
|
||||||
</InlineCode>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="!py-0">
|
|
||||||
<HStack justifyContent="end" space={1.5}>
|
|
||||||
{plugin != null && latestVersion != null ? (
|
|
||||||
<Button
|
|
||||||
variant="border"
|
|
||||||
color="success"
|
|
||||||
title={`Update to ${latestVersion}`}
|
|
||||||
size="xs"
|
|
||||||
isLoading={installPluginMutation.isPending}
|
|
||||||
onClick={() => installPluginMutation.mutate(name)}
|
|
||||||
>
|
|
||||||
Update
|
|
||||||
</Button>
|
|
||||||
) : plugin == null ? (
|
|
||||||
<Button
|
|
||||||
variant="border"
|
|
||||||
color="primary"
|
|
||||||
title={`Install ${version}`}
|
|
||||||
size="xs"
|
|
||||||
isLoading={installPluginMutation.isPending}
|
|
||||||
onClick={() => installPluginMutation.mutate(name)}
|
|
||||||
>
|
|
||||||
Install
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{showUninstall && uninstall != null && (
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
title="Uninstall plugin"
|
|
||||||
variant="border"
|
|
||||||
isLoading={uninstall.isPending}
|
|
||||||
onClick={() => uninstall.mutate()}
|
|
||||||
>
|
|
||||||
Uninstall
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PluginSearch() {
|
|
||||||
const [query, setQuery] = useState<string>("");
|
|
||||||
const debouncedQuery = useDebouncedValue(query);
|
|
||||||
const results = useQuery({
|
|
||||||
queryKey: ["plugins", debouncedQuery],
|
|
||||||
queryFn: () => searchPlugins(query),
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)] gap-3">
|
|
||||||
<HStack space={1.5}>
|
|
||||||
<PlainInput
|
|
||||||
hideLabel
|
|
||||||
label="Search"
|
|
||||||
placeholder="Search plugins..."
|
|
||||||
onChange={setQuery}
|
|
||||||
defaultValue={query}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
<div className="w-full h-full">
|
|
||||||
{results.data == null ? (
|
|
||||||
<EmptyStateText>
|
|
||||||
<LoadingIcon size="xl" className="text-text-subtlest" />
|
|
||||||
</EmptyStateText>
|
|
||||||
) : (results.data.plugins ?? []).length === 0 ? (
|
|
||||||
<EmptyStateText>No plugins found</EmptyStateText>
|
|
||||||
) : (
|
|
||||||
<Table scrollable>
|
|
||||||
<TableHead>
|
|
||||||
<TableRow>
|
|
||||||
<TableHeaderCell>Display Name</TableHeaderCell>
|
|
||||||
<TableHeaderCell>Name</TableHeaderCell>
|
|
||||||
<TableHeaderCell>Version</TableHeaderCell>
|
|
||||||
<TableHeaderCell />
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<TableBody>
|
|
||||||
{results.data.plugins.map((p) => (
|
|
||||||
<PluginTableRowForRemotePluginVersion key={p.id} pluginVersion={p} />
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function InstalledPlugins({ plugins, className }: { plugins: Plugin[]; className?: string }) {
|
|
||||||
return plugins.length === 0 ? (
|
|
||||||
<div className={classNames(className, "pb-4")}>
|
|
||||||
<EmptyStateText className="text-center">
|
|
||||||
Plugins extend the functionality of Yaak.
|
|
||||||
<br />
|
|
||||||
Add your first plugin to get started.
|
|
||||||
</EmptyStateText>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Table scrollable className={className}>
|
|
||||||
<TableHead>
|
|
||||||
<TableRow>
|
|
||||||
<TableHeaderCell className="w-0" />
|
|
||||||
<TableHeaderCell>Display Name</TableHeaderCell>
|
|
||||||
<TableHeaderCell>Name</TableHeaderCell>
|
|
||||||
<TableHeaderCell>Version</TableHeaderCell>
|
|
||||||
<TableHeaderCell />
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<tbody className="divide-y divide-surface-highlight">
|
|
||||||
{plugins.map((p) => (
|
|
||||||
<PluginTableRowForInstalledPlugin key={p.id} plugin={p} />
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</Table>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BundledPlugins({ plugins }: { plugins: Plugin[] }) {
|
|
||||||
return plugins.length === 0 ? (
|
|
||||||
<div className="pb-4">
|
|
||||||
<EmptyStateText className="text-center">No bundled plugins found.</EmptyStateText>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Table scrollable>
|
|
||||||
<TableHead>
|
|
||||||
<TableRow>
|
|
||||||
<TableHeaderCell className="w-0" />
|
|
||||||
<TableHeaderCell>Display Name</TableHeaderCell>
|
|
||||||
<TableHeaderCell>Name</TableHeaderCell>
|
|
||||||
<TableHeaderCell>Version</TableHeaderCell>
|
|
||||||
<TableHeaderCell />
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<tbody className="divide-y divide-surface-highlight">
|
|
||||||
{plugins.map((p) => (
|
|
||||||
<PluginTableRowForBundledPlugin key={p.id} plugin={p} />
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</Table>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function usePromptUninstall(pluginId: string | null, name: string) {
|
|
||||||
const mut = useMutation({
|
|
||||||
mutationKey: ["uninstall_plugin", pluginId],
|
|
||||||
mutationFn: async () => {
|
|
||||||
if (pluginId == null) return;
|
|
||||||
|
|
||||||
const confirmed = await showConfirmDelete({
|
|
||||||
id: `uninstall-plugin-${pluginId}`,
|
|
||||||
title: "Uninstall Plugin",
|
|
||||||
confirmText: "Uninstall",
|
|
||||||
description: (
|
|
||||||
<>
|
|
||||||
Permanently uninstall <InlineCode>{name}</InlineCode>?
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
if (confirmed) {
|
|
||||||
await minPromiseMillis(uninstallPlugin(pluginId), 700);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return pluginId == null ? null : mut;
|
|
||||||
}
|
|
||||||
|
|
||||||
function usePluginUpdates() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["plugin_updates", usePluginsKey()],
|
|
||||||
queryFn: () => checkPluginUpdates(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
|
||||||
import { Heading, HStack, InlineCode, VStack } from "@yaakapp-internal/ui";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { Checkbox } from "../core/Checkbox";
|
|
||||||
import { PlainInput } from "../core/PlainInput";
|
|
||||||
import { Select } from "../core/Select";
|
|
||||||
import { Separator } from "../core/Separator";
|
|
||||||
|
|
||||||
export function SettingsProxy() {
|
|
||||||
const settings = useAtomValue(settingsAtom);
|
|
||||||
|
|
||||||
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>
|
|
||||||
<Select
|
|
||||||
name="proxy"
|
|
||||||
label="Proxy"
|
|
||||||
hideLabel
|
|
||||||
size="sm"
|
|
||||||
value={settings.proxy?.type ?? "automatic"}
|
|
||||||
onChange={async (v) => {
|
|
||||||
if (v === "automatic") {
|
|
||||||
await patchModel(settings, { proxy: undefined });
|
|
||||||
} else if (v === "enabled") {
|
|
||||||
await patchModel(settings, {
|
|
||||||
proxy: {
|
|
||||||
disabled: false,
|
|
||||||
type: "enabled",
|
|
||||||
http: "",
|
|
||||||
https: "",
|
|
||||||
auth: { user: "", password: "" },
|
|
||||||
bypass: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} 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" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
{settings.proxy?.type === "enabled" && (
|
|
||||||
<VStack space={1.5}>
|
|
||||||
<Checkbox
|
|
||||||
className="my-3"
|
|
||||||
checked={!settings.proxy.disabled}
|
|
||||||
title="Enable proxy"
|
|
||||||
help="Use this to temporarily disable the proxy without losing the configuration"
|
|
||||||
onChange={async (enabled) => {
|
|
||||||
const { proxy } = settings;
|
|
||||||
const http = proxy?.type === "enabled" ? proxy.http : "";
|
|
||||||
const https = proxy?.type === "enabled" ? proxy.https : "";
|
|
||||||
const bypass = proxy?.type === "enabled" ? proxy.bypass : "";
|
|
||||||
const auth = proxy?.type === "enabled" ? proxy.auth : null;
|
|
||||||
const disabled = !enabled;
|
|
||||||
await patchModel(settings, {
|
|
||||||
proxy: { type: "enabled", http, https, auth, disabled, bypass },
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<HStack space={1.5}>
|
|
||||||
<PlainInput
|
|
||||||
size="sm"
|
|
||||||
label={
|
|
||||||
<>
|
|
||||||
Proxy for <InlineCode>http://</InlineCode> traffic
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
placeholder="localhost:9090"
|
|
||||||
defaultValue={settings.proxy?.http}
|
|
||||||
onChange={async (http) => {
|
|
||||||
const { proxy } = settings;
|
|
||||||
const https = proxy?.type === "enabled" ? proxy.https : "";
|
|
||||||
const bypass = proxy?.type === "enabled" ? proxy.bypass : "";
|
|
||||||
const auth = proxy?.type === "enabled" ? proxy.auth : null;
|
|
||||||
const disabled = proxy?.type === "enabled" ? proxy.disabled : false;
|
|
||||||
await patchModel(settings, {
|
|
||||||
proxy: {
|
|
||||||
type: "enabled",
|
|
||||||
http,
|
|
||||||
https,
|
|
||||||
auth,
|
|
||||||
disabled,
|
|
||||||
bypass,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<PlainInput
|
|
||||||
size="sm"
|
|
||||||
label={
|
|
||||||
<>
|
|
||||||
Proxy for <InlineCode>https://</InlineCode> traffic
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
placeholder="localhost:9090"
|
|
||||||
defaultValue={settings.proxy?.https}
|
|
||||||
onChange={async (https) => {
|
|
||||||
const { proxy } = settings;
|
|
||||||
const http = proxy?.type === "enabled" ? proxy.http : "";
|
|
||||||
const bypass = proxy?.type === "enabled" ? proxy.bypass : "";
|
|
||||||
const auth = proxy?.type === "enabled" ? proxy.auth : null;
|
|
||||||
const disabled = proxy?.type === "enabled" ? proxy.disabled : false;
|
|
||||||
await patchModel(settings, {
|
|
||||||
proxy: { type: "enabled", http, https, auth, disabled, bypass },
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
<Separator className="my-6" />
|
|
||||||
<Checkbox
|
|
||||||
checked={settings.proxy.auth != null}
|
|
||||||
title="Enable authentication"
|
|
||||||
onChange={async (enabled) => {
|
|
||||||
const { proxy } = settings;
|
|
||||||
const http = proxy?.type === "enabled" ? proxy.http : "";
|
|
||||||
const https = proxy?.type === "enabled" ? proxy.https : "";
|
|
||||||
const disabled = proxy?.type === "enabled" ? proxy.disabled : false;
|
|
||||||
const bypass = proxy?.type === "enabled" ? proxy.bypass : "";
|
|
||||||
const auth = enabled ? { user: "", password: "" } : null;
|
|
||||||
await patchModel(settings, {
|
|
||||||
proxy: { type: "enabled", http, https, auth, disabled, bypass },
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{settings.proxy.auth != null && (
|
|
||||||
<HStack space={1.5}>
|
|
||||||
<PlainInput
|
|
||||||
required
|
|
||||||
size="sm"
|
|
||||||
label="User"
|
|
||||||
placeholder="myUser"
|
|
||||||
defaultValue={settings.proxy.auth.user}
|
|
||||||
onChange={async (user) => {
|
|
||||||
const { proxy } = settings;
|
|
||||||
const http = proxy?.type === "enabled" ? proxy.http : "";
|
|
||||||
const https = proxy?.type === "enabled" ? proxy.https : "";
|
|
||||||
const disabled = proxy?.type === "enabled" ? proxy.disabled : false;
|
|
||||||
const bypass = proxy?.type === "enabled" ? proxy.bypass : "";
|
|
||||||
const password = proxy?.type === "enabled" ? (proxy.auth?.password ?? "") : "";
|
|
||||||
const auth = { user, password };
|
|
||||||
await patchModel(settings, {
|
|
||||||
proxy: { type: "enabled", http, https, auth, disabled, bypass },
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<PlainInput
|
|
||||||
size="sm"
|
|
||||||
label="Password"
|
|
||||||
type="password"
|
|
||||||
placeholder="s3cretPassw0rd"
|
|
||||||
defaultValue={settings.proxy.auth.password}
|
|
||||||
onChange={async (password) => {
|
|
||||||
const { proxy } = settings;
|
|
||||||
const http = proxy?.type === "enabled" ? proxy.http : "";
|
|
||||||
const https = proxy?.type === "enabled" ? proxy.https : "";
|
|
||||||
const disabled = proxy?.type === "enabled" ? proxy.disabled : false;
|
|
||||||
const bypass = proxy?.type === "enabled" ? proxy.bypass : "";
|
|
||||||
const user = proxy?.type === "enabled" ? (proxy.auth?.user ?? "") : "";
|
|
||||||
const auth = { user, password };
|
|
||||||
await patchModel(settings, {
|
|
||||||
proxy: { type: "enabled", http, https, auth, disabled, bypass },
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
)}
|
|
||||||
{settings.proxy.type === "enabled" && (
|
|
||||||
<>
|
|
||||||
<Separator className="my-6" />
|
|
||||||
<PlainInput
|
|
||||||
label="Proxy Bypass"
|
|
||||||
help="Comma-separated list to bypass the proxy."
|
|
||||||
defaultValue={settings.proxy.bypass}
|
|
||||||
placeholder="127.0.0.1, *.example.com, localhost:3000"
|
|
||||||
onChange={async (bypass) => {
|
|
||||||
const { proxy } = settings;
|
|
||||||
const http = proxy?.type === "enabled" ? proxy.http : "";
|
|
||||||
const https = proxy?.type === "enabled" ? proxy.https : "";
|
|
||||||
const disabled = proxy?.type === "enabled" ? proxy.disabled : false;
|
|
||||||
const user = proxy?.type === "enabled" ? (proxy.auth?.user ?? "") : "";
|
|
||||||
const password = proxy?.type === "enabled" ? (proxy.auth?.password ?? "") : "";
|
|
||||||
const auth = { user, password };
|
|
||||||
await patchModel(settings, {
|
|
||||||
proxy: { type: "enabled", http, https, auth, disabled, bypass },
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</VStack>
|
|
||||||
)}
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,172 +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 { Select } from "../core/Select";
|
|
||||||
|
|
||||||
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={3} 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>
|
|
||||||
<Select
|
|
||||||
name="appearance"
|
|
||||||
label="Appearance"
|
|
||||||
labelPosition="top"
|
|
||||||
size="sm"
|
|
||||||
value={settings.appearance}
|
|
||||||
onChange={(appearance) => patchModel(settings, { appearance })}
|
|
||||||
options={[
|
|
||||||
{ label: "Automatic", value: "system" },
|
|
||||||
{ label: "Light", value: "light" },
|
|
||||||
{ label: "Dark", value: "dark" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<HStack space={2}>
|
|
||||||
{(settings.appearance === "system" || settings.appearance === "light") && (
|
|
||||||
<Select
|
|
||||||
hideLabel
|
|
||||||
leftSlot={<Icon icon="sun" color="secondary" />}
|
|
||||||
name="lightTheme"
|
|
||||||
label="Light Theme"
|
|
||||||
size="sm"
|
|
||||||
className="flex-1"
|
|
||||||
value={activeTheme.data.light.id}
|
|
||||||
options={lightThemes}
|
|
||||||
onChange={(themeLight) => patchModel(settings, { themeLight })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{(settings.appearance === "system" || settings.appearance === "dark") && (
|
|
||||||
<Select
|
|
||||||
hideLabel
|
|
||||||
name="darkTheme"
|
|
||||||
className="flex-1"
|
|
||||||
label="Dark Theme"
|
|
||||||
leftSlot={<Icon icon="moon" color="secondary" />}
|
|
||||||
size="sm"
|
|
||||||
value={activeTheme.data.dark.id}
|
|
||||||
options={darkThemes}
|
|
||||||
onChange={(themeDark) => patchModel(settings, { themeDark })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
<VStack
|
|
||||||
space={3}
|
|
||||||
className="mt-3 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>
|
|
||||||
</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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user