mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-05-04 22:54:15 +02:00
Compare commits
236 Commits
omnara/pre
...
wip/yaak-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2000c86d8 | ||
|
|
7577846369 | ||
|
|
903f57a415 | ||
|
|
d0f1708017 | ||
|
|
ee69db0f12 | ||
|
|
7314aedc71 | ||
|
|
3c4035097a | ||
|
|
77ec87ea17 | ||
|
|
4d792c7f9f | ||
|
|
d253d1605c | ||
|
|
bc8a449b8a | ||
|
|
7fbce4e808 | ||
|
|
f91f40e3a1 | ||
|
|
024b0a3cd3 | ||
|
|
9e0a708011 | ||
|
|
d8ce5c9d1a | ||
|
|
f7ff964fe5 | ||
|
|
cc504e0a1c | ||
|
|
47f0daabff | ||
|
|
87e60372fe | ||
|
|
7e7faa69df | ||
|
|
0b7705d915 | ||
|
|
5e3ef70d93 | ||
|
|
4968237ece | ||
|
|
568a1b80ed | ||
|
|
d4a6735881 | ||
|
|
0c52fd03e2 | ||
|
|
3e7d04b2f3 | ||
|
|
6600116b1a | ||
|
|
7be53ca330 | ||
|
|
f51f72a332 | ||
|
|
8a330ad1ec | ||
|
|
90365f0723 | ||
|
|
e87c3291e7 | ||
|
|
a0442fb42b | ||
|
|
12ece44197 | ||
|
|
4c041e68a9 | ||
|
|
6534421733 | ||
|
|
6e11894f79 | ||
|
|
96a22c68f2 | ||
|
|
0a616eb5e2 | ||
|
|
7382287bef | ||
|
|
a5433fbc74 | ||
|
|
6f8c4c06bb | ||
|
|
4c37e62146 | ||
|
|
cf28229f5f | ||
|
|
3586c8fe24 | ||
|
|
d99898f39b | ||
|
|
ff6686f982 | ||
|
|
6f9e4ada15 | ||
|
|
fd100330a6 | ||
|
|
6915778c06 | ||
|
|
e26705f016 | ||
|
|
32f22aad67 | ||
|
|
b563319bed | ||
|
|
3d577dd7d9 | ||
|
|
591c68c59c | ||
|
|
a0cb7f813f | ||
|
|
cfab62707e | ||
|
|
267508e533 | ||
|
|
242f55b609 | ||
|
|
67a3dd15ac | ||
|
|
543325613b | ||
|
|
88f5f0e045 | ||
|
|
615f3134d2 | ||
|
|
0c7051d59c | ||
|
|
30f006401a | ||
|
|
3c12074db6 | ||
|
|
851f12f149 | ||
|
|
cc0d31fdbb | ||
|
|
bab4fe899b | ||
|
|
0b250ff5b5 | ||
|
|
fbf0473b20 | ||
|
|
876b7ef454 | ||
|
|
96e8572758 | ||
|
|
f302dc39a2 | ||
|
|
2ca51125a4 | ||
|
|
2d99e26f19 | ||
|
|
da1e04d99e | ||
|
|
d875eaa5bf | ||
|
|
5fa2469cd6 | ||
|
|
49053cb423 | ||
|
|
37d0cabb22 | ||
|
|
435ee54140 | ||
|
|
407f2c9921 | ||
|
|
3a6630a14d | ||
|
|
ffc80d234c | ||
|
|
644c683714 | ||
|
|
d2c1bd79ac | ||
|
|
020589f2e6 | ||
|
|
b2a70d8938 | ||
|
|
64c626ed30 | ||
|
|
35d9ed901a | ||
|
|
f04b34be1a | ||
|
|
1e7e1232da | ||
|
|
c31d477a90 | ||
|
|
443e1b8262 | ||
|
|
c6b7cb2e32 | ||
|
|
4aef826a80 | ||
|
|
50c7992b42 | ||
|
|
5e9aebda6f | ||
|
|
a1e84c7785 | ||
|
|
fea4411afa | ||
|
|
8315e4afad | ||
|
|
a19ee9b502 | ||
|
|
0130bdee6f | ||
|
|
71ae9f41ed | ||
|
|
d06b6ce636 | ||
|
|
f5727b28c4 | ||
|
|
c62db7be06 | ||
|
|
4e56daa555 | ||
|
|
746bedf885 | ||
|
|
949c4a445a | ||
|
|
1f588d0498 | ||
|
|
4573edc1e1 | ||
|
|
5a184c1b83 | ||
|
|
7b73401dcf | ||
|
|
8571440d84 | ||
|
|
bc37a5d666 | ||
|
|
a80f2ccf9a | ||
|
|
1eaf276b75 | ||
|
|
e9559dfdfa | ||
|
|
4c2e7b8609 | ||
|
|
e638cecf07 | ||
|
|
076058da4f | ||
|
|
f1bc4aa146 | ||
|
|
773c4a24a5 | ||
|
|
6cc659e5c4 | ||
|
|
e1580210dc | ||
|
|
0a4ffde319 | ||
|
|
cc4d598af3 | ||
|
|
f5d11cb6d3 | ||
|
|
65e91aec6b | ||
|
|
ae943a5fd2 | ||
|
|
9e1a11de0b | ||
|
|
52732e12ec | ||
|
|
1127d7e3fa | ||
|
|
7d4d228236 | ||
|
|
565e053ee8 | ||
|
|
26aba6034f | ||
|
|
9a1d613034 | ||
|
|
3e4de7d3c4 | ||
|
|
b64b5ec0f8 | ||
|
|
510d1c7d17 | ||
|
|
ed13a62269 | ||
|
|
935d613959 | ||
|
|
adeaaccc45 | ||
|
|
d253093333 | ||
|
|
f265b7a572 | ||
|
|
68b2ff016f | ||
|
|
a1c6295810 | ||
|
|
76ee3fa61b | ||
|
|
7fef35ce0a | ||
|
|
654af09951 | ||
|
|
484dcfade0 | ||
|
|
fda18c5434 | ||
|
|
a8176d6e9e | ||
|
|
957d8d9d46 | ||
|
|
5f18bf25e2 | ||
|
|
66942eaf2c | ||
|
|
38796b1833 | ||
|
|
49ffa6fc45 | ||
|
|
1f56ba2eb6 | ||
|
|
f98a70ecb4 | ||
|
|
2984eb40c9 | ||
|
|
cc5d4742f0 | ||
|
|
5b8e4b98a0 | ||
|
|
8637c90a21 | ||
|
|
b88c5e71a0 | ||
|
|
1899d512ab | ||
|
|
7c31718f5e | ||
|
|
8f1463e5d0 | ||
|
|
0dc8807808 | ||
|
|
f24a159b8a | ||
|
|
0b91d3aaff | ||
|
|
431dc1c896 | ||
|
|
bc8277b56b | ||
|
|
0afed185d9 | ||
|
|
55cee00601 | ||
|
|
b41a8e04cb | ||
|
|
eff4519d91 | ||
|
|
c4ce458f79 | ||
|
|
f02ae35634 | ||
|
|
c2f068970b | ||
|
|
eec2d6bc38 | ||
|
|
efa22e470e | ||
|
|
c00d2e981f | ||
|
|
9c45254952 | ||
|
|
d031ff231a | ||
|
|
f056894ddb | ||
|
|
1b0315165f | ||
|
|
bd7e840a57 | ||
|
|
8969748c3c | ||
|
|
4e15ac10a6 | ||
|
|
47a3d44888 | ||
|
|
eb10910d20 | ||
|
|
6ba83d424d | ||
|
|
beb47a6b6a | ||
|
|
1893b8f8dd | ||
|
|
7a5bca7aae | ||
|
|
9a75bc2ae7 | ||
|
|
65514e3882 | ||
|
|
9ddaafb79f | ||
|
|
de47ee19ec | ||
|
|
ea730d0184 | ||
|
|
fe706998d4 | ||
|
|
99209e088f | ||
|
|
3eb29ff2fe | ||
|
|
b759003c83 | ||
|
|
6cba38ac89 | ||
|
|
ba8f85baaf | ||
|
|
9970d5fa6f | ||
|
|
d550b42ca3 | ||
|
|
2e1f0cb53f | ||
|
|
eead422ada | ||
|
|
b5753da3b7 | ||
|
|
ae2f2459e9 | ||
|
|
306e6f358a | ||
|
|
822d52a57e | ||
|
|
e665ce04df | ||
|
|
e4828e1b17 | ||
|
|
42143249a2 | ||
|
|
72a7e6963d | ||
|
|
494e9efb64 | ||
|
|
9fe077f598 | ||
|
|
a6eca1cf2e | ||
|
|
31edd1013f | ||
|
|
28e9657ea5 | ||
|
|
ff084a224a | ||
|
|
bbcae34575 | ||
|
|
2a5587c128 | ||
|
|
c41e173a63 | ||
|
|
2b43407ddf | ||
|
|
4d75b8ef06 | ||
|
|
aa79fb05f9 | ||
|
|
fe01796536 |
@@ -1,23 +1,27 @@
|
||||
# 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, yaak-tauri-utils, etc.)
|
||||
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/`
|
||||
|
||||
- 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)
|
||||
@@ -25,6 +29,7 @@ crates-cli/ # CLI crate (yaak-cli)
|
||||
- **yaak-grpc**: Replaced AppHandle with GrpcConfig struct, uses tokio::process::Command instead of Tauri sidecar
|
||||
|
||||
### 3. CLI Implementation
|
||||
|
||||
- Basic CLI at `crates-cli/yaak-cli/src/main.rs`
|
||||
- Commands: workspaces, requests, send (by ID), get (ad-hoc URL), create
|
||||
- Uses same database as Tauri app via `yaak_models::init_standalone()`
|
||||
@@ -32,31 +37,36 @@ crates-cli/ # CLI crate (yaak-cli)
|
||||
## Remaining Work
|
||||
|
||||
### Crates Still Depending on Tauri (in `crates/`)
|
||||
|
||||
1. **yaak-git** (3 files) - Moderate complexity
|
||||
2. **yaak-plugins** (13 files) - **Hardest** - deeply integrated with Tauri for plugin-to-window communication
|
||||
3. **yaak-sync** (4 files) - Moderate complexity
|
||||
4. **yaak-ws** (5 files) - Moderate complexity
|
||||
|
||||
### Pattern for Decoupling
|
||||
|
||||
1. Remove Tauri plugin `init()` function from the crate
|
||||
2. Move commands to `yaak-app/src/commands.rs` or keep inline in `lib.rs`
|
||||
3. Move extension traits (e.g., `SomethingManagerExt`) to yaak-app or yaak-tauri-utils
|
||||
4. Initialize managers in yaak-app's `.setup()` block
|
||||
5. Remove `tauri` from Cargo.toml dependencies
|
||||
6. Update `crates-tauri/yaak-app/capabilities/default.json` to remove the plugin permission
|
||||
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/src/lib.rs` - Main Tauri app, setup block initializes managers
|
||||
- `crates-tauri/yaak-app/src/commands.rs` - Migrated Tauri commands
|
||||
- `crates-tauri/yaak-app/src/models_ext.rs` - Database plugin and extension traits
|
||||
|
||||
- `crates-tauri/yaak-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
|
||||
@@ -67,6 +77,7 @@ 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 app-dev` to test the Tauri app still works
|
||||
- Run `npm run client:dev` to test the Tauri app still works
|
||||
- Run `cargo run -p yaak-cli -- --help` to test the CLI
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
---
|
||||
description: Review a PR in a new worktree
|
||||
allowed-tools: Bash(git worktree:*), Bash(gh pr:*)
|
||||
---
|
||||
|
||||
Review a GitHub pull request in a new git worktree.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/review-pr <PR_NUMBER>
|
||||
```
|
||||
|
||||
## What to do
|
||||
|
||||
1. List all open pull requests and ask the user to select one
|
||||
2. Get PR information using `gh pr view <PR_NUMBER> --json number,headRefName`
|
||||
3. Extract the branch name from the PR
|
||||
4. Create a new worktree at `../yaak-worktrees/pr-<PR_NUMBER>` using `git worktree add` with a timeout of at least 300000ms (5 minutes) since the post-checkout hook runs a bootstrap script
|
||||
5. Checkout the PR branch in the new worktree using `gh pr checkout <PR_NUMBER>`
|
||||
6. The post-checkout hook will automatically:
|
||||
- Create `.env.local` with unique ports
|
||||
- Copy editor config folders
|
||||
- Run `npm install && npm run bootstrap`
|
||||
7. Inform the user:
|
||||
- Where the worktree was created
|
||||
- What ports were assigned
|
||||
- How to access it (cd command)
|
||||
- How to run the dev server
|
||||
- How to remove the worktree when done
|
||||
|
||||
## Example Output
|
||||
|
||||
```
|
||||
Created worktree for PR #123 at ../yaak-worktrees/pr-123
|
||||
Branch: feature-auth
|
||||
Ports: Vite (1421), MCP (64344)
|
||||
|
||||
To start working:
|
||||
cd ../yaak-worktrees/pr-123
|
||||
npm run app-dev
|
||||
|
||||
To remove when done:
|
||||
git worktree remove ../yaak-worktrees/pr-123
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
- If the PR doesn't exist, show a helpful error
|
||||
- If the worktree already exists, inform the user and ask if they want to remove and recreate it
|
||||
- If `gh` CLI is not available, inform the user to install it
|
||||
@@ -8,7 +8,7 @@ Generate formatted release notes for Yaak releases by analyzing git history and
|
||||
## What to do
|
||||
|
||||
1. Identifies the version tag and previous version
|
||||
2. Retrieves all commits between versions
|
||||
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:
|
||||
@@ -37,3 +37,14 @@ The skill generates markdown-formatted release notes following this structure:
|
||||
|
||||
**IMPORTANT**: Always add a blank lines around the markdown code fence and output the markdown code block last
|
||||
**IMPORTANT**: PRs by `@gschier` should not mention the @username
|
||||
**IMPORTANT**: These are app release notes. Exclude CLI-only changes (commits prefixed with `cli:` or only touching `crates-cli/`) since the CLI has its own release process.
|
||||
|
||||
## After Generating Release Notes
|
||||
|
||||
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,35 +0,0 @@
|
||||
# Worktree Management Skill
|
||||
|
||||
## Creating Worktrees
|
||||
|
||||
When creating git worktrees for this project, ALWAYS use the path format:
|
||||
```
|
||||
../yaak-worktrees/<NAME>
|
||||
```
|
||||
|
||||
For example:
|
||||
- `git worktree add ../yaak-worktrees/feature-auth`
|
||||
- `git worktree add ../yaak-worktrees/bugfix-login`
|
||||
- `git worktree add ../yaak-worktrees/refactor-api`
|
||||
|
||||
## What Happens Automatically
|
||||
|
||||
The post-checkout hook will automatically:
|
||||
1. Create `.env.local` with unique ports (YAAK_DEV_PORT and YAAK_PLUGIN_MCP_SERVER_PORT)
|
||||
2. Copy gitignored editor config folders (.zed, .idea, etc.)
|
||||
3. Run `npm install && npm run bootstrap`
|
||||
|
||||
## Deleting Worktrees
|
||||
|
||||
```bash
|
||||
git worktree remove ../yaak-worktrees/<NAME>
|
||||
```
|
||||
|
||||
## Port Assignments
|
||||
|
||||
- Main worktree: 1420 (Vite), 64343 (MCP)
|
||||
- First worktree: 1421, 64344
|
||||
- Second worktree: 1422, 64345
|
||||
- etc.
|
||||
|
||||
Each worktree can run `npm run app-dev` simultaneously without conflicts.
|
||||
49
.codex/skills/release-generate-release-notes/SKILL.md
Normal file
49
.codex/skills/release-generate-release-notes/SKILL.md
Normal file
@@ -0,0 +1,49 @@
|
||||
---
|
||||
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`.
|
||||
4
.gitattributes
vendored
4
.gitattributes
vendored
@@ -1,5 +1,5 @@
|
||||
crates-tauri/yaak-app/vendored/**/* linguist-generated=true
|
||||
crates-tauri/yaak-app/gen/schemas/**/* linguist-generated=true
|
||||
crates-tauri/yaak-app-client/vendored/**/* linguist-generated=true
|
||||
crates-tauri/yaak-app-client/gen/schemas/**/* linguist-generated=true
|
||||
**/bindings/* linguist-generated=true
|
||||
crates/yaak-templates/pkg/* linguist-generated=true
|
||||
|
||||
|
||||
24
.github/ISSUE_TEMPLATE/bug_report.md
vendored
24
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,10 +1,9 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
title: ""
|
||||
labels: ""
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
@@ -12,6 +11,7 @@ A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
@@ -24,15 +24,17 @@ A clear and concise description of what you expected to happen.
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
|
||||
19
.github/pull_request_template.md
vendored
Normal file
19
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
## 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. -->
|
||||
9
.github/workflows/ci.yml
vendored
9
.github/workflows/ci.yml
vendored
@@ -14,17 +14,20 @@ jobs:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@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: npm ci
|
||||
- run: vp install
|
||||
- run: npm run bootstrap
|
||||
- run: npm run lint
|
||||
- name: Run JS Tests
|
||||
run: npm test
|
||||
run: vp test
|
||||
- name: Run Rust Tests
|
||||
run: cargo test --all
|
||||
|
||||
1
.github/workflows/claude.yml
vendored
1
.github/workflows/claude.yml
vendored
@@ -47,4 +47,3 @@ jobs:
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
||||
|
||||
|
||||
52
.github/workflows/flathub.yml
vendored
Normal file
52
.github/workflows/flathub.yml
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
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
Normal file
59
.github/workflows/release-api-npm.yml
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
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
|
||||
182
.github/workflows/release-app.yml
vendored
Normal file
182
.github/workflows/release-app.yml
vendored
Normal file
@@ -0,0 +1,182 @@
|
||||
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
|
||||
args: "${{ matrix.args }} --config ./crates-tauri/yaak-app-client/tauri.release.conf.json"
|
||||
|
||||
# Build a per-machine NSIS installer for enterprise deployment (PDQ, SCCM, Intune)
|
||||
- name: Build and upload machine-wide installer (Windows only)
|
||||
if: matrix.os == 'windows'
|
||||
shell: pwsh
|
||||
env:
|
||||
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
run: |
|
||||
Get-ChildItem -Recurse -Path target -File -Filter "*.exe.sig" | Remove-Item -Force
|
||||
npx tauri bundle ${{ matrix.args }} --bundles nsis --config ./crates-tauri/yaak-app-client/tauri.release.conf.json --config '{"bundle":{"createUpdaterArtifacts":true,"windows":{"nsis":{"installMode":"perMachine"}}}}'
|
||||
$setup = Get-ChildItem -Recurse -Path target -Filter "*setup*.exe" | Select-Object -First 1
|
||||
$setupSig = "$($setup.FullName).sig"
|
||||
$dest = $setup.FullName -replace '-setup\.exe$', '-setup-machine.exe'
|
||||
$destSig = "$dest.sig"
|
||||
Copy-Item $setup.FullName $dest
|
||||
Copy-Item $setupSig $destSig
|
||||
gh release upload "${{ github.ref_name }}" "$dest" --clobber
|
||||
gh release upload "${{ github.ref_name }}" "$destSig" --clobber
|
||||
218
.github/workflows/release-cli-npm.yml
vendored
Normal file
218
.github/workflows/release-cli-npm.yml
vendored
Normal file
@@ -0,0 +1,218 @@
|
||||
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
|
||||
129
.github/workflows/release.yml
vendored
129
.github/workflows/release.yml
vendored
@@ -1,129 +0,0 @@
|
||||
name: Generate Artifacts
|
||||
on:
|
||||
push:
|
||||
tags: [ v* ]
|
||||
|
||||
jobs:
|
||||
build-artifacts:
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
name: Build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: 'macos-latest' # for Arm-based Macs (M1 and above).
|
||||
args: '--target aarch64-apple-darwin'
|
||||
yaak_arch: 'arm64'
|
||||
os: 'macos'
|
||||
targets: 'aarch64-apple-darwin'
|
||||
- platform: 'macos-latest' # for Intel-based Macs.
|
||||
args: '--target x86_64-apple-darwin'
|
||||
yaak_arch: 'x64'
|
||||
os: 'macos'
|
||||
targets: 'x86_64-apple-darwin'
|
||||
- platform: 'ubuntu-22.04'
|
||||
args: ''
|
||||
yaak_arch: 'x64'
|
||||
os: 'ubuntu'
|
||||
targets: ''
|
||||
- platform: 'ubuntu-22.04-arm'
|
||||
args: ''
|
||||
yaak_arch: 'arm64'
|
||||
os: 'ubuntu'
|
||||
targets: ''
|
||||
- platform: 'windows-latest'
|
||||
args: ''
|
||||
yaak_arch: 'x64'
|
||||
os: 'windows'
|
||||
targets: ''
|
||||
# Windows ARM64
|
||||
- platform: 'windows-latest'
|
||||
args: '--target aarch64-pc-windows-msvc'
|
||||
yaak_arch: 'arm64'
|
||||
os: 'windows'
|
||||
targets: 'aarch64-pc-windows-msvc'
|
||||
runs-on: ${{ matrix.platform }}
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- name: Checkout yaakapp/app
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.targets }}
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: ci
|
||||
cache-on-failure: true
|
||||
|
||||
- name: install dependencies (Linux only)
|
||||
if: matrix.os == 'ubuntu'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
|
||||
|
||||
- name: Install Protoc for plugin-runtime
|
||||
uses: arduino/setup-protoc@v3
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install trusted-signing-cli (Windows only)
|
||||
if: matrix.os == 'windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$dir = "$env:USERPROFILE\trusted-signing"
|
||||
New-Item -ItemType Directory -Force -Path $dir | Out-Null
|
||||
$url = "https://github.com/Levminer/trusted-signing-cli/releases/download/0.8.0/trusted-signing-cli.exe"
|
||||
$exe = Join-Path $dir "trusted-signing-cli.exe"
|
||||
Invoke-WebRequest -Uri $url -OutFile $exe
|
||||
echo $dir >> $env:GITHUB_PATH
|
||||
& $exe --version
|
||||
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
- name: Run JS Tests
|
||||
run: npm test
|
||||
- name: Run Rust Tests
|
||||
run: cargo test --all
|
||||
|
||||
- name: Set version
|
||||
run: npm run replace-version
|
||||
env:
|
||||
YAAK_VERSION: ${{ github.ref_name }}
|
||||
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
|
||||
|
||||
ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
|
||||
# Apple signing stuff
|
||||
APPLE_CERTIFICATE: ${{ matrix.os == 'macos' && secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ matrix.os == 'macos' && secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_ID: ${{ matrix.os == 'macos' && secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ matrix.os == 'macos' && secrets.APPLE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ matrix.os == 'macos' && secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_TEAM_ID: ${{ matrix.os == 'macos' && secrets.APPLE_TEAM_ID }}
|
||||
|
||||
# Windows signing stuff
|
||||
AZURE_CLIENT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_TENANT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_TENANT_ID }}
|
||||
with:
|
||||
tagName: 'v__VERSION__'
|
||||
releaseName: 'Release __VERSION__'
|
||||
releaseBody: '[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)'
|
||||
releaseDraft: true
|
||||
prerelease: true
|
||||
args: '${{ matrix.args }} --config ./crates-tauri/yaak-app/tauri.release.conf.json'
|
||||
10
.github/workflows/sponsors.yml
vendored
10
.github/workflows/sponsors.yml
vendored
@@ -16,23 +16,23 @@ jobs:
|
||||
uses: JamesIves/github-sponsors-readme-action@v1
|
||||
with:
|
||||
token: ${{ secrets.SPONSORS_PAT }}
|
||||
file: 'README.md'
|
||||
file: "README.md"
|
||||
maximum: 1999
|
||||
template: '<a href="https://github.com/{{{ login }}}"><img src="{{{ avatarUrl }}}" width="50px" alt="User avatar: {{{ login }}}" /></a> '
|
||||
active-only: false
|
||||
include-private: true
|
||||
marker: 'sponsors-base'
|
||||
marker: "sponsors-base"
|
||||
|
||||
- name: Generate Sponsors
|
||||
uses: JamesIves/github-sponsors-readme-action@v1
|
||||
with:
|
||||
token: ${{ secrets.SPONSORS_PAT }}
|
||||
file: 'README.md'
|
||||
file: "README.md"
|
||||
minimum: 2000
|
||||
template: '<a href="https://github.com/{{{ login }}}"><img src="{{{ avatarUrl }}}" width="80px" alt="User avatar: {{{ login }}}" /></a> '
|
||||
active-only: false
|
||||
include-private: true
|
||||
marker: 'sponsors-premium'
|
||||
marker: "sponsors-premium"
|
||||
|
||||
# ⚠️ Note: You can use any deployment step here to automatically push the README
|
||||
# changes back to your branch.
|
||||
@@ -41,4 +41,4 @@ jobs:
|
||||
with:
|
||||
branch: main
|
||||
force: false
|
||||
folder: '.'
|
||||
folder: "."
|
||||
|
||||
16
.gitignore
vendored
16
.gitignore
vendored
@@ -39,8 +39,22 @@ codebook.toml
|
||||
target
|
||||
|
||||
# Per-worktree Tauri config (generated by post-checkout hook)
|
||||
crates-tauri/yaak-app/tauri.worktree.conf.json
|
||||
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
.node-version
Normal file
1
.node-version
Normal file
@@ -0,0 +1 @@
|
||||
24.14.0
|
||||
2
.npmrc
Normal file
2
.npmrc
Normal file
@@ -0,0 +1,2 @@
|
||||
# vite-plugin-wasm has not yet declared Vite 8 in its peerDependencies
|
||||
legacy-peer-deps=true
|
||||
3
.oxfmtignore
Normal file
3
.oxfmtignore
Normal file
@@ -0,0 +1,3 @@
|
||||
**/bindings/**
|
||||
**/routeTree.gen.ts
|
||||
crates/yaak-templates/pkg/**
|
||||
1
.vite-hooks/pre-commit
Normal file
1
.vite-hooks/pre-commit
Normal file
@@ -0,0 +1 @@
|
||||
vp lint
|
||||
6
.vscode/extensions.json
vendored
6
.vscode/extensions.json
vendored
@@ -1,3 +1,7 @@
|
||||
{
|
||||
"recommendations": ["biomejs.biome", "rust-lang.rust-analyzer", "bradlc.vscode-tailwindcss"]
|
||||
"recommendations": [
|
||||
"rust-lang.rust-analyzer",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"VoidZero.vite-plus-extension-pack"
|
||||
]
|
||||
}
|
||||
|
||||
8
.vscode/settings.json
vendored
8
.vscode/settings.json
vendored
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"biome.enabled": true,
|
||||
"biome.lint.format.enable": true
|
||||
"editor.formatOnSaveMode": "file",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.oxc": "explicit"
|
||||
}
|
||||
}
|
||||
|
||||
2
AGENTS.md
Normal file
2
AGENTS.md
Normal file
@@ -0,0 +1,2 @@
|
||||
- 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
|
||||
16
CONTRIBUTING.md
Normal file
16
CONTRIBUTING.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# 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).
|
||||
2825
Cargo.lock
generated
2825
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
66
Cargo.toml
66
Cargo.toml
@@ -1,28 +1,38 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
# 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",
|
||||
# CLI crates
|
||||
"crates-cli/yaak-cli",
|
||||
# Tauri-specific crates
|
||||
"crates-tauri/yaak-app",
|
||||
"crates-tauri/yaak-fonts",
|
||||
"crates-tauri/yaak-license",
|
||||
"crates-tauri/yaak-mac-window",
|
||||
"crates-tauri/yaak-tauri-utils",
|
||||
"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]
|
||||
@@ -33,6 +43,7 @@ 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"
|
||||
@@ -44,8 +55,13 @@ 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" }
|
||||
@@ -58,12 +74,18 @@ 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,24 +1,26 @@
|
||||
# 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
|
||||
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.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Make sure you have the following tools installed:
|
||||
|
||||
- [Node.js](https://nodejs.org/en/download/package-manager)
|
||||
- [Node.js](https://nodejs.org/en/download/package-manager) (v24+)
|
||||
- [Rust](https://www.rust-lang.org/tools/install)
|
||||
- [Vite+](https://vite.dev/guide/vite-plus) (`vp` CLI)
|
||||
|
||||
Check the installations with the following commands:
|
||||
|
||||
```shell
|
||||
node -v
|
||||
npm -v
|
||||
vp --version
|
||||
rustc --version
|
||||
```
|
||||
|
||||
@@ -45,12 +47,12 @@ npm start
|
||||
## SQLite Migrations
|
||||
|
||||
New migrations can be created from the `src-tauri/` directory:
|
||||
|
||||
|
||||
```shell
|
||||
npm run migration
|
||||
```
|
||||
|
||||
Rerun the app to apply the migrations.
|
||||
Rerun the app to apply the migrations.
|
||||
|
||||
_Note: For safety, development builds use a separate database location from production builds._
|
||||
|
||||
@@ -61,9 +63,9 @@ _Note: For safety, development builds use a separate database location from prod
|
||||
lezer-generator components/core/Editor/<LANG>/<LANG>.grammar > components/core/Editor/<LANG>/<LANG>.ts
|
||||
```
|
||||
|
||||
## Linting & Formatting
|
||||
## Linting and Formatting
|
||||
|
||||
This repo uses Biome for linting and formatting (replacing ESLint + Prettier).
|
||||
This repo uses [Vite+](https://vite.dev/guide/vite-plus) for linting (oxlint) and formatting (oxfmt).
|
||||
|
||||
- Lint the entire repo:
|
||||
|
||||
@@ -71,12 +73,6 @@ This repo uses Biome for linting and formatting (replacing ESLint + Prettier).
|
||||
npm run lint
|
||||
```
|
||||
|
||||
- Auto-fix lint issues where possible:
|
||||
|
||||
```sh
|
||||
npm run lint:fix
|
||||
```
|
||||
|
||||
- Format code:
|
||||
|
||||
```sh
|
||||
@@ -84,5 +80,7 @@ npm run format
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Many workspace packages also expose the same scripts (`lint`, `lint:fix`, and `format`).
|
||||
- TypeScript type-checking still runs separately via `tsc --noEmit` in relevant packages.
|
||||
|
||||
- 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.
|
||||
|
||||
26
README.md
26
README.md
@@ -1,6 +1,6 @@
|
||||
<p align="center">
|
||||
<a href="https://github.com/JamesIves/github-sponsors-readme-action">
|
||||
<img width="200px" src="https://github.com/mountain-loop/yaak/raw/main/src-tauri/icons/icon.png">
|
||||
<img width="200px" src="https://github.com/mountain-loop/yaak/raw/main/crates-tauri/yaak-app-client/icons/icon.png">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -16,23 +16,19 @@
|
||||
</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> <!-- sponsors-base -->
|
||||
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https://github.com/seanwash.png" width="50px" alt="User avatar: seanwash" /></a> <a href="https://github.com/jerath"><img src="https://github.com/jerath.png" width="50px" alt="User avatar: jerath" /></a> <a href="https://github.com/itsa-sh"><img src="https://github.com/itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a> <a href="https://github.com/dmmulroy"><img src="https://github.com/dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a> <a href="https://github.com/timcole"><img src="https://github.com/timcole.png" width="50px" alt="User avatar: timcole" /></a> <a href="https://github.com/VLZH"><img src="https://github.com/VLZH.png" width="50px" alt="User avatar: VLZH" /></a> <a href="https://github.com/terasaka2k"><img src="https://github.com/terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a> <a href="https://github.com/andriyor"><img src="https://github.com/andriyor.png" width="50px" alt="User avatar: andriyor" /></a> <a href="https://github.com/majudhu"><img src="https://github.com/majudhu.png" width="50px" alt="User avatar: majudhu" /></a> <a href="https://github.com/axelrindle"><img src="https://github.com/axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a> <a href="https://github.com/jirizverina"><img src="https://github.com/jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a> <a href="https://github.com/chip-well"><img src="https://github.com/chip-well.png" width="50px" alt="User avatar: chip-well" /></a> <a href="https://github.com/GRAYAH"><img src="https://github.com/GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a> <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.
|
||||
|
||||
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
|
||||
|
||||
@@ -41,30 +37,34 @@ Built with [Tauri](https://tauri.app), Rust, and React, it’s fast, lightweight
|
||||
- Filter and inspect responses with JSONPath or XPath.
|
||||
|
||||
### 🔐 Stay secure
|
||||
|
||||
- Use OAuth 2.0, JWT, Basic Auth, or custom plugins for authentication.
|
||||
- Secure sensitive values with encrypted secrets.
|
||||
- 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
|
||||
|
||||
Yaak is open source but only accepting contributions for bug fixes. To get started,
|
||||
visit [`DEVELOPMENT.md`](DEVELOPMENT.md) for tips on setting up your environment.
|
||||
> [!IMPORTANT]
|
||||
> Community PRs are currently limited to bug fixes and small-scope improvements.
|
||||
> If your PR is out of scope, link an approved feedback item from [yaak.app/feedback](https://yaak.app/feedback).
|
||||
> See [`CONTRIBUTING.md`](CONTRIBUTING.md) for policy details and [`DEVELOPMENT.md`](DEVELOPMENT.md) for local setup.
|
||||
|
||||
## Useful Resources
|
||||
|
||||
- [Feedback and Bug Reports](https://feedback.yaak.app)
|
||||
- [Documentation](https://feedback.yaak.app/help)
|
||||
- [Documentation](https://yaak.app/docs)
|
||||
- [Yaak vs Postman](https://yaak.app/alternatives/postman)
|
||||
- [Yaak vs Bruno](https://yaak.app/alternatives/bruno)
|
||||
- [Yaak vs Insomnia](https://yaak.app/alternatives/insomnia)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createWorkspaceModel, type Folder, modelTypeLabel } from '@yaakapp-internal/models';
|
||||
import { applySync, calculateSync } from '@yaakapp-internal/sync';
|
||||
import { Banner } from '../components/core/Banner';
|
||||
import { Button } from '../components/core/Button';
|
||||
import { InlineCode } from '../components/core/InlineCode';
|
||||
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,
|
||||
@@ -11,21 +11,21 @@ import {
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
TruncatedWideTableCell,
|
||||
} from '../components/core/Table';
|
||||
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';
|
||||
} 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'>>
|
||||
Partial<Pick<Folder, "name" | "sortPriority" | "folderId">>
|
||||
>({
|
||||
mutationKey: ['create_folder'],
|
||||
mutationKey: ["create_folder"],
|
||||
mutationFn: async (patch) => {
|
||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||
if (workspaceId == null) {
|
||||
@@ -34,12 +34,12 @@ export const createFolder = createFastMutation<
|
||||
|
||||
if (!patch.name) {
|
||||
const name = await showPrompt({
|
||||
id: 'new-folder',
|
||||
label: 'Name',
|
||||
defaultValue: 'Folder',
|
||||
title: 'New Folder',
|
||||
confirmText: 'Create',
|
||||
placeholder: 'Name',
|
||||
id: "new-folder",
|
||||
label: "Name",
|
||||
defaultValue: "Folder",
|
||||
title: "New Folder",
|
||||
confirmText: "Create",
|
||||
placeholder: "Name",
|
||||
});
|
||||
if (name == null) return null;
|
||||
|
||||
@@ -47,7 +47,7 @@ export const createFolder = createFastMutation<
|
||||
}
|
||||
|
||||
patch.sortPriority = patch.sortPriority || -Date.now();
|
||||
const id = await createWorkspaceModel({ model: 'folder', workspaceId, ...patch });
|
||||
const id = await createWorkspaceModel({ model: "folder", workspaceId, ...patch });
|
||||
return id;
|
||||
},
|
||||
});
|
||||
@@ -61,12 +61,12 @@ export const syncWorkspace = createFastMutation<
|
||||
mutationFn: async ({ workspaceId, syncDir, force }) => {
|
||||
const ops = (await calculateSync(workspaceId, syncDir)) ?? [];
|
||||
if (ops.length === 0) {
|
||||
console.log('Nothing to sync', workspaceId, syncDir);
|
||||
console.log("Nothing to sync", workspaceId, syncDir);
|
||||
return;
|
||||
}
|
||||
console.log('Syncing workspace', workspaceId, syncDir, ops);
|
||||
console.log("Syncing workspace", workspaceId, syncDir, ops);
|
||||
|
||||
const dbOps = ops.filter((o) => o.type.startsWith('db'));
|
||||
const dbOps = ops.filter((o) => o.type.startsWith("db"));
|
||||
|
||||
if (dbOps.length === 0) {
|
||||
await applySync(workspaceId, syncDir, ops);
|
||||
@@ -74,10 +74,10 @@ export const syncWorkspace = createFastMutation<
|
||||
}
|
||||
|
||||
const isDeletingWorkspace = ops.some(
|
||||
(o) => o.type === 'dbDelete' && o.model.model === 'workspace',
|
||||
(o) => o.type === "dbDelete" && o.model.model === "workspace",
|
||||
);
|
||||
|
||||
console.log('Directory changes detected', { dbOps, ops });
|
||||
console.log("Directory changes detected", { dbOps, ops });
|
||||
|
||||
if (force) {
|
||||
await applySync(workspaceId, syncDir, ops);
|
||||
@@ -85,9 +85,9 @@ export const syncWorkspace = createFastMutation<
|
||||
}
|
||||
|
||||
showDialog({
|
||||
id: 'commit-sync',
|
||||
title: 'Changes Detected',
|
||||
size: 'md',
|
||||
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"
|
||||
@@ -105,8 +105,8 @@ export const syncWorkspace = createFastMutation<
|
||||
<span />
|
||||
)}
|
||||
<p>
|
||||
{pluralizeCount('file', dbOps.length)} in the directory{' '}
|
||||
{dbOps.length === 1 ? 'has' : 'have'} changed. Do you want to update your workspace?
|
||||
{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>
|
||||
@@ -123,27 +123,27 @@ export const syncWorkspace = createFastMutation<
|
||||
let color: string;
|
||||
let model: string;
|
||||
|
||||
if (op.type === 'dbCreate') {
|
||||
label = 'create';
|
||||
if (op.type === "dbCreate") {
|
||||
label = "create";
|
||||
name = resolvedModelNameWithFolders(op.fs.model);
|
||||
color = 'text-success';
|
||||
color = "text-success";
|
||||
model = modelTypeLabel(op.fs.model);
|
||||
} else if (op.type === 'dbUpdate') {
|
||||
label = 'update';
|
||||
} else if (op.type === "dbUpdate") {
|
||||
label = "update";
|
||||
name = resolvedModelNameWithFolders(op.fs.model);
|
||||
color = 'text-info';
|
||||
color = "text-info";
|
||||
model = modelTypeLabel(op.fs.model);
|
||||
} else if (op.type === 'dbDelete') {
|
||||
label = 'delete';
|
||||
} else if (op.type === "dbDelete") {
|
||||
label = "delete";
|
||||
name = resolvedModelNameWithFolders(op.model);
|
||||
color = 'text-danger';
|
||||
color = "text-danger";
|
||||
model = modelTypeLabel(op.model);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: none
|
||||
// oxlint-disable-next-line react/no-array-index-key
|
||||
<TableRow key={i}>
|
||||
<TableCell className="text-text-subtle">{model}</TableCell>
|
||||
<TruncatedWideTableCell>{name}</TruncatedWideTableCell>
|
||||
@@ -1,33 +1,33 @@
|
||||
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';
|
||||
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'],
|
||||
mutationKey: ["create_environment"],
|
||||
mutationFn: async (baseEnvironment) => {
|
||||
if (baseEnvironment == null) {
|
||||
throw new Error('No base environment passed');
|
||||
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');
|
||||
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',
|
||||
id: "new-environment",
|
||||
title: "New Environment",
|
||||
description: "Create multiple environments with different sets of variables",
|
||||
size: "sm",
|
||||
onClose: () => resolve(null),
|
||||
render: ({ hide }) => (
|
||||
<CreateEnvironmentDialog
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { WebsocketRequest } from '@yaakapp-internal/models';
|
||||
import { deleteWebsocketConnections as cmdDeleteWebsocketConnections } from '@yaakapp-internal/ws';
|
||||
import { createFastMutation } from '../hooks/useFastMutation';
|
||||
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'],
|
||||
mutationKey: ["delete_websocket_connections"],
|
||||
mutationFn: async (request: WebsocketRequest) => cmdDeleteWebsocketConnections(request.id),
|
||||
});
|
||||
33
apps/yaak-client/commands/moveToWorkspace.tsx
Normal file
33
apps/yaak-client/commands/moveToWorkspace.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
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}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
17
apps/yaak-client/commands/openFolderSettings.tsx
Normal file
17
apps/yaak-client/commands/openFolderSettings.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
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,29 +1,29 @@
|
||||
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';
|
||||
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'],
|
||||
mutationKey: ["open_settings"],
|
||||
mutationFn: async (tab) => {
|
||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||
if (workspaceId == null) return;
|
||||
|
||||
const location = router.buildLocation({
|
||||
to: '/workspaces/$workspaceId/settings',
|
||||
to: "/workspaces/$workspaceId/settings",
|
||||
params: { workspaceId },
|
||||
search: { tab: (tab ?? undefined) as SettingsTab | undefined },
|
||||
});
|
||||
|
||||
await invokeCmd('cmd_new_child_window', {
|
||||
await invokeCmd("cmd_new_child_window", {
|
||||
url: location.href,
|
||||
label: 'settings',
|
||||
title: 'Yaak Settings',
|
||||
label: "settings",
|
||||
title: "Yaak Settings",
|
||||
innerSize: [750, 600],
|
||||
});
|
||||
},
|
||||
27
apps/yaak-client/commands/openWorkspaceFromSyncDir.tsx
Normal file
27
apps/yaak-client/commands/openWorkspaceFromSyncDir.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
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 },
|
||||
});
|
||||
},
|
||||
});
|
||||
19
apps/yaak-client/commands/openWorkspaceSettings.tsx
Normal file
19
apps/yaak-client/commands/openWorkspaceSettings.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
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,9 +1,9 @@
|
||||
import { createFastMutation } from '../hooks/useFastMutation';
|
||||
import { getRecentCookieJars } from '../hooks/useRecentCookieJars';
|
||||
import { getRecentEnvironments } from '../hooks/useRecentEnvironments';
|
||||
import { getRecentRequests } from '../hooks/useRecentRequests';
|
||||
import { router } from '../lib/router';
|
||||
import { invokeCmd } from '../lib/tauri';
|
||||
import { createFastMutation } from "../hooks/useFastMutation";
|
||||
import { getRecentCookieJars } from "../hooks/useRecentCookieJars";
|
||||
import { getRecentEnvironments } from "../hooks/useRecentEnvironments";
|
||||
import { getRecentRequests } from "../hooks/useRecentRequests";
|
||||
import { router } from "../lib/router";
|
||||
import { invokeCmd } from "../lib/tauri";
|
||||
|
||||
export const switchWorkspace = createFastMutation<
|
||||
void,
|
||||
@@ -13,7 +13,7 @@ export const switchWorkspace = createFastMutation<
|
||||
inNewWindow: boolean;
|
||||
}
|
||||
>({
|
||||
mutationKey: ['open_workspace'],
|
||||
mutationKey: ["open_workspace"],
|
||||
mutationFn: async ({ workspaceId, inNewWindow }) => {
|
||||
const environmentId = (await getRecentEnvironments(workspaceId))[0] ?? undefined;
|
||||
const requestId = (await getRecentRequests(workspaceId))[0] ?? undefined;
|
||||
@@ -26,16 +26,16 @@ export const switchWorkspace = createFastMutation<
|
||||
|
||||
if (inNewWindow) {
|
||||
const location = router.buildLocation({
|
||||
to: '/workspaces/$workspaceId',
|
||||
to: "/workspaces/$workspaceId",
|
||||
params: { workspaceId },
|
||||
search,
|
||||
});
|
||||
await invokeCmd<void>('cmd_new_main_window', { url: location.href });
|
||||
await invokeCmd<void>("cmd_new_main_window", { url: location.href });
|
||||
return;
|
||||
}
|
||||
|
||||
await router.navigate({
|
||||
to: '/workspaces/$workspaceId',
|
||||
to: "/workspaces/$workspaceId",
|
||||
params: { workspaceId },
|
||||
search,
|
||||
});
|
||||
@@ -1,17 +1,15 @@
|
||||
import type { HttpRequest } from '@yaakapp-internal/models';
|
||||
import mime from 'mime';
|
||||
import { useKeyValue } from '../hooks/useKeyValue';
|
||||
import { Banner } from './core/Banner';
|
||||
import { Button } from './core/Button';
|
||||
import { InlineCode } from './core/InlineCode';
|
||||
import { HStack, VStack } from './core/Stacks';
|
||||
import { SelectFile } from './SelectFile';
|
||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||
import { Banner, HStack, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import mime from "mime";
|
||||
import { useKeyValue } from "../hooks/useKeyValue";
|
||||
import { Button } from "./core/Button";
|
||||
import { SelectFile } from "./SelectFile";
|
||||
|
||||
type Props = {
|
||||
requestId: string;
|
||||
contentType: string | null;
|
||||
body: HttpRequest['body'];
|
||||
onChange: (body: HttpRequest['body']) => void;
|
||||
body: HttpRequest["body"];
|
||||
onChange: (body: HttpRequest["body"]) => void;
|
||||
onChangeContentType: (contentType: string | null) => void;
|
||||
};
|
||||
|
||||
@@ -23,8 +21,8 @@ export function BinaryFileEditor({
|
||||
requestId,
|
||||
}: Props) {
|
||||
const ignoreContentType = useKeyValue<boolean>({
|
||||
namespace: 'global',
|
||||
key: ['ignore_content_type', requestId],
|
||||
namespace: "global",
|
||||
key: ["ignore_content_type", requestId],
|
||||
fallback: false,
|
||||
});
|
||||
|
||||
@@ -33,8 +31,8 @@ export function BinaryFileEditor({
|
||||
onChange({ filePath: filePath ?? undefined });
|
||||
};
|
||||
|
||||
const filePath = typeof body.filePath === 'string' ? body.filePath : null;
|
||||
const mimeType = mime.getType(filePath ?? '') ?? 'application/octet-stream';
|
||||
const filePath = typeof body.filePath === "string" ? body.filePath : null;
|
||||
const mimeType = mime.getType(filePath ?? "") ?? "application/octet-stream";
|
||||
|
||||
return (
|
||||
<VStack space={2}>
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { appInfo } from '../lib/appInfo';
|
||||
import type { ReactNode } from "react";
|
||||
import { appInfo } from "../lib/appInfo";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
feature: 'updater' | 'license';
|
||||
feature: "updater" | "license";
|
||||
}
|
||||
|
||||
const featureMap: Record<Props['feature'], boolean> = {
|
||||
const featureMap: Record<Props["feature"], boolean> = {
|
||||
updater: appInfo.featureUpdater,
|
||||
license: appInfo.featureLicense,
|
||||
};
|
||||
160
apps/yaak-client/components/CloneGitRepositoryDialog.tsx
Normal file
160
apps/yaak-client/components/CloneGitRepositoryDialog.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
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,5 +1,5 @@
|
||||
import classNames from 'classnames';
|
||||
import type { CSSProperties } from 'react';
|
||||
import classNames from "classnames";
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
interface Props {
|
||||
color: string | null;
|
||||
@@ -11,7 +11,7 @@ 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',
|
||||
"inline-block w-[0.75em] h-[0.75em] rounded-full mr-1.5 border border-transparent flex-shrink-0",
|
||||
);
|
||||
|
||||
if (onClick) {
|
||||
@@ -20,7 +20,7 @@ export function ColorIndicator({ color, onClick, className }: Props) {
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
style={style}
|
||||
className={classNames(finalClassName, 'hover:border-text')}
|
||||
className={classNames(finalClassName, "hover:border-text")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { workspacesAtom } from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
import { fuzzyFilter } from 'fuzzbunny';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { workspacesAtom } from "@yaakapp-internal/models";
|
||||
import { Heading, Icon, useDebouncedState } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { fuzzyFilter } from "fuzzbunny";
|
||||
import { useAtomValue } from "jotai";
|
||||
import {
|
||||
Fragment,
|
||||
type KeyboardEvent,
|
||||
@@ -10,46 +11,45 @@ import {
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { createFolder } from '../commands/commands';
|
||||
import { createSubEnvironmentAndActivate } from '../commands/createEnvironment';
|
||||
import { openSettings } from '../commands/openSettings';
|
||||
import { switchWorkspace } from '../commands/switchWorkspace';
|
||||
import { useActiveCookieJar } from '../hooks/useActiveCookieJar';
|
||||
import { useActiveEnvironment } from '../hooks/useActiveEnvironment';
|
||||
import { useActiveRequest } from '../hooks/useActiveRequest';
|
||||
import { activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace';
|
||||
import { useAllRequests } from '../hooks/useAllRequests';
|
||||
import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
|
||||
import { useDebouncedState } from '../hooks/useDebouncedState';
|
||||
import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown';
|
||||
import { useGrpcRequestActions } from '../hooks/useGrpcRequestActions';
|
||||
import type { HotkeyAction } from '../hooks/useHotKey';
|
||||
import { useHttpRequestActions } from '../hooks/useHttpRequestActions';
|
||||
import { useRecentEnvironments } from '../hooks/useRecentEnvironments';
|
||||
import { useRecentRequests } from '../hooks/useRecentRequests';
|
||||
import { useRecentWorkspaces } from '../hooks/useRecentWorkspaces';
|
||||
import { useScrollIntoView } from '../hooks/useScrollIntoView';
|
||||
import { useSendAnyHttpRequest } from '../hooks/useSendAnyHttpRequest';
|
||||
import { useSidebarHidden } from '../hooks/useSidebarHidden';
|
||||
import { createRequestAndNavigate } from '../lib/createRequestAndNavigate';
|
||||
import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm';
|
||||
import { showDialog } from '../lib/dialog';
|
||||
import { editEnvironment } from '../lib/editEnvironment';
|
||||
import { renameModelWithPrompt } from '../lib/renameModelWithPrompt';
|
||||
} from "react";
|
||||
import { createFolder } from "../commands/commands";
|
||||
import { createSubEnvironmentAndActivate } from "../commands/createEnvironment";
|
||||
import { openSettings } from "../commands/openSettings";
|
||||
import { switchWorkspace } from "../commands/switchWorkspace";
|
||||
import { useActiveCookieJar } from "../hooks/useActiveCookieJar";
|
||||
import { useActiveEnvironment } from "../hooks/useActiveEnvironment";
|
||||
import { useActiveRequest } from "../hooks/useActiveRequest";
|
||||
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
||||
import { useAllRequests } from "../hooks/useAllRequests";
|
||||
import { useCreateWorkspace } from "../hooks/useCreateWorkspace";
|
||||
import { useEnvironmentsBreakdown } from "../hooks/useEnvironmentsBreakdown";
|
||||
import { useGrpcRequestActions } from "../hooks/useGrpcRequestActions";
|
||||
import type { HotkeyAction } from "../hooks/useHotKey";
|
||||
import { useHttpRequestActions } from "../hooks/useHttpRequestActions";
|
||||
import { useRecentEnvironments } from "../hooks/useRecentEnvironments";
|
||||
import { useRecentRequests } from "../hooks/useRecentRequests";
|
||||
import { useRecentWorkspaces } from "../hooks/useRecentWorkspaces";
|
||||
import { useScrollIntoView } from "../hooks/useScrollIntoView";
|
||||
import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
|
||||
import { useSidebarHidden } from "../hooks/useSidebarHidden";
|
||||
import { appInfo } from "../lib/appInfo";
|
||||
import { copyToClipboard } from "../lib/copy";
|
||||
import { createRequestAndNavigate } from "../lib/createRequestAndNavigate";
|
||||
import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import { editEnvironment } from "../lib/editEnvironment";
|
||||
import { renameModelWithPrompt } from "../lib/renameModelWithPrompt";
|
||||
import {
|
||||
resolvedModelNameWithFolders,
|
||||
resolvedModelNameWithFoldersArray,
|
||||
} from '../lib/resolvedModelName';
|
||||
import { router } from '../lib/router';
|
||||
import { setWorkspaceSearchParams } from '../lib/setWorkspaceSearchParams';
|
||||
import { CookieDialog } from './CookieDialog';
|
||||
import { Button } from './core/Button';
|
||||
import { Heading } from './core/Heading';
|
||||
import { Hotkey } from './core/Hotkey';
|
||||
import { HttpMethodTag } from './core/HttpMethodTag';
|
||||
import { Icon } from './core/Icon';
|
||||
import { PlainInput } from './core/PlainInput';
|
||||
} from "../lib/resolvedModelName";
|
||||
import { router } from "../lib/router";
|
||||
import { setWorkspaceSearchParams } from "../lib/setWorkspaceSearchParams";
|
||||
import { CookieDialog } from "./CookieDialog";
|
||||
import { Button } from "./core/Button";
|
||||
import { Hotkey } from "./core/Hotkey";
|
||||
import { HttpMethodTag } from "./core/HttpMethodTag";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
|
||||
interface CommandPaletteGroup {
|
||||
key: string;
|
||||
@@ -66,7 +66,7 @@ type CommandPaletteItem = {
|
||||
const MAX_PER_GROUP = 8;
|
||||
|
||||
export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
const [command, setCommand] = useDebouncedState<string>('', 150);
|
||||
const [command, setCommand] = useDebouncedState<string>("", 150);
|
||||
const [selectedItemKey, setSelectedItemKey] = useState<string | null>(null);
|
||||
const activeEnvironment = useActiveEnvironment();
|
||||
const httpRequestActions = useHttpRequestActions();
|
||||
@@ -94,74 +94,82 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
|
||||
const commands: CommandPaletteItem[] = [
|
||||
{
|
||||
key: 'settings.open',
|
||||
label: 'Open Settings',
|
||||
action: 'settings.show',
|
||||
key: "settings.open",
|
||||
label: "Open Settings",
|
||||
action: "settings.show",
|
||||
onSelect: () => openSettings.mutate(null),
|
||||
},
|
||||
{
|
||||
key: 'app.create',
|
||||
label: 'Create Workspace',
|
||||
key: "app.create",
|
||||
label: "Create Workspace",
|
||||
onSelect: createWorkspace,
|
||||
},
|
||||
{
|
||||
key: 'model.create',
|
||||
label: 'Create HTTP Request',
|
||||
onSelect: () => createRequestAndNavigate({ model: 'http_request', workspaceId }),
|
||||
key: "model.create",
|
||||
label: "Create HTTP Request",
|
||||
onSelect: () => createRequestAndNavigate({ model: "http_request", workspaceId }),
|
||||
},
|
||||
{
|
||||
key: 'grpc_request.create',
|
||||
label: 'Create GRPC Request',
|
||||
onSelect: () => createRequestAndNavigate({ model: 'grpc_request', workspaceId }),
|
||||
key: "grpc_request.create",
|
||||
label: "Create GRPC Request",
|
||||
onSelect: () => createRequestAndNavigate({ model: "grpc_request", workspaceId }),
|
||||
},
|
||||
{
|
||||
key: 'websocket_request.create',
|
||||
label: 'Create Websocket Request',
|
||||
onSelect: () => createRequestAndNavigate({ model: 'websocket_request', workspaceId }),
|
||||
key: "websocket_request.create",
|
||||
label: "Create Websocket Request",
|
||||
onSelect: () => createRequestAndNavigate({ model: "websocket_request", workspaceId }),
|
||||
},
|
||||
{
|
||||
key: 'folder.create',
|
||||
label: 'Create Folder',
|
||||
key: "folder.create",
|
||||
label: "Create Folder",
|
||||
onSelect: () => createFolder.mutate({}),
|
||||
},
|
||||
{
|
||||
key: 'cookies.show',
|
||||
label: 'Show Cookies',
|
||||
key: "cookies.show",
|
||||
label: "Show Cookies",
|
||||
onSelect: async () => {
|
||||
showDialog({
|
||||
id: 'cookies',
|
||||
title: 'Manage Cookies',
|
||||
size: 'full',
|
||||
id: "cookies",
|
||||
title: "Manage Cookies",
|
||||
size: "full",
|
||||
render: () => <CookieDialog cookieJarId={activeCookieJar?.id ?? null} />,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'environment.edit',
|
||||
label: 'Edit Environment',
|
||||
action: 'environment_editor.toggle',
|
||||
key: "environment.edit",
|
||||
label: "Edit Environment",
|
||||
action: "environment_editor.toggle",
|
||||
onSelect: () => editEnvironment(activeEnvironment),
|
||||
},
|
||||
{
|
||||
key: 'environment.create',
|
||||
label: 'Create Environment',
|
||||
key: "environment.create",
|
||||
label: "Create Environment",
|
||||
onSelect: () => createSubEnvironmentAndActivate.mutate(baseEnvironment),
|
||||
},
|
||||
{
|
||||
key: 'sidebar.toggle',
|
||||
label: 'Toggle Sidebar',
|
||||
action: 'sidebar.focus',
|
||||
key: "sidebar.toggle",
|
||||
label: "Toggle Sidebar",
|
||||
action: "sidebar.focus",
|
||||
onSelect: () => setSidebarHidden((h) => !h),
|
||||
},
|
||||
];
|
||||
|
||||
if (activeRequest?.model === 'http_request') {
|
||||
if (activeRequest?.model === "http_request") {
|
||||
commands.push({
|
||||
key: 'request.send',
|
||||
action: 'request.send',
|
||||
label: 'Send Request',
|
||||
key: "request.send",
|
||||
action: "request.send",
|
||||
label: "Send Request",
|
||||
onSelect: () => sendRequest(activeRequest.id),
|
||||
});
|
||||
if (appInfo.cliVersion != null) {
|
||||
commands.push({
|
||||
key: "request.copy_cli_send",
|
||||
searchText: `copy cli send yaak request send ${activeRequest.id}`,
|
||||
label: "Copy CLI Send Command",
|
||||
onSelect: () => copyToClipboard(`yaak request send ${activeRequest.id}`),
|
||||
});
|
||||
}
|
||||
httpRequestActions.forEach((a, i) => {
|
||||
commands.push({
|
||||
key: `http_request_action.${i}`,
|
||||
@@ -171,7 +179,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
});
|
||||
}
|
||||
|
||||
if (activeRequest?.model === 'grpc_request') {
|
||||
if (activeRequest?.model === "grpc_request") {
|
||||
grpcRequestActions.forEach((a, i) => {
|
||||
commands.push({
|
||||
key: `grpc_request_action.${i}`,
|
||||
@@ -183,21 +191,21 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
|
||||
if (activeRequest != null) {
|
||||
commands.push({
|
||||
key: 'http_request.rename',
|
||||
label: 'Rename Request',
|
||||
key: "http_request.rename",
|
||||
label: "Rename Request",
|
||||
onSelect: () => renameModelWithPrompt(activeRequest),
|
||||
});
|
||||
|
||||
commands.push({
|
||||
key: 'sidebar.selected.delete',
|
||||
label: 'Delete Request',
|
||||
key: "sidebar.selected.delete",
|
||||
label: "Delete Request",
|
||||
onSelect: () => deleteModelWithConfirm(activeRequest),
|
||||
});
|
||||
}
|
||||
|
||||
return commands.sort((a, b) =>
|
||||
('searchText' in a ? a.searchText : a.label).localeCompare(
|
||||
'searchText' in b ? b.searchText : b.label,
|
||||
("searchText" in a ? a.searchText : a.label).localeCompare(
|
||||
"searchText" in b ? b.searchText : b.label,
|
||||
),
|
||||
);
|
||||
}, [
|
||||
@@ -274,14 +282,14 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
|
||||
const groups = useMemo<CommandPaletteGroup[]>(() => {
|
||||
const actionsGroup: CommandPaletteGroup = {
|
||||
key: 'actions',
|
||||
label: 'Actions',
|
||||
key: "actions",
|
||||
label: "Actions",
|
||||
items: workspaceCommands,
|
||||
};
|
||||
|
||||
const requestGroup: CommandPaletteGroup = {
|
||||
key: 'requests',
|
||||
label: 'Switch Request',
|
||||
key: "requests",
|
||||
label: "Switch Request",
|
||||
items: [],
|
||||
};
|
||||
|
||||
@@ -295,14 +303,14 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
{resolvedModelNameWithFoldersArray(r).map((name, i, all) => (
|
||||
<Fragment key={name}>
|
||||
{i !== 0 && <Icon icon="chevron_right" className="opacity-80" />}
|
||||
<div className={classNames(i < all.length - 1 && 'truncate')}>{name}</div>
|
||||
<div className={classNames(i < all.length - 1 && "truncate")}>{name}</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
onSelect: async () => {
|
||||
await router.navigate({
|
||||
to: '/workspaces/$workspaceId',
|
||||
to: "/workspaces/$workspaceId",
|
||||
params: { workspaceId: r.workspaceId },
|
||||
search: (prev) => ({ ...prev, request_id: r.id }),
|
||||
});
|
||||
@@ -311,8 +319,8 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
}
|
||||
|
||||
const environmentGroup: CommandPaletteGroup = {
|
||||
key: 'environments',
|
||||
label: 'Switch Environment',
|
||||
key: "environments",
|
||||
label: "Switch Environment",
|
||||
items: [],
|
||||
};
|
||||
|
||||
@@ -328,8 +336,8 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
}
|
||||
|
||||
const workspaceGroup: CommandPaletteGroup = {
|
||||
key: 'workspaces',
|
||||
label: 'Switch Workspace',
|
||||
key: "workspaces",
|
||||
label: "Switch Workspace",
|
||||
items: [],
|
||||
};
|
||||
|
||||
@@ -357,10 +365,10 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
? fuzzyFilter(
|
||||
allItems.map((i) => ({
|
||||
...i,
|
||||
filterBy: 'searchText' in i ? i.searchText : i.label,
|
||||
filterBy: "searchText" in i ? i.searchText : i.label,
|
||||
})),
|
||||
command,
|
||||
{ fields: ['filterBy'] },
|
||||
{ fields: ["filterBy"] },
|
||||
)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map((v) => v.item)
|
||||
@@ -398,13 +406,13 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
const index = filteredAllItems.findIndex((v) => v.key === selectedItem?.key);
|
||||
if (e.key === 'ArrowDown' || (e.ctrlKey && e.key === 'n')) {
|
||||
if (e.key === "ArrowDown" || (e.ctrlKey && e.key === "n")) {
|
||||
const next = filteredAllItems[index + 1] ?? filteredAllItems[0];
|
||||
setSelectedItemKey(next?.key ?? null);
|
||||
} else if (e.key === 'ArrowUp' || (e.ctrlKey && e.key === 'k')) {
|
||||
} else if (e.key === "ArrowUp" || (e.ctrlKey && e.key === "k")) {
|
||||
const prev = filteredAllItems[index - 1] ?? filteredAllItems[filteredAllItems.length - 1];
|
||||
setSelectedItemKey(prev?.key ?? null);
|
||||
} else if (e.key === 'Enter') {
|
||||
} else if (e.key === "Enter") {
|
||||
const selected = filteredAllItems[index];
|
||||
setSelectedItemKey(selected?.key ?? null);
|
||||
if (selected) {
|
||||
@@ -481,10 +489,10 @@ function CommandPaletteItem({
|
||||
color="custom"
|
||||
justify="start"
|
||||
className={classNames(
|
||||
'w-full h-sm flex items-center rounded px-1.5',
|
||||
'hover:text-text',
|
||||
active && 'bg-surface-highlight',
|
||||
!active && 'text-text-subtle',
|
||||
"w-full h-sm flex items-center rounded px-1.5",
|
||||
"hover:text-text",
|
||||
active && "bg-surface-highlight",
|
||||
!active && "text-text-subtle",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{children}</span>
|
||||
@@ -1,14 +1,12 @@
|
||||
import type { HttpRequest } from '@yaakapp-internal/models';
|
||||
import { patchModel } from '@yaakapp-internal/models';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useToggle } from '../hooks/useToggle';
|
||||
import { showConfirm } from '../lib/confirm';
|
||||
import { Banner } from './core/Banner';
|
||||
import { Button } from './core/Button';
|
||||
import { InlineCode } from './core/InlineCode';
|
||||
import { Link } from './core/Link';
|
||||
import { SizeTag } from './core/SizeTag';
|
||||
import { HStack } from './core/Stacks';
|
||||
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;
|
||||
@@ -31,17 +29,17 @@ export function ConfirmLargeRequestBody({ children, request }: Props) {
|
||||
return (
|
||||
<Banner color="primary" className="flex flex-col gap-3">
|
||||
<p>
|
||||
Rendering content over{' '}
|
||||
Rendering content over{" "}
|
||||
<InlineCode>
|
||||
<SizeTag contentLength={tooLargeBytes} />
|
||||
</InlineCode>{' '}
|
||||
</InlineCode>{" "}
|
||||
may impact performance.
|
||||
</p>
|
||||
<p>
|
||||
See{' '}
|
||||
See{" "}
|
||||
<Link href="https://feedback.yaak.app/en/help/articles/1198684-working-with-large-values">
|
||||
Working With Large Values
|
||||
</Link>{' '}
|
||||
</Link>{" "}
|
||||
for tips.
|
||||
</p>
|
||||
<HStack wrap space={2}>
|
||||
@@ -55,13 +53,13 @@ export function ConfirmLargeRequestBody({ children, request }: Props) {
|
||||
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',
|
||||
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: '' } });
|
||||
await patchModel(request, { body: { ...request.body, text: "" } });
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -1,16 +1,14 @@
|
||||
import type { HttpResponse } from '@yaakapp-internal/models';
|
||||
import { type ReactNode, useMemo } from 'react';
|
||||
import { useSaveResponse } from '../hooks/useSaveResponse';
|
||||
import { useToggle } from '../hooks/useToggle';
|
||||
import { isProbablyTextContentType } from '../lib/contentType';
|
||||
import { getContentTypeFromHeaders } from '../lib/model_util';
|
||||
import { getResponseBodyText } from '../lib/responseBody';
|
||||
import { CopyButton } from './CopyButton';
|
||||
import { Banner } from './core/Banner';
|
||||
import { Button } from './core/Button';
|
||||
import { InlineCode } from './core/InlineCode';
|
||||
import { SizeTag } from './core/SizeTag';
|
||||
import { HStack } from './core/Stacks';
|
||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||
import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import { useSaveResponse } from "../hooks/useSaveResponse";
|
||||
import { useToggle } from "../hooks/useToggle";
|
||||
import { isProbablyTextContentType } from "../lib/contentType";
|
||||
import { getContentTypeFromHeaders } from "../lib/model_util";
|
||||
import { getResponseBodyText } from "../lib/responseBody";
|
||||
import { CopyButton } from "./CopyButton";
|
||||
import { Button } from "./core/Button";
|
||||
import { SizeTag } from "./core/SizeTag";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
@@ -33,10 +31,10 @@ export function ConfirmLargeResponse({ children, response }: Props) {
|
||||
return (
|
||||
<Banner color="primary" className="flex flex-col gap-3">
|
||||
<p>
|
||||
Showing responses over{' '}
|
||||
Showing responses over{" "}
|
||||
<InlineCode>
|
||||
<SizeTag contentLength={LARGE_BYTES} />
|
||||
</InlineCode>{' '}
|
||||
</InlineCode>{" "}
|
||||
may impact performance
|
||||
</p>
|
||||
<HStack wrap space={2}>
|
||||
@@ -1,15 +1,13 @@
|
||||
import type { HttpResponse } from '@yaakapp-internal/models';
|
||||
import { type ReactNode, useMemo } from 'react';
|
||||
import { getRequestBodyText as getHttpResponseRequestBodyText } from '../hooks/useHttpRequestBody';
|
||||
import { useToggle } from '../hooks/useToggle';
|
||||
import { isProbablyTextContentType } from '../lib/contentType';
|
||||
import { getContentTypeFromHeaders } from '../lib/model_util';
|
||||
import { CopyButton } from './CopyButton';
|
||||
import { Banner } from './core/Banner';
|
||||
import { Button } from './core/Button';
|
||||
import { InlineCode } from './core/InlineCode';
|
||||
import { SizeTag } from './core/SizeTag';
|
||||
import { HStack } from './core/Stacks';
|
||||
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;
|
||||
@@ -31,10 +29,10 @@ export function ConfirmLargeResponseRequest({ children, response }: Props) {
|
||||
return (
|
||||
<Banner color="primary" className="flex flex-col gap-3">
|
||||
<p>
|
||||
Showing content over{' '}
|
||||
Showing content over{" "}
|
||||
<InlineCode>
|
||||
<SizeTag contentLength={LARGE_BYTES} />
|
||||
</InlineCode>{' '}
|
||||
</InlineCode>{" "}
|
||||
may impact performance
|
||||
</p>
|
||||
<HStack wrap space={2}>
|
||||
@@ -46,7 +44,7 @@ export function ConfirmLargeResponseRequest({ children, response }: Props) {
|
||||
color="secondary"
|
||||
variant="border"
|
||||
size="xs"
|
||||
text={() => getHttpResponseRequestBodyText(response).then((d) => d?.bodyText ?? '')}
|
||||
text={() => getHttpResponseRequestBodyText(response).then((d) => d?.bodyText ?? "")}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { Cookie } from '@yaakapp-internal/models';
|
||||
import { cookieJarsAtom, patchModel } from '@yaakapp-internal/models';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { cookieDomain } from '../lib/model_util';
|
||||
import { Banner } from './core/Banner';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { InlineCode } from './core/InlineCode';
|
||||
import type { Cookie } from "@yaakapp-internal/models";
|
||||
import { cookieJarsAtom, patchModel } from "@yaakapp-internal/models";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { cookieDomain } from "../lib/model_util";
|
||||
import { Banner, InlineCode } from "@yaakapp-internal/ui";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
|
||||
interface Props {
|
||||
cookieJarId: string | null;
|
||||
@@ -1,17 +1,16 @@
|
||||
import { cookieJarsAtom, patchModel } from '@yaakapp-internal/models';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useActiveCookieJar } from '../hooks/useActiveCookieJar';
|
||||
import { useCreateCookieJar } from '../hooks/useCreateCookieJar';
|
||||
import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm';
|
||||
import { showDialog } from '../lib/dialog';
|
||||
import { showPrompt } from '../lib/prompt';
|
||||
import { setWorkspaceSearchParams } from '../lib/setWorkspaceSearchParams';
|
||||
import { CookieDialog } from './CookieDialog';
|
||||
import { Dropdown, type DropdownItem } from './core/Dropdown';
|
||||
import { Icon } from './core/Icon';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { InlineCode } from './core/InlineCode';
|
||||
import { cookieJarsAtom, patchModel } from "@yaakapp-internal/models";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { memo, useMemo } from "react";
|
||||
import { useActiveCookieJar } from "../hooks/useActiveCookieJar";
|
||||
import { useCreateCookieJar } from "../hooks/useCreateCookieJar";
|
||||
import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import { showPrompt } from "../lib/prompt";
|
||||
import { setWorkspaceSearchParams } from "../lib/setWorkspaceSearchParams";
|
||||
import { CookieDialog } from "./CookieDialog";
|
||||
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
||||
import { Icon, InlineCode } from "@yaakapp-internal/ui";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
|
||||
export const CookieDropdown = memo(function CookieDropdown() {
|
||||
const activeCookieJar = useActiveCookieJar();
|
||||
@@ -23,44 +22,44 @@ export const CookieDropdown = memo(function CookieDropdown() {
|
||||
...(cookieJars ?? []).map((j) => ({
|
||||
key: j.id,
|
||||
label: j.name,
|
||||
leftSlot: <Icon icon={j.id === activeCookieJar?.id ? 'check' : 'empty'} />,
|
||||
leftSlot: <Icon icon={j.id === activeCookieJar?.id ? "check" : "empty"} />,
|
||||
onSelect: () => {
|
||||
setWorkspaceSearchParams({ cookie_jar_id: j.id });
|
||||
},
|
||||
})),
|
||||
...(((cookieJars ?? []).length > 0 && activeCookieJar != null
|
||||
? [
|
||||
{ type: 'separator', label: activeCookieJar.name },
|
||||
{ type: "separator", label: activeCookieJar.name },
|
||||
{
|
||||
key: 'manage',
|
||||
label: 'Manage Cookies',
|
||||
key: "manage",
|
||||
label: "Manage Cookies",
|
||||
leftSlot: <Icon icon="cookie" />,
|
||||
onSelect: () => {
|
||||
if (activeCookieJar == null) return;
|
||||
showDialog({
|
||||
id: 'cookies',
|
||||
title: 'Manage Cookies',
|
||||
size: 'full',
|
||||
id: "cookies",
|
||||
title: "Manage Cookies",
|
||||
size: "full",
|
||||
render: () => <CookieDialog cookieJarId={activeCookieJar.id} />,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'rename',
|
||||
label: 'Rename',
|
||||
key: "rename",
|
||||
label: "Rename",
|
||||
leftSlot: <Icon icon="pencil" />,
|
||||
onSelect: async () => {
|
||||
const name = await showPrompt({
|
||||
id: 'rename-cookie-jar',
|
||||
title: 'Rename Cookie Jar',
|
||||
id: "rename-cookie-jar",
|
||||
title: "Rename Cookie Jar",
|
||||
description: (
|
||||
<>
|
||||
Enter a new name for <InlineCode>{activeCookieJar?.name}</InlineCode>
|
||||
</>
|
||||
),
|
||||
label: 'Name',
|
||||
confirmText: 'Save',
|
||||
placeholder: 'New name',
|
||||
label: "Name",
|
||||
confirmText: "Save",
|
||||
placeholder: "New name",
|
||||
defaultValue: activeCookieJar?.name,
|
||||
});
|
||||
if (name == null) return;
|
||||
@@ -70,9 +69,9 @@ export const CookieDropdown = memo(function CookieDropdown() {
|
||||
...(((cookieJars ?? []).length > 1 // Never delete the last one
|
||||
? [
|
||||
{
|
||||
label: 'Delete',
|
||||
label: "Delete",
|
||||
leftSlot: <Icon icon="trash" />,
|
||||
color: 'danger',
|
||||
color: "danger",
|
||||
onSelect: async () => {
|
||||
await deleteModelWithConfirm(activeCookieJar);
|
||||
},
|
||||
@@ -81,10 +80,10 @@ export const CookieDropdown = memo(function CookieDropdown() {
|
||||
: []) as DropdownItem[]),
|
||||
]
|
||||
: []) as DropdownItem[]),
|
||||
{ type: 'separator' },
|
||||
{ type: "separator" },
|
||||
{
|
||||
key: 'create-cookie-jar',
|
||||
label: 'New Cookie Jar',
|
||||
key: "create-cookie-jar",
|
||||
label: "New Cookie Jar",
|
||||
leftSlot: <Icon icon="plus" />,
|
||||
onSelect: () => createCookieJar.mutate(),
|
||||
},
|
||||
33
apps/yaak-client/components/CopyButton.tsx
Normal file
33
apps/yaak-client/components/CopyButton.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
31
apps/yaak-client/components/CopyIconButton.tsx
Normal file
31
apps/yaak-client/components/CopyIconButton.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
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,8 +1,8 @@
|
||||
import { useCreateDropdownItems } from '../hooks/useCreateDropdownItems';
|
||||
import type { DropdownProps } from './core/Dropdown';
|
||||
import { Dropdown } from './core/Dropdown';
|
||||
import { useCreateDropdownItems } from "../hooks/useCreateDropdownItems";
|
||||
import type { DropdownProps } from "./core/Dropdown";
|
||||
import { Dropdown } from "./core/Dropdown";
|
||||
|
||||
interface Props extends Omit<DropdownProps, 'items'> {
|
||||
interface Props extends Omit<DropdownProps, "items"> {
|
||||
hideFolder?: boolean;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export function CreateDropdown({ hideFolder, children, ...props }: Props) {
|
||||
const getItems = useCreateDropdownItems({
|
||||
hideFolder,
|
||||
hideIcons: true,
|
||||
folderId: 'active-folder',
|
||||
folderId: "active-folder",
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -1,12 +1,12 @@
|
||||
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';
|
||||
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;
|
||||
@@ -15,7 +15,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export function CreateEnvironmentDialog({ workspaceId, hide, onCreate }: Props) {
|
||||
const [name, setName] = useState<string>('');
|
||||
const [name, setName] = useState<string>("");
|
||||
const [color, setColor] = useState<string | null>(null);
|
||||
const [sharable, toggleSharable] = useToggle(false);
|
||||
return (
|
||||
@@ -24,13 +24,13 @@ export function CreateEnvironmentDialog({ workspaceId, hide, onCreate }: Props)
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const id = await createWorkspaceModel({
|
||||
model: 'environment',
|
||||
model: "environment",
|
||||
name,
|
||||
color,
|
||||
variables: [],
|
||||
public: sharable,
|
||||
workspaceId,
|
||||
parentModel: 'environment',
|
||||
parentModel: "environment",
|
||||
});
|
||||
hide();
|
||||
onCreate(id);
|
||||
@@ -1,26 +1,26 @@
|
||||
import { gitMutations } from '@yaakapp-internal/git';
|
||||
import type { WorkspaceMeta } from '@yaakapp-internal/models';
|
||||
import { createGlobalModel, updateModel } from '@yaakapp-internal/models';
|
||||
import { useState } from 'react';
|
||||
import { router } from '../lib/router';
|
||||
import { setupOrConfigureEncryption } from '../lib/setupOrConfigureEncryption';
|
||||
import { invokeCmd } from '../lib/tauri';
|
||||
import { showErrorToast } from '../lib/toast';
|
||||
import { Button } from './core/Button';
|
||||
import { Checkbox } from './core/Checkbox';
|
||||
import { Label } from './core/Label';
|
||||
import { PlainInput } from './core/PlainInput';
|
||||
import { VStack } from './core/Stacks';
|
||||
import { EncryptionHelp } from './EncryptionHelp';
|
||||
import { gitCallbacks } from './git/callbacks';
|
||||
import { SyncToFilesystemSetting } from './SyncToFilesystemSetting';
|
||||
import { gitMutations } from "@yaakapp-internal/git";
|
||||
import type { WorkspaceMeta } from "@yaakapp-internal/models";
|
||||
import { createGlobalModel, updateModel } from "@yaakapp-internal/models";
|
||||
import { VStack } from "@yaakapp-internal/ui";
|
||||
import { useState } from "react";
|
||||
import { router } from "../lib/router";
|
||||
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
|
||||
import { invokeCmd } from "../lib/tauri";
|
||||
import { showErrorToast } from "../lib/toast";
|
||||
import { Button } from "./core/Button";
|
||||
import { Checkbox } from "./core/Checkbox";
|
||||
import { Label } from "./core/Label";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import { EncryptionHelp } from "./EncryptionHelp";
|
||||
import { gitCallbacks } from "./git/callbacks";
|
||||
import { SyncToFilesystemSetting } from "./SyncToFilesystemSetting";
|
||||
|
||||
interface Props {
|
||||
hide: () => void;
|
||||
}
|
||||
|
||||
export function CreateWorkspaceDialog({ hide }: Props) {
|
||||
const [name, setName] = useState<string>('');
|
||||
const [name, setName] = useState<string>("");
|
||||
const [syncConfig, setSyncConfig] = useState<{
|
||||
filePath: string | null;
|
||||
initGit?: boolean;
|
||||
@@ -34,12 +34,12 @@ export function CreateWorkspaceDialog({ hide }: Props) {
|
||||
className="pb-3"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const workspaceId = await createGlobalModel({ model: 'workspace', name });
|
||||
const workspaceId = await createGlobalModel({ model: "workspace", name });
|
||||
if (workspaceId == null) return;
|
||||
|
||||
// Do getWorkspaceMeta instead of naively creating one because it might have
|
||||
// been created already when the store refreshes the workspace meta after
|
||||
const workspaceMeta = await invokeCmd<WorkspaceMeta>('cmd_get_workspace_meta', {
|
||||
const workspaceMeta = await invokeCmd<WorkspaceMeta>("cmd_get_workspace_meta", {
|
||||
workspaceId,
|
||||
});
|
||||
await updateModel({
|
||||
@@ -52,8 +52,8 @@ export function CreateWorkspaceDialog({ hide }: Props) {
|
||||
.init.mutateAsync()
|
||||
.catch((err) => {
|
||||
showErrorToast({
|
||||
id: 'git-init-error',
|
||||
title: 'Error initializing Git',
|
||||
id: "git-init-error",
|
||||
title: "Error initializing Git",
|
||||
message: String(err),
|
||||
});
|
||||
});
|
||||
@@ -61,7 +61,7 @@ export function CreateWorkspaceDialog({ hide }: Props) {
|
||||
|
||||
// Navigate to workspace
|
||||
await router.navigate({
|
||||
to: '/workspaces/$workspaceId',
|
||||
to: "/workspaces/$workspaceId",
|
||||
params: { workspaceId },
|
||||
});
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
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';
|
||||
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'>;
|
||||
} & Omit<DialogProps, "open" | "children">;
|
||||
|
||||
export function Dialogs() {
|
||||
const dialogs = useAtomValue(dialogsAtom);
|
||||
190
apps/yaak-client/components/DnsOverridesEditor.tsx
Normal file
190
apps/yaak-client/components/DnsOverridesEditor.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
34
apps/yaak-client/components/DropMarker.tsx
Normal file
34
apps/yaak-client/components/DropMarker.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
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,5 +1,5 @@
|
||||
import type { Folder, HttpRequest } from '@yaakapp-internal/models';
|
||||
import { foldersAtom, httpRequestsAtom } from '@yaakapp-internal/models';
|
||||
import type { Folder, HttpRequest } from "@yaakapp-internal/models";
|
||||
import { foldersAtom, httpRequestsAtom } from "@yaakapp-internal/models";
|
||||
import type {
|
||||
FormInput,
|
||||
FormInputCheckbox,
|
||||
@@ -10,33 +10,32 @@ import type {
|
||||
FormInputSelect,
|
||||
FormInputText,
|
||||
JsonPrimitive,
|
||||
} from '@yaakapp-internal/plugins';
|
||||
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 { Banner } from './core/Banner';
|
||||
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 { VStack } from './core/Stacks';
|
||||
import { Markdown } from './Markdown';
|
||||
import { SelectFile } from './SelectFile';
|
||||
} 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';
|
||||
export const DYNAMIC_FORM_NULL_ARG = "__NULL__";
|
||||
const INPUT_SIZE = "sm";
|
||||
|
||||
interface Props<T> {
|
||||
inputs: FormInput[] | undefined | null;
|
||||
@@ -75,7 +74,7 @@ export function DynamicForm<T extends Record<string, JsonPrimitive>>({
|
||||
autocompleteFunctions={autocompleteFunctions}
|
||||
autocompleteVariables={autocompleteVariables}
|
||||
data={data}
|
||||
className={classNames(className, 'pb-4')} // Pad the bottom to look nice
|
||||
className={classNames(className, "pb-4")} // Pad the bottom to look nice
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -83,14 +82,14 @@ export function DynamicForm<T extends Record<string, JsonPrimitive>>({
|
||||
function FormInputsStack<T extends Record<string, JsonPrimitive>>({
|
||||
className,
|
||||
...props
|
||||
}: FormInputsProps<T> & { className?: string}) {
|
||||
}: 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
|
||||
"h-full overflow-auto",
|
||||
"pr-1", // A bit of space between inputs and scrollbar
|
||||
)}
|
||||
>
|
||||
<FormInputs {...props} />
|
||||
@@ -100,7 +99,7 @@ function FormInputsStack<T extends Record<string, JsonPrimitive>>({
|
||||
|
||||
type FormInputsProps<T> = Pick<
|
||||
Props<T>,
|
||||
'inputs' | 'autocompleteFunctions' | 'autocompleteVariables' | 'stateKey' | 'data'
|
||||
"inputs" | "autocompleteFunctions" | "autocompleteVariables" | "stateKey" | "data"
|
||||
> & {
|
||||
setDataAttr: (name: string, value: JsonPrimitive) => void;
|
||||
disabled?: boolean;
|
||||
@@ -118,16 +117,16 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
|
||||
return (
|
||||
<>
|
||||
{inputs?.map((input, i) => {
|
||||
if ('hidden' in input && input.hidden) {
|
||||
if ("hidden" in input && input.hidden) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ('disabled' in input && disabled != null) {
|
||||
if ("disabled" in input && disabled != null) {
|
||||
input.disabled = disabled;
|
||||
}
|
||||
|
||||
switch (input.type) {
|
||||
case 'select':
|
||||
case "select":
|
||||
return (
|
||||
<SelectArg
|
||||
key={i + stateKey}
|
||||
@@ -140,7 +139,7 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
|
||||
}
|
||||
/>
|
||||
);
|
||||
case 'text':
|
||||
case "text":
|
||||
return (
|
||||
<TextArg
|
||||
key={i + stateKey}
|
||||
@@ -150,11 +149,11 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
|
||||
autocompleteVariables={autocompleteVariables || false}
|
||||
onChange={(v) => setDataAttr(input.name, v)}
|
||||
value={
|
||||
data[input.name] != null ? String(data[input.name]) : (input.defaultValue ?? '')
|
||||
data[input.name] != null ? String(data[input.name]) : (input.defaultValue ?? "")
|
||||
}
|
||||
/>
|
||||
);
|
||||
case 'editor':
|
||||
case "editor":
|
||||
return (
|
||||
<EditorArg
|
||||
key={i + stateKey}
|
||||
@@ -164,11 +163,11 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
|
||||
autocompleteVariables={autocompleteVariables || false}
|
||||
onChange={(v) => setDataAttr(input.name, v)}
|
||||
value={
|
||||
data[input.name] != null ? String(data[input.name]) : (input.defaultValue ?? '')
|
||||
data[input.name] != null ? String(data[input.name]) : (input.defaultValue ?? "")
|
||||
}
|
||||
/>
|
||||
);
|
||||
case 'checkbox':
|
||||
case "checkbox":
|
||||
return (
|
||||
<CheckboxArg
|
||||
key={i + stateKey}
|
||||
@@ -177,7 +176,7 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
|
||||
value={data[input.name] != null ? data[input.name] === true : false}
|
||||
/>
|
||||
);
|
||||
case 'http_request':
|
||||
case "http_request":
|
||||
return (
|
||||
<HttpRequestArg
|
||||
key={i + stateKey}
|
||||
@@ -186,7 +185,7 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
|
||||
value={data[input.name] != null ? String(data[input.name]) : DYNAMIC_FORM_NULL_ARG}
|
||||
/>
|
||||
);
|
||||
case 'file':
|
||||
case "file":
|
||||
return (
|
||||
<FileArg
|
||||
key={i + stateKey}
|
||||
@@ -197,12 +196,15 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
|
||||
}
|
||||
/>
|
||||
);
|
||||
case 'accordion':
|
||||
case "accordion":
|
||||
if (!hasVisibleInputs(input.inputs)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div key={i + stateKey}>
|
||||
<DetailsBanner
|
||||
summary={input.label}
|
||||
className={classNames('!mb-auto', disabled && 'opacity-disabled')}
|
||||
className={classNames("!mb-auto", disabled && "opacity-disabled")}
|
||||
>
|
||||
<div className="mt-3">
|
||||
<FormInputsStack
|
||||
@@ -218,7 +220,10 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
|
||||
</DetailsBanner>
|
||||
</div>
|
||||
);
|
||||
case 'h_stack':
|
||||
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
|
||||
@@ -232,12 +237,15 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case 'banner':
|
||||
case "banner":
|
||||
if (!hasVisibleInputs(input.inputs)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Banner
|
||||
key={i + stateKey}
|
||||
color={input.color}
|
||||
className={classNames(disabled && 'opacity-disabled')}
|
||||
className={classNames(disabled && "opacity-disabled")}
|
||||
>
|
||||
<FormInputsStack
|
||||
data={data}
|
||||
@@ -250,9 +258,9 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
|
||||
/>
|
||||
</Banner>
|
||||
);
|
||||
case 'markdown':
|
||||
case "markdown":
|
||||
return <Markdown key={i + stateKey}>{input.content}</Markdown>;
|
||||
case 'key_value':
|
||||
case "key_value":
|
||||
return (
|
||||
<KeyValueArg
|
||||
key={i + stateKey}
|
||||
@@ -260,7 +268,7 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
|
||||
stateKey={stateKey}
|
||||
onChange={(v) => setDataAttr(input.name, v)}
|
||||
value={
|
||||
data[input.name] != null ? String(data[input.name]) : (input.defaultValue ?? '[]')
|
||||
data[input.name] != null ? String(data[input.name]) : (input.defaultValue ?? "[]")
|
||||
}
|
||||
/>
|
||||
);
|
||||
@@ -292,12 +300,12 @@ function TextArg({
|
||||
onChange,
|
||||
name: arg.name,
|
||||
multiLine: arg.multiLine,
|
||||
className: arg.multiLine ? 'min-h-[4rem]' : undefined,
|
||||
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',
|
||||
type: arg.password ? "password" : "text",
|
||||
label: arg.label ?? arg.name,
|
||||
size: INPUT_SIZE,
|
||||
hideLabel: arg.hideLabel ?? arg.label == null,
|
||||
@@ -308,7 +316,7 @@ function TextArg({
|
||||
autocompleteFunctions,
|
||||
autocompleteVariables,
|
||||
};
|
||||
if (autocompleteVariables || autocompleteFunctions) {
|
||||
if (autocompleteVariables || autocompleteFunctions || arg.completionOptions) {
|
||||
return <Input {...props} />;
|
||||
}
|
||||
return <PlainInput {...props} />;
|
||||
@@ -349,10 +357,11 @@ function EditorArg({
|
||||
</Label>
|
||||
<div
|
||||
className={classNames(
|
||||
'border border-border rounded-md overflow-hidden px-2 py-1',
|
||||
'focus-within:border-border-focus',
|
||||
'max-h-[10rem]', // So it doesn't take up too much space
|
||||
"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}
|
||||
@@ -380,10 +389,10 @@ function EditorArg({
|
||||
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]',
|
||||
id: "id",
|
||||
size: "full",
|
||||
title: arg.readOnly ? "View Value" : "Edit Value",
|
||||
className: "!max-w-[50rem] !max-h-[60rem]",
|
||||
description: arg.label && (
|
||||
<Label
|
||||
htmlFor={id}
|
||||
@@ -486,7 +495,7 @@ function HttpRequestArg({
|
||||
}) {
|
||||
const folders = useAtomValue(foldersAtom);
|
||||
const httpRequests = useAtomValue(httpRequestsAtom);
|
||||
const activeHttpRequest = useActiveRequest('http_request');
|
||||
const activeHttpRequest = useActiveRequest("http_request");
|
||||
|
||||
useEffect(() => {
|
||||
if (value === DYNAMIC_FORM_NULL_ARG && activeHttpRequest) {
|
||||
@@ -502,16 +511,14 @@ function HttpRequestArg({
|
||||
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,
|
||||
};
|
||||
}),
|
||||
]}
|
||||
options={httpRequests.map((r) => {
|
||||
return {
|
||||
label:
|
||||
buildRequestBreadcrumbs(r, folders).join(" / ") +
|
||||
(r.id === activeHttpRequest?.id ? " (current)" : ""),
|
||||
value: r.id,
|
||||
};
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -531,7 +538,7 @@ function buildRequestBreadcrumbs(request: HttpRequest, folders: Folder[]): strin
|
||||
};
|
||||
next();
|
||||
|
||||
return ancestors.map((a) => (a.model === 'folder' ? a.name : resolvedModelName(a)));
|
||||
return ancestors.map((a) => (a.model === "folder" ? a.name : resolvedModelName(a)));
|
||||
}
|
||||
|
||||
function CheckboxArg({
|
||||
@@ -603,3 +610,19 @@ function KeyValueArg({
|
||||
</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,5 +1,5 @@
|
||||
import classNames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
import classNames from "classnames";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
@@ -12,8 +12,8 @@ export function EmptyStateText({ children, className }: Props) {
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
'rounded-lg border border-dashed border-border-subtle',
|
||||
'h-full py-2 text-text-subtlest flex items-center justify-center italic',
|
||||
"rounded-lg border border-dashed border-border-subtle",
|
||||
"h-full py-2 text-text-subtlest flex items-center justify-center italic",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { VStack } from './core/Stacks';
|
||||
import { VStack } from "@yaakapp-internal/ui";
|
||||
|
||||
export function EncryptionHelp() {
|
||||
return (
|
||||
@@ -1,19 +1,19 @@
|
||||
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 './core/Icon';
|
||||
import { EnvironmentColorIndicator } from './EnvironmentColorIndicator';
|
||||
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'>;
|
||||
} & Pick<ButtonProps, "forDropdown" | "leftSlot">;
|
||||
|
||||
export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdown({
|
||||
className,
|
||||
@@ -41,11 +41,11 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
|
||||
[activeEnvironment?.id],
|
||||
),
|
||||
...((subEnvironments.length > 0
|
||||
? [{ type: 'separator', label: 'Environments' }]
|
||||
? [{ type: "separator", label: "Environments" }]
|
||||
: []) as DropdownItem[]),
|
||||
{
|
||||
label: 'Manage Environments',
|
||||
hotKeyAction: 'environment_editor.toggle',
|
||||
label: "Manage Environments",
|
||||
hotKeyAction: "environment_editor.toggle",
|
||||
leftSlot: <Icon icon="box" />,
|
||||
onSelect: () => editEnvironment(activeEnvironment),
|
||||
},
|
||||
@@ -62,8 +62,8 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
|
||||
size="sm"
|
||||
className={classNames(
|
||||
className,
|
||||
'text !px-2 truncate',
|
||||
!activeEnvironment && !hasBaseVars && 'text-text-subtlest italic',
|
||||
"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
|
||||
@@ -71,7 +71,7 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
|
||||
{...buttonProps}
|
||||
>
|
||||
<EnvironmentColorIndicator environment={activeEnvironment ?? null} />
|
||||
{activeEnvironment?.name ?? (hasBaseVars ? 'Environment' : 'No Environment')}
|
||||
{activeEnvironment?.name ?? (hasBaseVars ? "Environment" : "No Environment")}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Environment } from '@yaakapp-internal/models';
|
||||
import { showColorPicker } from '../lib/showColorPicker';
|
||||
import { ColorIndicator } from './ColorIndicator';
|
||||
import type { Environment } from "@yaakapp-internal/models";
|
||||
import { showColorPicker } from "../lib/showColorPicker";
|
||||
import { ColorIndicator } from "./ColorIndicator";
|
||||
|
||||
export function EnvironmentColorIndicator({
|
||||
environment,
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { ColorIndicator } from './ColorIndicator';
|
||||
import { Banner } from './core/Banner';
|
||||
import { Button } from './core/Button';
|
||||
import { ColorPickerWithThemeColors } from './core/ColorPicker';
|
||||
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,
|
||||
@@ -1,32 +1,37 @@
|
||||
import type { Environment, Workspace } from '@yaakapp-internal/models';
|
||||
import { duplicateModel, patchModel } from '@yaakapp-internal/models';
|
||||
import { atom, useAtomValue } from 'jotai';
|
||||
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createSubEnvironmentAndActivate } from '../commands/createEnvironment';
|
||||
import { activeWorkspaceAtom, activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace';
|
||||
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/utils";
|
||||
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 { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm';
|
||||
import { jotaiStore } from '../lib/jotai';
|
||||
import { isBaseEnvironment, isSubEnvironment } from '../lib/model_util';
|
||||
import { resolvedModelName } from '../lib/resolvedModelName';
|
||||
import { showColorPicker } from '../lib/showColorPicker';
|
||||
import { Banner } from './core/Banner';
|
||||
import type { ContextMenuProps, DropdownItem } from './core/Dropdown';
|
||||
import { Icon } from './core/Icon';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { IconTooltip } from './core/IconTooltip';
|
||||
import { InlineCode } from './core/InlineCode';
|
||||
import type { PairEditorHandle } from './core/PairEditor';
|
||||
import { SplitLayout } from './core/SplitLayout';
|
||||
import type { TreeNode } from './core/tree/common';
|
||||
import type { TreeHandle, TreeProps } from './core/tree/Tree';
|
||||
import { Tree } from './core/tree/Tree';
|
||||
import { EnvironmentColorIndicator } from './EnvironmentColorIndicator';
|
||||
import { EnvironmentEditor } from './EnvironmentEditor';
|
||||
import { EnvironmentSharableTooltip } from './EnvironmentSharableTooltip';
|
||||
} 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;
|
||||
@@ -48,7 +53,7 @@ export function EnvironmentEditDialog({ initialEnvironmentId, setRef }: Props) {
|
||||
|
||||
return (
|
||||
<SplitLayout
|
||||
name="env_editor"
|
||||
storageKey="env_editor"
|
||||
defaultRatio={0.75}
|
||||
layout="horizontal"
|
||||
className="gap-0"
|
||||
@@ -107,12 +112,12 @@ function EnvironmentEditDialogSidebar({
|
||||
selectedEnvironmentId: string | null;
|
||||
setSelectedEnvironmentId: (id: string | null) => void;
|
||||
}) {
|
||||
const activeWorkspaceId = useAtomValue(activeWorkspaceIdAtom) ?? '';
|
||||
const activeWorkspaceId = useAtomValue(activeWorkspaceIdAtom) ?? "";
|
||||
const treeId = `environment.${activeWorkspaceId}.sidebar`;
|
||||
const treeRef = useRef<TreeHandle>(null);
|
||||
const { baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown();
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: none
|
||||
// oxlint-disable-next-line react-hooks/exhaustive-deps -- none
|
||||
useLayoutEffect(() => {
|
||||
if (selectedEnvironmentId == null) return;
|
||||
treeRef.current?.selectItem(selectedEnvironmentId);
|
||||
@@ -129,94 +134,111 @@ function EnvironmentEditDialogSidebar({
|
||||
[baseEnvironment?.id, selectedEnvironmentId, setSelectedEnvironmentId],
|
||||
);
|
||||
|
||||
const actions = useMemo(() => {
|
||||
const enable = () => treeRef.current?.hasFocus() ?? false;
|
||||
const treeHasFocus = useCallback(() => treeRef.current?.hasFocus() ?? false, []);
|
||||
|
||||
const actions = {
|
||||
'sidebar.selected.rename': {
|
||||
enable,
|
||||
allowDefault: true,
|
||||
priority: 100,
|
||||
cb: async (items: TreeModel[]) => {
|
||||
const item = items[0];
|
||||
if (items.length === 1 && item != null) {
|
||||
treeRef.current?.renameItem(item.id);
|
||||
}
|
||||
},
|
||||
},
|
||||
'sidebar.selected.delete': {
|
||||
priority: 100,
|
||||
enable,
|
||||
cb: (items: TreeModel[]) => deleteModelWithConfirm(items),
|
||||
},
|
||||
'sidebar.selected.duplicate': {
|
||||
priority: 100,
|
||||
enable,
|
||||
cb: async (items: TreeModel[]) => {
|
||||
if (items.length === 1 && items[0]) {
|
||||
const item = items[0];
|
||||
const newId = await duplicateModel(item);
|
||||
setSelectedEnvironmentId(newId);
|
||||
} else {
|
||||
await Promise.all(items.map(duplicateModel));
|
||||
}
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
return actions;
|
||||
}, [setSelectedEnvironmentId]);
|
||||
const getSelectedTreeModels = useCallback(
|
||||
() => treeRef.current?.getSelectedItems() as TreeModel[] | undefined,
|
||||
[],
|
||||
);
|
||||
|
||||
const hotkeys = useMemo<TreeProps<TreeModel>['hotkeys']>(() => ({ actions }), [actions]);
|
||||
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'] => {
|
||||
(items: TreeModel[]): ContextMenuProps["items"] => {
|
||||
const environment = items[0];
|
||||
const addEnvironmentItem: DropdownItem = {
|
||||
label: 'Create Sub Environment',
|
||||
label: "Create Sub Environment",
|
||||
leftSlot: <Icon icon="plus" />,
|
||||
onSelect: async () => {
|
||||
await createSubEnvironment();
|
||||
},
|
||||
};
|
||||
|
||||
if (environment == null || environment.model !== 'environment') {
|
||||
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',
|
||||
label: "Rename",
|
||||
leftSlot: <Icon icon="pencil" />,
|
||||
hidden: isBaseEnvironment(environment) || !singleEnvironment,
|
||||
hotKeyAction: 'sidebar.selected.rename',
|
||||
hotKeyAction: "sidebar.selected.rename",
|
||||
hotKeyLabelOnly: true,
|
||||
onSelect: async () => {
|
||||
onSelect: () => {
|
||||
// Not sure why this is needed, but without it the
|
||||
// edit input blurs immediately after opening.
|
||||
requestAnimationFrame(() => {
|
||||
actions['sidebar.selected.rename'].cb(items);
|
||||
});
|
||||
requestAnimationFrame(() => handleRenameSelected());
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Duplicate',
|
||||
label: "Duplicate",
|
||||
leftSlot: <Icon icon="copy" />,
|
||||
hidden: isBaseEnvironment(environment),
|
||||
hotKeyAction: 'sidebar.selected.duplicate',
|
||||
hotKeyAction: "sidebar.selected.duplicate",
|
||||
hotKeyLabelOnly: true,
|
||||
onSelect: () => actions['sidebar.selected.duplicate'].cb(items),
|
||||
onSelect: () => handleDuplicateSelected(items),
|
||||
},
|
||||
{
|
||||
label: environment.color ? 'Change Color' : 'Assign Color',
|
||||
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'} />,
|
||||
label: `Make ${environment.public ? "Private" : "Sharable"}`,
|
||||
leftSlot: <Icon icon={environment.public ? "eye_closed" : "eye"} />,
|
||||
rightSlot: <EnvironmentSharableTooltip />,
|
||||
hidden: items.length > 1,
|
||||
onSelect: async () => {
|
||||
@@ -224,13 +246,11 @@ function EnvironmentEditDialogSidebar({
|
||||
},
|
||||
},
|
||||
{
|
||||
color: 'danger',
|
||||
label: 'Delete',
|
||||
hotKeyAction: 'sidebar.selected.delete',
|
||||
color: "danger",
|
||||
label: "Delete",
|
||||
hotKeyAction: "sidebar.selected.delete",
|
||||
hotKeyLabelOnly: true,
|
||||
hidden:
|
||||
(isBaseEnvironment(environment) && baseEnvironments.length <= 1) ||
|
||||
!isSubEnvironment(environment),
|
||||
hidden: !canDeleteEnvironment,
|
||||
leftSlot: <Icon icon="trash" />,
|
||||
onSelect: () => handleDeleteEnvironment(environment),
|
||||
},
|
||||
@@ -238,13 +258,18 @@ function EnvironmentEditDialogSidebar({
|
||||
|
||||
// Add sub environment to base environment
|
||||
if (isBaseEnvironment(environment) && singleEnvironment) {
|
||||
menuItems.push({ type: 'separator' });
|
||||
menuItems.push({ type: "separator" });
|
||||
menuItems.push(addEnvironmentItem);
|
||||
}
|
||||
|
||||
return menuItems;
|
||||
},
|
||||
[actions, baseEnvironments.length, handleDeleteEnvironment],
|
||||
[
|
||||
baseEnvironments.length,
|
||||
handleDeleteEnvironment,
|
||||
handleDuplicateSelected,
|
||||
handleRenameSelected,
|
||||
],
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(async function handleDragEnd({
|
||||
@@ -291,6 +316,13 @@ function EnvironmentEditDialogSidebar({
|
||||
[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 ">
|
||||
@@ -299,10 +331,11 @@ function EnvironmentEditDialogSidebar({
|
||||
<Tree
|
||||
ref={treeRef}
|
||||
treeId={treeId}
|
||||
collapsedAtom={collapsedFamily(treeId)}
|
||||
className="px-2 pb-10"
|
||||
hotkeys={hotkeys}
|
||||
root={tree}
|
||||
getContextMenu={getContextMenu}
|
||||
renderContextMenu={renderContextMenuFn}
|
||||
onDragEnd={handleDragEnd}
|
||||
getItemKey={(i) => `${i.id}::${i.name}`}
|
||||
ItemLeftSlotInner={ItemLeftSlotInner}
|
||||
@@ -356,7 +389,7 @@ function ItemLeftSlotInner({ item }: { item: TreeModel }) {
|
||||
return baseEnvironments.length > 1 ? (
|
||||
<Icon icon="alert_triangle" color="notice" />
|
||||
) : (
|
||||
item.model === 'environment' && item.color && <EnvironmentColorIndicator environment={item} />
|
||||
item.model === "environment" && item.color && <EnvironmentColorIndicator environment={item} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -364,7 +397,7 @@ function ItemRightSlot({ item }: { item: TreeModel }) {
|
||||
const { baseEnvironments } = useEnvironmentsBreakdown();
|
||||
return (
|
||||
<>
|
||||
{item.model === 'environment' && baseEnvironments.length <= 1 && isBaseEnvironment(item) && (
|
||||
{item.model === "environment" && baseEnvironments.length <= 1 && isBaseEnvironment(item) && (
|
||||
<IconButton
|
||||
size="sm"
|
||||
color="custom"
|
||||
@@ -382,7 +415,7 @@ function ItemRightSlot({ item }: { item: TreeModel }) {
|
||||
function ItemInner({ item }: { item: TreeModel }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[auto_minmax(0,1fr)] w-full items-center">
|
||||
{item.model === 'environment' && item.public ? (
|
||||
{item.model === "environment" && item.public ? (
|
||||
<div className="mr-2 flex items-center">{sharableTooltip}</div>
|
||||
) : (
|
||||
<span aria-hidden />
|
||||
@@ -400,9 +433,9 @@ async function createSubEnvironment() {
|
||||
}
|
||||
|
||||
function getEditOptions(item: TreeModel) {
|
||||
const options: ReturnType<NonNullable<TreeProps<TreeModel>['getEditOptions']>> = {
|
||||
const options: ReturnType<NonNullable<TreeProps<TreeModel>["getEditOptions"]>> = {
|
||||
defaultValue: item.name,
|
||||
placeholder: 'Name',
|
||||
placeholder: "Name",
|
||||
async onChange(item, name) {
|
||||
await patchModel(item, { name });
|
||||
},
|
||||
@@ -1,27 +1,27 @@
|
||||
import type { Environment } from '@yaakapp-internal/models';
|
||||
import { patchModel } from '@yaakapp-internal/models';
|
||||
import type { GenericCompletionOption } from '@yaakapp-internal/plugins';
|
||||
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 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 { Heading } from './core/Heading';
|
||||
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';
|
||||
} 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;
|
||||
@@ -34,8 +34,8 @@ export function EnvironmentEditor({ environment, hideName, className, setRef }:
|
||||
const workspaceId = environment.workspaceId;
|
||||
const isEncryptionEnabled = useIsEncryptionEnabled();
|
||||
const valueVisibility = useKeyValue<boolean>({
|
||||
namespace: 'global',
|
||||
key: ['environmentValueVisibility', workspaceId],
|
||||
namespace: "global",
|
||||
key: ["environmentValueVisibility", workspaceId],
|
||||
fallback: false,
|
||||
});
|
||||
const { allEnvironments } = useEnvironmentsBreakdown();
|
||||
@@ -64,8 +64,8 @@ export function EnvironmentEditor({ environment, hideName, className, setRef }:
|
||||
}
|
||||
options.push({
|
||||
label: name,
|
||||
type: 'constant',
|
||||
detail: containingEnvs.map((e) => e.name).join(', '),
|
||||
type: "constant",
|
||||
detail: containingEnvs.map((e) => e.name).join(", "),
|
||||
});
|
||||
}
|
||||
return { options };
|
||||
@@ -73,14 +73,14 @@ export function EnvironmentEditor({ environment, hideName, className, setRef }:
|
||||
|
||||
const validateName = useCallback((name: string) => {
|
||||
// Empty just means the variable doesn't have a name yet and is unusable
|
||||
if (name === '') return true;
|
||||
if (name === "") return true;
|
||||
return name.match(/^[a-z_][a-z0-9_.-]*$/i) != null;
|
||||
}, []);
|
||||
|
||||
const valueType = !isEncryptionEnabled && valueVisibility.value ? 'text' : 'password';
|
||||
const valueType = !isEncryptionEnabled && valueVisibility.value ? "text" : "password";
|
||||
const allVariableAreEncrypted = useMemo(
|
||||
() =>
|
||||
environment.variables.every((v) => v.value === '' || analyzeTemplate(v.value) !== 'insecure'),
|
||||
environment.variables.every((v) => v.value === "" || analyzeTemplate(v.value) !== "insecure"),
|
||||
[environment.variables],
|
||||
);
|
||||
|
||||
@@ -88,7 +88,7 @@ export function EnvironmentEditor({ environment, hideName, className, setRef }:
|
||||
withEncryptionEnabled(async () => {
|
||||
const encryptedVariables: PairWithId[] = [];
|
||||
for (const variable of environment.variables) {
|
||||
const value = variable.value ? await convertTemplateToSecure(variable.value) : '';
|
||||
const value = variable.value ? await convertTemplateToSecure(variable.value) : "";
|
||||
encryptedVariables.push(ensurePairId({ ...variable, value }));
|
||||
}
|
||||
await handleChange(encryptedVariables);
|
||||
@@ -100,7 +100,7 @@ export function EnvironmentEditor({ environment, hideName, className, setRef }:
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
'h-full grid grid-rows-[auto_minmax(0,1fr)] gap-2 pr-3 pb-3',
|
||||
"h-full grid grid-rows-[auto_minmax(0,1fr)] gap-2 pr-3 pb-3",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -123,7 +123,7 @@ export function EnvironmentEditor({ environment, hideName, className, setRef }:
|
||||
)
|
||||
) : (
|
||||
<PillButton color="secondary" onClick={() => valueVisibility.set((v) => !v)}>
|
||||
{valueVisibility.value ? 'Hide Values' : 'Show Values'}
|
||||
{valueVisibility.value ? "Hide Values" : "Show Values"}
|
||||
</PillButton>
|
||||
)}
|
||||
<PillButton
|
||||
@@ -133,7 +133,7 @@ export function EnvironmentEditor({ environment, hideName, className, setRef }:
|
||||
await patchModel(environment, { public: !environment.public });
|
||||
}}
|
||||
>
|
||||
{environment.public ? 'Sharable' : 'Private'}
|
||||
{environment.public ? "Sharable" : "Private"}
|
||||
</PillButton>
|
||||
</Heading>
|
||||
{environment.public && (!isEncryptionEnabled || !allVariableAreEncrypted) && (
|
||||
@@ -143,9 +143,9 @@ export function EnvironmentEditor({ environment, hideName, className, setRef }:
|
||||
className="mr-3"
|
||||
actions={[
|
||||
{
|
||||
label: 'Encrypt Variables',
|
||||
label: "Encrypt Variables",
|
||||
onClick: () => encryptEnvironment(environment),
|
||||
color: 'success',
|
||||
color: "success",
|
||||
},
|
||||
]}
|
||||
>
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IconTooltip } from './core/IconTooltip';
|
||||
import { IconTooltip } from "./core/IconTooltip";
|
||||
|
||||
export function EnvironmentSharableTooltip() {
|
||||
return (
|
||||
@@ -1,10 +1,8 @@
|
||||
import type { ErrorInfo, ReactNode } from 'react';
|
||||
import { Component, useEffect } from 'react';
|
||||
import { showDialog } from '../lib/dialog';
|
||||
import { Banner } from './core/Banner';
|
||||
import { Button } from './core/Button';
|
||||
import { InlineCode } from './core/InlineCode';
|
||||
import RouteError from './RouteError';
|
||||
import { Banner, Button, InlineCode } from "@yaakapp-internal/ui";
|
||||
import type { ErrorInfo, ReactNode } from "react";
|
||||
import { Component, useEffect } from "react";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import RouteError from "./RouteError";
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
name: string;
|
||||
@@ -27,7 +25,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.warn('Error caught by ErrorBoundary:', error, info);
|
||||
console.warn("Error caught by ErrorBoundary:", error, info);
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -44,7 +42,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
||||
size="2xs"
|
||||
onClick={() => {
|
||||
showDialog({
|
||||
id: 'error-boundary',
|
||||
id: "error-boundary",
|
||||
render: () => <RouteError error={this.state.error} />,
|
||||
});
|
||||
}}
|
||||
@@ -61,7 +59,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
||||
|
||||
export function ErrorBoundaryTestThrow() {
|
||||
useEffect(() => {
|
||||
throw new Error('test error');
|
||||
throw new Error("test error");
|
||||
});
|
||||
|
||||
return <div>Hello</div>;
|
||||
@@ -1,17 +1,17 @@
|
||||
import { save } from '@tauri-apps/plugin-dialog';
|
||||
import type { Workspace } from '@yaakapp-internal/models';
|
||||
import { workspacesAtom } from '@yaakapp-internal/models';
|
||||
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';
|
||||
import { HStack, VStack } from './core/Stacks';
|
||||
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;
|
||||
@@ -55,7 +55,7 @@ function ExportDataDialogContent({
|
||||
|
||||
const handleToggleAll = () => {
|
||||
setSelectedWorkspaces(
|
||||
// biome-ignore lint/performance/noAccumulatingSpread: none
|
||||
// oxlint-disable-next-line no-accumulating-spread
|
||||
allSelected ? {} : workspaces.reduce((acc, w) => ({ ...acc, [w.id]: true }), {}),
|
||||
);
|
||||
};
|
||||
@@ -63,16 +63,16 @@ function ExportDataDialogContent({
|
||||
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 slug = workspace ? slugify(workspace.name, { lower: true }) : "workspaces";
|
||||
const exportPath = await save({
|
||||
title: 'Export Data',
|
||||
title: "Export Data",
|
||||
defaultPath: `yaak.${slug}.json`,
|
||||
});
|
||||
if (exportPath == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await invokeCmd('cmd_export_data', {
|
||||
await invokeCmd("cmd_export_data", {
|
||||
workspaceIds: ids,
|
||||
exportPath,
|
||||
includePrivateEnvironments: includePrivateEnvironments,
|
||||
@@ -92,7 +92,7 @@ function ExportDataDialogContent({
|
||||
<tr>
|
||||
<th className="w-6 min-w-0 py-2 text-left pl-1">
|
||||
<Checkbox
|
||||
checked={!allSelected && !noneSelected ? 'indeterminate' : allSelected}
|
||||
checked={!allSelected && !noneSelected ? "indeterminate" : allSelected}
|
||||
hideLabel
|
||||
title="All workspaces"
|
||||
onChange={handleToggleAll}
|
||||
@@ -122,7 +122,7 @@ function ExportDataDialogContent({
|
||||
setSelectedWorkspaces((prev) => ({ ...prev, [w.id]: !prev[w.id] }))
|
||||
}
|
||||
>
|
||||
{w.name} {w.id === activeWorkspace.id ? '(current workspace)' : ''}
|
||||
{w.name} {w.id === activeWorkspace.id ? "(current workspace)" : ""}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -155,8 +155,8 @@ function ExportDataDialogContent({
|
||||
disabled={noneSelected}
|
||||
onClick={() => handleExport()}
|
||||
>
|
||||
Export{' '}
|
||||
{pluralizeCount('Workspace', numSelected, { omitSingle: true, noneWord: 'Nothing' })}
|
||||
Export{" "}
|
||||
{pluralizeCount("Workspace", numSelected, { omitSingle: true, noneWord: "Nothing" })}
|
||||
</Button>
|
||||
</HStack>
|
||||
</footer>
|
||||
@@ -1,27 +1,24 @@
|
||||
import type { Folder, GrpcRequest, HttpRequest, WebsocketRequest } from '@yaakapp-internal/models';
|
||||
import { foldersAtom } from '@yaakapp-internal/models';
|
||||
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 { Heading } from './core/Heading';
|
||||
import { HttpResponseDurationTag } from './core/HttpResponseDurationTag';
|
||||
import { HttpStatusTag } from './core/HttpStatusTag';
|
||||
import { Icon } from './core/Icon';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { LoadingIcon } from './core/LoadingIcon';
|
||||
import { Separator } from './core/Separator';
|
||||
import { SizeTag } from './core/SizeTag';
|
||||
import { HStack } from './core/Stacks';
|
||||
import { HttpResponsePane } from './HttpResponsePane';
|
||||
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;
|
||||
@@ -33,7 +30,7 @@ export function FolderLayout({ folder, style }: Props) {
|
||||
const requests = useAtomValue(allRequestsAtom);
|
||||
const folderActions = useFolderActions();
|
||||
const sendAllAction = useMemo(
|
||||
() => folderActions.find((a) => a.label === 'Send All'),
|
||||
() => folderActions.find((a) => a.label === "Send All"),
|
||||
[folderActions],
|
||||
);
|
||||
|
||||
@@ -45,7 +42,7 @@ export function FolderLayout({ folder, style }: Props) {
|
||||
}, [folder.id, folders, requests]);
|
||||
|
||||
const handleSendAll = useCallback(() => {
|
||||
sendAllAction?.call(folder);
|
||||
void sendAllAction?.call(folder);
|
||||
}, [sendAllAction, folder]);
|
||||
|
||||
return (
|
||||
@@ -78,13 +75,13 @@ export function FolderLayout({ folder, style }: Props) {
|
||||
|
||||
function ChildCard({ child }: { child: Folder | HttpRequest | GrpcRequest | WebsocketRequest }) {
|
||||
let card: ReactNode;
|
||||
if (child.model === 'folder') {
|
||||
if (child.model === "folder") {
|
||||
card = <FolderCard folder={child} />;
|
||||
} else if (child.model === 'http_request') {
|
||||
} else if (child.model === "http_request") {
|
||||
card = <HttpRequestCard request={child} />;
|
||||
} else if (child.model === 'grpc_request') {
|
||||
} else if (child.model === "grpc_request") {
|
||||
card = <RequestCard request={child} />;
|
||||
} else if (child.model === 'websocket_request') {
|
||||
} else if (child.model === "websocket_request") {
|
||||
card = <RequestCard request={child} />;
|
||||
} else {
|
||||
card = <div>Unknown model</div>;
|
||||
@@ -92,7 +89,7 @@ function ChildCard({ child }: { child: Folder | HttpRequest | GrpcRequest | Webs
|
||||
|
||||
const navigate = useCallback(async () => {
|
||||
await router.navigate({
|
||||
to: '/workspaces/$workspaceId',
|
||||
to: "/workspaces/$workspaceId",
|
||||
params: { workspaceId: child.workspaceId },
|
||||
search: (prev) => ({ ...prev, request_id: child.id }),
|
||||
});
|
||||
@@ -101,12 +98,12 @@ function ChildCard({ child }: { child: Folder | HttpRequest | GrpcRequest | Webs
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'rounded-lg bg-surface-highlight p-3 pt-1 border border-border',
|
||||
'flex flex-col gap-3',
|
||||
"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" />}
|
||||
{child.model === "folder" && <Icon icon="folder" size="lg" />}
|
||||
<Heading className="truncate" level={2}>
|
||||
{resolvedModelName(child)}
|
||||
</Heading>
|
||||
@@ -143,7 +140,7 @@ function FolderCard({ folder }: { folder: Folder }) {
|
||||
color="primary"
|
||||
onClick={async () => {
|
||||
await router.navigate({
|
||||
to: '/workspaces/$workspaceId',
|
||||
to: "/workspaces/$workspaceId",
|
||||
params: { workspaceId: folder.workspaceId },
|
||||
search: (prev) => {
|
||||
return { ...prev, request_id: null, folder_id: folder.id };
|
||||
@@ -177,10 +174,10 @@ function HttpRequestCard({ request }: { request: HttpRequest }) {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
showDialog({
|
||||
id: 'response-preview',
|
||||
title: 'Response Preview',
|
||||
size: 'md',
|
||||
className: 'h-full',
|
||||
id: "response-preview",
|
||||
title: "Response Preview",
|
||||
size: "md",
|
||||
className: "h-full",
|
||||
render: () => {
|
||||
return <HttpResponsePane activeRequestId={request.id} />;
|
||||
},
|
||||
@@ -191,12 +188,12 @@ function HttpRequestCard({ request }: { request: HttpRequest }) {
|
||||
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',
|
||||
"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" />}
|
||||
{latestResponse.state !== "closed" && <LoadingIcon size="sm" />}
|
||||
<HttpStatusTag showReason response={latestResponse} />
|
||||
<span>•</span>
|
||||
<HttpResponseDurationTag response={latestResponse} />
|
||||
197
apps/yaak-client/components/FolderSettingsDialog.tsx
Normal file
197
apps/yaak-client/components/FolderSettingsDialog.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
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,12 +1,12 @@
|
||||
import type { HttpRequest } from '@yaakapp-internal/models';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { Pair, PairEditorProps } from './core/PairEditor';
|
||||
import { PairEditor } from './core/PairEditor';
|
||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type { Pair, PairEditorProps } from "./core/PairEditor";
|
||||
import { PairEditor } from "./core/PairEditor";
|
||||
|
||||
type Props = {
|
||||
forceUpdateKey: string;
|
||||
request: HttpRequest;
|
||||
onChange: (body: HttpRequest['body']) => void;
|
||||
onChange: (body: HttpRequest["body"]) => void;
|
||||
};
|
||||
|
||||
export function FormMultipartEditor({ request, forceUpdateKey, onChange }: Props) {
|
||||
@@ -24,7 +24,7 @@ export function FormMultipartEditor({ request, forceUpdateKey, onChange }: Props
|
||||
[request.body.form],
|
||||
);
|
||||
|
||||
const handleChange = useCallback<PairEditorProps['onChange']>(
|
||||
const handleChange = useCallback<PairEditorProps["onChange"]>(
|
||||
(pairs) =>
|
||||
onChange({
|
||||
form: pairs.map((p) => ({
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { HttpRequest } from '@yaakapp-internal/models';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { Pair, PairEditorProps } from './core/PairEditor';
|
||||
import { PairOrBulkEditor } from './core/PairOrBulkEditor';
|
||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type { Pair, PairEditorProps } from "./core/PairEditor";
|
||||
import { PairOrBulkEditor } from "./core/PairOrBulkEditor";
|
||||
|
||||
type Props = {
|
||||
forceUpdateKey: string;
|
||||
request: HttpRequest;
|
||||
onChange: (headers: HttpRequest['body']) => void;
|
||||
onChange: (headers: HttpRequest["body"]) => void;
|
||||
};
|
||||
|
||||
export function FormUrlencodedEditor({ request, forceUpdateKey, onChange }: Props) {
|
||||
@@ -14,14 +14,14 @@ export function FormUrlencodedEditor({ request, forceUpdateKey, onChange }: Prop
|
||||
() =>
|
||||
(Array.isArray(request.body.form) ? request.body.form : []).map((p) => ({
|
||||
enabled: !!p.enabled,
|
||||
name: p.name || '',
|
||||
value: p.value || '',
|
||||
name: p.name || "",
|
||||
value: p.value || "",
|
||||
id: p.id,
|
||||
})),
|
||||
[request.body.form],
|
||||
);
|
||||
|
||||
const handleChange = useCallback<PairEditorProps['onChange']>(
|
||||
const handleChange = useCallback<PairEditorProps["onChange"]>(
|
||||
(pairs) =>
|
||||
onChange({ form: pairs.map((p) => ({ enabled: p.enabled, name: p.name, value: p.value })) }),
|
||||
[onChange],
|
||||
38
apps/yaak-client/components/GlobalHooks.tsx
Normal file
38
apps/yaak-client/components/GlobalHooks.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
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,18 +1,18 @@
|
||||
import { patchModel } from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useActiveRequest } from '../hooks/useActiveRequest';
|
||||
import { useGrpc } from '../hooks/useGrpc';
|
||||
import { useGrpcProtoFiles } from '../hooks/useGrpcProtoFiles';
|
||||
import { activeGrpcConnectionAtom, useGrpcEvents } from '../hooks/usePinnedGrpcConnection';
|
||||
import { workspaceLayoutAtom } from '../lib/atoms';
|
||||
import { Banner } from './core/Banner';
|
||||
import { HotkeyList } from './core/HotkeyList';
|
||||
import { SplitLayout } from './core/SplitLayout';
|
||||
import { GrpcRequestPane } from './GrpcRequestPane';
|
||||
import { GrpcResponsePane } from './GrpcResponsePane';
|
||||
import { patchModel } from "@yaakapp-internal/models";
|
||||
import classNames from "classnames";
|
||||
import { useAtomValue } from "jotai";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useActiveRequest } from "../hooks/useActiveRequest";
|
||||
import { useGrpc } from "../hooks/useGrpc";
|
||||
import { useGrpcProtoFiles } from "../hooks/useGrpcProtoFiles";
|
||||
import { activeGrpcConnectionAtom, useGrpcEvents } from "../hooks/usePinnedGrpcConnection";
|
||||
import { Banner, SplitLayout } from "@yaakapp-internal/ui";
|
||||
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
|
||||
import { workspaceLayoutAtom } from "../lib/atoms";
|
||||
import { HotkeyList } from "./core/HotkeyList";
|
||||
import { GrpcRequestPane } from "./GrpcRequestPane";
|
||||
import { GrpcResponsePane } from "./GrpcResponsePane";
|
||||
|
||||
interface Props {
|
||||
style: CSSProperties;
|
||||
@@ -22,7 +22,9 @@ const emptyArray: string[] = [];
|
||||
|
||||
export function GrpcConnectionLayout({ style }: Props) {
|
||||
const workspaceLayout = useAtomValue(workspaceLayoutAtom);
|
||||
const activeRequest = useActiveRequest('grpc_request');
|
||||
const activeWorkspace = useAtomValue(activeWorkspaceAtom);
|
||||
const wsId = activeWorkspace?.id ?? "n/a";
|
||||
const activeRequest = useActiveRequest("grpc_request");
|
||||
const activeConnection = useAtomValue(activeGrpcConnectionAtom);
|
||||
const grpcEvents = useGrpcEvents(activeConnection?.id ?? null);
|
||||
const protoFilesKv = useGrpcProtoFiles(activeRequest?.id ?? null);
|
||||
@@ -59,18 +61,18 @@ export function GrpcConnectionLayout({ style }: Props) {
|
||||
}, [activeRequest, services]);
|
||||
|
||||
const methodType:
|
||||
| 'unary'
|
||||
| 'server_streaming'
|
||||
| 'client_streaming'
|
||||
| 'streaming'
|
||||
| 'no-schema'
|
||||
| 'no-method' = useMemo(() => {
|
||||
if (services == null) return 'no-schema';
|
||||
if (activeMethod == null) return 'no-method';
|
||||
if (activeMethod.clientStreaming && activeMethod.serverStreaming) return 'streaming';
|
||||
if (activeMethod.clientStreaming) return 'client_streaming';
|
||||
if (activeMethod.serverStreaming) return 'server_streaming';
|
||||
return 'unary';
|
||||
| "unary"
|
||||
| "server_streaming"
|
||||
| "client_streaming"
|
||||
| "streaming"
|
||||
| "no-schema"
|
||||
| "no-method" = useMemo(() => {
|
||||
if (services == null) return "no-schema";
|
||||
if (activeMethod == null) return "no-method";
|
||||
if (activeMethod.clientStreaming && activeMethod.serverStreaming) return "streaming";
|
||||
if (activeMethod.clientStreaming) return "client_streaming";
|
||||
if (activeMethod.serverStreaming) return "server_streaming";
|
||||
return "unary";
|
||||
}, [activeMethod, services]);
|
||||
|
||||
if (activeRequest == null) {
|
||||
@@ -79,7 +81,7 @@ export function GrpcConnectionLayout({ style }: Props) {
|
||||
|
||||
return (
|
||||
<SplitLayout
|
||||
name="grpc_layout"
|
||||
storageKey={`grpc_layout::${wsId}`}
|
||||
className="p-3 gap-1.5"
|
||||
style={style}
|
||||
layout={workspaceLayout}
|
||||
@@ -104,10 +106,10 @@ export function GrpcConnectionLayout({ style }: Props) {
|
||||
<div
|
||||
style={style}
|
||||
className={classNames(
|
||||
'x-theme-responsePane',
|
||||
'max-h-full h-full grid grid-rows-[minmax(0,1fr)] grid-cols-1',
|
||||
'bg-surface rounded-md border border-border-subtle',
|
||||
'shadow relative',
|
||||
"x-theme-responsePane",
|
||||
"max-h-full h-full grid grid-rows-[minmax(0,1fr)] grid-cols-1",
|
||||
"bg-surface rounded-md border border-border-subtle",
|
||||
"shadow relative",
|
||||
)}
|
||||
>
|
||||
{grpc.go.error ? (
|
||||
@@ -117,7 +119,7 @@ export function GrpcConnectionLayout({ style }: Props) {
|
||||
) : grpcEvents.length >= 0 ? (
|
||||
<GrpcResponsePane activeRequest={activeRequest} methodType={methodType} />
|
||||
) : (
|
||||
<HotkeyList hotkeys={['request.send', 'sidebar.focus', 'url_bar.focus']} />
|
||||
<HotkeyList hotkeys={["request.send", "sidebar.focus", "url_bar.focus"]} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -1,29 +1,27 @@
|
||||
import { jsonLanguage } from '@codemirror/lang-json';
|
||||
import { linter } from '@codemirror/lint';
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import type { GrpcRequest } from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
import { linter } from "@codemirror/lint";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { jsoncLanguage } from "@shopify/lang-jsonc";
|
||||
import type { GrpcRequest } from "@yaakapp-internal/models";
|
||||
import { FormattedError, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import {
|
||||
handleRefresh,
|
||||
jsonCompletion,
|
||||
jsonSchemaLinter,
|
||||
stateExtensions,
|
||||
updateSchema,
|
||||
} from 'codemirror-json-schema';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import type { ReflectResponseService } from '../hooks/useGrpc';
|
||||
import { showAlert } from '../lib/alert';
|
||||
import { showDialog } from '../lib/dialog';
|
||||
import { pluralizeCount } from '../lib/pluralize';
|
||||
import { Button } from './core/Button';
|
||||
import type { EditorProps } from './core/Editor/Editor';
|
||||
import { Editor } from './core/Editor/LazyEditor';
|
||||
import { FormattedError } from './core/FormattedError';
|
||||
import { InlineCode } from './core/InlineCode';
|
||||
import { VStack } from './core/Stacks';
|
||||
import { GrpcProtoSelectionDialog } from './GrpcProtoSelectionDialog';
|
||||
} from "codemirror-json-schema";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { ReflectResponseService } from "../hooks/useGrpc";
|
||||
import { showAlert } from "../lib/alert";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import { pluralizeCount } from "../lib/pluralize";
|
||||
import { Button } from "./core/Button";
|
||||
import type { EditorProps } from "./core/Editor/Editor";
|
||||
import { Editor } from "./core/Editor/LazyEditor";
|
||||
import { GrpcProtoSelectionDialog } from "./GrpcProtoSelectionDialog";
|
||||
|
||||
type Props = Pick<EditorProps, 'heightMode' | 'onChange' | 'className' | 'forceUpdateKey'> & {
|
||||
type Props = Pick<EditorProps, "heightMode" | "onChange" | "className" | "forceUpdateKey"> & {
|
||||
services: ReflectResponseService[] | null;
|
||||
reflectionError?: string;
|
||||
reflectionLoading?: boolean;
|
||||
@@ -39,15 +37,15 @@ export function GrpcEditor({
|
||||
protoFiles,
|
||||
...extraEditorProps
|
||||
}: Props) {
|
||||
const editorViewRef = useRef<EditorView>(null);
|
||||
const [editorView, setEditorView] = useState<EditorView | null>(null);
|
||||
const handleInitEditorViewRef = useCallback((h: EditorView | null) => {
|
||||
editorViewRef.current = h;
|
||||
setEditorView(h);
|
||||
}, []);
|
||||
|
||||
// Find the schema for the selected service and method and update the editor
|
||||
useEffect(() => {
|
||||
if (
|
||||
editorViewRef.current == null ||
|
||||
editorView == null ||
|
||||
services === null ||
|
||||
request.service === null ||
|
||||
request.method === null
|
||||
@@ -57,9 +55,9 @@ export function GrpcEditor({
|
||||
|
||||
const s = services.find((s) => s.name === request.service);
|
||||
if (s == null) {
|
||||
console.log('Failed to find service', { service: request.service, services });
|
||||
console.log("Failed to find service", { service: request.service, services });
|
||||
showAlert({
|
||||
id: 'grpc-find-service-error',
|
||||
id: "grpc-find-service-error",
|
||||
title: "Couldn't Find Service",
|
||||
body: (
|
||||
<>
|
||||
@@ -72,13 +70,13 @@ export function GrpcEditor({
|
||||
|
||||
const schema = s.methods.find((m) => m.name === request.method)?.schema;
|
||||
if (request.method != null && schema == null) {
|
||||
console.log('Failed to find method', { method: request.method, methods: s?.methods });
|
||||
console.log("Failed to find method", { method: request.method, methods: s?.methods });
|
||||
showAlert({
|
||||
id: 'grpc-find-schema-error',
|
||||
id: "grpc-find-schema-error",
|
||||
title: "Couldn't Find Method",
|
||||
body: (
|
||||
<>
|
||||
Failed to find method <InlineCode>{request.method}</InlineCode> for{' '}
|
||||
Failed to find method <InlineCode>{request.method}</InlineCode> for{" "}
|
||||
<InlineCode>{request.service}</InlineCode> in schema
|
||||
</>
|
||||
),
|
||||
@@ -91,15 +89,15 @@ export function GrpcEditor({
|
||||
}
|
||||
|
||||
try {
|
||||
updateSchema(editorViewRef.current, JSON.parse(schema));
|
||||
updateSchema(editorView, JSON.parse(schema));
|
||||
} catch (err) {
|
||||
showAlert({
|
||||
id: 'grpc-parse-schema-error',
|
||||
title: 'Failed to Parse Schema',
|
||||
id: "grpc-parse-schema-error",
|
||||
title: "Failed to Parse Schema",
|
||||
body: (
|
||||
<VStack space={4}>
|
||||
<p>
|
||||
For service <InlineCode>{request.service}</InlineCode> and method{' '}
|
||||
For service <InlineCode>{request.service}</InlineCode> and method{" "}
|
||||
<InlineCode>{request.method}</InlineCode>
|
||||
</p>
|
||||
<FormattedError>{String(err)}</FormattedError>
|
||||
@@ -107,7 +105,7 @@ export function GrpcEditor({
|
||||
),
|
||||
});
|
||||
}
|
||||
}, [services, request.method, request.service]);
|
||||
}, [editorView, services, request.method, request.service]);
|
||||
|
||||
const extraExtensions = useMemo(
|
||||
() => [
|
||||
@@ -115,10 +113,10 @@ export function GrpcEditor({
|
||||
delay: 200,
|
||||
needsRefresh: handleRefresh,
|
||||
}),
|
||||
jsonLanguage.data.of({
|
||||
jsoncLanguage.data.of({
|
||||
autocomplete: jsonCompletion(),
|
||||
}),
|
||||
stateExtensions(/** Init with empty schema **/),
|
||||
stateExtensions({}),
|
||||
],
|
||||
[],
|
||||
);
|
||||
@@ -128,39 +126,39 @@ export function GrpcEditor({
|
||||
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
<div key="reflection" className={classNames(services == null && '!opacity-100')}>
|
||||
<div key="reflection" className={classNames(services == null && "!opacity-100")}>
|
||||
<Button
|
||||
size="xs"
|
||||
color={
|
||||
reflectionLoading
|
||||
? 'secondary'
|
||||
? "secondary"
|
||||
: reflectionUnavailable
|
||||
? 'info'
|
||||
? "info"
|
||||
: reflectionError
|
||||
? 'danger'
|
||||
: 'secondary'
|
||||
? "danger"
|
||||
: "secondary"
|
||||
}
|
||||
isLoading={reflectionLoading}
|
||||
onClick={() => {
|
||||
showDialog({
|
||||
title: 'Configure Schema',
|
||||
size: 'md',
|
||||
id: 'reflection-failed',
|
||||
title: "Configure Schema",
|
||||
size: "md",
|
||||
id: "reflection-failed",
|
||||
render: ({ hide }) => <GrpcProtoSelectionDialog onDone={hide} />,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{reflectionLoading
|
||||
? 'Inspecting Schema'
|
||||
? "Inspecting Schema"
|
||||
: reflectionUnavailable
|
||||
? 'Select Proto Files'
|
||||
? "Select Proto Files"
|
||||
: reflectionError
|
||||
? 'Server Error'
|
||||
? "Server Error"
|
||||
: protoFiles.length > 0
|
||||
? pluralizeCount('File', protoFiles.length)
|
||||
? pluralizeCount("File", protoFiles.length)
|
||||
: services != null && protoFiles.length === 0
|
||||
? 'Schema Detected'
|
||||
: 'Select Schema'}
|
||||
? "Schema Detected"
|
||||
: "Select Schema"}
|
||||
</Button>
|
||||
</div>,
|
||||
],
|
||||
@@ -1,16 +1,13 @@
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import type { GrpcRequest } from '@yaakapp-internal/models';
|
||||
import { useActiveRequest } from '../hooks/useActiveRequest';
|
||||
import { useGrpc } from '../hooks/useGrpc';
|
||||
import { useGrpcProtoFiles } from '../hooks/useGrpcProtoFiles';
|
||||
import { pluralizeCount } from '../lib/pluralize';
|
||||
import { Banner } from './core/Banner';
|
||||
import { Button } from './core/Button';
|
||||
import { Icon } from './core/Icon';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { InlineCode } from './core/InlineCode';
|
||||
import { Link } from './core/Link';
|
||||
import { HStack, VStack } from './core/Stacks';
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import type { GrpcRequest } from "@yaakapp-internal/models";
|
||||
import { Banner, HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import { useActiveRequest } from "../hooks/useActiveRequest";
|
||||
import { useGrpc } from "../hooks/useGrpc";
|
||||
import { useGrpcProtoFiles } from "../hooks/useGrpcProtoFiles";
|
||||
import { pluralizeCount } from "../lib/pluralize";
|
||||
import { Button } from "./core/Button";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { Link } from "./core/Link";
|
||||
|
||||
interface Props {
|
||||
onDone: () => void;
|
||||
@@ -18,7 +15,7 @@ interface Props {
|
||||
|
||||
export function GrpcProtoSelectionDialog(props: Props) {
|
||||
const request = useActiveRequest();
|
||||
if (request?.model !== 'grpc_request') return null;
|
||||
if (request?.model !== "grpc_request") return null;
|
||||
|
||||
return <GrpcProtoSelectionDialogWithRequest request={request} {...props} />;
|
||||
}
|
||||
@@ -30,7 +27,7 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
|
||||
const services = grpc.reflect.data;
|
||||
const serverReflection = protoFiles.length === 0 && services != null;
|
||||
let reflectError = grpc.reflect.error ?? null;
|
||||
const reflectionUnimplemented = `${reflectError}`.match(/unimplemented/i);
|
||||
const reflectionUnimplemented = String(reflectError).match(/unimplemented/i);
|
||||
|
||||
if (reflectionUnimplemented) {
|
||||
reflectError = null;
|
||||
@@ -49,9 +46,9 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
|
||||
variant="border"
|
||||
onClick={async () => {
|
||||
const selected = await open({
|
||||
title: 'Select Proto Files',
|
||||
title: "Select Proto Files",
|
||||
multiple: true,
|
||||
filters: [{ name: 'Proto Files', extensions: ['proto'] }],
|
||||
filters: [{ name: "Proto Files", extensions: ["proto"] }],
|
||||
});
|
||||
if (selected == null) return;
|
||||
|
||||
@@ -67,7 +64,7 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
|
||||
color="primary"
|
||||
onClick={async () => {
|
||||
const selected = await open({
|
||||
title: 'Select Proto Directory',
|
||||
title: "Select Proto Directory",
|
||||
directory: true,
|
||||
});
|
||||
if (selected == null) return;
|
||||
@@ -92,7 +89,7 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
|
||||
{reflectError && (
|
||||
<Banner color="warning">
|
||||
<h1 className="font-bold">
|
||||
Reflection failed on URL <InlineCode>{request.url || 'n/a'}</InlineCode>
|
||||
Reflection failed on URL <InlineCode>{request.url || "n/a"}</InlineCode>
|
||||
</h1>
|
||||
<p>{reflectError.trim()}</p>
|
||||
</Banner>
|
||||
@@ -100,16 +97,16 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
|
||||
{!serverReflection && services != null && services.length > 0 && (
|
||||
<Banner className="flex flex-col gap-2">
|
||||
<p>
|
||||
Found services{' '}
|
||||
Found services{" "}
|
||||
{services?.slice(0, 5).map((s, i) => {
|
||||
return (
|
||||
<span key={s.name + s.methods.join(',')}>
|
||||
<span key={s.name + s.methods.map((m) => m.name).join(",")}>
|
||||
<InlineCode>{s.name}</InlineCode>
|
||||
{i === services.length - 1 ? '' : i === services.length - 2 ? ' and ' : ', '}
|
||||
{i === services.length - 1 ? "" : i === services.length - 2 ? " and " : ", "}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{services?.length > 5 && pluralizeCount('other', services?.length - 5)}
|
||||
{services?.length > 5 && pluralizeCount("other", services?.length - 5)}
|
||||
</p>
|
||||
</Banner>
|
||||
)}
|
||||
@@ -119,13 +116,13 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
|
||||
Server reflection found services
|
||||
{services?.map((s, i) => {
|
||||
return (
|
||||
<span key={s.name + s.methods.join(',')}>
|
||||
<span key={s.name + s.methods.map((m) => m.name).join(",")}>
|
||||
<InlineCode>{s.name}</InlineCode>
|
||||
{i === services.length - 1 ? '' : i === services.length - 2 ? ' and ' : ', '}
|
||||
{i === services.length - 1 ? "" : i === services.length - 2 ? " and " : ", "}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
. You can override this schema by manually selecting <InlineCode>*.proto</InlineCode>{' '}
|
||||
. You can override this schema by manually selecting <InlineCode>*.proto</InlineCode>{" "}
|
||||
files.
|
||||
</p>
|
||||
</Banner>
|
||||
@@ -142,16 +139,16 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
|
||||
</thead>
|
||||
<tbody className="divide-y divide-surface-highlight">
|
||||
{protoFiles.map((f, i) => {
|
||||
const parts = f.split('/');
|
||||
const parts = f.split("/");
|
||||
// oxlint-disable-next-line no-array-index-key -- none
|
||||
return (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: none
|
||||
<tr key={f + i} className="group">
|
||||
<td>
|
||||
<Icon icon={f.endsWith('.proto') ? 'file_code' : 'folder_code'} />
|
||||
<Icon icon={f.endsWith(".proto") ? "file_code" : "folder_code"} />
|
||||
</td>
|
||||
<td className="pl-1 font-mono text-sm" title={f}>
|
||||
{parts.length > 3 && '.../'}
|
||||
{parts.slice(-3).join('/')}
|
||||
{parts.length > 3 && ".../"}
|
||||
{parts.slice(-3).join("/")}
|
||||
</td>
|
||||
<td className="w-0 py-0.5">
|
||||
<IconButton
|
||||
@@ -173,10 +170,10 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
|
||||
)}
|
||||
{reflectionUnimplemented && protoFiles.length === 0 && (
|
||||
<Banner>
|
||||
<InlineCode>{request.url}</InlineCode> doesn't implement{' '}
|
||||
<InlineCode>{request.url}</InlineCode> doesn't implement{" "}
|
||||
<Link href="https://github.com/grpc/grpc/blob/9aa3c5835a4ed6afae9455b63ed45c761d695bca/doc/server-reflection.md">
|
||||
Server Reflection
|
||||
</Link>{' '}
|
||||
</Link>{" "}
|
||||
. Please manually add the <InlineCode>.proto</InlineCode> file to get started.
|
||||
</Banner>
|
||||
)}
|
||||
@@ -1,29 +1,26 @@
|
||||
import { type GrpcRequest, type HttpRequestHeader, patchModel } from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { useAuthTab } from '../hooks/useAuthTab';
|
||||
import { useContainerSize } from '../hooks/useContainerQuery';
|
||||
import type { ReflectResponseService } from '../hooks/useGrpc';
|
||||
import { useHeadersTab } from '../hooks/useHeadersTab';
|
||||
import { useInheritedHeaders } from '../hooks/useInheritedHeaders';
|
||||
import { useKeyValue } from '../hooks/useKeyValue';
|
||||
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
|
||||
import { resolvedModelName } from '../lib/resolvedModelName';
|
||||
import { Button } from './core/Button';
|
||||
import { CountBadge } from './core/CountBadge';
|
||||
import { Icon } from './core/Icon';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { PlainInput } from './core/PlainInput';
|
||||
import { RadioDropdown } from './core/RadioDropdown';
|
||||
import { HStack, VStack } from './core/Stacks';
|
||||
import type { TabItem } from './core/Tabs/Tabs';
|
||||
import { TabContent, Tabs } from './core/Tabs/Tabs';
|
||||
import { GrpcEditor } from './GrpcEditor';
|
||||
import { HeadersEditor } from './HeadersEditor';
|
||||
import { HttpAuthenticationEditor } from './HttpAuthenticationEditor';
|
||||
import { MarkdownEditor } from './MarkdownEditor';
|
||||
import { UrlBar } from './UrlBar';
|
||||
import { type GrpcRequest, type HttpRequestHeader, patchModel } from "@yaakapp-internal/models";
|
||||
import { HStack, Icon, useContainerSize, VStack } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { useAuthTab } from "../hooks/useAuthTab";
|
||||
import type { ReflectResponseService } from "../hooks/useGrpc";
|
||||
import { useHeadersTab } from "../hooks/useHeadersTab";
|
||||
import { useInheritedHeaders } from "../hooks/useInheritedHeaders";
|
||||
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
|
||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
||||
import { Button } from "./core/Button";
|
||||
import { CountBadge } from "./core/CountBadge";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import { RadioDropdown } from "./core/RadioDropdown";
|
||||
import type { TabItem } from "./core/Tabs/Tabs";
|
||||
import { TabContent, Tabs } from "./core/Tabs/Tabs";
|
||||
import { GrpcEditor } from "./GrpcEditor";
|
||||
import { HeadersEditor } from "./HeadersEditor";
|
||||
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
|
||||
import { MarkdownEditor } from "./MarkdownEditor";
|
||||
import { UrlBar } from "./UrlBar";
|
||||
|
||||
interface Props {
|
||||
style?: CSSProperties;
|
||||
@@ -33,12 +30,12 @@ interface Props {
|
||||
reflectionError?: string;
|
||||
reflectionLoading?: boolean;
|
||||
methodType:
|
||||
| 'unary'
|
||||
| 'client_streaming'
|
||||
| 'server_streaming'
|
||||
| 'streaming'
|
||||
| 'no-schema'
|
||||
| 'no-method';
|
||||
| "unary"
|
||||
| "client_streaming"
|
||||
| "server_streaming"
|
||||
| "streaming"
|
||||
| "no-schema"
|
||||
| "no-method";
|
||||
isStreaming: boolean;
|
||||
onCommit: () => void;
|
||||
onCancel: () => void;
|
||||
@@ -47,10 +44,10 @@ interface Props {
|
||||
services: ReflectResponseService[] | null;
|
||||
}
|
||||
|
||||
const TAB_MESSAGE = 'message';
|
||||
const TAB_METADATA = 'metadata';
|
||||
const TAB_AUTH = 'auth';
|
||||
const TAB_DESCRIPTION = 'description';
|
||||
const TAB_MESSAGE = "message";
|
||||
const TAB_METADATA = "metadata";
|
||||
const TAB_AUTH = "auth";
|
||||
const TAB_DESCRIPTION = "description";
|
||||
|
||||
export function GrpcRequestPane({
|
||||
style,
|
||||
@@ -67,13 +64,8 @@ export function GrpcRequestPane({
|
||||
onSend,
|
||||
}: Props) {
|
||||
const authTab = useAuthTab(TAB_AUTH, activeRequest);
|
||||
const metadataTab = useHeadersTab(TAB_METADATA, activeRequest, 'Metadata');
|
||||
const metadataTab = useHeadersTab(TAB_METADATA, activeRequest, "Metadata");
|
||||
const inheritedHeaders = useInheritedHeaders(activeRequest);
|
||||
const { value: activeTabs, set: setActiveTabs } = useKeyValue<Record<string, string>>({
|
||||
namespace: 'no_sync',
|
||||
key: 'grpcRequestActiveTabs',
|
||||
fallback: {},
|
||||
});
|
||||
const forceUpdateKey = useRequestUpdateKey(activeRequest.id ?? null);
|
||||
|
||||
const urlContainerEl = useRef<HTMLDivElement>(null);
|
||||
@@ -93,18 +85,18 @@ export function GrpcRequestPane({
|
||||
const options =
|
||||
services?.flatMap((s) =>
|
||||
s.methods.map((m) => ({
|
||||
label: `${s.name.split('.').pop() ?? s.name}/${m.name}`,
|
||||
label: `${s.name.split(".").pop() ?? s.name}/${m.name}`,
|
||||
value: `${s.name}/${m.name}`,
|
||||
})),
|
||||
) ?? [];
|
||||
const value = `${activeRequest?.service ?? ''}/${activeRequest?.method ?? ''}`;
|
||||
const value = `${activeRequest?.service ?? ""}/${activeRequest?.method ?? ""}`;
|
||||
return { value, options };
|
||||
}, [activeRequest?.method, activeRequest?.service, services]);
|
||||
|
||||
const handleChangeService = useCallback(
|
||||
async (v: string) => {
|
||||
const [serviceName, methodName] = v.split('/', 2);
|
||||
if (serviceName == null || methodName == null) throw new Error('Should never happen');
|
||||
const [serviceName, methodName] = v.split("/", 2);
|
||||
if (serviceName == null || methodName == null) throw new Error("Should never happen");
|
||||
await patchModel(activeRequest, {
|
||||
service: serviceName,
|
||||
method: methodName,
|
||||
@@ -118,9 +110,9 @@ export function GrpcRequestPane({
|
||||
|
||||
if (activeRequest.service == null || activeRequest.method == null) {
|
||||
alert({
|
||||
id: 'grpc-invalid-service-method',
|
||||
title: 'Error',
|
||||
body: 'Service or method not selected',
|
||||
id: "grpc-invalid-service-method",
|
||||
title: "Error",
|
||||
body: "Service or method not selected",
|
||||
});
|
||||
}
|
||||
onGo();
|
||||
@@ -133,26 +125,18 @@ export function GrpcRequestPane({
|
||||
|
||||
const tabs: TabItem[] = useMemo(
|
||||
() => [
|
||||
{ value: TAB_MESSAGE, label: 'Message' },
|
||||
{ value: TAB_MESSAGE, label: "Message" },
|
||||
...metadataTab,
|
||||
...authTab,
|
||||
{
|
||||
value: TAB_DESCRIPTION,
|
||||
label: 'Info',
|
||||
label: "Info",
|
||||
rightSlot: activeRequest.description && <CountBadge count={true} />,
|
||||
},
|
||||
],
|
||||
[activeRequest.description, authTab, metadataTab],
|
||||
);
|
||||
|
||||
const activeTab = activeTabs?.[activeRequest.id];
|
||||
const setActiveTab = useCallback(
|
||||
async (tab: string) => {
|
||||
await setActiveTabs((r) => ({ ...r, [activeRequest.id]: tab }));
|
||||
},
|
||||
[activeRequest.id, setActiveTabs],
|
||||
);
|
||||
|
||||
const handleMetadataChange = useCallback(
|
||||
(metadata: HttpRequestHeader[]) => patchModel(activeRequest, { metadata }),
|
||||
[activeRequest],
|
||||
@@ -168,14 +152,14 @@ export function GrpcRequestPane({
|
||||
<div
|
||||
ref={urlContainerEl}
|
||||
className={classNames(
|
||||
'grid grid-cols-[minmax(0,1fr)_auto] gap-1.5',
|
||||
paneWidth === 0 && 'opacity-0',
|
||||
paneWidth > 0 && paneWidth < 400 && '!grid-cols-1',
|
||||
"grid grid-cols-[minmax(0,1fr)_auto] gap-1.5",
|
||||
paneWidth === 0 && "opacity-0",
|
||||
paneWidth > 0 && paneWidth < 400 && "!grid-cols-1",
|
||||
)}
|
||||
>
|
||||
<UrlBar
|
||||
key={forceUpdateKey}
|
||||
url={activeRequest.url ?? ''}
|
||||
url={activeRequest.url ?? ""}
|
||||
submitIcon={null}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
placeholder="localhost:50051"
|
||||
@@ -192,13 +176,13 @@ export function GrpcRequestPane({
|
||||
items={select.options.map((o) => ({
|
||||
label: o.label,
|
||||
value: o.value,
|
||||
type: 'default',
|
||||
type: "default",
|
||||
shortLabel: o.label,
|
||||
}))}
|
||||
itemsAfter={[
|
||||
{
|
||||
label: 'Refresh',
|
||||
type: 'default',
|
||||
label: "Refresh",
|
||||
type: "default",
|
||||
leftSlot: <Icon size="sm" icon="refresh" />,
|
||||
},
|
||||
]}
|
||||
@@ -209,14 +193,14 @@ export function GrpcRequestPane({
|
||||
rightSlot={<Icon size="sm" icon="chevron_down" />}
|
||||
disabled={isStreaming || services == null}
|
||||
className={classNames(
|
||||
'font-mono text-editor min-w-[5rem] !ring-0',
|
||||
paneWidth < 400 && 'flex-1',
|
||||
"font-mono text-editor min-w-[5rem] !ring-0",
|
||||
paneWidth < 400 && "flex-1",
|
||||
)}
|
||||
>
|
||||
{select.options.find((o) => o.value === select.value)?.label ?? 'No Schema'}
|
||||
{select.options.find((o) => o.value === select.value)?.label ?? "No Schema"}
|
||||
</Button>
|
||||
</RadioDropdown>
|
||||
{methodType === 'client_streaming' || methodType === 'streaming' ? (
|
||||
{methodType === "client_streaming" || methodType === "streaming" ? (
|
||||
<>
|
||||
{isStreaming && (
|
||||
<>
|
||||
@@ -239,38 +223,37 @@ export function GrpcRequestPane({
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="border"
|
||||
title={isStreaming ? 'Connect' : 'Send'}
|
||||
title={isStreaming ? "Connect" : "Send"}
|
||||
hotkeyAction="request.send"
|
||||
onClick={isStreaming ? handleSend : handleConnect}
|
||||
icon={isStreaming ? 'send_horizontal' : 'arrow_up_down'}
|
||||
icon={isStreaming ? "send_horizontal" : "arrow_up_down"}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="border"
|
||||
title={methodType === 'unary' ? 'Send' : 'Connect'}
|
||||
title={methodType === "unary" ? "Send" : "Connect"}
|
||||
hotkeyAction="request.send"
|
||||
onClick={isStreaming ? onCancel : handleConnect}
|
||||
disabled={methodType === 'no-schema' || methodType === 'no-method'}
|
||||
disabled={methodType === "no-schema" || methodType === "no-method"}
|
||||
icon={
|
||||
isStreaming
|
||||
? 'x'
|
||||
: methodType.includes('streaming')
|
||||
? 'arrow_up_down'
|
||||
: 'send_horizontal'
|
||||
? "x"
|
||||
: methodType.includes("streaming")
|
||||
? "arrow_up_down"
|
||||
: "send_horizontal"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
</div>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
label="Request"
|
||||
onChangeValue={setActiveTab}
|
||||
tabs={tabs}
|
||||
tabListClassName="mt-1 !mb-1.5"
|
||||
storageKey="grpc_request_tabs_order"
|
||||
storageKey="grpc_request_tabs"
|
||||
activeTabKey={activeRequest.id}
|
||||
>
|
||||
<TabContent value="message">
|
||||
<GrpcEditor
|
||||
248
apps/yaak-client/components/GrpcResponsePane.tsx
Normal file
248
apps/yaak-client/components/GrpcResponsePane.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
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,24 +1,25 @@
|
||||
import type { HttpRequestHeader } from '@yaakapp-internal/models';
|
||||
import type { GenericCompletionOption } from '@yaakapp-internal/plugins';
|
||||
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';
|
||||
import { HStack } from './core/Stacks';
|
||||
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;
|
||||
@@ -28,20 +29,38 @@ 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)) ?? [];
|
||||
inheritedHeaders?.filter(
|
||||
(pair) =>
|
||||
pair.enabled &&
|
||||
(pair.name || pair.value) &&
|
||||
!currentHeaderNames.has(pair.name.toLowerCase()),
|
||||
) ?? [];
|
||||
const hasInheritedHeaders = validInheritedHeaders.length > 0;
|
||||
return (
|
||||
<div className="@container w-full h-full grid grid-rows-[auto_minmax(0,1fr)] gap-y-1.5">
|
||||
{validInheritedHeaders.length > 0 ? (
|
||||
<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>
|
||||
Inherited <CountBadge count={validInheritedHeaders.length} />
|
||||
{inheritedHeadersLabel} <CountBadge count={validInheritedHeaders.length} />
|
||||
</HStack>
|
||||
}
|
||||
>
|
||||
@@ -63,8 +82,6 @@ export function HeadersEditor({
|
||||
))}
|
||||
</div>
|
||||
</DetailsBanner>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<PairOrBulkEditor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
@@ -89,28 +106,28 @@ export function HeadersEditor({
|
||||
const MIN_MATCH = 3;
|
||||
|
||||
const headerOptionsMap: Record<string, string[]> = {
|
||||
'content-type': mimeTypes,
|
||||
accept: ['*/*', ...mimeTypes],
|
||||
'accept-encoding': encodings,
|
||||
"content-type": mimeTypes,
|
||||
accept: ["*/*", ...mimeTypes],
|
||||
"accept-encoding": encodings,
|
||||
connection: connections,
|
||||
'accept-charset': charsets,
|
||||
"accept-charset": charsets,
|
||||
};
|
||||
|
||||
const valueType = (pair: Pair): InputProps['type'] => {
|
||||
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'
|
||||
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 "password";
|
||||
}
|
||||
return 'text';
|
||||
return "text";
|
||||
};
|
||||
|
||||
const valueAutocomplete = (headerName: string): GenericCompletionConfig | undefined => {
|
||||
@@ -118,19 +135,19 @@ const valueAutocomplete = (headerName: string): GenericCompletionConfig | undefi
|
||||
const options: GenericCompletionOption[] =
|
||||
headerOptionsMap[name]?.map((o) => ({
|
||||
label: o,
|
||||
type: 'constant',
|
||||
type: "constant",
|
||||
boost: 1, // Put above other completions
|
||||
})) ?? [];
|
||||
return { minMatch: MIN_MATCH, options };
|
||||
};
|
||||
|
||||
const nameAutocomplete: PairEditorProps['nameAutocomplete'] = {
|
||||
const nameAutocomplete: PairEditorProps["nameAutocomplete"] = {
|
||||
minMatch: MIN_MATCH,
|
||||
options: headerNames.map((t) =>
|
||||
typeof t === 'string'
|
||||
typeof t === "string"
|
||||
? {
|
||||
label: t,
|
||||
type: 'constant',
|
||||
type: "constant",
|
||||
boost: 1, // Put above other completions
|
||||
}
|
||||
: {
|
||||
@@ -141,11 +158,11 @@ const nameAutocomplete: PairEditorProps['nameAutocomplete'] = {
|
||||
};
|
||||
|
||||
const validateHttpHeader = (v: string) => {
|
||||
if (v === '') {
|
||||
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');
|
||||
const withoutTemplateStrings = v.replace(/\$\{\[\s*[^\]\s]+\s*]}/gi, "123");
|
||||
return withoutTemplateStrings.match(/^[a-zA-Z0-9-_]+$/) !== null;
|
||||
};
|
||||
@@ -4,25 +4,23 @@ import type {
|
||||
HttpRequest,
|
||||
WebsocketRequest,
|
||||
Workspace,
|
||||
} from '@yaakapp-internal/models';
|
||||
import { patchModel } from '@yaakapp-internal/models';
|
||||
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 { Icon } from './core/Icon';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { InlineCode } from './core/InlineCode';
|
||||
import { Input, type InputProps } from './core/Input';
|
||||
import { Link } from './core/Link';
|
||||
import { SegmentedControl } from './core/SegmentedControl';
|
||||
import { HStack } from './core/Stacks';
|
||||
import { DynamicForm } from './DynamicForm';
|
||||
import { EmptyStateText } from './EmptyStateText';
|
||||
} 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;
|
||||
@@ -41,7 +39,7 @@ export function HttpAuthenticationEditor({ model }: Props) {
|
||||
[model],
|
||||
);
|
||||
|
||||
if (model.authenticationType === 'none') {
|
||||
if (model.authenticationType === "none") {
|
||||
return <EmptyStateText>No authentication</EmptyStateText>;
|
||||
}
|
||||
|
||||
@@ -56,39 +54,37 @@ export function HttpAuthenticationEditor({ model }: Props) {
|
||||
}
|
||||
|
||||
if (inheritedAuth == null) {
|
||||
if (model.model === 'workspace' || model.model === 'folder') {
|
||||
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://feedback.yaak.app/help/articles/2112119-request-inheritance">
|
||||
Documentation
|
||||
</Link>
|
||||
<Link href="https://yaak.app/docs/using-yaak/request-inheritance">Documentation</Link>
|
||||
</EmptyStateText>
|
||||
);
|
||||
}
|
||||
return <EmptyStateText>No authentication</EmptyStateText>;
|
||||
}
|
||||
|
||||
if (inheritedAuth.authenticationType === 'none') {
|
||||
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;
|
||||
const cta = inheritedAuth.model === "workspace" ? "Workspace" : name;
|
||||
return (
|
||||
<EmptyStateText>
|
||||
<p>
|
||||
Inherited from{' '}
|
||||
Inherited from{" "}
|
||||
<button
|
||||
type="submit"
|
||||
className="underline hover:text-text"
|
||||
onClick={() => {
|
||||
if (inheritedAuth.model === 'folder') openFolderSettings(inheritedAuth.id, 'auth');
|
||||
else openWorkspaceSettings('auth');
|
||||
if (inheritedAuth.model === "folder") openFolderSettings(inheritedAuth.id, "auth");
|
||||
else openWorkspaceSettings("auth");
|
||||
}}
|
||||
>
|
||||
{cta}
|
||||
@@ -108,24 +104,24 @@ export function HttpAuthenticationEditor({ model }: Props) {
|
||||
name="enabled"
|
||||
value={
|
||||
model.authentication.disabled === false || model.authentication.disabled == null
|
||||
? '__TRUE__'
|
||||
? "__TRUE__"
|
||||
: model.authentication.disabled === true
|
||||
? '__FALSE__'
|
||||
: '__DYNAMIC__'
|
||||
? "__FALSE__"
|
||||
: "__DYNAMIC__"
|
||||
}
|
||||
options={[
|
||||
{ label: 'Enabled', value: '__TRUE__' },
|
||||
{ label: 'Disabled', value: '__FALSE__' },
|
||||
{ label: 'Enabled when...', value: '__DYNAMIC__' },
|
||||
{ label: "Enabled", value: "__TRUE__" },
|
||||
{ label: "Disabled", value: "__FALSE__" },
|
||||
{ label: "Enabled when...", value: "__DYNAMIC__" },
|
||||
]}
|
||||
onChange={async (enabled) => {
|
||||
let disabled: boolean | string;
|
||||
if (enabled === '__TRUE__') {
|
||||
if (enabled === "__TRUE__") {
|
||||
disabled = false;
|
||||
} else if (enabled === '__FALSE__') {
|
||||
} else if (enabled === "__FALSE__") {
|
||||
disabled = true;
|
||||
} else {
|
||||
disabled = '';
|
||||
disabled = "";
|
||||
}
|
||||
await handleChange({ ...model.authentication, disabled });
|
||||
}}
|
||||
@@ -140,11 +136,16 @@ export function HttpAuthenticationEditor({ model }: Props) {
|
||||
}),
|
||||
)}
|
||||
>
|
||||
<IconButton title="Authentication Actions" icon="settings" size="xs" />
|
||||
<IconButton
|
||||
title="Authentication Actions"
|
||||
icon="settings"
|
||||
size="xs"
|
||||
className="!text-secondary"
|
||||
/>
|
||||
</Dropdown>
|
||||
)}
|
||||
</HStack>
|
||||
{typeof model.authentication.disabled === 'string' && (
|
||||
{typeof model.authentication.disabled === "string" && (
|
||||
<div className="mt-3">
|
||||
<AuthenticationDisabledInput
|
||||
className="w-full"
|
||||
@@ -175,14 +176,14 @@ function AuthenticationDisabledInput({
|
||||
className,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: InputProps['onChange'];
|
||||
onChange: InputProps["onChange"];
|
||||
stateKey: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const rendered = useRenderTemplate({
|
||||
template: value,
|
||||
enabled: true,
|
||||
purpose: 'preview',
|
||||
purpose: "preview",
|
||||
refreshKey: value,
|
||||
});
|
||||
|
||||
@@ -197,7 +198,7 @@ function AuthenticationDisabledInput({
|
||||
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'}
|
||||
{rendered.isPending ? "loading" : rendered.data ? "enabled" : "disabled"}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -1,15 +1,16 @@
|
||||
import type { HttpRequest } from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useCurrentGraphQLSchema } from '../hooks/useIntrospectGraphQL';
|
||||
import { workspaceLayoutAtom } from '../lib/atoms';
|
||||
import type { SlotProps } from './core/SplitLayout';
|
||||
import { SplitLayout } from './core/SplitLayout';
|
||||
import { GraphQLDocsExplorer } from './graphql/GraphQLDocsExplorer';
|
||||
import { showGraphQLDocExplorerAtom } from './graphql/graphqlAtoms';
|
||||
import { HttpRequestPane } from './HttpRequestPane';
|
||||
import { HttpResponsePane } from './HttpResponsePane';
|
||||
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;
|
||||
@@ -20,10 +21,12 @@ 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'>) => (
|
||||
const requestResponseSplit = ({ style }: Pick<SlotProps, "style">) => (
|
||||
<SplitLayout
|
||||
name="http_layout"
|
||||
storageKey={`http_layout::${wsId}`}
|
||||
className="p-3 gap-1.5"
|
||||
style={style}
|
||||
layout={workspaceLayout}
|
||||
@@ -31,7 +34,7 @@ export function HttpRequestLayout({ activeRequest, style }: Props) {
|
||||
<HttpRequestPane
|
||||
style={style}
|
||||
activeRequest={activeRequest}
|
||||
fullHeight={orientation === 'horizontal'}
|
||||
fullHeight={orientation === "horizontal"}
|
||||
/>
|
||||
)}
|
||||
secondSlot={({ style }) => (
|
||||
@@ -41,20 +44,20 @@ export function HttpRequestLayout({ activeRequest, style }: Props) {
|
||||
);
|
||||
|
||||
if (
|
||||
activeRequest.bodyType === 'graphql' &&
|
||||
activeRequest.bodyType === "graphql" &&
|
||||
showGraphQLDocExplorer[activeRequest.id] !== undefined &&
|
||||
graphQLSchema != null
|
||||
) {
|
||||
return (
|
||||
<SplitLayout
|
||||
name="graphql_layout"
|
||||
storageKey={`graphql_layout::${wsId}`}
|
||||
defaultRatio={1 / 3}
|
||||
firstSlot={requestResponseSplit}
|
||||
secondSlot={({ style, orientation }) => (
|
||||
<GraphQLDocsExplorer
|
||||
requestId={activeRequest.id}
|
||||
schema={graphQLSchema}
|
||||
className={classNames(orientation === 'horizontal' && '!ml-0')}
|
||||
className={classNames(orientation === "horizontal" && "!ml-0")}
|
||||
style={style}
|
||||
/>
|
||||
)}
|
||||
@@ -1,25 +1,24 @@
|
||||
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, 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 { useKeyValue } from '../hooks/useKeyValue';
|
||||
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 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,
|
||||
@@ -30,32 +29,33 @@ import {
|
||||
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 './core/InlineCode';
|
||||
import type { Pair } from './core/PairEditor';
|
||||
import { PlainInput } from './core/PlainInput';
|
||||
import type { TabItem } from './core/Tabs/Tabs';
|
||||
import { 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 { MarkdownEditor } from './MarkdownEditor';
|
||||
import { RequestMethodDropdown } from './RequestMethodDropdown';
|
||||
import { UrlBar } from './UrlBar';
|
||||
import { UrlParametersEditor } from './UrlParameterEditor';
|
||||
} 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 })),
|
||||
import("./graphql/GraphQLEditor").then((m) => ({ default: m.GraphQLEditor })),
|
||||
);
|
||||
|
||||
interface Props {
|
||||
@@ -65,49 +65,55 @@ interface Props {
|
||||
activeRequest: HttpRequest;
|
||||
}
|
||||
|
||||
const TAB_BODY = 'body';
|
||||
const TAB_PARAMS = 'params';
|
||||
const TAB_HEADERS = 'headers';
|
||||
const TAB_AUTH = 'auth';
|
||||
const TAB_DESCRIPTION = 'description';
|
||||
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 }));
|
||||
.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 { value: activeTabs, set: setActiveTabs } = useKeyValue<Record<string, string>>({
|
||||
namespace: 'no_sync',
|
||||
key: 'httpRequestActiveTabs',
|
||||
fallback: {},
|
||||
});
|
||||
const tabsRef = useRef<TabsRef>(null);
|
||||
const [forceUpdateHeaderEditorKey, setForceUpdateHeaderEditorKey] = useState<number>(0);
|
||||
const forceUpdateKey = useRequestUpdateKey(activeRequest.id ?? null);
|
||||
const [{ urlKey }, { focusParamsTab, forceUrlRefresh, forceParamsRefresh }] = useRequestEditor();
|
||||
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'>> = {}) => {
|
||||
async (contentType: string | null, patch: Partial<Omit<HttpRequest, "headers">> = {}) => {
|
||||
if (activeRequest == null) {
|
||||
console.error('Failed to get active request to update', activeRequest);
|
||||
console.error("Failed to get active request to update", activeRequest);
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = activeRequest.headers.filter((h) => h.name.toLowerCase() !== 'content-type');
|
||||
const headers = activeRequest.headers.filter((h) => h.name.toLowerCase() !== "content-type");
|
||||
|
||||
if (contentType != null) {
|
||||
headers.push({
|
||||
name: 'Content-Type',
|
||||
name: "Content-Type",
|
||||
value: contentType,
|
||||
enabled: true,
|
||||
id: generateId(),
|
||||
@@ -123,7 +129,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
|
||||
const { urlParameterPairs, urlParametersKey } = useMemo(() => {
|
||||
const placeholderNames = Array.from(activeRequest.url.matchAll(/\/(:[^/]+)/g)).map(
|
||||
(m) => m[1] ?? '',
|
||||
(m) => m[1] ?? "",
|
||||
);
|
||||
const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);
|
||||
const items: Pair[] = [...nonEmptyParameters];
|
||||
@@ -132,10 +138,10 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
if (item) {
|
||||
item.readOnlyName = true;
|
||||
} else {
|
||||
items.push({ name, value: '', enabled: true, readOnlyName: true, id: generateId() });
|
||||
items.push({ name, value: "", enabled: true, readOnlyName: true, id: generateId() });
|
||||
}
|
||||
}
|
||||
return { urlParameterPairs: items, urlParametersKey: placeholderNames.join(',') };
|
||||
return { urlParameterPairs: items, urlParametersKey: placeholderNames.join(",") };
|
||||
}, [activeRequest.url, activeRequest.urlParameters]);
|
||||
|
||||
let numParams = 0;
|
||||
@@ -156,21 +162,21 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
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 },
|
||||
{ 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',
|
||||
label: "Other",
|
||||
value: BODY_TYPE_OTHER,
|
||||
shortLabel: nameOfContentTypeOr(contentType, '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 },
|
||||
{ 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;
|
||||
@@ -178,7 +184,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
const showMethodToast = (newMethod: string) => {
|
||||
if (activeRequest.method.toLowerCase() === newMethod.toLowerCase()) return;
|
||||
showToast({
|
||||
id: 'switched-method',
|
||||
id: "switched-method",
|
||||
message: (
|
||||
<>
|
||||
Request method switched to <InlineCode>POST</InlineCode>
|
||||
@@ -200,16 +206,16 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
) {
|
||||
const isDefaultishRequest =
|
||||
activeRequest.bodyType === BODY_TYPE_NONE &&
|
||||
activeRequest.method.toLowerCase() === 'get';
|
||||
activeRequest.method.toLowerCase() === "get";
|
||||
const requiresPost = bodyType === BODY_TYPE_FORM_MULTIPART;
|
||||
if (isDefaultishRequest || requiresPost) {
|
||||
patch.method = 'POST';
|
||||
patch.method = "POST";
|
||||
showMethodToast(patch.method);
|
||||
}
|
||||
newContentType = bodyType === BODY_TYPE_OTHER ? 'text/plain' : bodyType;
|
||||
newContentType = bodyType === BODY_TYPE_OTHER ? "text/plain" : bodyType;
|
||||
} else if (bodyType === BODY_TYPE_GRAPHQL) {
|
||||
patch.method = 'POST';
|
||||
newContentType = 'application/json';
|
||||
patch.method = "POST";
|
||||
newContentType = "application/json";
|
||||
showMethodToast(patch.method);
|
||||
}
|
||||
|
||||
@@ -224,13 +230,13 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
{
|
||||
value: TAB_PARAMS,
|
||||
rightSlot: <CountBadge count={urlParameterPairs.length} />,
|
||||
label: 'Params',
|
||||
label: "Params",
|
||||
},
|
||||
...headersTab,
|
||||
...authTab,
|
||||
{
|
||||
value: TAB_DESCRIPTION,
|
||||
label: 'Info',
|
||||
label: "Info",
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -251,27 +257,15 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
const { mutate: importCurl } = useImportCurl();
|
||||
|
||||
const handleBodyChange = useCallback(
|
||||
(body: HttpRequest['body']) => patchModel(activeRequest, { body }),
|
||||
(body: HttpRequest["body"]) => patchModel(activeRequest, { body }),
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
const handleBodyTextChange = useCallback(
|
||||
(text: string) => patchModel(activeRequest, { body: { text } }),
|
||||
(text: string) => patchModel(activeRequest, { body: { ...activeRequest.body, text } }),
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
const activeTab = activeTabs?.[activeRequestId];
|
||||
const setActiveTab = useCallback(
|
||||
async (tab: string) => {
|
||||
await setActiveTabs((r) => ({ ...r, [activeRequest.id]: tab }));
|
||||
},
|
||||
[activeRequest.id, setActiveTabs],
|
||||
);
|
||||
|
||||
useRequestEditorEvent('request_pane.focus_tab', async () => {
|
||||
await setActiveTab(TAB_PARAMS);
|
||||
});
|
||||
|
||||
const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom);
|
||||
|
||||
const autocomplete: GenericCompletionConfig = useMemo(
|
||||
@@ -281,8 +275,8 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
autocompleteUrls.length > 0
|
||||
? autocompleteUrls
|
||||
: [
|
||||
{ label: 'http://', type: 'constant' },
|
||||
{ label: 'https://', type: 'constant' },
|
||||
{ label: "http://", type: "constant" },
|
||||
{ label: "https://", type: "constant" },
|
||||
],
|
||||
}),
|
||||
[autocompleteUrls],
|
||||
@@ -290,7 +284,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
|
||||
const handlePaste = useCallback(
|
||||
async (e: ClipboardEvent, text: string) => {
|
||||
if (text.startsWith('curl ')) {
|
||||
if (text.startsWith("curl ")) {
|
||||
importCurl({ overwriteRequestId: activeRequestId, command: text });
|
||||
} else {
|
||||
const patch = prepareImportQuerystring(text);
|
||||
@@ -298,7 +292,11 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
e.preventDefault(); // Prevent input onChange
|
||||
|
||||
await patchModel(activeRequest, patch);
|
||||
focusParamsTab();
|
||||
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
|
||||
@@ -309,14 +307,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
activeRequest,
|
||||
activeRequestId,
|
||||
focusParamsTab,
|
||||
forceParamsRefresh,
|
||||
forceUrlRefresh,
|
||||
importCurl,
|
||||
],
|
||||
[activeRequest, activeRequestId, forceParamsRefresh, forceUrlRefresh, importCurl],
|
||||
);
|
||||
const handleSend = useCallback(
|
||||
() => sendRequest(activeRequest.id ?? null),
|
||||
@@ -331,7 +322,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
className={classNames(className, 'h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1')}
|
||||
className={classNames(className, "h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1")}
|
||||
>
|
||||
{activeRequest && (
|
||||
<>
|
||||
@@ -351,15 +342,15 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
</div>
|
||||
}
|
||||
forceUpdateKey={updateKey}
|
||||
isLoading={activeResponse != null && activeResponse.state !== 'closed'}
|
||||
isLoading={activeResponse != null && activeResponse.state !== "closed"}
|
||||
/>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
ref={tabsRef}
|
||||
label="Request"
|
||||
onChangeValue={setActiveTab}
|
||||
tabs={tabs}
|
||||
tabListClassName="mt-1 mb-1.5"
|
||||
storageKey="http_request_tabs_order"
|
||||
tabListClassName="mt-1 -mb-1.5"
|
||||
storageKey={TABS_STORAGE_KEY}
|
||||
activeTabKey={activeRequestId}
|
||||
>
|
||||
<TabContent value={TAB_AUTH}>
|
||||
<HttpAuthenticationEditor model={activeRequest} />
|
||||
@@ -384,16 +375,10 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
<TabContent value={TAB_BODY}>
|
||||
<ConfirmLargeRequestBody request={activeRequest}>
|
||||
{activeRequest.bodyType === BODY_TYPE_JSON ? (
|
||||
<Editor
|
||||
<JsonBodyEditor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
autocompleteFunctions
|
||||
autocompleteVariables
|
||||
placeholder="..."
|
||||
heightMode={fullHeight ? 'full' : 'auto'}
|
||||
defaultValue={`${activeRequest.body?.text ?? ''}`}
|
||||
language="json"
|
||||
onChange={handleBodyTextChange}
|
||||
stateKey={`json.${activeRequest.id}`}
|
||||
heightMode={fullHeight ? "full" : "auto"}
|
||||
request={activeRequest}
|
||||
/>
|
||||
) : activeRequest.bodyType === BODY_TYPE_XML ? (
|
||||
<Editor
|
||||
@@ -401,8 +386,8 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
autocompleteFunctions
|
||||
autocompleteVariables
|
||||
placeholder="..."
|
||||
heightMode={fullHeight ? 'full' : 'auto'}
|
||||
defaultValue={`${activeRequest.body?.text ?? ''}`}
|
||||
heightMode={fullHeight ? "full" : "auto"}
|
||||
defaultValue={`${activeRequest.body?.text ?? ""}`}
|
||||
language="xml"
|
||||
onChange={handleBodyTextChange}
|
||||
stateKey={`xml.${activeRequest.id}`}
|
||||
@@ -436,15 +421,15 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
onChange={(body) => patchModel(activeRequest, { body })}
|
||||
onChangeContentType={handleContentTypeChange}
|
||||
/>
|
||||
) : typeof activeRequest.bodyType === 'string' ? (
|
||||
) : typeof activeRequest.bodyType === "string" ? (
|
||||
<Editor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
autocompleteFunctions
|
||||
autocompleteVariables
|
||||
language={languageFromContentType(contentType)}
|
||||
placeholder="..."
|
||||
heightMode={fullHeight ? 'full' : 'auto'}
|
||||
defaultValue={`${activeRequest.body?.text ?? ''}`}
|
||||
heightMode={fullHeight ? "full" : "auto"}
|
||||
defaultValue={`${activeRequest.body?.text ?? ""}`}
|
||||
onChange={handleBodyTextChange}
|
||||
stateKey={`other.${activeRequest.id}`}
|
||||
/>
|
||||
@@ -484,8 +469,8 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
|
||||
function nameOfContentTypeOr(contentType: string | null, fallback: string) {
|
||||
const language = languageFromContentType(contentType);
|
||||
if (language === 'markdown') {
|
||||
return 'Markdown';
|
||||
if (language === "markdown") {
|
||||
return "Markdown";
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
427
apps/yaak-client/components/HttpResponsePane.tsx
Normal file
427
apps/yaak-client/components/HttpResponsePane.tsx
Normal file
@@ -0,0 +1,427 @@
|
||||
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} />;
|
||||
}
|
||||
418
apps/yaak-client/components/HttpResponseTimeline.tsx
Normal file
418
apps/yaak-client/components/HttpResponseTimeline.tsx
Normal file
@@ -0,0 +1,418 @@
|
||||
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,24 +1,24 @@
|
||||
import { clear, readText } from '@tauri-apps/plugin-clipboard-manager';
|
||||
import * as m from 'motion/react-m';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useImportCurl } from '../hooks/useImportCurl';
|
||||
import { useWindowFocus } from '../hooks/useWindowFocus';
|
||||
import { Button } from './core/Button';
|
||||
import { Icon } from './core/Icon';
|
||||
import { clear, readText } from "@tauri-apps/plugin-clipboard-manager";
|
||||
import * as m from "motion/react-m";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useImportCurl } from "../hooks/useImportCurl";
|
||||
import { useWindowFocus } from "../hooks/useWindowFocus";
|
||||
import { Button } from "./core/Button";
|
||||
import { Icon } from "@yaakapp-internal/ui";
|
||||
|
||||
export function ImportCurlButton() {
|
||||
const focused = useWindowFocus();
|
||||
const [clipboardText, setClipboardText] = useState('');
|
||||
const [clipboardText, setClipboardText] = useState("");
|
||||
|
||||
const importCurl = useImportCurl();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: none
|
||||
// oxlint-disable-next-line react-hooks/exhaustive-deps -- none
|
||||
useEffect(() => {
|
||||
readText().then(setClipboardText);
|
||||
void readText().then(setClipboardText);
|
||||
}, [focused]);
|
||||
|
||||
if (!clipboardText?.trim().startsWith('curl ')) {
|
||||
if (!clipboardText?.trim().startsWith("curl ")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -41,9 +41,9 @@ export function ImportCurlButton() {
|
||||
try {
|
||||
await importCurl.mutateAsync({ command: clipboardText });
|
||||
await clear(); // Clear the clipboard so the button goes away
|
||||
setClipboardText('');
|
||||
setClipboardText("");
|
||||
} catch (e) {
|
||||
console.log('Failed to import curl', e);
|
||||
console.log("Failed to import curl", e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useLocalStorage } from 'react-use';
|
||||
import { Button } from './core/Button';
|
||||
import { VStack } from './core/Stacks';
|
||||
import { SelectFile } from './SelectFile';
|
||||
import { VStack } from "@yaakapp-internal/ui";
|
||||
import { useState } from "react";
|
||||
import { useLocalStorage } from "react-use";
|
||||
import { Button } from "./core/Button";
|
||||
import { SelectFile } from "./SelectFile";
|
||||
|
||||
interface Props {
|
||||
importData: (filePath: string) => Promise<void>;
|
||||
@@ -10,7 +10,7 @@ interface Props {
|
||||
|
||||
export function ImportDataDialog({ importData }: Props) {
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [filePath, setFilePath] = useLocalStorage<string | null>('importFilePath', null);
|
||||
const [filePath, setFilePath] = useLocalStorage<string | null>("importFilePath", null);
|
||||
|
||||
return (
|
||||
<VStack space={5} className="pb-4">
|
||||
@@ -45,7 +45,7 @@ export function ImportDataDialog({ importData }: Props) {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isLoading ? 'Importing' : 'Import'}
|
||||
{isLoading ? "Importing" : "Import"}
|
||||
</Button>
|
||||
)}
|
||||
</VStack>
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { appInfo } from '../lib/appInfo';
|
||||
import type { ReactNode } from "react";
|
||||
import { appInfo } from "../lib/appInfo";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
122
apps/yaak-client/components/JsonBodyEditor.tsx
Normal file
122
apps/yaak-client/components/JsonBodyEditor.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
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,5 +1,5 @@
|
||||
import { hotkeyActions } from '../hooks/useHotKey';
|
||||
import { HotkeyList } from './core/HotkeyList';
|
||||
import { hotkeyActions } from "../hooks/useHotKey";
|
||||
import { HotkeyList } from "./core/HotkeyList";
|
||||
|
||||
export function KeyboardShortcutsDialog() {
|
||||
return (
|
||||
@@ -1,62 +1,62 @@
|
||||
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 './core/Icon';
|
||||
import { PillButton } from './core/PillButton';
|
||||
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);
|
||||
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 {
|
||||
): { label: ReactNode; color: ButtonProps["color"]; options?: DropdownItem[] } | null | undefined {
|
||||
const dismissedAt = dismissedExpired ? new Date(dismissedExpired).getTime() : null;
|
||||
|
||||
switch (data.status) {
|
||||
case 'active':
|
||||
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':
|
||||
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',
|
||||
color: "notice",
|
||||
label: data.data.changes > 0 ? "Updates Paused" : "License Expired",
|
||||
options: [
|
||||
{
|
||||
label: `${data.data.changes} New Updates`,
|
||||
color: 'success',
|
||||
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 ?? ''),
|
||||
onSelect: () => openUrl(data.data.changesUrl ?? ""),
|
||||
},
|
||||
{
|
||||
type: 'separator',
|
||||
label: `License expired ${formatDate(data.data.periodEnd, 'MMM dd, yyyy')}`,
|
||||
type: "separator",
|
||||
label: `License expired ${formatDate(data.data.periodEnd, "MMM dd, yyyy")}`,
|
||||
},
|
||||
{
|
||||
label: <div className="min-w-[12rem]">Renew License</div>,
|
||||
@@ -66,12 +66,12 @@ function getDetail(
|
||||
onSelect: () => openUrl(data.data.billingUrl),
|
||||
},
|
||||
{
|
||||
label: 'Enter License Key',
|
||||
label: "Enter License Key",
|
||||
leftSlot: <Icon icon="key_round" />,
|
||||
hidden: data.data.changesUrl == null,
|
||||
onSelect: openLicenseDialog,
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: <span className="text-text-subtle">Remind me Later</span>,
|
||||
leftSlot: <Icon icon="alarm_clock" className="text-text-subtle" />,
|
||||
@@ -135,5 +135,5 @@ function LicenseBadgeCmp() {
|
||||
}
|
||||
|
||||
function openLicenseDialog() {
|
||||
openSettings.mutate('license');
|
||||
openSettings.mutate("license");
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||
import { resolveResource } from '@tauri-apps/api/path';
|
||||
import classNames from 'classnames';
|
||||
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;
|
||||
@@ -10,7 +10,7 @@ interface Props {
|
||||
|
||||
export function LocalImage({ src: srcPath, className }: Props) {
|
||||
const src = useQuery({
|
||||
queryKey: ['local-image', srcPath],
|
||||
queryKey: ["local-image", srcPath],
|
||||
queryFn: async () => {
|
||||
const p = await resolveResource(srcPath);
|
||||
return convertFileSrc(p);
|
||||
@@ -23,8 +23,8 @@ export function LocalImage({ src: srcPath, className }: Props) {
|
||||
alt="Response preview"
|
||||
className={classNames(
|
||||
className,
|
||||
'transition-opacity',
|
||||
src.data == null ? 'opacity-0' : 'opacity-100',
|
||||
"transition-opacity",
|
||||
src.data == null ? "opacity-0" : "opacity-100",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
113
apps/yaak-client/components/Markdown.tsx
Normal file
113
apps/yaak-client/components/Markdown.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
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,13 +1,13 @@
|
||||
import classNames from 'classnames';
|
||||
import { useRef, useState } from 'react';
|
||||
import type { EditorProps } from './core/Editor/Editor';
|
||||
import { Editor } from './core/Editor/LazyEditor';
|
||||
import { SegmentedControl } from './core/SegmentedControl';
|
||||
import { Markdown } from './Markdown';
|
||||
import classNames from "classnames";
|
||||
import { useRef, useState } from "react";
|
||||
import type { EditorProps } from "./core/Editor/Editor";
|
||||
import { Editor } from "./core/Editor/LazyEditor";
|
||||
import { SegmentedControl } from "./core/SegmentedControl";
|
||||
import { Markdown } from "./Markdown";
|
||||
|
||||
type ViewMode = 'edit' | 'preview';
|
||||
type ViewMode = "edit" | "preview";
|
||||
|
||||
interface Props extends Pick<EditorProps, 'heightMode' | 'stateKey' | 'forceUpdateKey'> {
|
||||
interface Props extends Pick<EditorProps, "heightMode" | "stateKey" | "forceUpdateKey"> {
|
||||
placeholder: string;
|
||||
className?: string;
|
||||
editorClassName?: string;
|
||||
@@ -25,7 +25,7 @@ export function MarkdownEditor({
|
||||
forceUpdateKey,
|
||||
...editorProps
|
||||
}: Props) {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(defaultValue ? 'preview' : 'edit');
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(defaultValue ? "preview" : "edit");
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -33,7 +33,7 @@ export function MarkdownEditor({
|
||||
<Editor
|
||||
hideGutter
|
||||
wrapLines
|
||||
className={classNames(editorClassName, '[&_.cm-line]:!max-w-lg max-h-full')}
|
||||
className={classNames(editorClassName, "[&_.cm-line]:!max-w-lg max-h-full")}
|
||||
language="markdown"
|
||||
defaultValue={defaultValue}
|
||||
onChange={onChange}
|
||||
@@ -51,15 +51,15 @@ export function MarkdownEditor({
|
||||
</div>
|
||||
);
|
||||
|
||||
const contents = viewMode === 'preview' ? preview : editor;
|
||||
const contents = viewMode === "preview" ? preview : editor;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={classNames(
|
||||
'group/markdown',
|
||||
'relative w-full h-full pt-1.5 rounded-md gap-x-1.5',
|
||||
'min-w-0', // Not sure why this is needed
|
||||
"group/markdown",
|
||||
"relative w-full h-full pt-1.5 rounded-md gap-x-1.5",
|
||||
"min-w-0", // Not sure why this is needed
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -73,8 +73,8 @@ export function MarkdownEditor({
|
||||
value={viewMode}
|
||||
className="opacity-0 group-focus-within/markdown:opacity-100 group-hover/markdown:opacity-100"
|
||||
options={[
|
||||
{ icon: 'eye', label: 'Preview mode', value: 'preview' },
|
||||
{ icon: 'pencil', label: 'Edit mode', value: 'edit' },
|
||||
{ icon: "eye", label: "Preview mode", value: "preview" },
|
||||
{ icon: "pencil", label: "Edit mode", value: "edit" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
88
apps/yaak-client/components/MoveToWorkspaceDialog.tsx
Normal file
88
apps/yaak-client/components/MoveToWorkspaceDialog.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
12
apps/yaak-client/components/Prose.tsx
Normal file
12
apps/yaak-client/components/Prose.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
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,12 +1,11 @@
|
||||
import type { GrpcConnection } from '@yaakapp-internal/models';
|
||||
import { deleteModel } from '@yaakapp-internal/models';
|
||||
import { formatDistanceToNowStrict } from 'date-fns';
|
||||
import { useDeleteGrpcConnections } from '../hooks/useDeleteGrpcConnections';
|
||||
import { pluralizeCount } from '../lib/pluralize';
|
||||
import { Dropdown } from './core/Dropdown';
|
||||
import { Icon } from './core/Icon';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { HStack } from './core/Stacks';
|
||||
import type { GrpcConnection } from "@yaakapp-internal/models";
|
||||
import { deleteModel } from "@yaakapp-internal/models";
|
||||
import { HStack, Icon } from "@yaakapp-internal/ui";
|
||||
import { formatDistanceToNowStrict } from "date-fns";
|
||||
import { useDeleteGrpcConnections } from "../hooks/useDeleteGrpcConnections";
|
||||
import { pluralizeCount } from "../lib/pluralize";
|
||||
import { Dropdown } from "./core/Dropdown";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
|
||||
interface Props {
|
||||
connections: GrpcConnection[];
|
||||
@@ -20,27 +19,27 @@ export function RecentGrpcConnectionsDropdown({
|
||||
onPinnedConnectionId,
|
||||
}: Props) {
|
||||
const deleteAllConnections = useDeleteGrpcConnections(activeConnection?.requestId);
|
||||
const latestConnectionId = connections[0]?.id ?? 'n/a';
|
||||
const latestConnectionId = connections[0]?.id ?? "n/a";
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: 'Clear Connection',
|
||||
label: "Clear Connection",
|
||||
onSelect: () => deleteModel(activeConnection),
|
||||
disabled: connections.length === 0,
|
||||
},
|
||||
{
|
||||
label: `Clear ${pluralizeCount('Connection', connections.length)}`,
|
||||
label: `Clear ${pluralizeCount("Connection", connections.length)}`,
|
||||
onSelect: deleteAllConnections.mutate,
|
||||
hidden: connections.length <= 1,
|
||||
disabled: connections.length === 0,
|
||||
},
|
||||
{ type: 'separator', label: 'History' },
|
||||
{ type: "separator", label: "History" },
|
||||
...connections.map((c) => ({
|
||||
label: (
|
||||
<HStack space={2}>
|
||||
{formatDistanceToNowStrict(`${c.createdAt}Z`)} ago •{' '}
|
||||
{formatDistanceToNowStrict(`${c.createdAt}Z`)} ago •{" "}
|
||||
<span className="font-mono text-sm">{c.elapsed}ms</span>
|
||||
</HStack>
|
||||
),
|
||||
@@ -51,7 +50,7 @@ export function RecentGrpcConnectionsDropdown({
|
||||
>
|
||||
<IconButton
|
||||
title="Show connection history"
|
||||
icon={activeConnection?.id === latestConnectionId ? 'history' : 'pin'}
|
||||
icon={activeConnection?.id === latestConnectionId ? "history" : "pin"}
|
||||
className="m-0.5 text-text-subtle"
|
||||
size="sm"
|
||||
iconSize="md"
|
||||
@@ -1,14 +1,13 @@
|
||||
import type { HttpResponse } from '@yaakapp-internal/models';
|
||||
import { deleteModel } from '@yaakapp-internal/models';
|
||||
import { useCopyHttpResponse } from '../hooks/useCopyHttpResponse';
|
||||
import { useDeleteHttpResponses } from '../hooks/useDeleteHttpResponses';
|
||||
import { useSaveResponse } from '../hooks/useSaveResponse';
|
||||
import { pluralize } from '../lib/pluralize';
|
||||
import { Dropdown } from './core/Dropdown';
|
||||
import { HttpStatusTag } from './core/HttpStatusTag';
|
||||
import { Icon } from './core/Icon';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { HStack } from './core/Stacks';
|
||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||
import { deleteModel } from "@yaakapp-internal/models";
|
||||
import { HStack, Icon } from "@yaakapp-internal/ui";
|
||||
import { useCopyHttpResponse } from "../hooks/useCopyHttpResponse";
|
||||
import { useDeleteHttpResponses } from "../hooks/useDeleteHttpResponses";
|
||||
import { useSaveResponse } from "../hooks/useSaveResponse";
|
||||
import { pluralize } from "../lib/pluralize";
|
||||
import { Dropdown } from "./core/Dropdown";
|
||||
import { HttpStatusTag } from "./core/HttpStatusTag";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
|
||||
interface Props {
|
||||
responses: HttpResponse[];
|
||||
@@ -23,7 +22,7 @@ export const RecentHttpResponsesDropdown = function ResponsePane({
|
||||
onPinnedResponseId,
|
||||
}: Props) {
|
||||
const deleteAllResponses = useDeleteHttpResponses(activeResponse?.requestId);
|
||||
const latestResponseId = responses[0]?.id ?? 'n/a';
|
||||
const latestResponseId = responses[0]?.id ?? "n/a";
|
||||
const saveResponse = useSaveResponse(activeResponse);
|
||||
const copyResponse = useCopyHttpResponse(activeResponse);
|
||||
|
||||
@@ -31,45 +30,45 @@ export const RecentHttpResponsesDropdown = function ResponsePane({
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: 'Save to File',
|
||||
label: "Save to File",
|
||||
onSelect: saveResponse.mutate,
|
||||
leftSlot: <Icon icon="save" />,
|
||||
hidden: responses.length === 0 || !!activeResponse.error,
|
||||
disabled: activeResponse.state !== 'closed' && activeResponse.status >= 100,
|
||||
disabled: activeResponse.state !== "closed" && activeResponse.status >= 100,
|
||||
},
|
||||
{
|
||||
label: 'Copy Body',
|
||||
label: "Copy Body",
|
||||
onSelect: copyResponse.mutate,
|
||||
leftSlot: <Icon icon="copy" />,
|
||||
hidden: responses.length === 0 || !!activeResponse.error,
|
||||
disabled: activeResponse.state !== 'closed' && activeResponse.status >= 100,
|
||||
disabled: activeResponse.state !== "closed" && activeResponse.status >= 100,
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
label: "Delete",
|
||||
leftSlot: <Icon icon="trash" />,
|
||||
onSelect: () => deleteModel(activeResponse),
|
||||
},
|
||||
{
|
||||
label: 'Unpin Response',
|
||||
label: "Unpin Response",
|
||||
onSelect: () => onPinnedResponseId(activeResponse.id),
|
||||
leftSlot: <Icon icon="unpin" />,
|
||||
hidden: latestResponseId === activeResponse.id,
|
||||
disabled: responses.length === 0,
|
||||
},
|
||||
{ type: 'separator', label: 'History' },
|
||||
{ type: "separator", label: "History" },
|
||||
{
|
||||
label: `Delete ${responses.length} ${pluralize('Response', responses.length)}`,
|
||||
label: `Delete ${responses.length} ${pluralize("Response", responses.length)}`,
|
||||
onSelect: deleteAllResponses.mutate,
|
||||
hidden: responses.length === 0,
|
||||
disabled: responses.length === 0,
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ type: "separator" },
|
||||
...responses.map((r: HttpResponse) => ({
|
||||
label: (
|
||||
<HStack space={2}>
|
||||
<HttpStatusTag short className="text-xs" response={r} />
|
||||
<span className="text-text-subtle">→</span>{' '}
|
||||
<span className="font-mono text-sm">{r.elapsed >= 0 ? `${r.elapsed}ms` : 'n/a'}</span>
|
||||
<span className="text-text-subtle">→</span>{" "}
|
||||
<span className="font-mono text-sm">{r.elapsed >= 0 ? `${r.elapsed}ms` : "n/a"}</span>
|
||||
</HStack>
|
||||
),
|
||||
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <Icon icon="empty" />,
|
||||
@@ -79,7 +78,7 @@ export const RecentHttpResponsesDropdown = function ResponsePane({
|
||||
>
|
||||
<IconButton
|
||||
title="Show response history"
|
||||
icon={activeResponse?.id === latestResponseId ? 'history' : 'pin'}
|
||||
icon={activeResponse?.id === latestResponseId ? "history" : "pin"}
|
||||
className="m-0.5 text-text-subtle"
|
||||
size="sm"
|
||||
iconSize="md"
|
||||
@@ -1,18 +1,18 @@
|
||||
import classNames from 'classnames';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useActiveRequest } from '../hooks/useActiveRequest';
|
||||
import { activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace';
|
||||
import { allRequestsAtom } from '../hooks/useAllRequests';
|
||||
import { useHotKey } from '../hooks/useHotKey';
|
||||
import { useKeyboardEvent } from '../hooks/useKeyboardEvent';
|
||||
import { useRecentRequests } from '../hooks/useRecentRequests';
|
||||
import { jotaiStore } from '../lib/jotai';
|
||||
import { resolvedModelName } from '../lib/resolvedModelName';
|
||||
import { router } from '../lib/router';
|
||||
import { Button } from './core/Button';
|
||||
import type { DropdownItem, DropdownRef } from './core/Dropdown';
|
||||
import { Dropdown } from './core/Dropdown';
|
||||
import { HttpMethodTag } from './core/HttpMethodTag';
|
||||
import classNames from "classnames";
|
||||
import { useMemo, useRef } from "react";
|
||||
import { useActiveRequest } from "../hooks/useActiveRequest";
|
||||
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
||||
import { allRequestsAtom } from "../hooks/useAllRequests";
|
||||
import { useHotKey } from "../hooks/useHotKey";
|
||||
import { useKeyboardEvent } from "../hooks/useKeyboardEvent";
|
||||
import { useRecentRequests } from "../hooks/useRecentRequests";
|
||||
import { jotaiStore } from "../lib/jotai";
|
||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
||||
import { router } from "../lib/router";
|
||||
import { Button } from "./core/Button";
|
||||
import type { DropdownItem, DropdownRef } from "./core/Dropdown";
|
||||
import { Dropdown } from "./core/Dropdown";
|
||||
import { HttpMethodTag } from "./core/HttpMethodTag";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
@@ -26,13 +26,13 @@ export function RecentRequestsDropdown({ className }: Props) {
|
||||
// Handle key-up
|
||||
// TODO: Somehow make useHotKey have this functionality. Note: e.key does not work
|
||||
// on Linux, for example, when Control is mapped to CAPS. This will never fire.
|
||||
useKeyboardEvent('keyup', 'Control', () => {
|
||||
useKeyboardEvent("keyup", "Control", () => {
|
||||
if (dropdownRef.current?.isOpen) {
|
||||
dropdownRef.current?.select?.();
|
||||
}
|
||||
});
|
||||
|
||||
useHotKey('switcher.prev', () => {
|
||||
useHotKey("switcher.prev", () => {
|
||||
if (!dropdownRef.current?.isOpen) {
|
||||
// Select the second because the first is the current request
|
||||
dropdownRef.current?.open(1);
|
||||
@@ -41,7 +41,7 @@ export function RecentRequestsDropdown({ className }: Props) {
|
||||
}
|
||||
});
|
||||
|
||||
useHotKey('switcher.next', () => {
|
||||
useHotKey("switcher.next", () => {
|
||||
if (!dropdownRef.current?.isOpen) dropdownRef.current?.open();
|
||||
dropdownRef.current?.prev?.();
|
||||
});
|
||||
@@ -61,7 +61,7 @@ export function RecentRequestsDropdown({ className }: Props) {
|
||||
leftSlot: <HttpMethodTag short className="text-xs" request={request} />,
|
||||
onSelect: async () => {
|
||||
await router.navigate({
|
||||
to: '/workspaces/$workspaceId',
|
||||
to: "/workspaces/$workspaceId",
|
||||
params: { workspaceId: activeWorkspaceId },
|
||||
search: (prev) => ({ ...prev, request_id: request.id }),
|
||||
});
|
||||
@@ -73,8 +73,8 @@ export function RecentRequestsDropdown({ className }: Props) {
|
||||
if (recentRequestItems.length === 0) {
|
||||
return [
|
||||
{
|
||||
key: 'no-recent-requests',
|
||||
label: 'No recent requests',
|
||||
key: "no-recent-requests",
|
||||
label: "No recent requests",
|
||||
disabled: true,
|
||||
},
|
||||
];
|
||||
@@ -90,8 +90,8 @@ export function RecentRequestsDropdown({ className }: Props) {
|
||||
hotkeyAction="switcher.toggle"
|
||||
className={classNames(
|
||||
className,
|
||||
'truncate pointer-events-auto',
|
||||
activeRequest == null && 'text-text-subtlest italic',
|
||||
"truncate pointer-events-auto",
|
||||
activeRequest == null && "text-text-subtlest italic",
|
||||
)}
|
||||
>
|
||||
{resolvedModelName(activeRequest)}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user