mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 20:23:40 +01:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cb42dedc9 |
No files matched your search
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"name": "yaak",
|
||||
"interface": {
|
||||
"displayName": "Yaak"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "yaak",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./crates-cli/yaak-cli"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "Developer Tools"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
# Claude Context: Detaching Tauri from Yaak
|
||||
|
||||
## Goal
|
||||
|
||||
Make Yaak runnable as a standalone CLI without Tauri as a dependency. The core Rust crates in `crates/` should be usable independently, while Tauri-specific code lives in `crates-tauri/`.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
crates/ # Core crates - should NOT depend on Tauri
|
||||
crates-tauri/ # Tauri-specific crates (yaak-app-client, yaak-tauri-utils, etc.)
|
||||
crates-cli/ # CLI crate (yaak-cli)
|
||||
```
|
||||
|
||||
## Completed Work
|
||||
|
||||
### 1. Folder Restructure
|
||||
|
||||
- Moved Tauri-dependent app code to `crates-tauri/yaak-app-client/`
|
||||
- Created `crates-tauri/yaak-tauri-utils/` for shared Tauri utilities (window traits, api_client, error handling)
|
||||
- Created `crates-cli/yaak-cli/` for the standalone CLI
|
||||
|
||||
### 2. Decoupled Crates (no longer depend on Tauri)
|
||||
|
||||
- **yaak-models**: Uses `init_standalone()` pattern for CLI database access
|
||||
- **yaak-http**: Removed Tauri plugin, HttpConnectionManager initialized in yaak-app setup
|
||||
- **yaak-common**: Only contains Tauri-free utilities (serde, platform)
|
||||
- **yaak-crypto**: Removed Tauri plugin, EncryptionManager initialized in yaak-app setup, commands moved to yaak-app
|
||||
- **yaak-grpc**: Replaced AppHandle with GrpcConfig struct, uses tokio::process::Command instead of Tauri sidecar
|
||||
|
||||
### 3. CLI Implementation
|
||||
|
||||
- Basic CLI at `crates-cli/yaak-cli/src/main.rs`
|
||||
- Commands: workspaces, requests, send (by ID), get (ad-hoc URL), create
|
||||
- Uses same database as Tauri app via `yaak_models::init_standalone()`
|
||||
|
||||
## Remaining Work
|
||||
|
||||
### Crates Still Depending on Tauri (in `crates/`)
|
||||
|
||||
1. **yaak-git** (3 files) - Moderate complexity
|
||||
2. **yaak-plugins** (13 files) - **Hardest** - deeply integrated with Tauri for plugin-to-window communication
|
||||
3. **yaak-sync** (4 files) - Moderate complexity
|
||||
4. **yaak-ws** (5 files) - Moderate complexity
|
||||
|
||||
### Pattern for Decoupling
|
||||
|
||||
1. Remove Tauri plugin `init()` function from the crate
|
||||
2. Move commands to `yaak-app/src/commands.rs` or keep inline in `lib.rs`
|
||||
3. Move extension traits (e.g., `SomethingManagerExt`) to yaak-app or yaak-tauri-utils
|
||||
4. Initialize managers in yaak-app's `.setup()` block
|
||||
5. Remove `tauri` from Cargo.toml dependencies
|
||||
6. Update `crates-tauri/yaak-app-client/capabilities/default.json` to remove the plugin permission
|
||||
7. Replace `tauri::async_runtime::block_on` with `tokio::runtime::Handle::current().block_on()`
|
||||
|
||||
## Key Files
|
||||
|
||||
- `crates-tauri/yaak-app-client/src/lib.rs` - Main Tauri app, setup block initializes managers
|
||||
- `crates-tauri/yaak-app-client/src/commands.rs` - Migrated Tauri commands
|
||||
- `crates-tauri/yaak-app-client/src/models_ext.rs` - Database plugin and extension traits
|
||||
- `crates-tauri/yaak-tauri-utils/src/window.rs` - WorkspaceWindowTrait for window state
|
||||
- `crates/yaak-models/src/lib.rs` - Contains `init_standalone()` for CLI usage
|
||||
|
||||
## Git Branch
|
||||
|
||||
Working on `detach-tauri` branch.
|
||||
|
||||
## Recent Commits
|
||||
|
||||
```
|
||||
c40cff40 Remove Tauri dependencies from yaak-crypto and yaak-grpc
|
||||
df495f1d Move Tauri utilities from yaak-common to yaak-tauri-utils
|
||||
481e0273 Remove Tauri dependencies from yaak-http and yaak-common
|
||||
10568ac3 Add HTTP request sending to yaak-cli
|
||||
bcb7d600 Add yaak-cli stub with basic database access
|
||||
e718a5f1 Refactor models_ext to use init_standalone from yaak-models
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
- Run `cargo check -p <crate>` to verify a crate builds without Tauri
|
||||
- Run `npm run client:dev` to test the Tauri app still works
|
||||
- Run `cargo run -p yaak-cli -- --help` to test the CLI
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/claude-code-marketplace.json",
|
||||
"name": "yaak",
|
||||
"description": "Plugins for Yaak, the desktop API client",
|
||||
"owner": {
|
||||
"name": "Yaak",
|
||||
"url": "https://yaak.app"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "yaak",
|
||||
"source": "./crates-cli/yaak-cli",
|
||||
"description": "Build, save, and send API requests with the yaak CLI, with its MCP server for agents",
|
||||
"category": "development",
|
||||
"tags": ["api", "http", "rest", "graphql", "grpc", "openapi", "api-client"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
# Project Rules
|
||||
|
||||
## General Development
|
||||
|
||||
- **NEVER** commit or push without explicit confirmation
|
||||
|
||||
## Build and Lint
|
||||
|
||||
- **ALWAYS** run `npm run lint` after modifying TypeScript or JavaScript files
|
||||
- Run `npm run bootstrap` after changing plugin runtime or MCP server code
|
||||
|
||||
## Plugin System
|
||||
|
||||
### Backend Constraints
|
||||
|
||||
- Always use `UpdateSource::Plugin` when calling database methods from plugin events
|
||||
- Never send timestamps (`createdAt`, `updatedAt`) from TypeScript - Rust backend controls these
|
||||
- Backend uses `NaiveDateTime` (no timezone) so avoid sending ISO timestamp strings
|
||||
|
||||
### MCP Server
|
||||
|
||||
- MCP server has **no active window context** - cannot call `window.workspaceId()`
|
||||
- Get workspace ID from `workspaceCtx.yaak.workspace.list()` instead
|
||||
|
||||
## Rust Type Generation
|
||||
|
||||
- Run `cargo test --package yaak-plugins` (and for other crates) to regenerate TypeScript bindings after modifying Rust event types
|
||||
@@ -1,52 +0,0 @@
|
||||
---
|
||||
name: release-generate-release-notes
|
||||
description: Generate Yaak release notes from git history and PR metadata, including feedback links and full changelog compare links. Use when asked to run or replace the old Claude generate-release-notes command.
|
||||
---
|
||||
|
||||
# Generate Release Notes
|
||||
|
||||
Generate formatted markdown release notes for a Yaak tag.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Determine target tag.
|
||||
2. Determine previous comparable tag:
|
||||
- Beta tag: compare against previous beta (if the root version is the same) or stable tag.
|
||||
- Stable tag: compare against previous stable tag.
|
||||
3. Collect commits in range:
|
||||
- `git log --oneline <prev_tag>..<target_tag>`
|
||||
4. For linked PRs, fetch metadata:
|
||||
- `gh pr view <PR_NUMBER> --json number,title,body,author,url`
|
||||
5. Extract useful details:
|
||||
- Feedback URLs (`feedback.yaak.app`)
|
||||
- Contributor GitHub handles from `author.login`
|
||||
- Plugin install links or other notable context
|
||||
6. Format notes using Yaak style:
|
||||
- Changelog badge at top
|
||||
- Bulleted items with PR links where available
|
||||
- Contributor handles for external PRs
|
||||
- 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.
|
||||
- Append contributor attribution to PR-backed bullets for non-`@gschier` authors, using `by [@handle](https://github.com/handle)`.
|
||||
- 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`.
|
||||
@@ -1,104 +0,0 @@
|
||||
---
|
||||
name: yaak-changelog
|
||||
description: Create or edit Yaak changelogs. Beta and draft prerelease changelogs live only in the GitHub release body; stable release changelogs live in `src/content/changelog/YYYYMMDD_VERSION/`. Use when Codex needs to update a beta GitHub release body, generate a stable website changelog from beta releases, update `_release.yaml` or `_intro.md`, expand major entries into markdown files, or preserve Yaak's changelog writing style.
|
||||
---
|
||||
|
||||
# Yaak Changelog
|
||||
|
||||
Use this skill to create Yaak changelogs in the correct place:
|
||||
|
||||
- Beta or draft prerelease changelogs live only on the GitHub release.
|
||||
- Stable release changelogs live in website files under `src/content/changelog/YYYYMMDD_VERSION/`.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Identify the target release.
|
||||
- If the target tag contains `-beta` or the GitHub release is a draft prerelease, update the GitHub release body only. Do not create or edit `src/content/changelog/` files for beta releases.
|
||||
- For a new stable changelog, gather all beta release notes for that version since the previous stable release, then create or edit website changelog files.
|
||||
- Prefer `gh api` or GitHub release pages. If network access is restricted, request permission before querying GitHub.
|
||||
- Extract PR numbers and `yaak.app/feedback` URLs while fetching. If most bullets do not include PR references, fetch again with a more specific prompt.
|
||||
- Fetch PR authors when generating or revising release notes. Include contributor attribution for non-`@gschier` PR authors.
|
||||
|
||||
2. Parse release bullets.
|
||||
- Treat each release-note bullet as one changelog entry.
|
||||
- Skip dependency-only, generated, build-only, test-only, CI-only, and internal maintenance bullets unless they have a clear user-facing impact that can be described in user terms.
|
||||
- For stable website changelogs, skip bullets prefixed with `[beta-only]`.
|
||||
- Preserve the entry wording closely. Remove wrapping quotes from titles.
|
||||
- Map categories to `feature`, `fix`, `improvement`, or `breaking`.
|
||||
- Convert `#NNN` into `https://github.com/mountain-loop/yaak/pull/NNN`.
|
||||
|
||||
3. For beta or draft prerelease changelogs, update the GitHub release.
|
||||
- Keep the changelog in the GitHub release body. Do not create a website changelog directory.
|
||||
- Do not add a changelog badge or link to `yaak.app/changelog/VERSION` for beta releases.
|
||||
- Prefer concise bullets with PR links and feedback links when available.
|
||||
- When a bullet has a feedback URL, wrap the changelog item text itself in the feedback link, then put the PR link after it. Example: `- [Fixed request history timestamps](https://yaak.app/feedback/posts/request-history-time-stamp) in [#492](https://github.com/mountain-loop/yaak/pull/492)`.
|
||||
- Append `by [@handle](https://github.com/handle)` to PR-backed bullets authored by external contributors. Do not append `by @gschier` for `@gschier` PRs.
|
||||
- Include a `**Full Changelog**` comparison link using the previous beta tag when it exists, or the previous stable tag for `beta.1`.
|
||||
- Use `gh release edit TAG --repo mountain-loop/yaak --notes-file ...` or the GitHub release API to update the draft/prerelease body.
|
||||
- Stop after verifying the GitHub release body. The website checks below do not apply.
|
||||
|
||||
4. For stable website changelogs, create or edit the release directory.
|
||||
- Path format: `src/content/changelog/YYYYMMDD_VERSION/`.
|
||||
- For a new release, use today's date for `YYYYMMDD`.
|
||||
- For an existing release, keep the original directory date.
|
||||
- Do not create changelog directories for beta releases.
|
||||
|
||||
5. Write `_release.yaml`.
|
||||
- Include `draft`, optional `title`, `summary`, `image`, `youtube`, and `entries`.
|
||||
- Keep minor items as quick entries without `content`.
|
||||
- Use `content` only when an entry needs its own markdown section.
|
||||
|
||||
```yaml
|
||||
title: "What's New in 2026.1.0"
|
||||
summary: "Brief overview of the most important additions and fixes"
|
||||
draft: true
|
||||
entries:
|
||||
- title: "Request debugging"
|
||||
category: feature
|
||||
pr: "https://github.com/mountain-loop/yaak/pull/123"
|
||||
feedback: "https://feedback.yaak.app/p/request-debugging"
|
||||
content: "request-debugging.md"
|
||||
- title: "Fix broken cookie clearing"
|
||||
category: fix
|
||||
pr: "https://github.com/mountain-loop/yaak/pull/124"
|
||||
```
|
||||
|
||||
6. Expand major entries.
|
||||
- Expand 3 to 6 major items when enough context exists.
|
||||
- Create slugified markdown files and reference them with `content`.
|
||||
- Read the related PR before writing expanded content.
|
||||
- Add emoji prefixes only for expanded entry titles if it helps distinguish major sections.
|
||||
|
||||
7. Handle images.
|
||||
- Reuse screenshots from PRs when they exist.
|
||||
- Convert GitHub private attachment URLs to `https://github.com/user-attachments/assets/UUID` before upload.
|
||||
- Upload with `go run cmd/yaakadmin/main.go upload "URL"` when the environment permits it.
|
||||
- If no real image is available, use a placeholder with real alt text and a caption.
|
||||
|
||||
8. Write `_intro.md`.
|
||||
- Add a short overview paragraph at the top of the release.
|
||||
- Focus on the major themes across the release instead of repeating every bullet.
|
||||
|
||||
9. Follow Yaak writing style.
|
||||
- Be direct and factual. Avoid hype.
|
||||
- State what changed and how to use it.
|
||||
- Keep paragraphs short.
|
||||
- Use backticks for code symbols, settings, and literal values.
|
||||
- Use bold sparingly for the most important phrase in a section.
|
||||
|
||||
## File Rules
|
||||
|
||||
- Beta releases must not create or edit files in `src/content/changelog/`.
|
||||
- Main files are `_release.yaml` and optional `_intro.md`.
|
||||
- Expanded entry files are regular markdown files such as `request-debugging.md`.
|
||||
- `entries[].content` must match an existing markdown filename in the same directory.
|
||||
- Images for changelog pages live under `static/changelog/VERSION/` when committed to the repo.
|
||||
|
||||
## Checks
|
||||
|
||||
- For beta releases, verify `gh release view TAG --repo mountain-loop/yaak --json body,tagName,isDraft,isPrerelease` and ensure no website changelog files were created.
|
||||
- For beta releases, verify feedback-backed bullets use the feedback URL as the link target for the whole item text, not as a separate trailing `Feedback:` link.
|
||||
- For stable releases, ensure each user-facing source bullet becomes exactly one changelog entry unless it is `[beta-only]` or dependency-only/internal maintenance.
|
||||
- Ensure most entries include `pr` when the source release notes provide one.
|
||||
- For stable releases, ensure every referenced `content` file exists.
|
||||
- If the user wants stable website verification, run the site and inspect `/changelog/VERSION` and `/rss.xml`.
|
||||
@@ -1,7 +0,0 @@
|
||||
interface:
|
||||
display_name: "Yaak Changelog"
|
||||
short_description: "Generate Yaak changelog releases"
|
||||
default_prompt: "Use $yaak-changelog to create or update a Yaak changelog release from GitHub release notes."
|
||||
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
@@ -1,11 +0,0 @@
|
||||
node_modules
|
||||
**/node_modules
|
||||
dist
|
||||
**/dist
|
||||
target
|
||||
**/target
|
||||
.claude
|
||||
vendored
|
||||
**/vendored
|
||||
*.log
|
||||
.git
|
||||
+3
-6
@@ -1,8 +1,5 @@
|
||||
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
|
||||
src-tauri/vendored/**/* linguist-generated=true
|
||||
src-tauri/gen/schemas/**/* linguist-generated=true
|
||||
|
||||
# Ensure consistent line endings for test files that check exact content
|
||||
crates/yaak-http/tests/test.txt text eol=lf
|
||||
crates-proxy/yaak-proxy-lib/tests/fixtures/export.har text eol=lf
|
||||
src-tauri/yaak-http/tests/test.txt text eol=lf
|
||||
@@ -1,9 +1,10 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ""
|
||||
labels: ""
|
||||
assignees: ""
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
@@ -11,7 +12,6 @@ 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,17 +24,15 @@ 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.
|
||||
@@ -1,55 +0,0 @@
|
||||
version: 2
|
||||
|
||||
updates:
|
||||
- package-ecosystem: npm
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
open-pull-requests-limit: 5
|
||||
ignore:
|
||||
# Build tooling majors are opted into, not queued. These never reach a
|
||||
# user, so the cost of being a release behind is low and the cost of a
|
||||
# surprise is a broken build. Note this also suppresses security PRs for
|
||||
# these, so Dependabot alerts are the backstop.
|
||||
- dependency-name: typescript
|
||||
update-types: ["version-update:semver-major"]
|
||||
- dependency-name: vitest
|
||||
update-types: ["version-update:semver-major"]
|
||||
- dependency-name: "@types/*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
- dependency-name: "vite-plugin-*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
# 0.8 dropped stateExtensions and updateSchema from the package root and
|
||||
# exposes no subpath to reach them, which GrpcEditor needs. Unpin once
|
||||
# that editor moves to the jsonSchema helper.
|
||||
- dependency-name: codemirror-json-schema
|
||||
groups:
|
||||
npm-production:
|
||||
dependency-type: production
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
npm-development:
|
||||
dependency-type: development
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
|
||||
- package-ecosystem: cargo
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
open-pull-requests-limit: 5
|
||||
ignore:
|
||||
# Held at 11.10.0 to pin what rolldown_resolver ("^11") resolves to; it
|
||||
# does not build against 11.11. See crates-cli/yaak-cli/Cargo.toml.
|
||||
- dependency-name: oxc_resolver
|
||||
groups:
|
||||
cargo:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
@@ -1,22 +0,0 @@
|
||||
## Summary
|
||||
|
||||
<!-- Describe the bug and the fix in 1-3 sentences. -->
|
||||
|
||||
## Submission
|
||||
|
||||
<!-- Check every box below except at most one of the first two (bug fixes only need the first). The last two must be checked even when they do not apply — checking confirms you considered them. -->
|
||||
|
||||
- [ ] This PR is a bug fix.
|
||||
- [ ] If this PR is not a bug fix, I linked the feedback item where @gschier explicitly gave me permission to work on it.
|
||||
- [ ] I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).
|
||||
- [ ] I tested this change locally.
|
||||
- [ ] I added or updated tests, or tests are not reasonable for this change.
|
||||
- [ ] I added screenshots or recordings, or this change does not affect the UI.
|
||||
|
||||
Explicit permission feedback item (required if not a bug fix):
|
||||
|
||||
<!-- https://yaak.app/feedback/... -->
|
||||
|
||||
## Related
|
||||
|
||||
<!-- Link related issues, discussions, or feedback items. -->
|
||||
@@ -1,881 +0,0 @@
|
||||
const fs = require("node:fs");
|
||||
|
||||
const COMMENT_MARKER = "<!-- yaak-contribution-policy -->";
|
||||
|
||||
const MAINTAINER_LOGINS = new Set(["gschier"]);
|
||||
const MAINTAINER_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
|
||||
const MAINTAINER_PERMISSIONS = new Set(["admin", "maintain", "write"]);
|
||||
const REVIEWER_LOGIN = "gschier";
|
||||
|
||||
const LARGE_DIFF_CHANGED_FILES = 20;
|
||||
const LARGE_DIFF_CHANGED_LINES = 800;
|
||||
const SUMMARY_TITLE_MAX_LENGTH = 80;
|
||||
const MIN_AUTOMATIC_PR_NUMBER = 494;
|
||||
|
||||
const LABELS = {
|
||||
inScope: {
|
||||
name: "contribution: in scope",
|
||||
color: "0E8A16",
|
||||
description: "Community PR appears to be in scope for maintainer review.",
|
||||
},
|
||||
outOfScope: {
|
||||
name: "contribution: out of scope",
|
||||
color: "B60205",
|
||||
description: "Community PR does not match Yaak's contribution policy.",
|
||||
},
|
||||
explicitPermission: {
|
||||
name: "contribution: explicit permission",
|
||||
color: "5319E7",
|
||||
description:
|
||||
"Community PR links feedback where @gschier explicitly allowed the work.",
|
||||
},
|
||||
missingTemplate: {
|
||||
name: "contribution: missing template",
|
||||
color: "D93F0B",
|
||||
description:
|
||||
"Community PR is missing enough of the pull request template to review.",
|
||||
},
|
||||
policyUnmet: {
|
||||
name: "contribution: policy unmet",
|
||||
color: "B60205",
|
||||
description:
|
||||
"Community PR does not currently satisfy the contribution policy.",
|
||||
},
|
||||
needsScopeReview: {
|
||||
name: "contribution: needs scope review",
|
||||
color: "FBCA04",
|
||||
description:
|
||||
"Community PR may be broader than Yaak's bug-fix contribution policy.",
|
||||
},
|
||||
};
|
||||
|
||||
const MANAGED_LABEL_NAMES = [
|
||||
...new Set(Object.values(LABELS).map((label) => label.name)),
|
||||
];
|
||||
|
||||
// Each checkbox lists its current label first, followed by legacy labels still
|
||||
// accepted from PRs opened against older versions of the template.
|
||||
const CHECKBOXES = {
|
||||
bugFix: ["This PR is a bug fix."],
|
||||
explicitPermission: [
|
||||
"If this PR is not a bug fix, I linked the feedback item where @gschier explicitly gave me permission to work on it.",
|
||||
],
|
||||
readContributing: [
|
||||
"I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).",
|
||||
],
|
||||
testedLocally: ["I tested this change locally."],
|
||||
testsUpdated: [
|
||||
"I added or updated tests, or tests are not reasonable for this change.",
|
||||
"I added or updated tests when reasonable.",
|
||||
],
|
||||
screenshotsAdded: [
|
||||
"I added screenshots or recordings, or this change does not affect the UI.",
|
||||
"I added screenshots or recordings for UI changes when reasonable.",
|
||||
],
|
||||
};
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function normalizeBody(body) {
|
||||
return (body || "").replace(/\r\n/g, "\n");
|
||||
}
|
||||
|
||||
function stripComments(value) {
|
||||
return value.replace(/<!--[\s\S]*?-->/g, "").trim();
|
||||
}
|
||||
|
||||
function getSection(body, heading) {
|
||||
const pattern = new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*$`, "gim");
|
||||
const match = pattern.exec(body);
|
||||
|
||||
if (match == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rest = body.slice(match.index + match[0].length);
|
||||
const nextHeadingIndex = rest.search(/^##\s+/m);
|
||||
return nextHeadingIndex === -1 ? rest : rest.slice(0, nextHeadingIndex);
|
||||
}
|
||||
|
||||
function hasMeaningfulText(value) {
|
||||
return stripComments(value || "").length > 0;
|
||||
}
|
||||
|
||||
function normalizeCheckboxLabel(label) {
|
||||
return label
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
.replace(/`/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function checkboxState(body, labels) {
|
||||
const expectedLabels = new Set(labels.map(normalizeCheckboxLabel));
|
||||
|
||||
for (const line of body.split("\n")) {
|
||||
const match = line.match(/^\s*[-*]\s*\[([ xX])\]\s*(.*?)\s*$/i);
|
||||
|
||||
if (match == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (expectedLabels.has(normalizeCheckboxLabel(match[2]))) {
|
||||
return match[1].toLowerCase() === "x";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function findFeedbackUrl(body) {
|
||||
return (
|
||||
body.match(
|
||||
/https?:\/\/(?:www\.)?(?:yaak\.app\/feedback|feedback\.yaak\.app)\/[^\s)>\]]+/i,
|
||||
)?.[0] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function getLabelNames(pr) {
|
||||
return new Set((pr.labels || []).map((label) => label.name));
|
||||
}
|
||||
|
||||
function analyzePullRequest(pr) {
|
||||
const body = normalizeBody(pr.body);
|
||||
const labelNames = getLabelNames(pr);
|
||||
const states = Object.fromEntries(
|
||||
Object.entries(CHECKBOXES).map(([key, label]) => [
|
||||
key,
|
||||
checkboxState(body, label),
|
||||
]),
|
||||
);
|
||||
const sectionCount = ["Summary", "Submission", "Related"].filter(
|
||||
(heading) => getSection(body, heading) != null,
|
||||
).length;
|
||||
const checkboxCount = Object.values(states).filter(
|
||||
(state) => state != null,
|
||||
).length;
|
||||
const templateUsed = sectionCount >= 2 && checkboxCount >= 3;
|
||||
const blockers = [];
|
||||
const totalChangedLines =
|
||||
Number(pr.additions || 0) + Number(pr.deletions || 0);
|
||||
const changedFiles = Number(pr.changed_files || 0);
|
||||
const largeDiff =
|
||||
changedFiles > LARGE_DIFF_CHANGED_FILES ||
|
||||
totalChangedLines > LARGE_DIFF_CHANGED_LINES;
|
||||
|
||||
if (labelNames.has(LABELS.outOfScope.name)) {
|
||||
return {
|
||||
blockers: [
|
||||
{
|
||||
label: LABELS.outOfScope.name,
|
||||
message: "Marked out of scope by maintainer label.",
|
||||
},
|
||||
],
|
||||
changedFiles,
|
||||
desiredLabels: [LABELS.outOfScope.name],
|
||||
largeDiff,
|
||||
status: "out_of_scope",
|
||||
templateUsed,
|
||||
totalChangedLines,
|
||||
};
|
||||
}
|
||||
|
||||
if (labelNames.has(LABELS.inScope.name)) {
|
||||
return {
|
||||
blockers: [],
|
||||
changedFiles,
|
||||
desiredLabels: [LABELS.inScope.name],
|
||||
largeDiff,
|
||||
status: "in_scope",
|
||||
templateUsed,
|
||||
totalChangedLines,
|
||||
};
|
||||
}
|
||||
|
||||
if (labelNames.has(LABELS.explicitPermission.name)) {
|
||||
return {
|
||||
blockers: [],
|
||||
changedFiles,
|
||||
desiredLabels: [LABELS.explicitPermission.name],
|
||||
largeDiff,
|
||||
status: "in_scope",
|
||||
templateUsed,
|
||||
totalChangedLines,
|
||||
};
|
||||
}
|
||||
|
||||
if (!templateUsed) {
|
||||
blockers.push({
|
||||
label: LABELS.missingTemplate.name,
|
||||
message:
|
||||
"Update the PR description with the repository pull request template.",
|
||||
});
|
||||
} else {
|
||||
const summary = getSection(body, "Summary");
|
||||
const hasSummary = hasMeaningfulText(summary);
|
||||
const feedbackUrl = findFeedbackUrl(body);
|
||||
const bugFix = states.bugFix === true;
|
||||
const explicitPermission = states.explicitPermission === true;
|
||||
|
||||
if (!hasSummary) {
|
||||
blockers.push({
|
||||
label: LABELS.policyUnmet.name,
|
||||
message:
|
||||
"Add a short summary describing the bug fix or permitted change.",
|
||||
});
|
||||
}
|
||||
|
||||
if (bugFix && explicitPermission) {
|
||||
blockers.push({
|
||||
label: LABELS.policyUnmet.name,
|
||||
message:
|
||||
"Choose either the bug-fix checkbox or the explicit-permission checkbox, not both.",
|
||||
});
|
||||
} else if (!bugFix && !explicitPermission) {
|
||||
blockers.push({
|
||||
label: LABELS.policyUnmet.name,
|
||||
message:
|
||||
"Check whether this is a bug fix, or confirm that explicit permission from @gschier is linked.",
|
||||
});
|
||||
} else if (explicitPermission && feedbackUrl == null) {
|
||||
blockers.push({
|
||||
label: LABELS.policyUnmet.name,
|
||||
message:
|
||||
"Link the feedback item where @gschier explicitly gave you permission to work on this.",
|
||||
});
|
||||
}
|
||||
|
||||
if (states.readContributing !== true) {
|
||||
blockers.push({
|
||||
label: LABELS.policyUnmet.name,
|
||||
message: "Confirm that `CONTRIBUTING.md` was read and followed.",
|
||||
});
|
||||
}
|
||||
|
||||
if (states.testedLocally !== true) {
|
||||
blockers.push({
|
||||
label: LABELS.policyUnmet.name,
|
||||
message: "Confirm that the change was tested locally.",
|
||||
});
|
||||
}
|
||||
|
||||
if (states.testsUpdated !== true) {
|
||||
blockers.push({
|
||||
label: LABELS.policyUnmet.name,
|
||||
message:
|
||||
"Confirm that tests were added or updated, or that tests are not reasonable for this change. Check the box either way.",
|
||||
});
|
||||
}
|
||||
|
||||
if (states.screenshotsAdded !== true) {
|
||||
blockers.push({
|
||||
label: LABELS.policyUnmet.name,
|
||||
message:
|
||||
"Confirm that screenshots or recordings were added, or that this change does not affect the UI. Check the box either way.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const desiredLabels = new Set();
|
||||
|
||||
if (blockers.length === 0) {
|
||||
desiredLabels.add(
|
||||
largeDiff
|
||||
? LABELS.needsScopeReview.name
|
||||
: states.explicitPermission
|
||||
? LABELS.explicitPermission.name
|
||||
: LABELS.inScope.name,
|
||||
);
|
||||
} else if (
|
||||
blockers.some((blocker) => blocker.label === LABELS.missingTemplate.name)
|
||||
) {
|
||||
desiredLabels.add(LABELS.missingTemplate.name);
|
||||
} else {
|
||||
desiredLabels.add(LABELS.policyUnmet.name);
|
||||
}
|
||||
|
||||
return {
|
||||
blockers,
|
||||
changedFiles,
|
||||
desiredLabels: [...desiredLabels],
|
||||
largeDiff,
|
||||
status: blockers.length === 0 ? "in_scope" : "blocked",
|
||||
templateUsed,
|
||||
totalChangedLines,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBlockingComment(analysis) {
|
||||
const lines = [
|
||||
COMMENT_MARKER,
|
||||
"Thanks for the PR. Yaak currently accepts community PRs for bug fixes, plus larger changes that link a feedback item where @gschier explicitly gave permission to work on it.",
|
||||
"",
|
||||
"This PR cannot be accepted yet because the following contribution policy requirements were unmet:",
|
||||
"",
|
||||
...analysis.blockers.map((blocker) => `- ${blocker.message}`),
|
||||
];
|
||||
|
||||
if (!analysis.templateUsed) {
|
||||
lines.push(
|
||||
"",
|
||||
"You can copy this template into the PR description and keep any existing context that is still useful.",
|
||||
"",
|
||||
"<details>",
|
||||
"<summary>PR description template</summary>",
|
||||
"",
|
||||
"```md",
|
||||
getPullRequestTemplate(),
|
||||
"```",
|
||||
"",
|
||||
"</details>",
|
||||
);
|
||||
}
|
||||
|
||||
if (analysis.largeDiff) {
|
||||
lines.push(
|
||||
"",
|
||||
`This PR also changes ${analysis.changedFiles} files and ${analysis.totalChangedLines} lines, so it has been labeled as needing scope review. That label is advisory, but maintainers may ask for the scope to be reduced.`,
|
||||
);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function getPullRequestTemplate() {
|
||||
return fs.readFileSync(".github/pull_request_template.md", "utf8").trim();
|
||||
}
|
||||
|
||||
function buildInScopeComment() {
|
||||
return [
|
||||
COMMENT_MARKER,
|
||||
"Thanks for the PR. This appears to match Yaak's contribution policy and is awaiting review by @gschier.",
|
||||
"",
|
||||
"This only means the PR is in scope for review. It does not mean the change has been reviewed or accepted for merge.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildOutOfScopeComment() {
|
||||
return [
|
||||
COMMENT_MARKER,
|
||||
"Thanks for the PR. This does not appear to match Yaak's current contribution policy.",
|
||||
"",
|
||||
"Yaak currently accepts community PRs for bug fixes, or changes tied to a feedback item where @gschier explicitly gave permission to work on it.",
|
||||
"",
|
||||
"If this PR is tied to a feedback item where @gschier explicitly gave permission, please link it in the PR description.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildPolicyComment(analysis) {
|
||||
if (analysis.status === "out_of_scope") {
|
||||
return buildOutOfScopeComment();
|
||||
}
|
||||
|
||||
if (analysis.blockers.length > 0) {
|
||||
return buildBlockingComment(analysis);
|
||||
}
|
||||
|
||||
return buildInScopeComment();
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function truncateTitle(title) {
|
||||
if (title.length <= SUMMARY_TITLE_MAX_LENGTH) {
|
||||
return title;
|
||||
}
|
||||
|
||||
return `${title.slice(0, SUMMARY_TITLE_MAX_LENGTH - 3).trimEnd()}...`;
|
||||
}
|
||||
|
||||
function escapeTableText(value) {
|
||||
return escapeHtml(value).replace(/\n/g, "<br>");
|
||||
}
|
||||
|
||||
function summarizeResult({ pr, analysis, skipped, skipReason }) {
|
||||
const comment =
|
||||
analysis == null
|
||||
? "None"
|
||||
: buildPolicyComment(analysis).replace(COMMENT_MARKER, "").trim();
|
||||
const summary = {
|
||||
blocked: analysis?.blockers.length > 0,
|
||||
comment,
|
||||
details: "None",
|
||||
labels:
|
||||
analysis?.desiredLabels.length > 0
|
||||
? analysis.desiredLabels.join(", ")
|
||||
: "None",
|
||||
number: pr.number,
|
||||
prLink: `<a href="${escapeHtml(pr.html_url)}">#${pr.number}</a>`,
|
||||
status: "In scope",
|
||||
title: escapeHtml(truncateTitle(pr.title)),
|
||||
};
|
||||
|
||||
if (skipped) {
|
||||
return {
|
||||
...summary,
|
||||
blocked: false,
|
||||
comment: "None",
|
||||
details: escapeHtml(skipReason),
|
||||
labels: "None",
|
||||
status: "Skipped",
|
||||
};
|
||||
}
|
||||
|
||||
if (summary.blocked) {
|
||||
return {
|
||||
...summary,
|
||||
comment: escapeTableText(summary.comment),
|
||||
details: escapeHtml(
|
||||
analysis.blockers.map((blocker) => blocker.message).join("; "),
|
||||
),
|
||||
labels: escapeHtml(summary.labels),
|
||||
status: analysis.status === "out_of_scope" ? "Out of scope" : "Blocked",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...summary,
|
||||
comment: escapeTableText(summary.comment),
|
||||
labels: escapeHtml(summary.labels),
|
||||
};
|
||||
}
|
||||
|
||||
async function isOfficialMaintainer({ github, owner, repo, pr }) {
|
||||
if (MAINTAINER_LOGINS.has(pr.user.login)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (MAINTAINER_ASSOCIATIONS.has(pr.author_association)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner,
|
||||
repo,
|
||||
username: pr.user.login,
|
||||
});
|
||||
|
||||
return MAINTAINER_PERMISSIONS.has(response.data.permission);
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
return false;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureManagedLabels({ github, owner, repo }) {
|
||||
for (const label of Object.values(LABELS)) {
|
||||
try {
|
||||
await github.rest.issues.getLabel({
|
||||
owner,
|
||||
repo,
|
||||
name: label.name,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await github.rest.issues.createLabel({
|
||||
owner,
|
||||
repo,
|
||||
name: label.name,
|
||||
color: label.color,
|
||||
description: label.description,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function syncLabels({ github, owner, repo, issueNumber, desiredLabels }) {
|
||||
const desired = new Set(desiredLabels);
|
||||
|
||||
await ensureManagedLabels({ github, owner, repo });
|
||||
|
||||
for (const labelName of MANAGED_LABEL_NAMES) {
|
||||
if (desired.has(labelName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
name: labelName,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (desired.size > 0) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
labels: [...desired],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function findPolicyComment({ github, owner, repo, issueNumber }) {
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
return comments.find(
|
||||
(comment) =>
|
||||
comment.user.type === "Bot" && comment.body?.includes(COMMENT_MARKER),
|
||||
);
|
||||
}
|
||||
|
||||
async function upsertPolicyComment({ github, owner, repo, issueNumber, body }) {
|
||||
const existingComment = await findPolicyComment({
|
||||
github,
|
||||
owner,
|
||||
repo,
|
||||
issueNumber,
|
||||
});
|
||||
|
||||
if (existingComment == null) {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
body,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.updateComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: existingComment.id,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
async function deletePolicyComment({ github, owner, repo, issueNumber }) {
|
||||
const existingComment = await findPolicyComment({
|
||||
github,
|
||||
owner,
|
||||
repo,
|
||||
issueNumber,
|
||||
});
|
||||
|
||||
if (existingComment == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.deleteComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: existingComment.id,
|
||||
});
|
||||
}
|
||||
|
||||
async function requestMaintainerReview({ github, owner, repo, pr }) {
|
||||
if (pr.user.login === REVIEWER_LOGIN) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await github.rest.pulls.requestReviewers({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
reviewers: [REVIEWER_LOGIN],
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.status === 422) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkPullRequest({
|
||||
github,
|
||||
core,
|
||||
owner,
|
||||
repo,
|
||||
pullNumber,
|
||||
dryRun,
|
||||
minimumAutomaticPullNumber,
|
||||
}) {
|
||||
const response = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullNumber,
|
||||
});
|
||||
const pr = response.data;
|
||||
const issueNumber = pr.number;
|
||||
|
||||
if (pr.user.type === "Bot") {
|
||||
core.notice(
|
||||
`Skipping contribution policy for bot PR #${pr.number} from @${pr.user.login}.`,
|
||||
);
|
||||
return {
|
||||
blocked: false,
|
||||
number: pr.number,
|
||||
summary: summarizeResult({
|
||||
pr,
|
||||
skipped: true,
|
||||
skipReason: `bot @${pr.user.login}`,
|
||||
}),
|
||||
skipped: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
minimumAutomaticPullNumber != null &&
|
||||
pr.number < minimumAutomaticPullNumber
|
||||
) {
|
||||
core.notice(
|
||||
`Skipping contribution policy for PR #${pr.number} because automatic checks start at PR #${minimumAutomaticPullNumber}.`,
|
||||
);
|
||||
return {
|
||||
blocked: false,
|
||||
number: pr.number,
|
||||
summary: summarizeResult({
|
||||
pr,
|
||||
skipped: true,
|
||||
skipReason: `before automatic rollout PR #${minimumAutomaticPullNumber}`,
|
||||
}),
|
||||
skipped: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (pr.draft) {
|
||||
core.notice(`Skipping contribution policy for draft PR #${pr.number}.`);
|
||||
return {
|
||||
blocked: false,
|
||||
number: pr.number,
|
||||
summary: summarizeResult({
|
||||
pr,
|
||||
skipped: true,
|
||||
skipReason: "draft",
|
||||
}),
|
||||
skipped: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (await isOfficialMaintainer({ github, owner, repo, pr })) {
|
||||
core.notice(
|
||||
`Skipping contribution policy for maintainer PR #${pr.number} from @${pr.user.login}.`,
|
||||
);
|
||||
if (!dryRun) {
|
||||
await syncLabels({ github, owner, repo, issueNumber, desiredLabels: [] });
|
||||
await deletePolicyComment({ github, owner, repo, issueNumber });
|
||||
}
|
||||
return {
|
||||
blocked: false,
|
||||
number: pr.number,
|
||||
summary: summarizeResult({
|
||||
pr,
|
||||
skipped: true,
|
||||
skipReason: `maintainer @${pr.user.login}`,
|
||||
}),
|
||||
skipped: true,
|
||||
};
|
||||
}
|
||||
|
||||
const analysis = analyzePullRequest(pr);
|
||||
|
||||
if (dryRun) {
|
||||
const summary = summarizeResult({ pr, analysis });
|
||||
core.notice(
|
||||
`[dry-run] PR #${summary.number}: ${summary.status}; labels: ${summary.labels}; details: ${summary.details}`,
|
||||
);
|
||||
return {
|
||||
blocked: analysis.blockers.length > 0,
|
||||
number: pr.number,
|
||||
summary,
|
||||
skipped: false,
|
||||
};
|
||||
}
|
||||
|
||||
await syncLabels({
|
||||
github,
|
||||
owner,
|
||||
repo,
|
||||
issueNumber,
|
||||
desiredLabels: analysis.desiredLabels,
|
||||
});
|
||||
|
||||
if (analysis.blockers.length > 0) {
|
||||
await upsertPolicyComment({
|
||||
github,
|
||||
owner,
|
||||
repo,
|
||||
issueNumber,
|
||||
body: buildPolicyComment(analysis),
|
||||
});
|
||||
return {
|
||||
blocked: true,
|
||||
number: pr.number,
|
||||
summary: summarizeResult({ pr, analysis }),
|
||||
skipped: false,
|
||||
};
|
||||
}
|
||||
|
||||
await upsertPolicyComment({
|
||||
github,
|
||||
owner,
|
||||
repo,
|
||||
issueNumber,
|
||||
body: buildPolicyComment(analysis),
|
||||
});
|
||||
await requestMaintainerReview({ github, owner, repo, pr });
|
||||
core.notice(`Contribution policy check passed for PR #${pr.number}.`);
|
||||
return {
|
||||
blocked: false,
|
||||
number: pr.number,
|
||||
summary: summarizeResult({ pr, analysis }),
|
||||
skipped: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function listOpenPullRequests({ github, owner, repo }) {
|
||||
return github.paginate(github.rest.pulls.list, {
|
||||
owner,
|
||||
repo,
|
||||
state: "open",
|
||||
per_page: 100,
|
||||
});
|
||||
}
|
||||
|
||||
function getManualPullRequestNumbers({ context, core }) {
|
||||
const value = String(context.payload.inputs?.pr || "all").trim();
|
||||
|
||||
if (value.toLowerCase() === "all") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pullNumber = Number(value);
|
||||
|
||||
if (!Number.isInteger(pullNumber) || pullNumber <= 0) {
|
||||
core.setFailed('The "pr" input must be "all" or a positive PR number.');
|
||||
return [];
|
||||
}
|
||||
|
||||
return [pullNumber];
|
||||
}
|
||||
|
||||
async function run({ github, context, core }) {
|
||||
const { owner, repo } = context.repo;
|
||||
const payloadPr = context.payload.pull_request;
|
||||
const dryRunInput = context.payload.inputs?.dry_run;
|
||||
const dryRun =
|
||||
context.eventName === "workflow_dispatch" &&
|
||||
dryRunInput !== false &&
|
||||
dryRunInput !== "false";
|
||||
const minimumAutomaticPullNumber =
|
||||
payloadPr == null ? null : MIN_AUTOMATIC_PR_NUMBER;
|
||||
let pullNumbers;
|
||||
|
||||
if (payloadPr != null) {
|
||||
pullNumbers = [payloadPr.number];
|
||||
} else {
|
||||
pullNumbers = getManualPullRequestNumbers({ context, core });
|
||||
}
|
||||
|
||||
if (pullNumbers?.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pullRequests =
|
||||
pullNumbers == null
|
||||
? await listOpenPullRequests({ github, owner, repo })
|
||||
: pullNumbers.map((number) => ({ number }));
|
||||
const results = [];
|
||||
|
||||
if (dryRun) {
|
||||
core.notice(
|
||||
`Running contribution policy in dry-run mode for ${
|
||||
pullNumbers == null
|
||||
? "all open PRs"
|
||||
: pullNumbers.map((number) => `#${number}`).join(", ")
|
||||
}.`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const pr of pullRequests) {
|
||||
results.push(
|
||||
await checkPullRequest({
|
||||
github,
|
||||
core,
|
||||
owner,
|
||||
repo,
|
||||
pullNumber: pr.number,
|
||||
dryRun,
|
||||
minimumAutomaticPullNumber,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await core.summary
|
||||
.addHeading(`Contribution Policy ${dryRun ? "Dry Run" : "Results"}`)
|
||||
.addTable([
|
||||
[
|
||||
{ data: "PR", header: true },
|
||||
{ data: "Title", header: true },
|
||||
{ data: "Status", header: true },
|
||||
{ data: "Labels", header: true },
|
||||
{ data: "Details", header: true },
|
||||
{ data: "Comment", header: true },
|
||||
],
|
||||
...results.map((result) => [
|
||||
result.summary.prLink,
|
||||
result.summary.title,
|
||||
result.summary.status,
|
||||
result.summary.labels,
|
||||
result.summary.details,
|
||||
result.summary.comment,
|
||||
]),
|
||||
])
|
||||
.write();
|
||||
|
||||
const blockedPullRequests = results.filter((result) => result.blocked);
|
||||
|
||||
if (blockedPullRequests.length > 0) {
|
||||
if (dryRun) {
|
||||
core.warning(
|
||||
`Dry run found contribution policy failures for PR(s): ${blockedPullRequests
|
||||
.map((result) => `#${result.number}`)
|
||||
.join(", ")}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
core.setFailed(
|
||||
`Contribution policy failed for PR(s): ${blockedPullRequests
|
||||
.map((result) => `#${result.number}`)
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
analyzePullRequest,
|
||||
run,
|
||||
};
|
||||
@@ -1,79 +0,0 @@
|
||||
// Retain only distributable bundle files, never the target tree or signing material.
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
copyFileSync,
|
||||
createReadStream,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
|
||||
const env = process.env;
|
||||
const bundle = join("target", env.RELEASE_TARGET || "", "release", "bundle");
|
||||
const destination = join(env.RUNNER_TEMP, "yaak-release-artifacts");
|
||||
const extensions =
|
||||
/\.(dmg|deb|rpm|AppImage|exe|msi|app\.tar\.gz|AppImage\.tar\.gz|nsis\.zip|msi\.zip)(\.sig)?$/;
|
||||
const files = [];
|
||||
|
||||
function snapshot(directory) {
|
||||
if (!existsSync(directory)) return;
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
const source = join(directory, entry.name);
|
||||
// Only descend through the bundler's immediate output folders. This excludes
|
||||
// .app trees and the extracted CEF deb contents (which contain executables).
|
||||
if (entry.isDirectory() && directory === bundle) {
|
||||
snapshot(source);
|
||||
} else if (
|
||||
entry.isFile() &&
|
||||
(extensions.test(entry.name) || /^yaak-cef_.*_linux_.*\.tar\.gz$/.test(entry.name))
|
||||
) {
|
||||
// Match tauri-action's default macOS updater asset naming exactly.
|
||||
const arch = env.RELEASE_ARCH === "arm64" ? "aarch64" : "x64";
|
||||
const assetName = entry.name.replace(/\.app\.tar\.gz(\.sig)?$/, `_${arch}.app.tar.gz$1`);
|
||||
mkdirSync(destination, { recursive: true });
|
||||
const retained = join(destination, assetName);
|
||||
// The second snapshot must not overwrite the original user installer/signature.
|
||||
if (!existsSync(retained)) copyFileSync(source, retained);
|
||||
files.push({ source: relative(".", source), assetName });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
snapshot(bundle);
|
||||
if (existsSync(destination)) {
|
||||
const assets = readdirSync(destination)
|
||||
.filter((name) => name !== "provenance.json")
|
||||
.sort();
|
||||
const checksums = [];
|
||||
for (const assetName of assets) {
|
||||
const hash = createHash("sha256");
|
||||
for await (const chunk of createReadStream(join(destination, assetName))) hash.update(chunk);
|
||||
checksums.push({ assetName, sha256: hash.digest("hex") });
|
||||
}
|
||||
writeFileSync(
|
||||
join(destination, "provenance.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
repository: env.GITHUB_REPOSITORY,
|
||||
tag: env.GITHUB_REF_NAME,
|
||||
sha: env.GITHUB_SHA,
|
||||
runId: env.GITHUB_RUN_ID,
|
||||
runAttempt: env.GITHUB_RUN_ATTEMPT,
|
||||
workflowRef: env.GITHUB_WORKFLOW_REF,
|
||||
workflowSha: env.GITHUB_WORKFLOW_SHA,
|
||||
tauriAction: "tauri-apps/tauri-action@v0",
|
||||
platform: env.RELEASE_PLATFORM,
|
||||
runtime: env.RELEASE_RUNTIME,
|
||||
arch: env.RELEASE_ARCH,
|
||||
target: env.RELEASE_TARGET,
|
||||
args: env.RELEASE_ARGS,
|
||||
assets: checksums,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
);
|
||||
}
|
||||
console.log(`Found ${files.length} distributable bundle files in ${bundle}`);
|
||||
@@ -14,50 +14,18 @@ jobs:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: voidzero-dev/setup-vp@v1
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: true
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: 'src-tauri'
|
||||
shared-key: ci
|
||||
cache-on-failure: true
|
||||
|
||||
- run: vp install
|
||||
- run: npm run bootstrap
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
- name: Run JS Tests
|
||||
run: vp test
|
||||
run: npm test
|
||||
- name: Run Rust Tests
|
||||
run: cargo test --all --features yaak-app-client/wry
|
||||
- name: OpenAPI import round-trip
|
||||
run: |
|
||||
cargo build -p yaak-cli
|
||||
node plugins/importer-openapi/tests/roundtrip.mjs
|
||||
|
||||
test-windows:
|
||||
name: Rust Tests (Windows)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: voidzero-dev/setup-vp@v1
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: true
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: ci
|
||||
cache-on-failure: true
|
||||
- name: Install Protoc for plugin-runtime
|
||||
uses: arduino/setup-protoc@v3
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- run: vp install
|
||||
- run: npm run bootstrap
|
||||
env:
|
||||
YAAK_TARGET_ARCH: x64
|
||||
- name: Run Rust Tests
|
||||
run: cargo test --all --exclude yaak-cli --features yaak-app-client/wry
|
||||
run: cargo test --all
|
||||
working-directory: src-tauri
|
||||
@@ -1,48 +0,0 @@
|
||||
name: Contribution Policy
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr:
|
||||
description: PR number or all
|
||||
required: true
|
||||
default: all
|
||||
type: string
|
||||
dry_run:
|
||||
description: Dry run
|
||||
required: true
|
||||
default: true
|
||||
type: boolean
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- reopened
|
||||
- synchronize
|
||||
- ready_for_review
|
||||
- labeled
|
||||
- unlabeled
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
check:
|
||||
if: github.repository == 'mountain-loop/yaak'
|
||||
name: Check contribution policy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout policy script
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Check contribution policy
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const { run } = require("./.github/scripts/check-contribution-policy.js");
|
||||
await run({ github, context, core });
|
||||
@@ -1,56 +0,0 @@
|
||||
name: Update Flathub
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Release tag to publish to Flathub
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
update-flathub:
|
||||
if: github.repository == 'mountain-loop/yaak'
|
||||
name: Update Flathub manifest
|
||||
runs-on: ubuntu-latest
|
||||
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: "24"
|
||||
|
||||
- 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 "${{ inputs.tag }}" 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 ${{ inputs.tag }}"
|
||||
git push
|
||||
@@ -1,60 +0,0 @@
|
||||
name: Release API to NPM
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: [yaak-api-*]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: API version to publish (for example 0.9.0 or v0.9.0)
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
publish-npm:
|
||||
if: github.repository == 'mountain-loop/yaak'
|
||||
name: Publish @yaakapp/api
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Set @yaakapp/api version
|
||||
shell: bash
|
||||
env:
|
||||
WORKFLOW_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
VERSION="$WORKFLOW_VERSION"
|
||||
else
|
||||
VERSION="${GITHUB_REF_NAME#yaak-api-}"
|
||||
fi
|
||||
VERSION="${VERSION#v}"
|
||||
echo "Preparing @yaakapp/api version: $VERSION"
|
||||
cd packages/plugin-runtime-types
|
||||
npm version "$VERSION" --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build @yaakapp/api
|
||||
working-directory: packages/plugin-runtime-types
|
||||
run: npm run build
|
||||
|
||||
- name: Publish @yaakapp/api
|
||||
working-directory: packages/plugin-runtime-types
|
||||
run: npm publish --provenance --access public
|
||||
@@ -1,292 +0,0 @@
|
||||
name: Release App Artifacts
|
||||
on:
|
||||
push:
|
||||
tags: [v*]
|
||||
|
||||
jobs:
|
||||
build-artifacts:
|
||||
if: github.repository == 'mountain-loop/yaak'
|
||||
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 --config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}'''
|
||||
yaak_arch: "arm64"
|
||||
os: "macos"
|
||||
runtime: "wry"
|
||||
targets: "aarch64-apple-darwin"
|
||||
- platform: "macos-latest" # for Intel-based Macs.
|
||||
args: '--target x86_64-apple-darwin --config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}'''
|
||||
yaak_arch: "x64"
|
||||
os: "macos"
|
||||
runtime: "wry"
|
||||
targets: "x86_64-apple-darwin"
|
||||
- platform: "ubuntu-22.04"
|
||||
args: '--config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}'''
|
||||
yaak_arch: "x64"
|
||||
os: "ubuntu"
|
||||
runtime: "wry"
|
||||
targets: ""
|
||||
- platform: "ubuntu-22.04-arm"
|
||||
args: '--config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}'''
|
||||
yaak_arch: "arm64"
|
||||
os: "ubuntu"
|
||||
runtime: "wry"
|
||||
targets: ""
|
||||
- platform: "ubuntu-22.04"
|
||||
args: >-
|
||||
--bundles deb
|
||||
--config ./tauri.release.conf.json
|
||||
--config '{"productName":"yaak-cef","mainBinaryName":"yaak-cef","identifier":"app.yaak.desktop.cef","build":{"features":["license","cef"]},"bundle":{"createUpdaterArtifacts":false}}'
|
||||
yaak_arch: "x64"
|
||||
os: "ubuntu"
|
||||
runtime: "cef"
|
||||
targets: ""
|
||||
- platform: "ubuntu-22.04-arm"
|
||||
args: >-
|
||||
--bundles deb
|
||||
--config ./tauri.release.conf.json
|
||||
--config '{"productName":"yaak-cef","mainBinaryName":"yaak-cef","identifier":"app.yaak.desktop.cef","build":{"features":["license","cef"]},"bundle":{"createUpdaterArtifacts":false}}'
|
||||
yaak_arch: "arm64"
|
||||
os: "ubuntu"
|
||||
runtime: "cef"
|
||||
targets: ""
|
||||
- platform: "windows-latest"
|
||||
args: '--config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}'''
|
||||
yaak_arch: "x64"
|
||||
os: "windows"
|
||||
runtime: "wry"
|
||||
targets: ""
|
||||
# Windows ARM64
|
||||
- platform: "windows-latest"
|
||||
args: '--target aarch64-pc-windows-msvc --config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}'''
|
||||
yaak_arch: "arm64"
|
||||
os: "windows"
|
||||
runtime: "wry"
|
||||
targets: "aarch64-pc-windows-msvc"
|
||||
runs-on: ${{ matrix.platform }}
|
||||
timeout-minutes: 360
|
||||
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: Cache CEF (Linux only)
|
||||
if: matrix.os == 'ubuntu' && matrix.runtime == 'cef'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/tauri-cef
|
||||
key: cef-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('Cargo.lock') }}
|
||||
|
||||
- name: install dependencies (Linux only)
|
||||
if: matrix.os == 'ubuntu'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y cmake ninja-build libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libnss3 patchelf xdg-utils
|
||||
# crates/yaak-wasm compiles SQLite to wasm via sqlite-wasm-rs, whose C shim
|
||||
# uses C23 [[noreturn]] and expects a freestanding wasm32 target. Ubuntu
|
||||
# 22.04 ships only clang <=15: 14 rejects the attribute, and 15 falls
|
||||
# through to host glibc headers ("bits/libc-header-start.h" not found).
|
||||
# clang-18 handles it (it is what ubuntu-24.04 uses). Install it from
|
||||
# apt.llvm.org since 22.04's repos stop at 15. Only the wasm build uses
|
||||
# this compiler, so the shipped binary keeps 22.04's glibc floor.
|
||||
wget -qO /tmp/llvm.sh https://apt.llvm.org/llvm.sh
|
||||
chmod +x /tmp/llvm.sh
|
||||
sudo /tmp/llvm.sh 18
|
||||
echo "CC_wasm32_unknown_unknown=/usr/bin/clang-18" >> "$GITHUB_ENV"
|
||||
echo "AR_wasm32_unknown_unknown=/usr/bin/llvm-ar-18" >> "$GITHUB_ENV"
|
||||
|
||||
- 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 --features yaak-app-client/wry
|
||||
|
||||
- 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
|
||||
|
||||
- name: Build and upload Tauri bundles
|
||||
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:
|
||||
# Also retries builds; upload retries alone cannot clean up starter assets.
|
||||
retryAttempts: 3
|
||||
tauriScript: "node ../../node_modules/@tauri-apps/cli/tauri.js"
|
||||
tagName: "v__VERSION__"
|
||||
releaseName: "Release __VERSION__"
|
||||
releaseBody: "<!-- generated-by-yaak-releases -->"
|
||||
releaseDraft: true
|
||||
prerelease: true
|
||||
projectPath: ./crates-tauri/yaak-app-client
|
||||
args: "${{ matrix.args }}"
|
||||
|
||||
# Snapshot before the per-machine build overwrites the regular NSIS bundle/signature.
|
||||
- name: Snapshot Tauri bundles
|
||||
if: ${{ !cancelled() }}
|
||||
run: node .github/scripts/preserve-release-artifacts.mjs
|
||||
env:
|
||||
RELEASE_TARGET: ${{ matrix.targets }}
|
||||
RELEASE_PLATFORM: ${{ matrix.platform }}
|
||||
RELEASE_RUNTIME: ${{ matrix.runtime }}
|
||||
RELEASE_ARCH: ${{ matrix.yaak_arch }}
|
||||
RELEASE_ARGS: ${{ matrix.args }}
|
||||
|
||||
- name: Build and upload CEF tarball from deb (Linux only)
|
||||
if: matrix.os == 'ubuntu' && matrix.runtime == 'cef'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
deb=$(find target/release/bundle/deb -maxdepth 1 -type f -name '*.deb' | head -n 1)
|
||||
version="${GITHUB_REF_NAME#v}"
|
||||
extract_dir="target/release/bundle/deb/yaak-cef-linux-${{ matrix.yaak_arch }}"
|
||||
tarball="target/release/bundle/deb/yaak-cef_${version}_linux_${{ matrix.yaak_arch }}.tar.gz"
|
||||
rm -rf "$extract_dir"
|
||||
mkdir -p "$extract_dir"
|
||||
dpkg-deb -x "$deb" "$extract_dir"
|
||||
tar -C "$extract_dir" -czf "$tarball" .
|
||||
for attempt in 1 2 3 4; do
|
||||
if gh release upload "$GITHUB_REF_NAME" "$tarball" --clobber; then
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -eq 4 ]; then exit 1; fi
|
||||
sleep 10
|
||||
done
|
||||
|
||||
# Build a per-machine NSIS installer for enterprise deployment (PDQ, SCCM, Intune)
|
||||
- name: Build and upload machine-wide installer (Windows only)
|
||||
if: matrix.os == 'windows'
|
||||
shell: pwsh
|
||||
env:
|
||||
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
run: |
|
||||
Get-ChildItem -Recurse -Path target -File -Filter "*.exe.sig" | Remove-Item -Force
|
||||
Push-Location crates-tauri/yaak-app-client
|
||||
npx tauri bundle ${{ matrix.args }} --bundles nsis --config '{"bundle":{"createUpdaterArtifacts":true,"windows":{"nsis":{"installMode":"perMachine"}}}}'
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
Pop-Location
|
||||
$setup = Get-ChildItem -Recurse -Path target -Filter "*setup*.exe" | Select-Object -First 1
|
||||
$setupSig = "$($setup.FullName).sig"
|
||||
$dest = $setup.FullName -replace '-setup\.exe$', '-setup-machine.exe'
|
||||
$destSig = "$dest.sig"
|
||||
Copy-Item $setup.FullName $dest
|
||||
Copy-Item $setupSig $destSig
|
||||
foreach ($asset in @($dest, $destSig)) {
|
||||
for ($attempt = 1; $attempt -le 4; $attempt++) {
|
||||
gh release upload "$env:GITHUB_REF_NAME" "$asset" --clobber
|
||||
if ($LASTEXITCODE -eq 0) { break }
|
||||
if ($attempt -eq 4) { exit $LASTEXITCODE }
|
||||
Start-Sleep -Seconds 10
|
||||
}
|
||||
}
|
||||
|
||||
- name: Snapshot additional release bundles
|
||||
if: ${{ !cancelled() }}
|
||||
run: node .github/scripts/preserve-release-artifacts.mjs
|
||||
env:
|
||||
RELEASE_TARGET: ${{ matrix.targets }}
|
||||
RELEASE_PLATFORM: ${{ matrix.platform }}
|
||||
RELEASE_RUNTIME: ${{ matrix.runtime }}
|
||||
RELEASE_ARCH: ${{ matrix.yaak_arch }}
|
||||
RELEASE_ARGS: ${{ matrix.args }}
|
||||
|
||||
- name: Retain release bundles for recovery
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-${{ github.ref_name }}-${{ matrix.platform }}-${{ matrix.runtime }}-${{ matrix.yaak_arch }}-${{ github.run_id }}-attempt-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/yaak-release-artifacts/
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
@@ -1,219 +0,0 @@
|
||||
name: Release CLI to NPM
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: [v*]
|
||||
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:
|
||||
if: github.repository == 'mountain-loop/yaak'
|
||||
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}"
|
||||
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}"
|
||||
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
|
||||
@@ -1,123 +0,0 @@
|
||||
name: Release web image
|
||||
|
||||
# Builds ghcr.io/mountain-loop/yaak-web: the browser client and the server that serves it.
|
||||
# One image per architecture on its own native runner (emulating a Rust release build is hours),
|
||||
# joined into one multi-arch tag at the end.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: [v*]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: Version to publish, without the v (e.g. 2026.2.0). Empty publishes main and sha tags only.
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
IMAGE: ghcr.io/mountain-loop/yaak-web
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: github.repository == 'mountain-loop/yaak'
|
||||
name: Build ${{ matrix.platform }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-22.04
|
||||
arch: amd64
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-22.04-arm
|
||||
arch: arm64
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.web
|
||||
platforms: ${{ matrix.platform }}
|
||||
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p "${{ runner.temp }}/digests"
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digest-${{ matrix.arch }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
publish:
|
||||
name: Publish manifest
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digest-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# `latest` follows a release tag, and a manual run that names a version — the way to
|
||||
# publish before the first release. A prerelease (v2026.2.1-beta.1) never takes it.
|
||||
- name: Tags
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGE }}
|
||||
flavor: latest=false
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=raw,value=${{ inputs.version }},enable=${{ inputs.version != '' }}
|
||||
type=raw,value=latest,enable=${{ inputs.version != '' || (github.event_name == 'push' && !contains(github.ref_name, '-')) }}
|
||||
type=ref,event=branch
|
||||
type=sha,format=short
|
||||
|
||||
- name: Create and push the manifest
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.IMAGE }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect
|
||||
run: docker buildx imagetools inspect ${{ env.IMAGE }}:${{ steps.meta.outputs.version }}
|
||||
@@ -0,0 +1,131 @@
|
||||
name: Generate Artifacts
|
||||
on:
|
||||
push:
|
||||
tags: [ v* ]
|
||||
|
||||
jobs:
|
||||
build-artifacts:
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
name: Build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: 'macos-latest' # for Arm-based Macs (M1 and above).
|
||||
args: '--target aarch64-apple-darwin'
|
||||
yaak_arch: 'arm64'
|
||||
os: 'macos'
|
||||
targets: 'aarch64-apple-darwin'
|
||||
- platform: 'macos-latest' # for Intel-based Macs.
|
||||
args: '--target x86_64-apple-darwin'
|
||||
yaak_arch: 'x64'
|
||||
os: 'macos'
|
||||
targets: 'x86_64-apple-darwin'
|
||||
- platform: 'ubuntu-22.04'
|
||||
args: ''
|
||||
yaak_arch: 'x64'
|
||||
os: 'ubuntu'
|
||||
targets: ''
|
||||
- platform: 'ubuntu-22.04-arm'
|
||||
args: ''
|
||||
yaak_arch: 'arm64'
|
||||
os: 'ubuntu'
|
||||
targets: ''
|
||||
- platform: 'windows-latest'
|
||||
args: ''
|
||||
yaak_arch: 'x64'
|
||||
os: 'windows'
|
||||
targets: ''
|
||||
# Windows ARM64
|
||||
- platform: 'windows-latest'
|
||||
args: '--target aarch64-pc-windows-msvc'
|
||||
yaak_arch: 'arm64'
|
||||
os: 'windows'
|
||||
targets: 'aarch64-pc-windows-msvc'
|
||||
runs-on: ${{ matrix.platform }}
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- name: Checkout yaakapp/app
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.targets }}
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: 'src-tauri'
|
||||
shared-key: ci
|
||||
cache-on-failure: true
|
||||
|
||||
- name: install dependencies (Linux only)
|
||||
if: matrix.os == 'ubuntu'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
|
||||
|
||||
- name: Install Protoc for plugin-runtime
|
||||
uses: arduino/setup-protoc@v3
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install trusted-signing-cli (Windows only)
|
||||
if: matrix.os == 'windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$dir = "$env:USERPROFILE\trusted-signing"
|
||||
New-Item -ItemType Directory -Force -Path $dir | Out-Null
|
||||
$url = "https://github.com/Levminer/trusted-signing-cli/releases/download/0.8.0/trusted-signing-cli.exe"
|
||||
$exe = Join-Path $dir "trusted-signing-cli.exe"
|
||||
Invoke-WebRequest -Uri $url -OutFile $exe
|
||||
echo $dir >> $env:GITHUB_PATH
|
||||
& $exe --version
|
||||
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
- name: Run JS Tests
|
||||
run: npm test
|
||||
- name: Run Rust Tests
|
||||
run: cargo test --all
|
||||
working-directory: src-tauri
|
||||
|
||||
- name: Set version
|
||||
run: npm run replace-version
|
||||
env:
|
||||
YAAK_VERSION: ${{ github.ref_name }}
|
||||
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
|
||||
|
||||
ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
|
||||
# Apple signing stuff
|
||||
APPLE_CERTIFICATE: ${{ matrix.os == 'macos' && secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ matrix.os == 'macos' && secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_ID: ${{ matrix.os == 'macos' && secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ matrix.os == 'macos' && secrets.APPLE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ matrix.os == 'macos' && secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_TEAM_ID: ${{ matrix.os == 'macos' && secrets.APPLE_TEAM_ID }}
|
||||
|
||||
# Windows signing stuff
|
||||
AZURE_CLIENT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_TENANT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_TENANT_ID }}
|
||||
with:
|
||||
tagName: 'v__VERSION__'
|
||||
releaseName: 'Release __VERSION__'
|
||||
releaseBody: '[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)'
|
||||
releaseDraft: true
|
||||
prerelease: true
|
||||
args: '${{ matrix.args }} --config ./src-tauri/tauri.release.conf.json'
|
||||
@@ -7,7 +7,6 @@ permissions:
|
||||
contents: write
|
||||
jobs:
|
||||
deploy:
|
||||
if: github.repository == 'mountain-loop/yaak'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout 🛎️
|
||||
@@ -17,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.
|
||||
@@ -42,4 +41,4 @@ jobs:
|
||||
with:
|
||||
branch: main
|
||||
force: false
|
||||
folder: "."
|
||||
folder: '.'
|
||||
-26
@@ -36,29 +36,3 @@ out
|
||||
tmp
|
||||
.zed
|
||||
codebook.toml
|
||||
target
|
||||
|
||||
# Per-worktree Tauri config (generated by post-checkout hook)
|
||||
crates-tauri/yaak-app-client/tauri.worktree.conf.json
|
||||
crates-tauri/yaak-app-proxy/tauri.worktree.conf.json
|
||||
|
||||
# Tauri auto-generated permission files
|
||||
**/permissions/autogenerated
|
||||
**/permissions/schemas
|
||||
|
||||
# Flatpak build artifacts
|
||||
flatpak-repo/
|
||||
.flatpak-builder/
|
||||
flatpak/flatpak-builder-tools/
|
||||
flatpak/cargo-sources.json
|
||||
flatpak/node-sources.json
|
||||
|
||||
# Local Codex desktop env state
|
||||
.codex/environments/environment.toml
|
||||
|
||||
# Claude Code local settings
|
||||
.claude/settings.local.json
|
||||
.claude/worktrees/
|
||||
|
||||
# Headless screenshots taken while working
|
||||
.shots/
|
||||
@@ -1 +0,0 @@
|
||||
24.14.0
|
||||
@@ -1,2 +0,0 @@
|
||||
# vite-plugin-wasm has not yet declared Vite 8 in its peerDependencies
|
||||
legacy-peer-deps=true
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"ignorePatterns": [
|
||||
"**/bindings/**",
|
||||
"crates/yaak-templates/pkg/**",
|
||||
"apps/yaak-client/routeTree.gen.ts",
|
||||
"plugins/importer-bruno/tests/fixtures/upstream/**"
|
||||
]
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
node scripts/git-hooks/post-checkout.mjs "$@"
|
||||
@@ -1,2 +0,0 @@
|
||||
vp lint
|
||||
vp staged
|
||||
Vendored
+1
-5
@@ -1,7 +1,3 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"rust-lang.rust-analyzer",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"VoidZero.vite-plus-extension-pack"
|
||||
]
|
||||
"recommendations": ["biomejs.biome", "rust-lang.rust-analyzer", "bradlc.vscode-tailwindcss"]
|
||||
}
|
||||
Vendored
+3
-5
@@ -1,8 +1,6 @@
|
||||
{
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode",
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.formatOnSaveMode": "file",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.oxc": "explicit"
|
||||
}
|
||||
"biome.enabled": true,
|
||||
"biome.lint.format.enable": true
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
- Tag safety: app AND CLI releases both ship from `v*` tags (the CLI is version-locked to the app and publishes to npm on every app tag); `@yaakapp/api` uses `yaak-api-*` tags. Always confirm which is requested before retagging.
|
||||
- Do not commit, push, or tag without explicit approval
|
||||
@@ -1,15 +0,0 @@
|
||||
# Contributing to Yaak
|
||||
|
||||
Yaak accepts community pull requests for:
|
||||
|
||||
- Bug fixes
|
||||
|
||||
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, include a link to the [feedback item](https://yaak.app/feedback) where @gschier explicitly gave you permission to work on it.
|
||||
|
||||
## Development Setup
|
||||
|
||||
For local setup and development workflows, see [`DEVELOPMENT.md`](DEVELOPMENT.md).
|
||||
-126
@@ -1,126 +0,0 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/yaak",
|
||||
"crates/yaak-commands",
|
||||
# Common/foundation crates
|
||||
"crates/common/yaak-database",
|
||||
"crates/common/yaak-rpc",
|
||||
"crates/common/yaak-rpc-schema",
|
||||
# Shared crates (no Tauri dependency)
|
||||
"crates/yaak-core",
|
||||
"crates/yaak-common",
|
||||
"crates/yaak-crypto",
|
||||
"crates/yaak-filter",
|
||||
"crates/yaak-git",
|
||||
"crates/yaak-grpc",
|
||||
"crates/yaak-http",
|
||||
"crates/yaak-lifecycle",
|
||||
"crates/yaak-mcp",
|
||||
"crates/yaak-models",
|
||||
"crates/yaak-plugins",
|
||||
"crates/yaak-query",
|
||||
"crates/yaak-sse",
|
||||
"crates/yaak-sync",
|
||||
"crates/yaak-templates",
|
||||
"crates/yaak-tls",
|
||||
"crates/yaak-ws",
|
||||
"crates/yaak-wasm",
|
||||
"crates/yaak-api",
|
||||
"crates/yaak-proxy",
|
||||
# Proxy-specific crates
|
||||
"crates-proxy/yaak-proxy-lib",
|
||||
# Server crates (the browser tier's hosted send executor)
|
||||
"crates-server/yaak-web",
|
||||
"crates-server/yaak-playground",
|
||||
# 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-proxy-helper",
|
||||
"crates-tauri/yaak-system-appearance",
|
||||
"crates-tauri/yaak-tauri-utils",
|
||||
"crates-tauri/yaak-window",
|
||||
]
|
||||
|
||||
[workspace.dependencies]
|
||||
chrono = "0.4.42"
|
||||
hex = "0.4.3"
|
||||
keyring = "3.6.3"
|
||||
log = "0.4.29"
|
||||
reqwest = { version = "0.13.5", default-features = false, features = [
|
||||
# reqwest 0.13 switched `default-tls` from native-tls to rustls; keep the
|
||||
# rest of the old default set and stay on native-tls.
|
||||
"charset",
|
||||
"http2",
|
||||
"system-proxy",
|
||||
"native-tls",
|
||||
] }
|
||||
rustls = { version = "0.23.34", default-features = false }
|
||||
rustls-platform-verifier = "0.7.1"
|
||||
schemars = { version = "0.8.22", features = ["chrono"] }
|
||||
serde = "1.0.228"
|
||||
serde_json = "1.0.145"
|
||||
sha2 = "0.10.9"
|
||||
tauri = { version = "2.11.1", default-features = false, features = [
|
||||
"common-controls-v6",
|
||||
"compression",
|
||||
"dynamic-acl",
|
||||
] }
|
||||
tauri-plugin = "2.6.1"
|
||||
tauri-plugin-dialog = { version = "2.7.1", default-features = false }
|
||||
tauri-plugin-shell = "2.3.5"
|
||||
thiserror = "2.0.17"
|
||||
tokio = "1.48.0"
|
||||
ts-rs = "11.1.0"
|
||||
|
||||
# Internal crates - common/foundation
|
||||
yaak-database = { path = "crates/common/yaak-database" }
|
||||
yaak-rpc = { path = "crates/common/yaak-rpc" }
|
||||
yaak-rpc-schema = { path = "crates/common/yaak-rpc-schema" }
|
||||
|
||||
# Internal crates - shared
|
||||
yaak-core = { path = "crates/yaak-core" }
|
||||
yaak = { path = "crates/yaak" }
|
||||
yaak-commands = { path = "crates/yaak-commands" }
|
||||
yaak-common = { path = "crates/yaak-common" }
|
||||
yaak-crypto = { path = "crates/yaak-crypto" }
|
||||
yaak-filter = { path = "crates/yaak-filter" }
|
||||
yaak-git = { path = "crates/yaak-git" }
|
||||
yaak-grpc = { path = "crates/yaak-grpc" }
|
||||
yaak-http = { path = "crates/yaak-http" }
|
||||
yaak-lifecycle = { path = "crates/yaak-lifecycle" }
|
||||
yaak-mcp = { path = "crates/yaak-mcp" }
|
||||
yaak-models = { path = "crates/yaak-models" }
|
||||
yaak-plugins = { path = "crates/yaak-plugins" }
|
||||
yaak-query = { path = "crates/yaak-query" }
|
||||
yaak-sse = { path = "crates/yaak-sse" }
|
||||
yaak-sync = { path = "crates/yaak-sync" }
|
||||
yaak-templates = { path = "crates/yaak-templates" }
|
||||
yaak-tls = { path = "crates/yaak-tls" }
|
||||
yaak-ws = { path = "crates/yaak-ws" }
|
||||
yaak-api = { path = "crates/yaak-api" }
|
||||
yaak-proxy = { path = "crates/yaak-proxy" }
|
||||
|
||||
# Internal crates - proxy
|
||||
yaak-proxy-lib = { path = "crates-proxy/yaak-proxy-lib" }
|
||||
|
||||
# Internal crates - Tauri-specific
|
||||
yaak-fonts = { path = "crates-tauri/yaak-fonts" }
|
||||
yaak-license = { path = "crates-tauri/yaak-license" }
|
||||
yaak-mac-window = { path = "crates-tauri/yaak-mac-window" }
|
||||
yaak-proxy-helper = { path = "crates-tauri/yaak-proxy-helper" }
|
||||
yaak-system-appearance = { path = "crates-tauri/yaak-system-appearance" }
|
||||
yaak-tauri-utils = { path = "crates-tauri/yaak-tauri-utils" }
|
||||
yaak-window = { path = "crates-tauri/yaak-window" }
|
||||
|
||||
[profile.release]
|
||||
strip = false
|
||||
|
||||
[patch.crates-io]
|
||||
tauri = { git = "https://github.com/tauri-apps/tauri", rev = "d9bc695c18d9a25baec21d8a5f36d72e3a14ee53" }
|
||||
tauri-build = { git = "https://github.com/tauri-apps/tauri", rev = "d9bc695c18d9a25baec21d8a5f36d72e3a14ee53" }
|
||||
+15
-32
@@ -1,26 +1,24 @@
|
||||
# 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) (v24+)
|
||||
- [Node.js](https://nodejs.org/en/download/package-manager)
|
||||
- [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
|
||||
```
|
||||
|
||||
@@ -44,34 +42,15 @@ After bootstrapping, start the app in development mode:
|
||||
npm start
|
||||
```
|
||||
|
||||
## Run the App in a Browser
|
||||
|
||||
The client can also run as a plain web page, with no Tauri and no local process
|
||||
behind it. Set `YAAK_TARGET=web` and start the frontend on its own:
|
||||
|
||||
```shell
|
||||
YAAK_TARGET=web npm run dev --workspace @yaakapp/yaak-client
|
||||
```
|
||||
|
||||
That flag picks the browser host in `packages/platform/src/web/`, which answers
|
||||
commands from an IndexedDB database the page owns instead of from the Rust
|
||||
engine. Data persists across reloads and is shared between tabs on the same
|
||||
origin. Sending HTTP is not available yet — the Send button reports that and
|
||||
everything else about the request is still saved. `packages/platform/src/web/README.md`
|
||||
lists which commands the browser host implements and which it declines.
|
||||
|
||||
Desktop builds are unaffected: without the flag the platform package installs
|
||||
the Tauri host exactly as before.
|
||||
|
||||
## 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._
|
||||
|
||||
@@ -82,9 +61,9 @@ _Note: For safety, development builds use a separate database location from prod
|
||||
lezer-generator components/core/Editor/<LANG>/<LANG>.grammar > components/core/Editor/<LANG>/<LANG>.ts
|
||||
```
|
||||
|
||||
## Linting and Formatting
|
||||
## Linting & Formatting
|
||||
|
||||
This repo uses [Vite+](https://vite.dev/guide/vite-plus) for linting (oxlint) and formatting (oxfmt).
|
||||
This repo uses Biome for linting and formatting (replacing ESLint + Prettier).
|
||||
|
||||
- Lint the entire repo:
|
||||
|
||||
@@ -92,6 +71,12 @@ This repo uses [Vite+](https://vite.dev/guide/vite-plus) for linting (oxlint) an
|
||||
npm run lint
|
||||
```
|
||||
|
||||
- Auto-fix lint issues where possible:
|
||||
|
||||
```sh
|
||||
npm run lint:fix
|
||||
```
|
||||
|
||||
- Format code:
|
||||
|
||||
```sh
|
||||
@@ -99,7 +84,5 @@ npm run format
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- A pre-commit hook runs `vp lint` automatically on commit.
|
||||
- Some workspace packages also run `tsc --noEmit` for type-checking.
|
||||
- VS Code users should install the recommended extensions for format-on-save support.
|
||||
- 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.
|
||||
@@ -1,15 +0,0 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# The playground API behind yaak.run. See crates-server/yaak-playground/README.md.
|
||||
|
||||
FROM rust:1-bookworm AS build
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN cargo build --release -p yaak-playground
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
COPY --from=build /app/target/release/yaak-playground /usr/local/bin/yaak-playground
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=8080
|
||||
EXPOSE 8080
|
||||
USER nobody
|
||||
CMD ["yaak-playground"]
|
||||
@@ -1,49 +0,0 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# Yaak in a browser, whole: the web client and the server that executes its sends, in one
|
||||
# image serving both from one origin.
|
||||
#
|
||||
# docker run -p 8080:8080 ghcr.io/mountain-loop/yaak-web
|
||||
#
|
||||
# See crates-server/yaak-web/README.md for the knobs.
|
||||
|
||||
FROM node:22-slim AS web
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git python3 make g++ ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
COPY . .
|
||||
# `npm ci` runs a prepare hook (`vp config`) that shells out to git, and there is no .git in
|
||||
# the build context — it is ignored, and in a worktree it is a pointer file anyway.
|
||||
RUN git init -q && git add -A \
|
||||
&& git -c user.email=build@yaak.app -c user.name=build commit -qm build
|
||||
# Empty means the tab posts sends to its own origin, which is what this image serves. Set it
|
||||
# only to build a bundle for a deployment whose server lives somewhere else.
|
||||
ARG VITE_YAAK_WEB_URL=""
|
||||
ENV VITE_YAAK_WEB_URL=$VITE_YAAK_WEB_URL
|
||||
ENV YAAK_TARGET=web
|
||||
# crates/yaak-wasm's wasm package is committed; rebuilding it needs a clang with a WebAssembly
|
||||
# backend, which this image has no reason to carry.
|
||||
ENV SKIP_WASM_BUILD=1
|
||||
RUN npm ci
|
||||
RUN node_modules/.bin/vp -C apps/yaak-client build
|
||||
|
||||
FROM rust:1-bookworm AS server
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
pkg-config libssl-dev protobuf-compiler && rm -rf /var/lib/apt/lists/*
|
||||
COPY . .
|
||||
RUN cargo build --release -p yaak-web
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates libssl3 && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=server /app/target/release/yaak-web /usr/local/bin/yaak-web
|
||||
COPY --from=web /app/dist/apps/yaak-client /srv
|
||||
# 0.0.0.0 so the port is reachable from outside the container. PORT is the variable a
|
||||
# platform-as-a-service assigns, so a deploy that sets it needs nothing else changed.
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=8080
|
||||
EXPOSE 8080
|
||||
USER nobody
|
||||
# Overriding the command (dropping --serve) leaves the stateless send executor:
|
||||
# docker run ghcr.io/mountain-loop/yaak-web yaak-web
|
||||
CMD ["yaak-web", "--serve", "/srv"]
|
||||
@@ -1,6 +1,6 @@
|
||||
<p align="center">
|
||||
<a href="https://github.com/JamesIves/github-sponsors-readme-action">
|
||||
<img width="200px" src="https://github.com/mountain-loop/yaak/raw/main/crates-tauri/yaak-app-client/icons/icon.png">
|
||||
<img width="200px" src="https://github.com/mountain-loop/yaak/raw/main/src-tauri/icons/icon.png">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -16,23 +16,23 @@
|
||||
</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/rauchg"><img src="https://github.com/rauchg.png" width="80px" alt="User avatar: rauchg" /></a> <a href="https://github.com/"><img src="https://raw.githubusercontent.com/JamesIves/github-sponsors-readme-action/dev/.github/assets/placeholder.png" width="80px" alt="User avatar: " /></a> <!-- sponsors-premium -->
|
||||
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https://github.com/MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a> <a href="https://github.com/dharsanb"><img src="https://github.com/dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a> <a href="https://github.com/railwayapp"><img src="https://github.com/railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a> <a href="https://github.com/caseyamcl"><img src="https://github.com/caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a> <a href="https://github.com/"><img src="https://raw.githubusercontent.com/JamesIves/github-sponsors-readme-action/dev/.github/assets/placeholder.png" width="80px" alt="User avatar: " /></a> <!-- sponsors-premium -->
|
||||
</p>
|
||||
<p align="center">
|
||||
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https://github.com/seanwash.png" width="50px" alt="User avatar: seanwash" /></a> <a href="https://github.com/jerath"><img src="https://github.com/jerath.png" width="50px" alt="User avatar: jerath" /></a> <a href="https://github.com/itsa-sh"><img src="https://github.com/itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a> <a href="https://github.com/dmmulroy"><img src="https://github.com/dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a> <a href="https://github.com/timcole"><img src="https://github.com/timcole.png" width="50px" alt="User avatar: timcole" /></a> <a href="https://github.com/VLZH"><img src="https://github.com/VLZH.png" width="50px" alt="User avatar: VLZH" /></a> <a href="https://github.com/terasaka2k"><img src="https://github.com/terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a> <a href="https://github.com/andriyor"><img src="https://github.com/andriyor.png" width="50px" alt="User avatar: andriyor" /></a> <a href="https://github.com/majudhu"><img src="https://github.com/majudhu.png" width="50px" alt="User avatar: majudhu" /></a> <a href="https://github.com/axelrindle"><img src="https://github.com/axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a> <a href="https://github.com/jirizverina"><img src="https://github.com/jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a> <a href="https://github.com/chip-well"><img src="https://github.com/chip-well.png" width="50px" alt="User avatar: chip-well" /></a> <a href="https://github.com/GRAYAH"><img src="https://github.com/GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a> <a href="https://github.com/flashblaze"><img src="https://github.com/flashblaze.png" width="50px" alt="User avatar: flashblaze" /></a> <a href="https://github.com/Frostist"><img src="https://github.com/Frostist.png" width="50px" alt="User avatar: Frostist" /></a> <a href="https://github.com/PurplProto"><img src="https://github.com/PurplProto.png" width="50px" alt="User avatar: PurplProto" /></a> <!-- sponsors-base -->
|
||||
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https://github.com/seanwash.png" width="50px" alt="User avatar: seanwash" /></a> <a href="https://github.com/jerath"><img src="https://github.com/jerath.png" width="50px" alt="User avatar: jerath" /></a> <a href="https://github.com/itsa-sh"><img src="https://github.com/itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a> <a href="https://github.com/dmmulroy"><img src="https://github.com/dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a> <a href="https://github.com/timcole"><img src="https://github.com/timcole.png" width="50px" alt="User avatar: timcole" /></a> <a href="https://github.com/VLZH"><img src="https://github.com/VLZH.png" width="50px" alt="User avatar: VLZH" /></a> <a href="https://github.com/terasaka2k"><img src="https://github.com/terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a> <a href="https://github.com/andriyor"><img src="https://github.com/andriyor.png" width="50px" alt="User avatar: andriyor" /></a> <a href="https://github.com/majudhu"><img src="https://github.com/majudhu.png" width="50px" alt="User avatar: majudhu" /></a> <a href="https://github.com/axelrindle"><img src="https://github.com/axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a> <a href="https://github.com/jirizverina"><img src="https://github.com/jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a> <a href="https://github.com/chip-well"><img src="https://github.com/chip-well.png" width="50px" alt="User avatar: chip-well" /></a> <a href="https://github.com/GRAYAH"><img src="https://github.com/GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a> <!-- sponsors-base -->
|
||||
</p>
|
||||
|
||||

|
||||
|
||||
## Install
|
||||
|
||||
Download Yaak for macOS, Windows, or Linux from [yaak.app/download](https://yaak.app/download). Package managers and other install methods are in the [installation docs](https://yaak.app/docs/getting-started/installation).
|
||||
|
||||
## 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,40 +41,30 @@ 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
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Community PRs are currently limited to bug fixes.
|
||||
> If your PR is not a bug fix, link the [feedback item](https://yaak.app/feedback) where @gschier explicitly gave you permission to work on it.
|
||||
> See [`CONTRIBUTING.md`](CONTRIBUTING.md) for policy details and [`DEVELOPMENT.md`](DEVELOPMENT.md) for local setup.
|
||||
Yaak is open source but only accepting contributions for bug fixes. To get started,
|
||||
visit [`DEVELOPMENT.md`](DEVELOPMENT.md) for tips on setting up your environment.
|
||||
|
||||
## Useful Resources
|
||||
|
||||
- [Feedback and Bug Reports](https://feedback.yaak.app)
|
||||
- [Documentation](https://yaak.app/docs)
|
||||
- [Documentation](https://feedback.yaak.app/help)
|
||||
- [Yaak vs Postman](https://yaak.app/alternatives/postman)
|
||||
- [Yaak vs Bruno](https://yaak.app/alternatives/bruno)
|
||||
- [Yaak vs Insomnia](https://yaak.app/alternatives/insomnia)
|
||||
|
||||
## License
|
||||
|
||||
The source is [MIT licensed](LICENSE) and can be built and run for free, for both personal and commercial use.
|
||||
|
||||
The prebuilt binaries follow Yaak's [pricing terms](https://yaak.app/pricing): free for personal use, license required for commercial use.
|
||||
@@ -1,148 +0,0 @@
|
||||
import type { FunctionComponent, ReactElement } from "react";
|
||||
import { beforeEach, expect, test, vi } from "vite-plus/test";
|
||||
import type { SyncOp } from "@yaakapp-internal/sync/bindings/gen_sync";
|
||||
import type { DialogInstance } from "../components/Dialogs";
|
||||
import { syncWorkspace } from "./commands";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
calculateSync: vi.fn(),
|
||||
applySync: vi.fn(),
|
||||
showDialog: vi.fn<(dialog: DialogInstance) => void>(),
|
||||
hideDialog: vi.fn(),
|
||||
showToast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@yaakapp-internal/sync", () => mocks);
|
||||
vi.mock("@yaakapp-internal/models", () => ({
|
||||
createWorkspaceModel: vi.fn(),
|
||||
modelTypeLabel: () => "Request",
|
||||
}));
|
||||
vi.mock("@yaakapp-internal/ui", () => ({
|
||||
Banner: "div",
|
||||
InlineCode: "code",
|
||||
Table: "table",
|
||||
TableBody: "tbody",
|
||||
TableCell: "td",
|
||||
TableHead: "thead",
|
||||
TableHeaderCell: "th",
|
||||
TableRow: "tr",
|
||||
TruncatedWideTableCell: "td",
|
||||
}));
|
||||
vi.mock("../components/core/Button", () => ({ Button: "button" }));
|
||||
vi.mock("../hooks/useActiveWorkspace", () => ({ activeWorkspaceIdAtom: null }));
|
||||
vi.mock("../lib/dialog", () => mocks);
|
||||
vi.mock("../lib/jotai", () => ({ jotaiStore: { get: vi.fn() } }));
|
||||
vi.mock("../lib/prompt", () => ({ showPrompt: vi.fn() }));
|
||||
vi.mock("../lib/resolvedModelName", () => ({ resolvedModelNameWithFolders: () => "Request" }));
|
||||
vi.mock("../lib/toast", () => mocks);
|
||||
|
||||
const staleToast = expect.objectContaining({
|
||||
message: "Files changed while syncing. Review the changes again.",
|
||||
});
|
||||
|
||||
const params = { workspaceId: "wk_test", syncDir: "/sync" };
|
||||
const deletion = {
|
||||
type: "dbDelete",
|
||||
model: { id: "rq_test", model: "http_request" },
|
||||
state: {},
|
||||
} as Extract<SyncOp, { type: "dbDelete" }>;
|
||||
const update = {
|
||||
type: "dbUpdate",
|
||||
model: { id: "rq_test", model: "http_request" },
|
||||
fs: { model: { id: "rq_test", model: "http_request", name: "Updated" } },
|
||||
state: {},
|
||||
} as Extract<SyncOp, { type: "dbUpdate" }>;
|
||||
|
||||
async function submit(dialog: DialogInstance) {
|
||||
const render = dialog.render as FunctionComponent<{ hide: () => void }>;
|
||||
const form = render({ hide: () => mocks.hideDialog(dialog.id) }) as ReactElement<{
|
||||
onSubmit: (event: { preventDefault: () => void }) => Promise<void>;
|
||||
}>;
|
||||
await form.props.onSubmit({ preventDefault: vi.fn() });
|
||||
}
|
||||
|
||||
function latestDialog() {
|
||||
const dialog = mocks.showDialog.mock.calls.at(-1)?.[0];
|
||||
if (dialog == null) throw new Error("Expected a sync dialog");
|
||||
return dialog;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mocks.applySync.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
test("closes an obsolete dialog when the restored file leaves no changes", async () => {
|
||||
mocks.calculateSync.mockResolvedValueOnce([deletion]).mockResolvedValueOnce([]);
|
||||
await syncWorkspace.mutateAsync(params);
|
||||
mocks.applySync.mockResolvedValueOnce(false);
|
||||
|
||||
await submit(latestDialog());
|
||||
|
||||
expect(mocks.applySync).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.applySync).toHaveBeenCalledWith(params.workspaceId, params.syncDir, [deletion]);
|
||||
expect(mocks.hideDialog).toHaveBeenCalledWith("commit-sync");
|
||||
expect(mocks.showDialog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("requires another confirmation for a changed plan", async () => {
|
||||
mocks.calculateSync.mockResolvedValueOnce([deletion]).mockResolvedValueOnce([update]);
|
||||
await syncWorkspace.mutateAsync(params);
|
||||
const original = latestDialog();
|
||||
mocks.applySync.mockResolvedValueOnce(false);
|
||||
|
||||
await submit(original);
|
||||
|
||||
expect(mocks.applySync).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.showDialog).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.showToast).toHaveBeenCalledWith(staleToast);
|
||||
expect(mocks.hideDialog).not.toHaveBeenCalled();
|
||||
await submit(latestDialog());
|
||||
expect(mocks.applySync).toHaveBeenLastCalledWith(params.workspaceId, params.syncDir, [update]);
|
||||
expect(mocks.hideDialog).toHaveBeenCalledWith("commit-sync");
|
||||
});
|
||||
|
||||
test("applies an unchanged reviewed plan and closes the dialog", async () => {
|
||||
mocks.calculateSync.mockResolvedValue([update]);
|
||||
await syncWorkspace.mutateAsync(params);
|
||||
await submit(latestDialog());
|
||||
|
||||
expect(mocks.calculateSync).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.applySync).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.hideDialog).toHaveBeenCalledWith("commit-sync");
|
||||
});
|
||||
|
||||
test("forced sync retries the current plan without requesting another confirmation", async () => {
|
||||
mocks.calculateSync.mockResolvedValueOnce([deletion]).mockResolvedValueOnce([update]);
|
||||
mocks.applySync.mockResolvedValueOnce(false);
|
||||
|
||||
await syncWorkspace.mutateAsync({ ...params, force: true });
|
||||
|
||||
expect(mocks.applySync).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.applySync).toHaveBeenLastCalledWith(params.workspaceId, params.syncDir, [update]);
|
||||
expect(mocks.showDialog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("outbound-only changes clear the old dialog and sync automatically", async () => {
|
||||
const outbound = { type: "fsUpdate", model: update.model, state: {} } as SyncOp;
|
||||
mocks.calculateSync.mockResolvedValueOnce([deletion]).mockResolvedValueOnce([outbound]);
|
||||
await syncWorkspace.mutateAsync(params);
|
||||
await syncWorkspace.mutateAsync(params);
|
||||
|
||||
expect(mocks.applySync).toHaveBeenCalledWith(params.workspaceId, params.syncDir, [outbound]);
|
||||
expect(mocks.hideDialog).toHaveBeenCalledWith("commit-sync");
|
||||
expect(mocks.showDialog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("stops retrying a plan that keeps going stale", async () => {
|
||||
mocks.calculateSync.mockResolvedValue([update]);
|
||||
mocks.applySync.mockResolvedValue(false);
|
||||
|
||||
await syncWorkspace.mutateAsync({ ...params, force: true });
|
||||
|
||||
expect(mocks.calculateSync).toHaveBeenCalledTimes(3);
|
||||
expect(mocks.applySync).toHaveBeenCalledTimes(3);
|
||||
expect(mocks.showToast).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.showToast).toHaveBeenCalledWith(staleToast);
|
||||
expect(mocks.showDialog).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
import type { Environment } from "@yaakapp-internal/models";
|
||||
import { CreateEnvironmentDialog } from "../components/CreateEnvironmentDialog";
|
||||
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
||||
import { createFastMutation } from "../hooks/useFastMutation";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import { jotaiStore } from "../lib/jotai";
|
||||
import { setWorkspaceSearchParams } from "../lib/setWorkspaceSearchParams";
|
||||
|
||||
/**
|
||||
* Prompt for and create a sub-environment of the given base environment, resolving with the new
|
||||
* environment's ID (or null if the dialog was dismissed). This does not change the workspace's
|
||||
* active environment.
|
||||
*/
|
||||
export const createSubEnvironment = createFastMutation<string | null, unknown, Environment | null>({
|
||||
mutationKey: ["create_environment"],
|
||||
mutationFn: async (baseEnvironment) => {
|
||||
if (baseEnvironment == null) {
|
||||
throw new Error("No base environment passed");
|
||||
}
|
||||
|
||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||
if (workspaceId == null) {
|
||||
throw new Error("Cannot create environment when no active workspace");
|
||||
}
|
||||
|
||||
return new Promise<string | null>((resolve) => {
|
||||
showDialog({
|
||||
id: "new-environment",
|
||||
title: "New Environment",
|
||||
description: "Create multiple environments with different sets of variables",
|
||||
size: "sm",
|
||||
onClose: () => resolve(null),
|
||||
render: ({ hide }) => (
|
||||
<CreateEnvironmentDialog
|
||||
workspaceId={workspaceId}
|
||||
hide={hide}
|
||||
onCreate={(id: string) => {
|
||||
resolve(id);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/** Same as {@link createSubEnvironment}, but also makes the new environment the active one. */
|
||||
export const createSubEnvironmentAndActivate = createFastMutation<
|
||||
string | null,
|
||||
unknown,
|
||||
Environment | null
|
||||
>({
|
||||
mutationKey: ["create_environment_and_activate"],
|
||||
mutationFn: (baseEnvironment) => createSubEnvironment.mutateAsync(baseEnvironment),
|
||||
disableToastError: true, // Already shown by createSubEnvironment
|
||||
onSuccess: async (environmentId) => {
|
||||
if (environmentId == null) {
|
||||
return; // Was not created
|
||||
}
|
||||
|
||||
setWorkspaceSearchParams({ environment_id: environmentId });
|
||||
},
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||
import { patchModelById } from "@yaakapp-internal/models";
|
||||
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
||||
import { createFastMutation } from "../hooks/useFastMutation";
|
||||
import { wasUpdatedExternally } from "../hooks/useRequestUpdateKey";
|
||||
import { createRequestAndNavigate } from "../lib/createRequestAndNavigate";
|
||||
import { jotaiStore } from "../lib/jotai";
|
||||
import { rpc } from "../lib/rpc";
|
||||
import { showToast } from "../lib/toast";
|
||||
|
||||
export function looksLikeCurl(text: string) {
|
||||
return text.trim().startsWith("curl ");
|
||||
}
|
||||
|
||||
export const importCurl = createFastMutation<
|
||||
void,
|
||||
string,
|
||||
{ overwriteRequestId?: string; command: string }
|
||||
>({
|
||||
mutationKey: ["import_curl"],
|
||||
mutationFn: async ({ overwriteRequestId, command }) => {
|
||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||
const importedRequest: HttpRequest = await rpc("cmd_curl_to_request", {
|
||||
command,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
let verb: string;
|
||||
if (overwriteRequestId == null) {
|
||||
verb = "Created";
|
||||
await createRequestAndNavigate(importedRequest);
|
||||
} else {
|
||||
verb = "Updated";
|
||||
await patchModelById(importedRequest.model, overwriteRequestId, (r: HttpRequest) => ({
|
||||
...importedRequest,
|
||||
id: r.id,
|
||||
createdAt: r.createdAt,
|
||||
workspaceId: r.workspaceId,
|
||||
folderId: r.folderId,
|
||||
name: r.name,
|
||||
sortPriority: r.sortPriority,
|
||||
}));
|
||||
|
||||
setTimeout(() => wasUpdatedExternally(overwriteRequestId), 100);
|
||||
}
|
||||
|
||||
showToast({
|
||||
color: "success",
|
||||
message: `${verb} request from Curl`,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-internal/models";
|
||||
|
||||
import { MoveToWorkspaceDialog } from "../components/MoveToWorkspaceDialog";
|
||||
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
||||
import { createFastMutation } from "../hooks/useFastMutation";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import { jotaiStore } from "../lib/jotai";
|
||||
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
|
||||
|
||||
export const moveToWorkspace = createFastMutation({
|
||||
mutationKey: ["move_workspace"],
|
||||
mutationFn: async (requests: (HttpRequest | GrpcRequest | WebsocketRequest)[]) => {
|
||||
const activeWorkspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||
if (activeWorkspaceId == null) return;
|
||||
if (requests.length === 0) return;
|
||||
|
||||
const title =
|
||||
requests.length === 1 ? "Move Request" : `Move ${pluralizeCount("Request", requests.length)}`;
|
||||
|
||||
showDialog({
|
||||
id: "change-workspace",
|
||||
title,
|
||||
size: "sm",
|
||||
render: ({ hide }) => (
|
||||
<MoveToWorkspaceDialog
|
||||
onDone={hide}
|
||||
requests={requests}
|
||||
activeWorkspaceId={activeWorkspaceId}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import { getModel } from "@yaakapp-internal/models";
|
||||
import type { FolderSettingsTab } from "../components/FolderSettingsDialog";
|
||||
import { FolderSettingsDialog } from "../components/FolderSettingsDialog";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
|
||||
export function openFolderSettings(folderId: string, tab?: FolderSettingsTab) {
|
||||
const folder = getModel("folder", folderId);
|
||||
if (folder == null) return;
|
||||
showDialog({
|
||||
id: "folder-settings",
|
||||
title: null,
|
||||
size: "lg",
|
||||
className: "h-200",
|
||||
noPadding: true,
|
||||
render: () => <FolderSettingsDialog folderId={folderId} tab={tab} />,
|
||||
});
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import type { SettingsTab, SettingsTabWithSubtab } from "../components/Settings/Settings";
|
||||
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
||||
import { createFastMutation } from "../hooks/useFastMutation";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import { jotaiStore } from "../lib/jotai";
|
||||
import { router } from "../lib/router";
|
||||
import { rpc } from "../lib/rpc";
|
||||
|
||||
export const openSettings = createFastMutation<void, string, SettingsTabWithSubtab | null>({
|
||||
mutationKey: ["open_settings"],
|
||||
mutationFn: async (tab) => {
|
||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||
if (workspaceId == null) return;
|
||||
|
||||
// Settings is its own window where the host has windows to give. Where it
|
||||
// doesn't — a browser tab — it's a dialog like any other, so opening it
|
||||
// doesn't take you away from the request you were working on.
|
||||
if (!platform.capabilities.multiWindow) {
|
||||
// Imported here so Settings stays out of the startup bundle, the way the
|
||||
// route that renders it on desktop already keeps it
|
||||
const { default: Settings } = await import("../components/Settings/Settings");
|
||||
showDialog({
|
||||
id: "settings",
|
||||
size: "md",
|
||||
className: "h-[calc(100vh-5rem)] max-h-150! overflow-hidden",
|
||||
noPadding: true,
|
||||
noScroll: true,
|
||||
// Keyed so opening a specific tab while the dialog is already up moves to it
|
||||
render: ({ hide }) => <Settings key={tab ?? "general"} tab={tab} hide={hide} />,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const location = router.buildLocation({
|
||||
to: "/workspaces/$workspaceId/settings",
|
||||
params: { workspaceId },
|
||||
search: { tab: (tab ?? undefined) as SettingsTab | undefined },
|
||||
});
|
||||
|
||||
await rpc("cmd_new_child_window", {
|
||||
url: location.href,
|
||||
label: "settings",
|
||||
title: "Yaak Settings",
|
||||
innerSize: [750, 600],
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import { applySync, calculateSyncFsOnly } from "@yaakapp-internal/sync";
|
||||
import { createFastMutation } from "../hooks/useFastMutation";
|
||||
import { showSimpleAlert } from "../lib/alert";
|
||||
import { router } from "../lib/router";
|
||||
|
||||
export const openWorkspaceFromSyncDir = createFastMutation<void, void, string>({
|
||||
mutationKey: [],
|
||||
mutationFn: async (dir) => {
|
||||
const ops = await calculateSyncFsOnly(dir);
|
||||
|
||||
const workspace = ops
|
||||
.map((o) => (o.type === "dbCreate" && o.fs.model.type === "workspace" ? o.fs.model : null))
|
||||
.filter((m) => m)[0];
|
||||
|
||||
if (workspace == null) {
|
||||
showSimpleAlert("Failed to Open", "No workspace found in directory");
|
||||
return;
|
||||
}
|
||||
|
||||
await applySync(workspace.id, dir, ops, { fsOnly: true });
|
||||
|
||||
await router.navigate({
|
||||
to: "/workspaces/$workspaceId",
|
||||
params: { workspaceId: workspace.id },
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
import type { WorkspaceSettingsTab } from "../components/WorkspaceSettingsDialog";
|
||||
import { WorkspaceSettingsDialog } from "../components/WorkspaceSettingsDialog";
|
||||
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
||||
import { jotaiStore } from "../lib/jotai";
|
||||
|
||||
export function openWorkspaceSettings(tab?: WorkspaceSettingsTab) {
|
||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||
if (workspaceId == null) return;
|
||||
WorkspaceSettingsDialog.show(workspaceId, tab);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
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 { rpc } from "../lib/rpc";
|
||||
|
||||
export const switchWorkspace = createFastMutation<
|
||||
void,
|
||||
unknown,
|
||||
{
|
||||
workspaceId: string;
|
||||
inNewWindow: boolean;
|
||||
}
|
||||
>({
|
||||
mutationKey: ["open_workspace"],
|
||||
mutationFn: async ({ workspaceId, inNewWindow }) => {
|
||||
const environmentId = (await getRecentEnvironments(workspaceId))[0] ?? undefined;
|
||||
const requestId = (await getRecentRequests(workspaceId))[0] ?? undefined;
|
||||
const cookieJarId = (await getRecentCookieJars(workspaceId))[0] ?? undefined;
|
||||
const search = {
|
||||
environment_id: environmentId,
|
||||
cookie_jar_id: cookieJarId,
|
||||
request_id: requestId,
|
||||
};
|
||||
|
||||
// A host without windows opens the workspace here instead. Refusing would
|
||||
// strand the user on the workspace they were trying to leave.
|
||||
if (inNewWindow && platform.capabilities.multiWindow) {
|
||||
const location = router.buildLocation({
|
||||
to: "/workspaces/$workspaceId",
|
||||
params: { workspaceId },
|
||||
search,
|
||||
});
|
||||
await rpc<void>("cmd_new_main_window", { url: location.href });
|
||||
return;
|
||||
}
|
||||
|
||||
await router.navigate({
|
||||
to: "/workspaces/$workspaceId",
|
||||
params: { workspaceId },
|
||||
search,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import {
|
||||
Banner,
|
||||
type EditorHost,
|
||||
EditorHostProvider,
|
||||
EmptyStateText,
|
||||
type HostBodyViewerArgs,
|
||||
type HttpBodyLoader,
|
||||
LoadingIcon,
|
||||
type StoredValue,
|
||||
type StoredValueNamespace,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { lazy, type ReactNode, Suspense } from "react";
|
||||
import { useKeyValue } from "../hooks/useKeyValue";
|
||||
import { copyToClipboard } from "../lib/copy";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import { showPrompt } from "../lib/prompt";
|
||||
import { useDetailsBannerStoredValue } from "./core/DetailsBanner";
|
||||
import { Editor } from "./core/Editor/LazyEditor";
|
||||
import { Input } from "./core/Input";
|
||||
import { ErrorBoundary } from "./ErrorBoundary";
|
||||
import { MultipartViewer } from "./responseViewers/MultipartViewer";
|
||||
import { TextViewer } from "./responseViewers/TextViewer";
|
||||
import { SelectFile } from "./SelectFile";
|
||||
|
||||
const PdfViewer = lazy(() =>
|
||||
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
|
||||
);
|
||||
|
||||
function useClientStoredValue<T extends StoredValue>(
|
||||
key: string | string[] | null,
|
||||
fallback: T,
|
||||
namespace: StoredValueNamespace = "global",
|
||||
) {
|
||||
// A null key is never written by the shared components, so any stable key will do
|
||||
const { value, set } = useKeyValue<T>({ namespace, key: key ?? ["tabs", "default"], fallback });
|
||||
return [value ?? fallback, set] as const;
|
||||
}
|
||||
|
||||
function renderBodyViewer({ source, mimeType, body, complete }: HostBodyViewerArgs) {
|
||||
if (!mimeType?.match(/pdf/i)) return null;
|
||||
return source === "response" ? (
|
||||
<PdfResponseBody body={body} complete={complete} />
|
||||
) : (
|
||||
<PdfRequestBody body={body} />
|
||||
);
|
||||
}
|
||||
|
||||
/** Waits for the whole response, then loads the PDF from its file */
|
||||
function PdfResponseBody({ body, complete }: { body: HttpBodyLoader; complete: boolean }) {
|
||||
const bodyUrl = body.useUrl(complete);
|
||||
|
||||
if (!complete || bodyUrl.isLoading) {
|
||||
return (
|
||||
<EmptyStateText>
|
||||
<LoadingIcon />
|
||||
</EmptyStateText>
|
||||
);
|
||||
}
|
||||
if (bodyUrl.error) {
|
||||
return <Banner color="danger">{String(bodyUrl.error)}</Banner>;
|
||||
}
|
||||
if (bodyUrl.data == null) {
|
||||
return <div>Empty response body</div>;
|
||||
}
|
||||
return <PdfViewer bodyUrl={bodyUrl.data} />;
|
||||
}
|
||||
|
||||
function PdfRequestBody({ body }: { body: HttpBodyLoader }) {
|
||||
const bytes = body.useBytes();
|
||||
if (bytes.data == null) return null;
|
||||
return (
|
||||
<Suspense fallback={<LoadingIcon />}>
|
||||
<PdfViewer data={bytes.data} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
const clientEditorHost: EditorHost = {
|
||||
Editor,
|
||||
Input,
|
||||
SelectFile,
|
||||
showDialog,
|
||||
showPrompt,
|
||||
basename: (path) => platform.files.basename(path),
|
||||
useStoredValue: useClientStoredValue,
|
||||
useDetailsBannerStoredValue,
|
||||
ErrorBoundary,
|
||||
TextViewer,
|
||||
MultipartViewer,
|
||||
renderBodyViewer,
|
||||
openUrl: (url) => platform.openUrl(url),
|
||||
copy: (text) => copyToClipboard(text, { disableToast: true }),
|
||||
};
|
||||
|
||||
/** Gives the shared editing components the client's templated editor, dialogs and storage */
|
||||
export function ClientEditorHost({ children }: { children: ReactNode }) {
|
||||
return <EditorHostProvider value={clientEditorHost}>{children}</EditorHostProvider>;
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
import { gitClone } from "@yaakapp-internal/git";
|
||||
import { Banner, VStack } from "@yaakapp-internal/ui";
|
||||
import { useId, useState } from "react";
|
||||
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
|
||||
import { appInfo } from "../lib/appInfo";
|
||||
import { CommercialUseBanner } from "./CommercialUseBanner";
|
||||
import { showErrorToast } from "../lib/toast";
|
||||
import { Checkbox } from "./core/Checkbox";
|
||||
import { DialogFooter } from "./core/Dialog";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import { promptCredentials } from "./git/credentials";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
|
||||
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 formId = useId();
|
||||
|
||||
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 platform.dialog.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" id={formId} space={3} alignItems="start" onSubmit={handleClone}>
|
||||
{error && (
|
||||
<Banner color="danger" className="w-full">
|
||||
{error}
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
<CommercialUseBanner source="git-clone" title="Using Git for work?" />
|
||||
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DialogFooter
|
||||
actions={[
|
||||
{
|
||||
label: isCloning ? "Cloning..." : "Clone Repository",
|
||||
color: "primary",
|
||||
form: formId,
|
||||
disabled: !url || !directory || isCloning,
|
||||
isLoading: isCloning,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</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,129 +0,0 @@
|
||||
import { checkLicense } from "@yaakapp-internal/license";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useKeyValue } from "../hooks/useKeyValue";
|
||||
import { appInfo } from "../lib/appInfo";
|
||||
import { pricingUrl } from "../lib/pricingUrl";
|
||||
import { DismissibleBanner } from "./core/DismissibleBanner";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
|
||||
const COMMERCIAL_USE_SNOOZE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const COMMERCIAL_USE_BANNER_MESSAGE =
|
||||
"Personal use of Yaak is free. If you’re using Yaak at work, please purchase a license.";
|
||||
|
||||
export function CommercialUseBanner({
|
||||
source,
|
||||
title,
|
||||
}: {
|
||||
source: string;
|
||||
title: string;
|
||||
}) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const snoozeStartedRef = useRef(false);
|
||||
const {
|
||||
isLoading: isSnoozeLoading,
|
||||
set: setSnoozedAt,
|
||||
value: snoozedAt,
|
||||
} = useKeyValue<string | null>({
|
||||
namespace: "global",
|
||||
key: "commercial-use-banner-snoozed-at",
|
||||
fallback: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
|
||||
shouldShowCommercialUsePrompt()
|
||||
.then((shouldShow) => {
|
||||
if (!canceled) setVisible(shouldShow);
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [source]);
|
||||
|
||||
const snoozed = isSnoozed(snoozedAt, COMMERCIAL_USE_SNOOZE_MS);
|
||||
const handleShow = useCallback(() => {
|
||||
if (snoozeStartedRef.current || snoozed) {
|
||||
return;
|
||||
}
|
||||
|
||||
snoozeStartedRef.current = true;
|
||||
setSnoozedAt(JSON.stringify({ source, at: new Date().toISOString() })).catch(console.error);
|
||||
}, [setSnoozedAt, snoozed, source]);
|
||||
|
||||
if (!visible || isSnoozeLoading || (snoozed && !snoozeStartedRef.current)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<DismissibleBanner
|
||||
id={`commercial-use:${source}`}
|
||||
color="info"
|
||||
className="w-full"
|
||||
onDismiss={() =>
|
||||
setSnoozedAt(JSON.stringify({ source, at: new Date().toISOString() }))
|
||||
}
|
||||
onShow={handleShow}
|
||||
actions={[
|
||||
{
|
||||
label: "Purchase License",
|
||||
color: "info",
|
||||
variant: "solid",
|
||||
onClick: () => {
|
||||
openCommercialUsePricing(source).catch(console.error);
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<div className="text-sm">
|
||||
<p className="font-semibold text-text">{title}</p>
|
||||
<p className="mt-0.5 text-text-subtle">{COMMERCIAL_USE_BANNER_MESSAGE}</p>
|
||||
</div>
|
||||
</DismissibleBanner>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function shouldShowCommercialUsePrompt(): Promise<boolean> {
|
||||
// Open-source builds omit the Rust license plugin, so never show commercial-use prompts there.
|
||||
if (appInfo.featureLicense !== true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const license = await checkLicense();
|
||||
return license.status === "personal_use";
|
||||
} catch (err) {
|
||||
console.log("Failed to check license before commercial-use prompt", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openCommercialUsePricing(source: string): Promise<void> {
|
||||
await platform.openUrl(pricingUrl(`app.commercial-use.${source}`)).catch(console.error);
|
||||
}
|
||||
|
||||
function isSnoozed(value: string | null, ms: number): boolean {
|
||||
if (value == null) return false;
|
||||
|
||||
try {
|
||||
const snooze = JSON.parse(value) as { at?: unknown };
|
||||
const at = typeof snooze.at === "string" ? snooze.at : null;
|
||||
return isWithinMs(at, ms);
|
||||
} catch {
|
||||
// Older builds stored only the timestamp, so keep respecting that as a global snooze.
|
||||
return isWithinMs(value, ms);
|
||||
}
|
||||
}
|
||||
|
||||
function isWithinMs(date: string | null, ms: number): boolean {
|
||||
if (date == null) return false;
|
||||
|
||||
const time = new Date(date).getTime();
|
||||
if (Number.isNaN(time)) return false;
|
||||
|
||||
return Date.now() - time < ms;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { useAtomValue } from "jotai";
|
||||
import { contextMenusAtom, hideContextMenu } from "../lib/contextMenu";
|
||||
import { ContextMenu } from "./core/Dropdown";
|
||||
import { ErrorBoundary } from "./ErrorBoundary";
|
||||
|
||||
/** Renders menus opened by {@link showContextMenu}, the way {@link Dialogs} renders dialogs. */
|
||||
export function ContextMenus() {
|
||||
const menus = useAtomValue(contextMenusAtom);
|
||||
return (
|
||||
<>
|
||||
{menus.map(({ id, items, triggerPosition, triggerRect, triggerEl }) => (
|
||||
<ErrorBoundary key={id} name={`ContextMenu ${id}`}>
|
||||
<ContextMenu
|
||||
items={items}
|
||||
triggerPosition={triggerPosition}
|
||||
triggerRect={triggerRect}
|
||||
// The trigger isn't a React component here, so it arrives as an element and gets
|
||||
// wrapped to look like the ref the menu expects
|
||||
triggerRef={{ current: triggerEl ?? null }}
|
||||
onClose={() => hideContextMenu(id)}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,732 +0,0 @@
|
||||
import type { Cookie } from "@yaakapp-internal/models";
|
||||
import { cookieJarsAtom, patchModel } from "@yaakapp-internal/models";
|
||||
import { formatDate } from "date-fns/format";
|
||||
import { useAtomValue } from "jotai";
|
||||
import {
|
||||
type ComponentProps,
|
||||
type CSSProperties,
|
||||
type FormEvent,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import { jotaiStore } from "../lib/jotai";
|
||||
import { cookieDomain } from "../lib/model_util";
|
||||
import {
|
||||
Icon,
|
||||
SplitLayout,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
TruncatedWideTableCell,
|
||||
KeyValueRows,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { Checkbox } from "./core/Checkbox";
|
||||
import classNames from "classnames";
|
||||
import { EventDetailHeader } from "./core/EventViewer";
|
||||
import { KeyValueRow } from "./core/KeyValueRow";
|
||||
import { EmptyStateText } from "./EmptyStateText";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import { Select } from "./core/Select";
|
||||
import { showAlert } from "../lib/alert";
|
||||
|
||||
interface Props {
|
||||
cookieJarId: string | null;
|
||||
}
|
||||
|
||||
export const CookieDialog = ({ cookieJarId }: Props) => {
|
||||
const cookieJars = useAtomValue(cookieJarsAtom);
|
||||
const cookieJar = cookieJars?.find((c) => c.id === cookieJarId);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [filterUpdateKey, setFilterUpdateKey] = useState(0);
|
||||
const [selectedCookieKey, setSelectedCookieKey] = useState<string | null>(null);
|
||||
const [editingCookieKey, setEditingCookieKey] = useState<string | null>(null);
|
||||
const [draftCookie, setDraftCookie] = useState<Cookie | null>(null);
|
||||
const [draftExpiresInput, setDraftExpiresInput] = useState("");
|
||||
const editorFormRef = useRef<HTMLFormElement>(null);
|
||||
const filteredCookies = useMemo(() => {
|
||||
return cookieJar?.cookies.filter((cookie) => cookieMatchesFilter(cookie, filter)) ?? [];
|
||||
}, [cookieJar?.cookies, filter]);
|
||||
const selectedCookie = useMemo(
|
||||
() =>
|
||||
selectedCookieKey == null
|
||||
? null
|
||||
: (filteredCookies.find((cookie) => cookieKey(cookie) === selectedCookieKey) ?? null),
|
||||
[filteredCookies, selectedCookieKey],
|
||||
);
|
||||
const detailCookie = draftCookie ?? selectedCookie;
|
||||
const isCreatingCookie = editingCookieKey === NEW_COOKIE_KEY;
|
||||
const isEditingCookie = draftCookie != null;
|
||||
|
||||
const handleAddCookie = () => {
|
||||
setSelectedCookieKey(null);
|
||||
setEditingCookieKey(NEW_COOKIE_KEY);
|
||||
setDraftCookie(newCookieDraft());
|
||||
setDraftExpiresInput("");
|
||||
};
|
||||
|
||||
const handleEditCookie = () => {
|
||||
if (selectedCookie == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEditingCookieKey(cookieKey(selectedCookie));
|
||||
setDraftCookie(selectedCookie);
|
||||
setDraftExpiresInput(cookieExpiresInputValue(selectedCookie));
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
if (isCreatingCookie) {
|
||||
setSelectedCookieKey(null);
|
||||
}
|
||||
setEditingCookieKey(null);
|
||||
setDraftCookie(null);
|
||||
setDraftExpiresInput("");
|
||||
};
|
||||
|
||||
const handleCloseDetails = () => {
|
||||
if (isEditingCookie) {
|
||||
handleCancelEdit();
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedCookieKey(null);
|
||||
};
|
||||
|
||||
const handleSaveCookie = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (cookieJar == null || draftCookie == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
let nextCookie = normalizeCookie(draftCookie);
|
||||
if (nextCookie.expires !== "SessionEnd") {
|
||||
const expires = cookieExpiresFromInput(draftExpiresInput);
|
||||
if (expires == null) {
|
||||
showAlert({
|
||||
id: "invalid-cookie-expires",
|
||||
title: "Invalid Cookie",
|
||||
body: "Cookie expiration must be a valid date.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
nextCookie = { ...nextCookie, expires };
|
||||
}
|
||||
|
||||
const nextCookieKey = cookieKey(nextCookie);
|
||||
const nextCookies = cookieJar.cookies.filter((cookie) => {
|
||||
const key = cookieKey(cookie);
|
||||
if (editingCookieKey != null && key === editingCookieKey) {
|
||||
return false;
|
||||
}
|
||||
return key !== nextCookieKey;
|
||||
});
|
||||
|
||||
void patchModel(cookieJar, { cookies: [...nextCookies, nextCookie] });
|
||||
setSelectedCookieKey(nextCookieKey);
|
||||
setEditingCookieKey(null);
|
||||
setDraftCookie(null);
|
||||
setDraftExpiresInput("");
|
||||
};
|
||||
|
||||
if (cookieJar == null) {
|
||||
return <div>No cookie jar selected</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pb-2 grid grid-rows-[auto_minmax(0,1fr)] space-y-2">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
|
||||
<PlainInput
|
||||
name="cookie-filter"
|
||||
label="Filter cookies"
|
||||
hideLabel
|
||||
placeholder="Filter cookies"
|
||||
defaultValue={filter}
|
||||
forceUpdateKey={filterUpdateKey}
|
||||
onChange={setFilter}
|
||||
rightSlot={
|
||||
filter.length > 0 && (
|
||||
<IconButton
|
||||
className="bg-transparent! h-auto! min-h-full opacity-50 hover:opacity-100 -mr-1"
|
||||
icon="x"
|
||||
title="Clear filter"
|
||||
onClick={() => {
|
||||
setFilter("");
|
||||
setFilterUpdateKey((key) => key + 1);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<IconButton icon="plus" size="sm" title="Add cookie" onClick={handleAddCookie} />
|
||||
</div>
|
||||
{cookieJar.cookies.length === 0 && detailCookie == null ? (
|
||||
<EmptyStateText>
|
||||
Cookies will appear when a response includes a Set-Cookie header.
|
||||
</EmptyStateText>
|
||||
) : filteredCookies.length === 0 && detailCookie == null ? (
|
||||
<EmptyStateText>No cookies match the current filter.</EmptyStateText>
|
||||
) : (
|
||||
<SplitLayout
|
||||
layout="vertical"
|
||||
storageKey="cookie-dialog-details"
|
||||
defaultRatio={0.5}
|
||||
className="-mx-2"
|
||||
minHeightPx={10}
|
||||
firstSlot={({ style }) =>
|
||||
filteredCookies.length === 0 ? (
|
||||
<div style={style}>
|
||||
<EmptyStateText>No cookies match the current filter.</EmptyStateText>
|
||||
</div>
|
||||
) : (
|
||||
<Table scrollable style={style} className="pr-0.5">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Value</TableHeaderCell>
|
||||
<TableHeaderCell>Domain</TableHeaderCell>
|
||||
<TableHeaderCell>Path</TableHeaderCell>
|
||||
<TableHeaderCell>Expires</TableHeaderCell>
|
||||
<TableHeaderCell>Size</TableHeaderCell>
|
||||
<TableHeaderCell>HTTP Only</TableHeaderCell>
|
||||
<TableHeaderCell>Secure</TableHeaderCell>
|
||||
<TableHeaderCell>Same Site</TableHeaderCell>
|
||||
<TableHeaderCell>
|
||||
<IconButton
|
||||
icon="list_x"
|
||||
size="sm"
|
||||
className="text-text-subtle"
|
||||
title="Clear all cookies"
|
||||
onClick={() => {
|
||||
setSelectedCookieKey(null);
|
||||
setEditingCookieKey(null);
|
||||
setDraftCookie(null);
|
||||
setDraftExpiresInput("");
|
||||
void patchModel(cookieJar, { cookies: [] });
|
||||
}}
|
||||
/>
|
||||
</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody className="[&_td]:select-auto [&_td]:cursor-auto">
|
||||
{filteredCookies.map((c: Cookie) => {
|
||||
const key = cookieKey(c);
|
||||
const isSelected = key === selectedCookieKey;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={key}
|
||||
className={classNames(
|
||||
"group/tr cursor-default",
|
||||
isSelected && "[&_td]:bg-surface-highlight",
|
||||
!isSelected && "hover:[&_td]:bg-surface-hover",
|
||||
)}
|
||||
onClick={() => {
|
||||
setSelectedCookieKey(key);
|
||||
setEditingCookieKey(null);
|
||||
setDraftCookie(null);
|
||||
setDraftExpiresInput("");
|
||||
}}
|
||||
>
|
||||
<TableCell className={classNames("pl-2", isSelected && "rounded-l")}>
|
||||
{c.name}
|
||||
</TableCell>
|
||||
<TruncatedWideTableCell className="min-w-40">
|
||||
{c.value}
|
||||
</TruncatedWideTableCell>
|
||||
<TableCell>{cookieDomain(c)}</TableCell>
|
||||
<TableCell>{c.path}</TableCell>
|
||||
<TableCell>{cookieExpires(c)}</TableCell>
|
||||
<TableCell>{cookieSize(c)}</TableCell>
|
||||
<TableCell>
|
||||
<Icon
|
||||
icon={c.httpOnly ? "check" : "x"}
|
||||
className={classNames(!c.httpOnly && "opacity-10")}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Icon
|
||||
icon={c.secure ? "check" : "x"}
|
||||
className={classNames(!c.secure && "opacity-10")}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{c.sameSite}</TableCell>
|
||||
<TableCell className="rounded-r pr-2">
|
||||
<IconButton
|
||||
icon="trash"
|
||||
size="xs"
|
||||
iconSize="sm"
|
||||
title="Delete"
|
||||
className="text-text-subtlest ml-auto group-hover/tr:text-text transition-colors"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (isSelected) {
|
||||
setSelectedCookieKey(null);
|
||||
}
|
||||
if (editingCookieKey === key) {
|
||||
setEditingCookieKey(null);
|
||||
setDraftCookie(null);
|
||||
setDraftExpiresInput("");
|
||||
}
|
||||
void patchModel(cookieJar, {
|
||||
cookies: cookieJar.cookies.filter(
|
||||
(c2: Cookie) => cookieKey(c2) !== key,
|
||||
),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
secondSlot={
|
||||
detailCookie == null
|
||||
? null
|
||||
: ({ style }) => (
|
||||
<CookieDetailsPane
|
||||
formRef={editorFormRef}
|
||||
isEditing={isEditingCookie}
|
||||
onSubmit={handleSaveCookie}
|
||||
style={style}
|
||||
>
|
||||
<EventDetailHeader
|
||||
title={isCreatingCookie ? "New Cookie" : detailCookie.name || "Cookie"}
|
||||
copyText={isEditingCookie ? undefined : detailCookie.value}
|
||||
actions={
|
||||
isEditingCookie
|
||||
? [
|
||||
{
|
||||
key: "save",
|
||||
label: isCreatingCookie ? "Create" : "Save",
|
||||
onClick: () => editorFormRef.current?.requestSubmit(),
|
||||
},
|
||||
{
|
||||
key: "cancel",
|
||||
label: "Cancel",
|
||||
onClick: handleCancelEdit,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
key: "edit",
|
||||
label: "Edit",
|
||||
onClick: handleEditCookie,
|
||||
},
|
||||
]
|
||||
}
|
||||
onClose={handleCloseDetails}
|
||||
/>
|
||||
{isEditingCookie ? (
|
||||
<CookieEditor
|
||||
cookie={detailCookie}
|
||||
expiresInputValue={draftExpiresInput}
|
||||
onChange={setDraftCookie}
|
||||
onExpiresInputChange={setDraftExpiresInput}
|
||||
/>
|
||||
) : (
|
||||
<CookieDetails cookie={detailCookie} />
|
||||
)}
|
||||
</CookieDetailsPane>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function CookieDetailsPane({
|
||||
children,
|
||||
formRef,
|
||||
isEditing,
|
||||
onSubmit,
|
||||
style,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
formRef: RefObject<HTMLFormElement | null>;
|
||||
isEditing: boolean;
|
||||
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
||||
style: CSSProperties;
|
||||
}) {
|
||||
const className = "grid grid-rows-[auto_minmax(0,1fr)] bg-surface border-t border-border pt-2";
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<form ref={formRef} style={style} className={className} onSubmit={onSubmit}>
|
||||
{children}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={style} className={className}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CookieDialog.show = (cookieJarId: string | null) => {
|
||||
const cookieJar = jotaiStore.get(cookieJarsAtom)?.find((jar) => jar.id === cookieJarId);
|
||||
if (cookieJar == null) {
|
||||
showAlert({
|
||||
id: "invalid-jar",
|
||||
body: `Failed to find cookie jar for ID: ${cookieJarId}`,
|
||||
title: "Invalid Cookie Jar",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
showDialog({
|
||||
id: "cookies",
|
||||
title: `${cookieJar.name} Cookies`,
|
||||
size: "full",
|
||||
render: () => <CookieDialog cookieJarId={cookieJarId} />,
|
||||
});
|
||||
};
|
||||
|
||||
function CookieDetails({ cookie }: { cookie: Cookie }) {
|
||||
return (
|
||||
<div className="overflow-y-auto">
|
||||
<KeyValueRows selectable>
|
||||
<CookieKeyValueRow label="Name">{cookie.name}</CookieKeyValueRow>
|
||||
<CookieKeyValueRow label="Value" enableCopy copyText={cookie.value}>
|
||||
<pre className="whitespace-pre-wrap break-all">{cookie.value}</pre>
|
||||
</CookieKeyValueRow>
|
||||
<CookieKeyValueRow label="Domain">{cookieDomain(cookie)}</CookieKeyValueRow>
|
||||
<CookieKeyValueRow label="Path">{cookie.path}</CookieKeyValueRow>
|
||||
<CookieKeyValueRow label="Expires">{cookieExpires(cookie)}</CookieKeyValueRow>
|
||||
<CookieKeyValueRow label="Size">{cookieSize(cookie)}</CookieKeyValueRow>
|
||||
<CookieKeyValueRow label="HTTP Only">{cookie.httpOnly ? "Yes" : "No"}</CookieKeyValueRow>
|
||||
<CookieKeyValueRow label="Secure">{cookie.secure ? "Yes" : "No"}</CookieKeyValueRow>
|
||||
{cookie.sameSite && (
|
||||
<CookieKeyValueRow label="Same Site">{cookie.sameSite}</CookieKeyValueRow>
|
||||
)}
|
||||
</KeyValueRows>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CookieEditor({
|
||||
cookie,
|
||||
expiresInputValue,
|
||||
onChange,
|
||||
onExpiresInputChange,
|
||||
}: {
|
||||
cookie: Cookie;
|
||||
expiresInputValue: string;
|
||||
onChange: (cookie: Cookie) => void;
|
||||
onExpiresInputChange: (value: string) => void;
|
||||
}) {
|
||||
const sessionCookie = cookie.expires === "SessionEnd";
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto">
|
||||
<KeyValueRows>
|
||||
<CookieKeyValueRow align="middle" label="Name">
|
||||
<CookieTextInput
|
||||
required
|
||||
autoFocus
|
||||
pattern={NON_EMPTY_INPUT_PATTERN}
|
||||
value={cookie.name}
|
||||
onChange={(name) => onChange({ ...cookie, name })}
|
||||
/>
|
||||
</CookieKeyValueRow>
|
||||
<CookieKeyValueRow label="Value">
|
||||
<CookieTextarea
|
||||
value={cookie.value}
|
||||
onChange={(value) => onChange({ ...cookie, value })}
|
||||
/>
|
||||
</CookieKeyValueRow>
|
||||
<CookieKeyValueRow align="middle" label="Domain">
|
||||
<CookieTextInput
|
||||
required
|
||||
pattern={NON_EMPTY_INPUT_PATTERN}
|
||||
value={cookieDomainInputValue(cookie)}
|
||||
placeholder="example.com"
|
||||
onChange={(domain) => onChange(cookieWithDomain(cookie, domain))}
|
||||
/>
|
||||
</CookieKeyValueRow>
|
||||
<CookieKeyValueRow align="middle" label="Path">
|
||||
<CookieTextInput
|
||||
value={cookie.path}
|
||||
placeholder="/"
|
||||
onChange={(path) => onChange({ ...cookie, path })}
|
||||
/>
|
||||
</CookieKeyValueRow>
|
||||
<CookieKeyValueRow label="Expires">
|
||||
<div className="grid gap-1">
|
||||
<Checkbox
|
||||
checked={sessionCookie}
|
||||
title="Session cookie"
|
||||
onChange={(checked) => {
|
||||
if (checked) {
|
||||
onChange({ ...cookie, expires: "SessionEnd" });
|
||||
return;
|
||||
}
|
||||
|
||||
const expiresInput =
|
||||
cookieExpiresFromInput(expiresInputValue) == null
|
||||
? defaultCookieExpiresInputValue()
|
||||
: expiresInputValue;
|
||||
|
||||
onExpiresInputChange(expiresInput);
|
||||
onChange({
|
||||
...cookie,
|
||||
expires: cookieExpiresFromInput(expiresInput)!,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<CookieTextInput
|
||||
value={sessionCookie ? "" : expiresInputValue}
|
||||
disabled={sessionCookie}
|
||||
onChange={(value) => {
|
||||
onExpiresInputChange(value);
|
||||
|
||||
const expires = cookieExpiresFromInput(value);
|
||||
if (expires != null) {
|
||||
onChange({ ...cookie, expires });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</CookieKeyValueRow>
|
||||
<CookieKeyValueRow label="Size">{cookieSize(cookie)}</CookieKeyValueRow>
|
||||
<CookieKeyValueRow align="middle" label="HTTP Only">
|
||||
<Checkbox
|
||||
hideLabel
|
||||
title="HTTP Only"
|
||||
checked={cookie.httpOnly}
|
||||
onChange={(httpOnly) => onChange({ ...cookie, httpOnly })}
|
||||
/>
|
||||
</CookieKeyValueRow>
|
||||
<CookieKeyValueRow align="middle" label="Secure">
|
||||
<Checkbox
|
||||
hideLabel
|
||||
title="Secure"
|
||||
checked={cookie.secure}
|
||||
onChange={(secure) => onChange({ ...cookie, secure })}
|
||||
/>
|
||||
</CookieKeyValueRow>
|
||||
<CookieKeyValueRow align="middle" label="Same Site">
|
||||
<Select
|
||||
hideLabel
|
||||
name="cookie-same-site"
|
||||
label="Same Site"
|
||||
value={cookie.sameSite ?? ""}
|
||||
size="xs"
|
||||
className="w-full"
|
||||
options={[
|
||||
{ label: "n/a", value: "" },
|
||||
{ label: "Lax", value: "Lax" },
|
||||
{ label: "Strict", value: "Strict" },
|
||||
{ label: "None", value: "None" },
|
||||
]}
|
||||
onChange={(sameSite) =>
|
||||
onChange({
|
||||
...cookie,
|
||||
sameSite: sameSite === "" ? null : (sameSite as Cookie["sameSite"]),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</CookieKeyValueRow>
|
||||
</KeyValueRows>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CookieKeyValueRow({ labelClassName, ...props }: ComponentProps<typeof KeyValueRow>) {
|
||||
return <KeyValueRow labelClassName={classNames("w-28", labelClassName)} {...props} />;
|
||||
}
|
||||
|
||||
function CookieTextInput({
|
||||
autoFocus,
|
||||
disabled,
|
||||
onChange,
|
||||
pattern,
|
||||
placeholder,
|
||||
required,
|
||||
value,
|
||||
}: {
|
||||
autoFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
onChange: (value: string) => void;
|
||||
pattern?: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
autoFocus={autoFocus}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
className={cookieInputClassName}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
pattern={pattern}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
type="text"
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CookieTextarea({ onChange, value }: { onChange: (value: string) => void; value: string }) {
|
||||
return (
|
||||
<textarea
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
className={classNames(cookieInputClassName, "min-h-20 resize-y")}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const NEW_COOKIE_KEY = "__new-cookie__";
|
||||
const NON_EMPTY_INPUT_PATTERN = ".*\\S.*";
|
||||
const cookieInputClassName = classNames(
|
||||
"x-theme-input w-full min-w-0 min-h-sm rounded-md bg-transparent",
|
||||
"border border-border-subtle outline-hidden",
|
||||
"px-2 text-xs font-mono cursor-text placeholder:text-placeholder",
|
||||
"focus:border-border-focus invalid:border-danger",
|
||||
"disabled:opacity-disabled disabled:border-dotted",
|
||||
);
|
||||
|
||||
function cookieSize(cookie: Cookie) {
|
||||
const encoder = new TextEncoder();
|
||||
return encoder.encode(cookie.name).length + encoder.encode(cookie.value).length;
|
||||
}
|
||||
|
||||
function newCookieDraft(): Cookie {
|
||||
return {
|
||||
name: "",
|
||||
value: "",
|
||||
domain: "NotPresent",
|
||||
expires: "SessionEnd",
|
||||
path: "/",
|
||||
secure: false,
|
||||
httpOnly: false,
|
||||
sameSite: null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCookie(cookie: Cookie): Cookie {
|
||||
return {
|
||||
...cookie,
|
||||
domain: normalizeCookieDomain(cookie.domain),
|
||||
name: cookie.name.trim(),
|
||||
path: cookie.path.trim() || "/",
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCookieDomain(domain: Cookie["domain"]): Cookie["domain"] {
|
||||
if (domain === "NotPresent" || domain === "Empty") {
|
||||
return domain;
|
||||
}
|
||||
|
||||
if ("Suffix" in domain) {
|
||||
return { Suffix: domain.Suffix.trim() };
|
||||
}
|
||||
|
||||
return { HostOnly: domain.HostOnly.trim() };
|
||||
}
|
||||
|
||||
function cookieDomainInputValue(cookie: Cookie) {
|
||||
const domain = cookieDomain(cookie);
|
||||
return domain === "n/a" ? "" : domain;
|
||||
}
|
||||
|
||||
function cookieWithDomain(cookie: Cookie, domain: string): Cookie {
|
||||
const trimmedDomain = domain.trim();
|
||||
if (trimmedDomain.length === 0) {
|
||||
return { ...cookie, domain: "NotPresent" };
|
||||
}
|
||||
|
||||
if (cookie.domain !== "NotPresent" && cookie.domain !== "Empty" && "Suffix" in cookie.domain) {
|
||||
return { ...cookie, domain: { Suffix: trimmedDomain } };
|
||||
}
|
||||
|
||||
return { ...cookie, domain: { HostOnly: trimmedDomain } };
|
||||
}
|
||||
|
||||
function cookieExpires(cookie: Cookie) {
|
||||
if (cookie.expires === "SessionEnd") {
|
||||
return "Session";
|
||||
}
|
||||
|
||||
const expiresSeconds = Number(cookie.expires.AtUtc);
|
||||
if (!Number.isFinite(expiresSeconds)) {
|
||||
return cookie.expires.AtUtc;
|
||||
}
|
||||
|
||||
const date = new Date(expiresSeconds * 1000);
|
||||
return formatDate(date, "MMM d, yyyy, h:mm:ss a");
|
||||
}
|
||||
|
||||
function cookieExpiresInputValue(cookie: Cookie) {
|
||||
if (cookie.expires === "SessionEnd") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const expiresSeconds = Number(cookie.expires.AtUtc);
|
||||
if (!Number.isFinite(expiresSeconds)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return new Date(expiresSeconds * 1000).toISOString();
|
||||
}
|
||||
|
||||
function defaultCookieExpiresInputValue() {
|
||||
return new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
function cookieExpiresFromInput(value: string): Cookie["expires"] | null {
|
||||
const time = new Date(value).getTime();
|
||||
if (!Number.isFinite(time)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { AtUtc: `${Math.floor(time / 1000)}` };
|
||||
}
|
||||
|
||||
function cookieMatchesFilter(cookie: Cookie, filter: string) {
|
||||
const query = filter.trim().toLowerCase();
|
||||
if (query.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return [cookie.name, cookie.value, cookieDomain(cookie)].some((value) =>
|
||||
value.toLowerCase().includes(query),
|
||||
);
|
||||
}
|
||||
|
||||
function cookieKey(cookie: Cookie) {
|
||||
return [cookie.name, cookieDomainKey(cookie.domain), cookie.path].join("|");
|
||||
}
|
||||
|
||||
function cookieDomainKey(domain: Cookie["domain"]) {
|
||||
if (typeof domain !== "string" && "HostOnly" in domain) {
|
||||
return `HostOnly:${domain.HostOnly}`;
|
||||
}
|
||||
|
||||
if (typeof domain !== "string" && "Suffix" in domain) {
|
||||
return `Suffix:${domain.Suffix}`;
|
||||
}
|
||||
|
||||
return domain;
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { useTimedBoolean } from "@yaakapp-internal/ui";
|
||||
import { copyToClipboard } from "../lib/copy";
|
||||
import { showToast } from "../lib/toast";
|
||||
import type { ButtonProps } from "./core/Button";
|
||||
import { Button } from "./core/Button";
|
||||
|
||||
interface Props extends Omit<ButtonProps, "onClick"> {
|
||||
text: string | (() => Promise<string | null>);
|
||||
}
|
||||
|
||||
export function CopyButton({ text, ...props }: Props) {
|
||||
const [copied, setCopied] = useTimedBoolean();
|
||||
return (
|
||||
<Button
|
||||
{...props}
|
||||
onClick={async () => {
|
||||
const content = typeof text === "function" ? await text() : text;
|
||||
if (content == null) {
|
||||
showToast({
|
||||
id: "failed-to-copy",
|
||||
color: "danger",
|
||||
message: "Failed to copy",
|
||||
});
|
||||
} else {
|
||||
copyToClipboard(content, { disableToast: true });
|
||||
setCopied();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import {
|
||||
CopyIconButton as BaseCopyIconButton,
|
||||
type CopyIconButtonProps,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { copyToClipboard } from "../lib/copy";
|
||||
import { showToast } from "../lib/toast";
|
||||
|
||||
export function CopyIconButton(props: Omit<CopyIconButtonProps, "copy" | "onCopyFailed">) {
|
||||
return <BaseCopyIconButton {...props} copy={copy} onCopyFailed={showCopyFailed} />;
|
||||
}
|
||||
|
||||
function copy(text: string) {
|
||||
copyToClipboard(text, { disableToast: true });
|
||||
}
|
||||
|
||||
function showCopyFailed() {
|
||||
showToast({
|
||||
id: "failed-to-copy",
|
||||
color: "danger",
|
||||
message: "Failed to copy",
|
||||
});
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
import type { DnsOverride, Workspace } from "@yaakapp-internal/models";
|
||||
import { patchModel } from "@yaakapp-internal/models";
|
||||
import { fireAndForget } from "../lib/fireAndForget";
|
||||
import {
|
||||
HStack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
VStack,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { useCallback, useId, useMemo } from "react";
|
||||
import { Button } from "./core/Button";
|
||||
import { Checkbox } from "./core/Checkbox";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
|
||||
interface Props {
|
||||
workspace: Workspace;
|
||||
}
|
||||
|
||||
interface DnsOverrideWithId extends DnsOverride {
|
||||
_id: string;
|
||||
}
|
||||
|
||||
export function DnsOverridesEditor({ workspace }: Props) {
|
||||
const reactId = useId();
|
||||
|
||||
// Ensure each override has an internal ID for React keys
|
||||
const overridesWithIds = useMemo<DnsOverrideWithId[]>(() => {
|
||||
return workspace.settingDnsOverrides.map((override, index) => ({
|
||||
...override,
|
||||
_id: `${reactId}-${index}`,
|
||||
}));
|
||||
}, [workspace.settingDnsOverrides, reactId]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(overrides: DnsOverride[]) => {
|
||||
fireAndForget(patchModel(workspace, { settingDnsOverrides: overrides }));
|
||||
},
|
||||
[workspace],
|
||||
);
|
||||
|
||||
const handleAdd = useCallback(() => {
|
||||
const newOverride: DnsOverride = {
|
||||
hostname: "",
|
||||
ipv4: [""],
|
||||
ipv6: [],
|
||||
enabled: true,
|
||||
};
|
||||
handleChange([...workspace.settingDnsOverrides, newOverride]);
|
||||
}, [workspace.settingDnsOverrides, handleChange]);
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(index: number, update: Partial<DnsOverride>) => {
|
||||
const updated = workspace.settingDnsOverrides.map((o, i) =>
|
||||
i === index ? { ...o, ...update } : o,
|
||||
);
|
||||
handleChange(updated);
|
||||
},
|
||||
[workspace.settingDnsOverrides, handleChange],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(index: number) => {
|
||||
const updated = workspace.settingDnsOverrides.filter((_, i) => i !== index);
|
||||
handleChange(updated);
|
||||
},
|
||||
[workspace.settingDnsOverrides, handleChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<VStack space={3} className="pb-3">
|
||||
<div className="text-text-subtle text-sm">
|
||||
Override DNS resolution for specific hostnames. This works like{" "}
|
||||
<code className="text-text-subtlest bg-surface-highlight px-1 rounded-sm">/etc/hosts</code> but
|
||||
only for requests made from this workspace.
|
||||
</div>
|
||||
|
||||
{overridesWithIds.length > 0 && (
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell className="w-8" />
|
||||
<TableHeaderCell>Hostname</TableHeaderCell>
|
||||
<TableHeaderCell>IPv4 Address</TableHeaderCell>
|
||||
<TableHeaderCell>IPv6 Address</TableHeaderCell>
|
||||
<TableHeaderCell className="w-10" />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{overridesWithIds.map((override, index) => (
|
||||
<DnsOverrideRow
|
||||
key={override._id}
|
||||
override={override}
|
||||
onUpdate={(update) => handleUpdate(index, update)}
|
||||
onDelete={() => handleDelete(index)}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
<HStack>
|
||||
<Button size="xs" color="secondary" variant="border" onClick={handleAdd}>
|
||||
Add DNS Override
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
interface DnsOverrideRowProps {
|
||||
override: DnsOverride;
|
||||
onUpdate: (update: Partial<DnsOverride>) => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function DnsOverrideRow({ override, onUpdate, onDelete }: DnsOverrideRowProps) {
|
||||
const ipv4Value = override.ipv4.join(", ");
|
||||
const ipv6Value = override.ipv6.join(", ");
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
hideLabel
|
||||
title={override.enabled ? "Disable override" : "Enable override"}
|
||||
checked={override.enabled ?? true}
|
||||
onChange={(enabled) => onUpdate({ enabled })}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<PlainInput
|
||||
size="sm"
|
||||
hideLabel
|
||||
label="Hostname"
|
||||
placeholder="api.example.com"
|
||||
defaultValue={override.hostname}
|
||||
onChange={(hostname) => onUpdate({ hostname })}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<PlainInput
|
||||
size="sm"
|
||||
hideLabel
|
||||
label="IPv4 addresses"
|
||||
placeholder="127.0.0.1"
|
||||
defaultValue={ipv4Value}
|
||||
onChange={(value) =>
|
||||
onUpdate({
|
||||
ipv4: value
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<PlainInput
|
||||
size="sm"
|
||||
hideLabel
|
||||
label="IPv6 addresses"
|
||||
placeholder="::1"
|
||||
defaultValue={ipv6Value}
|
||||
onChange={(value) =>
|
||||
onUpdate({
|
||||
ipv6: value
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<IconButton
|
||||
size="xs"
|
||||
iconSize="sm"
|
||||
icon="trash"
|
||||
title="Delete override"
|
||||
onClick={onDelete}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import classNames from "classnames";
|
||||
import type { CSSProperties } from "react";
|
||||
import { memo } from "react";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
}
|
||||
|
||||
export const DropMarker = memo(
|
||||
function DropMarker({ className, style, orientation = "horizontal" }: Props) {
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
className={classNames(
|
||||
className,
|
||||
"absolute pointer-events-none z-50",
|
||||
orientation === "horizontal" && "w-full",
|
||||
orientation === "vertical" && "w-0 top-0 bottom-0",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
"absolute bg-primary rounded-full",
|
||||
orientation === "horizontal" && "left-2 right-2 bottom-[-0.1rem] h-[0.2rem]",
|
||||
orientation === "vertical" && "left-[-0.1rem] top-0 bottom-0 w-[0.2rem]",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
() => true,
|
||||
);
|
||||
@@ -1 +0,0 @@
|
||||
export { EmptyStateText } from "@yaakapp-internal/ui";
|
||||
@@ -1,495 +0,0 @@
|
||||
import type { Environment, Workspace } from "@yaakapp-internal/models";
|
||||
import { duplicateModel, patchModel } from "@yaakapp-internal/models";
|
||||
import type { TreeHandle, TreeNode, TreeProps } from "@yaakapp-internal/ui";
|
||||
import { Banner, Icon, InlineCode, SplitLayout, Tree, IconTooltip } from "@yaakapp-internal/ui";
|
||||
import { atom, useAtomValue } from "jotai";
|
||||
import { atomFamily } from "jotai-family";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { createSubEnvironment } from "../commands/createEnvironment";
|
||||
import { activeWorkspaceAtom, activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
||||
import {
|
||||
environmentsBreakdownAtom,
|
||||
useEnvironmentsBreakdown,
|
||||
} from "../hooks/useEnvironmentsBreakdown";
|
||||
import { useHotKey } from "../hooks/useHotKey";
|
||||
import { atomWithKVStorage } from "../lib/atoms/atomWithKVStorage";
|
||||
import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
|
||||
import { fireAndForget } from "../lib/fireAndForget";
|
||||
import { jotaiStore } from "../lib/jotai";
|
||||
import { isBaseEnvironment, isSubEnvironment } from "../lib/model_util";
|
||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
||||
import { showColorPicker } from "../lib/showColorPicker";
|
||||
import type { ContextMenuProps, DropdownItem } from "./core/Dropdown";
|
||||
import { ContextMenu } from "./core/Dropdown";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import type { PairEditorHandle } from "./core/PairEditor";
|
||||
import { EnvironmentColorIndicator } from "./EnvironmentColorIndicator";
|
||||
import { EnvironmentEditor } from "./EnvironmentEditor";
|
||||
import { EnvironmentSharableTooltip } from "./EnvironmentSharableTooltip";
|
||||
|
||||
const collapsedFamily = atomFamily((treeId: string) => {
|
||||
const key = ["env_collapsed", treeId ?? "n/a"];
|
||||
return atomWithKVStorage<Record<string, boolean>>(key, {});
|
||||
});
|
||||
|
||||
interface Props {
|
||||
initialEnvironmentId: string | null;
|
||||
setRef?: (ref: PairEditorHandle | null) => void;
|
||||
}
|
||||
|
||||
type TreeModel = Environment | Workspace;
|
||||
|
||||
/** Lets tree row slots create a sub-environment and select it in the editor. */
|
||||
const CreateSubEnvironmentContext = createContext<() => Promise<void>>(async () => {});
|
||||
|
||||
export function EnvironmentEditDialog({ initialEnvironmentId, setRef }: Props) {
|
||||
const { allEnvironments, baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown();
|
||||
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<string | null>(
|
||||
initialEnvironmentId ?? null,
|
||||
);
|
||||
|
||||
const selectedEnvironment =
|
||||
selectedEnvironmentId != null
|
||||
? allEnvironments.find((e) => e.id === selectedEnvironmentId)
|
||||
: baseEnvironment;
|
||||
|
||||
return (
|
||||
<SplitLayout
|
||||
storageKey="env_editor"
|
||||
defaultRatio={0.75}
|
||||
layout="horizontal"
|
||||
className="gap-0"
|
||||
resizeHandleClassName="-translate-x-px"
|
||||
firstSlot={() => (
|
||||
<EnvironmentEditDialogSidebar
|
||||
selectedEnvironmentId={selectedEnvironment?.id ?? null}
|
||||
setSelectedEnvironmentId={setSelectedEnvironmentId}
|
||||
/>
|
||||
)}
|
||||
secondSlot={() => (
|
||||
<div className="grid grid-rows-[auto_minmax(0,1fr)]">
|
||||
{baseEnvironments.length > 1 ? (
|
||||
<div className="p-3">
|
||||
<Banner color="notice">
|
||||
There are multiple base environments for this workspace. Please delete the
|
||||
environments you no longer need.
|
||||
</Banner>
|
||||
</div>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{selectedEnvironment == null ? (
|
||||
<div className="p-3 mt-10">
|
||||
<Banner color="danger">
|
||||
Failed to find selected environment <InlineCode>{selectedEnvironmentId}</InlineCode>
|
||||
</Banner>
|
||||
</div>
|
||||
) : (
|
||||
<EnvironmentEditor
|
||||
key={selectedEnvironment.id}
|
||||
setRef={setRef}
|
||||
className="pl-4 pt-3"
|
||||
environment={selectedEnvironment}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const sharableTooltip = (
|
||||
<IconTooltip
|
||||
tabIndex={-1}
|
||||
icon="eye"
|
||||
iconSize="sm"
|
||||
content="This environment will be included in Directory Sync and data exports"
|
||||
/>
|
||||
);
|
||||
|
||||
function EnvironmentEditDialogSidebar({
|
||||
selectedEnvironmentId,
|
||||
setSelectedEnvironmentId,
|
||||
}: {
|
||||
selectedEnvironmentId: string | null;
|
||||
setSelectedEnvironmentId: (id: string | null) => void;
|
||||
}) {
|
||||
const activeWorkspaceId = useAtomValue(activeWorkspaceIdAtom) ?? "";
|
||||
const treeId = `environment.${activeWorkspaceId}.sidebar`;
|
||||
const treeRef = useRef<TreeHandle>(null);
|
||||
const { allEnvironments, baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown();
|
||||
|
||||
// oxlint-disable-next-line react-hooks/exhaustive-deps -- none
|
||||
useLayoutEffect(() => {
|
||||
if (selectedEnvironmentId == null) return;
|
||||
treeRef.current?.selectItem(selectedEnvironmentId);
|
||||
treeRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// A just-created environment to select once it reaches the model store. Selecting it before
|
||||
// then would show a "failed to find" error in the editor, and the tree would drop the
|
||||
// selection of an item it doesn't have yet.
|
||||
const [pendingSelectionId, setPendingSelectionId] = useState<string | null>(null);
|
||||
const tree = useAtomValue(treeAtom);
|
||||
useEffect(() => {
|
||||
if (pendingSelectionId == null) return;
|
||||
if (!allEnvironments.some((e) => e.id === pendingSelectionId)) return;
|
||||
|
||||
setPendingSelectionId(null);
|
||||
setSelectedEnvironmentId(pendingSelectionId);
|
||||
|
||||
// The tree leaves out sub-environments while there are multiple base environments
|
||||
const node = tree == null ? null : findTreeNode(tree, pendingSelectionId);
|
||||
if (node == null) return;
|
||||
if (node.parent != null) {
|
||||
// Expand the parent so the new item is visible to be selected
|
||||
const parentId = node.parent.item.id;
|
||||
jotaiStore.set(collapsedFamily(treeId), (prev) => ({ ...prev, [parentId]: false }));
|
||||
}
|
||||
// Wait a frame so the tree's own pending selection fixups, which may have been scheduled
|
||||
// before the new item arrived, don't reset this selection
|
||||
requestAnimationFrame(() => treeRef.current?.selectItem(pendingSelectionId));
|
||||
}, [allEnvironments, pendingSelectionId, setSelectedEnvironmentId, tree, treeId]);
|
||||
|
||||
const handleDeleteEnvironment = useCallback(
|
||||
async (environment: Environment) => {
|
||||
await deleteModelWithConfirm(environment);
|
||||
if (selectedEnvironmentId === environment.id) {
|
||||
setSelectedEnvironmentId(baseEnvironment?.id ?? null);
|
||||
}
|
||||
},
|
||||
[baseEnvironment?.id, selectedEnvironmentId, setSelectedEnvironmentId],
|
||||
);
|
||||
|
||||
const handleCreateSubEnvironment = useCallback(async () => {
|
||||
const { baseEnvironment } = jotaiStore.get(environmentsBreakdownAtom);
|
||||
if (baseEnvironment == null) return;
|
||||
const id = await createSubEnvironment.mutateAsync(baseEnvironment);
|
||||
// Show the new environment in the editor without changing the workspace's active environment
|
||||
if (id != null) setPendingSelectionId(id);
|
||||
}, []);
|
||||
|
||||
const treeHasFocus = useCallback(() => treeRef.current?.hasFocus() ?? false, []);
|
||||
|
||||
const getSelectedTreeModels = useCallback(
|
||||
() => treeRef.current?.getSelectedItems() as TreeModel[] | undefined,
|
||||
[],
|
||||
);
|
||||
|
||||
const handleRenameSelected = useCallback(() => {
|
||||
const items = getSelectedTreeModels();
|
||||
if (items?.length === 1 && items[0] != null) {
|
||||
treeRef.current?.renameItem(items[0].id);
|
||||
}
|
||||
}, [getSelectedTreeModels]);
|
||||
|
||||
const handleDeleteSelected = useCallback(
|
||||
(items: TreeModel[]) => deleteModelWithConfirm(items),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDuplicateSelected = useCallback(
|
||||
async (items: TreeModel[]) => {
|
||||
if (items.length === 1 && items[0]) {
|
||||
const newId = await duplicateModel(items[0]);
|
||||
setSelectedEnvironmentId(newId);
|
||||
} else {
|
||||
await Promise.all(items.map(duplicateModel));
|
||||
}
|
||||
},
|
||||
[setSelectedEnvironmentId],
|
||||
);
|
||||
|
||||
useHotKey("sidebar.selected.rename", handleRenameSelected, {
|
||||
enable: treeHasFocus,
|
||||
allowDefault: true,
|
||||
priority: 100,
|
||||
});
|
||||
useHotKey(
|
||||
"sidebar.selected.delete",
|
||||
useCallback(() => {
|
||||
const items = getSelectedTreeModels();
|
||||
if (items) {
|
||||
fireAndForget(handleDeleteSelected(items));
|
||||
}
|
||||
}, [getSelectedTreeModels, handleDeleteSelected]),
|
||||
{ enable: treeHasFocus, priority: 100 },
|
||||
);
|
||||
useHotKey(
|
||||
"sidebar.selected.duplicate",
|
||||
useCallback(async () => {
|
||||
const items = getSelectedTreeModels();
|
||||
if (items) await handleDuplicateSelected(items);
|
||||
}, [getSelectedTreeModels, handleDuplicateSelected]),
|
||||
{ enable: treeHasFocus, priority: 100 },
|
||||
);
|
||||
|
||||
const getContextMenu = useCallback(
|
||||
(items: TreeModel[]): ContextMenuProps["items"] => {
|
||||
const environment = items[0];
|
||||
const addEnvironmentItem: DropdownItem = {
|
||||
label: "Create Sub Environment",
|
||||
leftSlot: <Icon icon="plus" />,
|
||||
onSelect: handleCreateSubEnvironment,
|
||||
};
|
||||
// Sub-environments aren't shown while there are multiple base environments, same as the
|
||||
// plus button on the base environment row
|
||||
const canCreateSubEnvironment = baseEnvironments.length <= 1;
|
||||
|
||||
if (environment == null || environment.model !== "environment") {
|
||||
return canCreateSubEnvironment ? [addEnvironmentItem] : [];
|
||||
}
|
||||
|
||||
const singleEnvironment = items.length === 1;
|
||||
const canDeleteEnvironment =
|
||||
isSubEnvironment(environment) ||
|
||||
(isBaseEnvironment(environment) && baseEnvironments.length > 1);
|
||||
|
||||
const menuItems: DropdownItem[] = [
|
||||
{
|
||||
label: "Rename",
|
||||
leftSlot: <Icon icon="pencil" />,
|
||||
hidden: isBaseEnvironment(environment) || !singleEnvironment,
|
||||
hotKeyAction: "sidebar.selected.rename",
|
||||
hotKeyLabelOnly: true,
|
||||
onSelect: () => {
|
||||
// Not sure why this is needed, but without it the
|
||||
// edit input blurs immediately after opening.
|
||||
requestAnimationFrame(() => handleRenameSelected());
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Duplicate",
|
||||
leftSlot: <Icon icon="copy" />,
|
||||
hidden: isBaseEnvironment(environment),
|
||||
hotKeyAction: "sidebar.selected.duplicate",
|
||||
hotKeyLabelOnly: true,
|
||||
onSelect: () => handleDuplicateSelected(items),
|
||||
},
|
||||
{
|
||||
label: environment.color ? "Change Color" : "Assign Color",
|
||||
leftSlot: <Icon icon="palette" />,
|
||||
hidden: isBaseEnvironment(environment) || !singleEnvironment,
|
||||
onSelect: async () => showColorPicker(environment),
|
||||
},
|
||||
{
|
||||
label: `Make ${environment.public ? "Private" : "Sharable"}`,
|
||||
leftSlot: <Icon icon={environment.public ? "eye_closed" : "eye"} />,
|
||||
rightSlot: <EnvironmentSharableTooltip />,
|
||||
hidden: items.length > 1,
|
||||
onSelect: async () => {
|
||||
await patchModel(environment, { public: !environment.public });
|
||||
},
|
||||
},
|
||||
{
|
||||
color: "danger",
|
||||
label: "Delete",
|
||||
hotKeyAction: "sidebar.selected.delete",
|
||||
hotKeyLabelOnly: true,
|
||||
hidden: !canDeleteEnvironment,
|
||||
leftSlot: <Icon icon="trash" />,
|
||||
onSelect: () => handleDeleteEnvironment(environment),
|
||||
},
|
||||
];
|
||||
|
||||
// Add sub environment to base environment
|
||||
if (isBaseEnvironment(environment) && singleEnvironment && canCreateSubEnvironment) {
|
||||
menuItems.push({ type: "separator" });
|
||||
menuItems.push(addEnvironmentItem);
|
||||
}
|
||||
|
||||
return menuItems;
|
||||
},
|
||||
[
|
||||
baseEnvironments.length,
|
||||
handleCreateSubEnvironment,
|
||||
handleDeleteEnvironment,
|
||||
handleDuplicateSelected,
|
||||
handleRenameSelected,
|
||||
],
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(async function handleDragEnd({
|
||||
items,
|
||||
children,
|
||||
insertAt,
|
||||
}: {
|
||||
items: TreeModel[];
|
||||
children: TreeModel[];
|
||||
insertAt: number;
|
||||
}) {
|
||||
const prev = children[insertAt - 1] as Exclude<TreeModel, Workspace>;
|
||||
const next = children[insertAt] as Exclude<TreeModel, Workspace>;
|
||||
|
||||
const beforePriority = prev?.sortPriority ?? 0;
|
||||
const afterPriority = next?.sortPriority ?? 0;
|
||||
const shouldUpdateAll = afterPriority - beforePriority < 1;
|
||||
|
||||
try {
|
||||
if (shouldUpdateAll) {
|
||||
// Add items to children at insertAt
|
||||
children.splice(insertAt, 0, ...items);
|
||||
await Promise.all(children.map((m, i) => patchModel(m, { sortPriority: i * 1000 })));
|
||||
} else {
|
||||
const range = afterPriority - beforePriority;
|
||||
const increment = range / (items.length + 2);
|
||||
await Promise.all(
|
||||
items.map((m, i) => {
|
||||
const sortPriority = beforePriority + (i + 1) * increment;
|
||||
// Spread item sortPriority out over before/after range
|
||||
return patchModel(m, { sortPriority });
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleActivate = useCallback(
|
||||
(item: TreeModel) => {
|
||||
setSelectedEnvironmentId(item.id);
|
||||
},
|
||||
[setSelectedEnvironmentId],
|
||||
);
|
||||
|
||||
const renderContextMenuFn = useCallback<NonNullable<TreeProps<TreeModel>["renderContextMenu"]>>(
|
||||
({ items, position, onClose }) =>
|
||||
// Right-clicking empty space has nothing to offer while there are multiple base environments
|
||||
items.length === 0 ? null : (
|
||||
<ContextMenu items={items as DropdownItem[]} triggerPosition={position} onClose={onClose} />
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<aside className="x-theme-sidebar h-full w-full min-w-0 grid overflow-y-auto border-r border-border-subtle ">
|
||||
{tree != null && (
|
||||
<CreateSubEnvironmentContext.Provider value={handleCreateSubEnvironment}>
|
||||
<div className="pt-2">
|
||||
<Tree
|
||||
ref={treeRef}
|
||||
treeId={treeId}
|
||||
collapsedAtom={collapsedFamily(treeId)}
|
||||
className="px-2 pb-10"
|
||||
root={tree}
|
||||
getContextMenu={getContextMenu}
|
||||
renderContextMenu={renderContextMenuFn}
|
||||
onDragEnd={handleDragEnd}
|
||||
getItemKey={(i) => `${i.id}::${i.name}`}
|
||||
ItemLeftSlotInner={ItemLeftSlotInner}
|
||||
ItemRightSlot={ItemRightSlot}
|
||||
ItemInner={ItemInner}
|
||||
onActivate={handleActivate}
|
||||
getEditOptions={getEditOptions}
|
||||
/>
|
||||
</div>
|
||||
</CreateSubEnvironmentContext.Provider>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
const treeAtom = atom<TreeNode<TreeModel> | null>((get) => {
|
||||
const activeWorkspace = get(activeWorkspaceAtom);
|
||||
const { baseEnvironment, baseEnvironments, subEnvironments } = get(environmentsBreakdownAtom);
|
||||
if (activeWorkspace == null || baseEnvironment == null) return null;
|
||||
|
||||
const root: TreeNode<TreeModel> = {
|
||||
item: activeWorkspace,
|
||||
parent: null,
|
||||
children: [],
|
||||
depth: 0,
|
||||
};
|
||||
|
||||
for (const item of baseEnvironments) {
|
||||
root.children?.push({
|
||||
item,
|
||||
parent: root,
|
||||
depth: 0,
|
||||
draggable: false,
|
||||
});
|
||||
}
|
||||
|
||||
const parent = root.children?.[0];
|
||||
if (baseEnvironments.length <= 1 && parent != null) {
|
||||
parent.children = subEnvironments.map((item) => ({
|
||||
item,
|
||||
parent,
|
||||
depth: 1,
|
||||
localDrag: true,
|
||||
}));
|
||||
}
|
||||
|
||||
return root;
|
||||
});
|
||||
|
||||
function findTreeNode(node: TreeNode<TreeModel>, id: string): TreeNode<TreeModel> | null {
|
||||
if (node.item.id === id) return node;
|
||||
for (const child of node.children ?? []) {
|
||||
const found = findTreeNode(child, id);
|
||||
if (found != null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function ItemLeftSlotInner({ item }: { item: TreeModel }) {
|
||||
const { baseEnvironments } = useEnvironmentsBreakdown();
|
||||
return baseEnvironments.length > 1 ? (
|
||||
<Icon icon="alert_triangle" color="notice" />
|
||||
) : (
|
||||
item.model === "environment" && item.color && <EnvironmentColorIndicator environment={item} />
|
||||
);
|
||||
}
|
||||
|
||||
function ItemRightSlot({ item }: { item: TreeModel }) {
|
||||
const { baseEnvironments } = useEnvironmentsBreakdown();
|
||||
const handleCreateSubEnvironment = useContext(CreateSubEnvironmentContext);
|
||||
return (
|
||||
<>
|
||||
{item.model === "environment" && baseEnvironments.length <= 1 && isBaseEnvironment(item) && (
|
||||
<IconButton
|
||||
size="sm"
|
||||
color="custom"
|
||||
iconSize="sm"
|
||||
icon="plus_circle"
|
||||
className="opacity-50 hover:opacity-100"
|
||||
title="Add Sub-Environment"
|
||||
onClick={handleCreateSubEnvironment}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemInner({ item }: { item: TreeModel }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[auto_minmax(0,1fr)] w-full items-center">
|
||||
{item.model === "environment" && item.public ? (
|
||||
<div className="mr-2 flex items-center">{sharableTooltip}</div>
|
||||
) : (
|
||||
<span aria-hidden />
|
||||
)}
|
||||
<div className="truncate min-w-0 text-left">{resolvedModelName(item)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getEditOptions(item: TreeModel) {
|
||||
const options: ReturnType<NonNullable<TreeProps<TreeModel>["getEditOptions"]>> = {
|
||||
defaultValue: item.name,
|
||||
placeholder: "Name",
|
||||
async onChange(item, name) {
|
||||
await patchModel(item, { name });
|
||||
},
|
||||
};
|
||||
return options;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { ErrorBoundary as BaseErrorBoundary } from "@yaakapp-internal/ui";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import RouteError from "./RouteError";
|
||||
|
||||
export function ErrorBoundary({ name, children }: { name: string; children: ReactNode }) {
|
||||
return (
|
||||
<BaseErrorBoundary name={name} onShowError={showErrorDialog}>
|
||||
{children}
|
||||
</BaseErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
export function showErrorDialog(error: unknown) {
|
||||
showDialog({ id: "error-boundary", render: () => <RouteError error={error} /> });
|
||||
}
|
||||
|
||||
export function ErrorBoundaryTestThrow() {
|
||||
useEffect(() => {
|
||||
throw new Error("test error");
|
||||
});
|
||||
|
||||
return <div>Hello</div>;
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { HStack, VStack } from "@yaakapp-internal/ui";
|
||||
import { useRef, useState } from "react";
|
||||
import type { FeedbackFeature } from "../lib/featureFeedbackConstants";
|
||||
import { FEEDBACK_FEATURES } from "../lib/featureFeedbackConstants";
|
||||
import { rpc } from "../lib/rpc";
|
||||
import { hideToastById, showToast } from "../lib/toast";
|
||||
import { Button } from "./core/Button";
|
||||
import { Input } from "./core/Input";
|
||||
|
||||
interface Props {
|
||||
feature: FeedbackFeature;
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
export function FeedbackToast({ feature, onDone }: Props) {
|
||||
const [text, setText] = useState<string>("");
|
||||
const [sent, setSent] = useState(false);
|
||||
const sentRef = useRef(false);
|
||||
|
||||
const handleDismiss = () => {
|
||||
onDone();
|
||||
hideToastById(`feature-feedback-${feature}`);
|
||||
};
|
||||
|
||||
const handleSend = () => {
|
||||
const trimmedText = text.trim();
|
||||
if (sentRef.current || trimmedText.length === 0) return;
|
||||
|
||||
sentRef.current = true;
|
||||
setSent(true);
|
||||
onDone();
|
||||
|
||||
// Fire-and-forget; failures are intentionally ignored
|
||||
rpc("cmd_send_feedback", { feature, text: trimmedText }).catch(() => {});
|
||||
showToast({
|
||||
id: `feature-feedback-${feature}`,
|
||||
timeout: 3000,
|
||||
color: "success",
|
||||
message: "Thanks for the feedback!",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack space={2}>
|
||||
<p className="text-sm font-semibold">{FEEDBACK_FEATURES[feature]}</p>
|
||||
<div className="h-20">
|
||||
<Input
|
||||
size="xs"
|
||||
// The editor forces its mono font on the scroller, so the override
|
||||
// has to target it directly
|
||||
className="[&_.cm-scroller]:font-sans! [&_.cm-scroller]:text-sm!"
|
||||
label="Feedback"
|
||||
hideLabel
|
||||
stateKey={null}
|
||||
multiLine
|
||||
fullHeight
|
||||
placeholder="Your thoughts..."
|
||||
onChange={setText}
|
||||
/>
|
||||
</div>
|
||||
<HStack space={1.5} justifyContent="end">
|
||||
<Button size="xs" color="secondary" variant="border" onClick={handleDismiss}>
|
||||
Dismiss
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
color="primary"
|
||||
disabled={sent || text.trim().length === 0}
|
||||
onClick={handleSend}
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
import { createWorkspaceModel, foldersAtom, patchModel } from "@yaakapp-internal/models";
|
||||
import { HStack, Icon, InlineCode, VStack, CountBadge } 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 { Input } from "./core/Input";
|
||||
import { Link } from "./core/Link";
|
||||
import type { TabItem } from "./core/Tabs/Tabs";
|
||||
import { TabContent, Tabs } from "./core/Tabs/Tabs";
|
||||
import { EmptyStateText } from "./EmptyStateText";
|
||||
import { EnvironmentEditor } from "./EnvironmentEditor";
|
||||
import { HeadersEditor } from "./HeadersEditor";
|
||||
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
|
||||
import { MarkdownEditor } from "./MarkdownEditor";
|
||||
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
|
||||
|
||||
interface Props {
|
||||
folderId: string | null;
|
||||
tab?: FolderSettingsTab;
|
||||
}
|
||||
|
||||
const TAB_AUTH = "auth";
|
||||
const TAB_HEADERS = "headers";
|
||||
const TAB_SETTINGS = "settings";
|
||||
const TAB_VARIABLES = "variables";
|
||||
const TAB_GENERAL = "general";
|
||||
|
||||
export type FolderSettingsTab =
|
||||
| typeof TAB_AUTH
|
||||
| typeof TAB_HEADERS
|
||||
| typeof TAB_GENERAL
|
||||
| typeof TAB_SETTINGS
|
||||
| typeof TAB_VARIABLES;
|
||||
|
||||
export function FolderSettingsDialog({ folderId, tab }: Props) {
|
||||
const folders = useAtomValue(foldersAtom);
|
||||
const folder = folders.find((f) => f.id === folderId) ?? null;
|
||||
const ancestors = useModelAncestors(folder);
|
||||
const breadcrumbs = useMemo(() => ancestors.toReversed(), [ancestors]);
|
||||
const authTab = useAuthTab(TAB_AUTH, folder);
|
||||
const headersTab = useHeadersTab(TAB_HEADERS, folder);
|
||||
const inheritedHeaders = useInheritedHeaders(folder);
|
||||
const environments = useEnvironmentsBreakdown();
|
||||
const folderEnvironment = environments.allEnvironments.find(
|
||||
(e) => e.parentModel === "folder" && e.parentId === folderId,
|
||||
);
|
||||
const numVars = (folderEnvironment?.variables ?? []).filter((v) => v.name).length;
|
||||
const numSettingsOverrides = folder == null ? 0 : countOverriddenSettings(folder);
|
||||
|
||||
const tabs = useMemo<TabItem[]>(() => {
|
||||
if (folder == null) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
value: TAB_GENERAL,
|
||||
label: "General",
|
||||
},
|
||||
{
|
||||
value: TAB_SETTINGS,
|
||||
label: "Settings",
|
||||
rightSlot: <CountBadge count={numSettingsOverrides} />,
|
||||
},
|
||||
...headersTab,
|
||||
...authTab,
|
||||
{
|
||||
value: TAB_VARIABLES,
|
||||
label: "Variables",
|
||||
rightSlot: numVars > 0 ? <CountBadge count={numVars} /> : null,
|
||||
},
|
||||
];
|
||||
}, [authTab, folder, headersTab, numSettingsOverrides, numVars]);
|
||||
|
||||
if (folder == null) return null;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex items-center gap-3 px-6 pr-10 mt-4 mb-2 min-w-0 text-xl">
|
||||
<Icon icon="folder_cog" size="lg" color="secondary" className="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 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 shrink-0" />
|
||||
)}
|
||||
<span className="whitespace-nowrap" title={folder.name}>
|
||||
{folder.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
defaultValue={tab ?? TAB_GENERAL}
|
||||
label="Folder Settings"
|
||||
className="pt-2 pb-2 pl-3 pr-1 flex-1"
|
||||
layout="horizontal"
|
||||
addBorders
|
||||
tabs={tabs}
|
||||
>
|
||||
<TabContent value={TAB_AUTH} className="overflow-y-auto h-full px-4">
|
||||
<HttpAuthenticationEditor model={folder} />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-4">
|
||||
<div className="grid grid-rows-[auto_minmax(0,1fr)_auto] gap-3 pb-3 h-full">
|
||||
<Input
|
||||
label="Folder Name"
|
||||
defaultValue={folder.name}
|
||||
onChange={(name) => patchModel(folder, { name })}
|
||||
stateKey={`name.${folder.id}`}
|
||||
/>
|
||||
<MarkdownEditor
|
||||
name="folder-description"
|
||||
placeholder="Folder description"
|
||||
className="border border-border px-2"
|
||||
defaultValue={folder.description}
|
||||
stateKey={`description.${folder.id}`}
|
||||
onChange={(description) => patchModel(folder, { description })}
|
||||
/>
|
||||
<HStack alignItems="center" justifyContent="between" className="w-full">
|
||||
<Button
|
||||
onClick={async () => {
|
||||
const didDelete = await deleteModelWithConfirm(folder);
|
||||
if (didDelete) {
|
||||
hideDialog("folder-settings");
|
||||
}
|
||||
}}
|
||||
color="danger"
|
||||
variant="border"
|
||||
size="xs"
|
||||
>
|
||||
Delete Folder
|
||||
</Button>
|
||||
<InlineCode className="flex gap-1 items-center text-primary pl-2.5">
|
||||
{folder.id}
|
||||
<CopyIconButton
|
||||
className="opacity-70 text-primary!"
|
||||
size="2xs"
|
||||
iconSize="sm"
|
||||
title="Copy folder ID"
|
||||
text={folder.id}
|
||||
/>
|
||||
</InlineCode>
|
||||
</HStack>
|
||||
</div>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_HEADERS} className="overflow-y-auto h-full px-4">
|
||||
<HeadersEditor
|
||||
inheritedHeaders={inheritedHeaders}
|
||||
forceUpdateKey={folder.id}
|
||||
headers={folder.headers}
|
||||
onChange={(headers) => patchModel(folder, { headers })}
|
||||
stateKey={`headers.${folder.id}`}
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_SETTINGS} className="overflow-y-auto h-full px-4">
|
||||
<ModelSettingsEditor model={folder} />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_VARIABLES} className="overflow-y-auto h-full px-4">
|
||||
{folderEnvironment == null ? (
|
||||
<EmptyStateText>
|
||||
<VStack alignItems="center" space={1.5}>
|
||||
<p>
|
||||
Override{" "}
|
||||
<Link href="https://yaak.app/docs/using-yaak/environments-and-variables">
|
||||
Variables
|
||||
</Link>{" "}
|
||||
for requests within this folder.
|
||||
</p>
|
||||
<Button
|
||||
variant="border"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
await createWorkspaceModel({
|
||||
workspaceId: folder.workspaceId,
|
||||
parentModel: "folder",
|
||||
parentId: folder.id,
|
||||
model: "environment",
|
||||
name: "Folder Environment",
|
||||
});
|
||||
}}
|
||||
>
|
||||
Create Folder Environment
|
||||
</Button>
|
||||
</VStack>
|
||||
</EmptyStateText>
|
||||
) : (
|
||||
<EnvironmentEditor hideName environment={folderEnvironment} />
|
||||
)}
|
||||
</TabContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import type { NavigateWorkspace } from "@yaakapp-internal/tauri-client";
|
||||
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 { usePlatformEvent } from "../hooks/usePlatformEvent";
|
||||
import { useSyncZoomSetting } from "../hooks/useSyncZoomSetting";
|
||||
import { useSubscribeTemplateFunctions } from "../hooks/useTemplateFunctions";
|
||||
import { fireAndForget } from "../lib/fireAndForget";
|
||||
import { jotaiStore } from "../lib/jotai";
|
||||
import { renameModelWithPrompt } from "../lib/renameModelWithPrompt";
|
||||
import { router } from "../lib/router";
|
||||
|
||||
export function GlobalHooks() {
|
||||
useSyncZoomSetting();
|
||||
useSyncFontSizeSetting();
|
||||
|
||||
useSubscribeActiveWorkspaceId();
|
||||
|
||||
useSyncWorkspaceChildModels();
|
||||
useSubscribeTemplateFunctions();
|
||||
useSubscribeHttpAuthentication();
|
||||
|
||||
// Other useful things
|
||||
useActiveWorkspaceChangedToast();
|
||||
useSubscribeHotKeys();
|
||||
|
||||
usePlatformEvent("show_home", () => {
|
||||
fireAndForget(router.navigate({ to: "/", search: { home: true } }));
|
||||
});
|
||||
|
||||
usePlatformEvent<NavigateWorkspace>("navigate_workspace", ({ workspaceId, environmentId }) => {
|
||||
fireAndForget(
|
||||
router.navigate({
|
||||
to: "/workspaces/$workspaceId",
|
||||
params: { workspaceId },
|
||||
search: { environment_id: environmentId },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
useHotKey(
|
||||
"request.rename",
|
||||
async () => {
|
||||
const model = jotaiStore.get(activeRequestAtom);
|
||||
if (model == null) return;
|
||||
await renameModelWithPrompt(model);
|
||||
},
|
||||
{ allowDefault: true },
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,303 +0,0 @@
|
||||
import { linter } from "@codemirror/lint";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { jsoncLanguage } from "@shopify/lang-jsonc";
|
||||
import { type GrpcRequest, patchModel } from "@yaakapp-internal/models";
|
||||
import { Banner, FormattedError, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import {
|
||||
handleRefresh,
|
||||
jsonCompletion,
|
||||
jsonSchemaLinter,
|
||||
stateExtensions,
|
||||
updateSchema,
|
||||
} from "codemirror-json-schema";
|
||||
import type { JSONSchema7 } from "json-schema";
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { ReflectResponseService } from "../hooks/useGrpc";
|
||||
import { wasUpdatedExternally } from "../hooks/useRequestUpdateKey";
|
||||
import { showAlert } from "../lib/alert";
|
||||
import { showConfirm } from "../lib/confirm";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import type { JsonSchema } from "../lib/jsonSchemaExample";
|
||||
import { buildExampleFromSchema } from "../lib/jsonSchemaExample";
|
||||
import { queryClient } from "../lib/queryClient";
|
||||
import { Button } from "./core/Button";
|
||||
import { Dropdown } from "./core/Dropdown";
|
||||
import type { EditorProps } from "./core/Editor/Editor";
|
||||
import { Editor } from "./core/Editor/LazyEditor";
|
||||
import { GrpcProtoSelectionDialog } from "./GrpcProtoSelectionDialog";
|
||||
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
|
||||
|
||||
type Props = Pick<EditorProps, "heightMode" | "onChange" | "className" | "forceUpdateKey"> & {
|
||||
services: ReflectResponseService[] | null;
|
||||
reflectionError?: string;
|
||||
reflectionLoading?: boolean;
|
||||
request: GrpcRequest;
|
||||
protoFiles: string[];
|
||||
};
|
||||
|
||||
type MethodSchema =
|
||||
| { type: "none" }
|
||||
| { type: "schema"; schema: JsonSchema }
|
||||
| { type: "error"; id: string; title: string; body: ReactNode; log: unknown[] };
|
||||
|
||||
export function GrpcEditor({
|
||||
services,
|
||||
reflectionError,
|
||||
reflectionLoading,
|
||||
request,
|
||||
protoFiles,
|
||||
...extraEditorProps
|
||||
}: Props) {
|
||||
const [editorView, setEditorView] = useState<EditorView | null>(null);
|
||||
const handleInitEditorViewRef = useCallback((h: EditorView | null) => {
|
||||
setEditorView(h);
|
||||
}, []);
|
||||
|
||||
// Find the schema for the selected service and method
|
||||
const methodSchema = useMemo<MethodSchema>(() => {
|
||||
if (services === null || request.service === null || request.method === null) {
|
||||
return { type: "none" };
|
||||
}
|
||||
|
||||
const s = services.find((s) => s.name === request.service);
|
||||
if (s == null) {
|
||||
return {
|
||||
type: "error",
|
||||
id: "grpc-find-service-error",
|
||||
title: "Couldn't Find Service",
|
||||
body: (
|
||||
<>
|
||||
Failed to find service <InlineCode>{request.service}</InlineCode> in schema
|
||||
</>
|
||||
),
|
||||
log: ["Failed to find service", { service: request.service, services }],
|
||||
};
|
||||
}
|
||||
|
||||
const schema = s.methods.find((m) => m.name === request.method)?.schema;
|
||||
if (schema == null) {
|
||||
return {
|
||||
type: "error",
|
||||
id: "grpc-find-schema-error",
|
||||
title: "Couldn't Find Method",
|
||||
body: (
|
||||
<>
|
||||
Failed to find method <InlineCode>{request.method}</InlineCode> for{" "}
|
||||
<InlineCode>{request.service}</InlineCode> in schema
|
||||
</>
|
||||
),
|
||||
log: ["Failed to find method", { method: request.method, methods: s.methods }],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return { type: "schema", schema: JSON.parse(schema) as JsonSchema };
|
||||
} catch (err) {
|
||||
return {
|
||||
type: "error",
|
||||
id: "grpc-parse-schema-error",
|
||||
title: "Failed to Parse Schema",
|
||||
body: (
|
||||
<VStack space={4}>
|
||||
<p>
|
||||
For service <InlineCode>{request.service}</InlineCode> and method{" "}
|
||||
<InlineCode>{request.method}</InlineCode>
|
||||
</p>
|
||||
<FormattedError>{String(err)}</FormattedError>
|
||||
</VStack>
|
||||
),
|
||||
log: ["Failed to parse schema", err],
|
||||
};
|
||||
}
|
||||
}, [services, request.method, request.service]);
|
||||
|
||||
useEffect(() => {
|
||||
if (methodSchema.type !== "error") return;
|
||||
console.log(...methodSchema.log);
|
||||
showAlert({ id: methodSchema.id, title: methodSchema.title, body: methodSchema.body });
|
||||
}, [methodSchema]);
|
||||
|
||||
// Update the editor whenever the schema changes
|
||||
useEffect(() => {
|
||||
if (editorView == null || methodSchema.type !== "schema") return;
|
||||
updateSchema(editorView, methodSchema.schema as JSONSchema7);
|
||||
}, [editorView, methodSchema]);
|
||||
|
||||
const extraExtensions = useMemo(
|
||||
() => [
|
||||
linter(jsonSchemaLinter(), {
|
||||
delay: 200,
|
||||
needsRefresh: handleRefresh,
|
||||
}),
|
||||
jsoncLanguage.data.of({
|
||||
autocomplete: jsonCompletion(),
|
||||
}),
|
||||
stateExtensions({}),
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const reflectionUnavailable = reflectionError?.match(/unimplemented/i);
|
||||
reflectionError = reflectionUnavailable ? undefined : reflectionError;
|
||||
|
||||
const handleGenerateExample = useCallback(async () => {
|
||||
if (methodSchema.type !== "schema") return;
|
||||
|
||||
if (request.message.trim() !== "") {
|
||||
const confirmed = await showConfirm({
|
||||
id: "grpc-generate-example",
|
||||
title: "Generate Example",
|
||||
description: "The current message will be replaced with an example.",
|
||||
confirmText: "Generate",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
const message = JSON.stringify(buildExampleFromSchema(methodSchema.schema), null, 2);
|
||||
await patchModel(request, { message });
|
||||
|
||||
// Force the editor to pick up the new message
|
||||
wasUpdatedExternally(request.id);
|
||||
}, [methodSchema, request]);
|
||||
|
||||
// The reflect query is keyed by request, url and proto files, so a prefix invalidate
|
||||
// reaches it without threading a refetch down from the connection layout.
|
||||
const handleReloadSchema = useCallback(
|
||||
() => queryClient.invalidateQueries({ queryKey: ["grpc_reflect", request.id] }),
|
||||
[request.id],
|
||||
);
|
||||
|
||||
const handleShowReflectionError = useCallback(() => {
|
||||
showDialog({
|
||||
id: "grpc-reflection-error",
|
||||
title: "Reflection Failed",
|
||||
size: "sm",
|
||||
render: ({ hide }) => (
|
||||
<>
|
||||
<FormattedError>{reflectionError ?? "unknown"}</FormattedError>
|
||||
<div className="w-full my-4">
|
||||
<Button
|
||||
className="ml-auto"
|
||||
color="primary"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
hide();
|
||||
await handleReloadSchema();
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
});
|
||||
}, [handleReloadSchema, reflectionError]);
|
||||
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
// Matches the GraphQL editor: one always-visible control labelled by schema state,
|
||||
// with everything schema-related behind it.
|
||||
<div key="schema" className="opacity-100!">
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
// Hidden for servers without reflection, which isn't an error
|
||||
hidden: !reflectionError,
|
||||
type: "content",
|
||||
label: (
|
||||
<Banner color="danger">
|
||||
<p className="mb-1">Reflection failed</p>
|
||||
<Button
|
||||
size="xs"
|
||||
color="danger"
|
||||
variant="border"
|
||||
onClick={handleShowReflectionError}
|
||||
>
|
||||
View Error
|
||||
</Button>
|
||||
</Banner>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Generate Example Message",
|
||||
leftSlot: <Icon icon="magic_wand" />,
|
||||
disabled: methodSchema.type !== "schema",
|
||||
onSelect: handleGenerateExample,
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Reload Schema",
|
||||
leftSlot: <Icon icon="refresh" spin={reflectionLoading} />,
|
||||
keepOpenOnSelect: true,
|
||||
onSelect: handleReloadSchema,
|
||||
},
|
||||
{
|
||||
label: protoFiles.length > 0 ? "Select Proto Files\u2026" : "Configure Schema\u2026",
|
||||
leftSlot: <Icon icon="settings" />,
|
||||
onSelect: () => {
|
||||
showDialog({
|
||||
title: "Configure Schema",
|
||||
size: "md",
|
||||
className: "h-[30rem]",
|
||||
id: "grpc-configure-schema",
|
||||
render: ({ hide }) => <GrpcProtoSelectionDialog onDone={hide} />,
|
||||
});
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="border"
|
||||
title="Schema"
|
||||
forDropdown
|
||||
isLoading={reflectionLoading}
|
||||
color={reflectionUnavailable ? "info" : reflectionError ? "danger" : "default"}
|
||||
>
|
||||
{reflectionLoading
|
||||
? "Inspecting Schema"
|
||||
: reflectionUnavailable
|
||||
? "Select Proto Files"
|
||||
: reflectionError
|
||||
? "Server Error"
|
||||
: protoFiles.length > 0
|
||||
? pluralizeCount("File", protoFiles.length)
|
||||
: services != null
|
||||
? "Schema Detected"
|
||||
: "Select Schema"}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>,
|
||||
],
|
||||
[
|
||||
handleGenerateExample,
|
||||
handleReloadSchema,
|
||||
handleShowReflectionError,
|
||||
methodSchema.type,
|
||||
protoFiles.length,
|
||||
reflectionError,
|
||||
reflectionLoading,
|
||||
reflectionUnavailable,
|
||||
services,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full grid grid-cols-1 grid-rows-[minmax(0,100%)_auto_auto_minmax(0,auto)]">
|
||||
<Editor
|
||||
setRef={handleInitEditorViewRef}
|
||||
language="json"
|
||||
autocompleteFunctions
|
||||
autocompleteVariables
|
||||
defaultValue={request.message}
|
||||
heightMode="auto"
|
||||
placeholder="..."
|
||||
extraExtensions={extraExtensions}
|
||||
actions={actions}
|
||||
stateKey={`grpc_message.${request.id}`}
|
||||
{...extraEditorProps}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
import type { GrpcRequest } from "@yaakapp-internal/models";
|
||||
import { Banner, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import { useActiveRequest } from "../hooks/useActiveRequest";
|
||||
import { useGrpc } from "../hooks/useGrpc";
|
||||
import { useGrpcProtoFiles } from "../hooks/useGrpcProtoFiles";
|
||||
import { DialogFooter } from "./core/Dialog";
|
||||
import { PathList } from "./core/PathList";
|
||||
import { Link } from "./core/Link";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
|
||||
|
||||
interface Props {
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
export function GrpcProtoSelectionDialog(props: Props) {
|
||||
const request = useActiveRequest();
|
||||
if (request?.model !== "grpc_request") return null;
|
||||
|
||||
return <GrpcProtoSelectionDialogWithRequest request={request} {...props} />;
|
||||
}
|
||||
|
||||
function GrpcProtoSelectionDialogWithRequest({
|
||||
request,
|
||||
onDone,
|
||||
}: Props & { request: GrpcRequest }) {
|
||||
const protoFilesKv = useGrpcProtoFiles(request.id);
|
||||
const protoFiles = protoFilesKv.value ?? [];
|
||||
const grpc = useGrpc(request, null, protoFiles);
|
||||
const services = grpc.reflect.data;
|
||||
const serverReflection = protoFiles.length === 0 && services != null;
|
||||
let reflectError = grpc.reflect.error ?? null;
|
||||
const reflectionUnimplemented = String(reflectError).match(/unimplemented/i);
|
||||
|
||||
if (reflectionUnimplemented) {
|
||||
reflectError = null;
|
||||
}
|
||||
|
||||
if (request == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<VStack space={5} className="pb-4">
|
||||
{reflectError && (
|
||||
<Banner color="warning">
|
||||
<h1 className="font-bold">
|
||||
Reflection failed on URL <InlineCode>{request.url || "n/a"}</InlineCode>
|
||||
</h1>
|
||||
<p>{reflectError.trim()}</p>
|
||||
</Banner>
|
||||
)}
|
||||
{!serverReflection && services != null && services.length > 0 && (
|
||||
<Banner className="flex flex-col gap-2">
|
||||
<p>
|
||||
Found services{" "}
|
||||
{services?.slice(0, 5).map((s, i) => {
|
||||
return (
|
||||
<span key={s.name + s.methods.map((m) => m.name).join(",")}>
|
||||
<InlineCode>{s.name}</InlineCode>
|
||||
{i === services.length - 1 ? "" : i === services.length - 2 ? " and " : ", "}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{services?.length > 5 && pluralizeCount("other", services?.length - 5)}
|
||||
</p>
|
||||
</Banner>
|
||||
)}
|
||||
{serverReflection && services != null && services.length > 0 && (
|
||||
<Banner className="flex flex-col gap-2">
|
||||
<p>
|
||||
Server reflection found services
|
||||
{services?.map((s, i) => {
|
||||
return (
|
||||
<span key={s.name + s.methods.map((m) => m.name).join(",")}>
|
||||
<InlineCode>{s.name}</InlineCode>
|
||||
{i === services.length - 1 ? "" : i === services.length - 2 ? " and " : ", "}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
. You can override this schema by manually selecting <InlineCode>*.proto</InlineCode>{" "}
|
||||
files.
|
||||
</p>
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
<PathList
|
||||
items={protoFiles.map((path) => ({
|
||||
path,
|
||||
icon: path.endsWith(".proto") ? "file_code" : "folder_code",
|
||||
}))}
|
||||
onRemove={async (path) => {
|
||||
await protoFilesKv.set(protoFiles.filter((p) => p !== path));
|
||||
await grpc.reflect.refetch();
|
||||
}}
|
||||
addLabel="Add proto files or an import folder"
|
||||
addItems={[
|
||||
{
|
||||
label: "Proto files",
|
||||
icon: "file_code",
|
||||
onSelect: async () => {
|
||||
const selected = await platform.dialog.open({
|
||||
title: "Select Proto Files",
|
||||
multiple: true,
|
||||
filters: [{ name: "Proto Files", extensions: ["proto"] }],
|
||||
});
|
||||
if (selected == null) return;
|
||||
const newFiles = selected.filter((p) => !protoFiles.includes(p));
|
||||
await protoFilesKv.set([...protoFiles, ...newFiles]);
|
||||
await grpc.reflect.refetch();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Import folder",
|
||||
icon: "folder_code",
|
||||
onSelect: async () => {
|
||||
const selected = await platform.dialog.open({
|
||||
title: "Select Proto Directory",
|
||||
directory: true,
|
||||
});
|
||||
if (selected == null) return;
|
||||
await protoFilesKv.set([...protoFiles.filter((f) => f !== selected), selected]);
|
||||
await grpc.reflect.refetch();
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{reflectionUnimplemented && protoFiles.length === 0 && (
|
||||
<Banner>
|
||||
<InlineCode>{request.url}</InlineCode> doesn't implement{" "}
|
||||
<Link href="https://github.com/grpc/grpc/blob/9aa3c5835a4ed6afae9455b63ed45c761d695bca/doc/server-reflection.md">
|
||||
Server Reflection
|
||||
</Link>{" "}
|
||||
. Please manually add the <InlineCode>.proto</InlineCode> file to get started.
|
||||
</Banner>
|
||||
)}
|
||||
</VStack>
|
||||
<DialogFooter
|
||||
actions={[
|
||||
{
|
||||
label: "Refresh Schema",
|
||||
isLoading: grpc.reflect.isFetching,
|
||||
disabled: grpc.reflect.isFetching,
|
||||
onClick: () => grpc.reflect.refetch(),
|
||||
},
|
||||
{ label: "Done", color: "primary", onClick: onDone },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
import type { GrpcEvent, GrpcRequest } from "@yaakapp-internal/models";
|
||||
import {
|
||||
HStack,
|
||||
Icon,
|
||||
type IconProps,
|
||||
LoadingIcon,
|
||||
VStack,
|
||||
useStateWithDeps,
|
||||
KeyValueRows,
|
||||
} 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 { 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 } 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 +0,0 @@
|
||||
export { HeadersEditor } from "@yaakapp-internal/ui";
|
||||
@@ -1,259 +0,0 @@
|
||||
import type {
|
||||
Folder,
|
||||
GrpcRequest,
|
||||
HttpRequest,
|
||||
WebsocketRequest,
|
||||
Workspace,
|
||||
} from "@yaakapp-internal/models";
|
||||
import { patchModel } from "@yaakapp-internal/models";
|
||||
import { HStack, Icon, InlineCode, SegmentedControl } from "@yaakapp-internal/ui";
|
||||
import { useCallback } from "react";
|
||||
import { openFolderSettings } from "../commands/openFolderSettings";
|
||||
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
|
||||
import { useAuthDropdownOptions } from "../hooks/useAuthTab";
|
||||
import { useHttpAuthenticationConfig } from "../hooks/useHttpAuthenticationConfig";
|
||||
import { useInheritedAuthentication } from "../hooks/useInheritedAuthentication";
|
||||
import { useRenderTemplate } from "../hooks/useRenderTemplate";
|
||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
||||
import { Button } from "./core/Button";
|
||||
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 { RadioDropdown } from "./core/RadioDropdown";
|
||||
import { DynamicForm } from "./DynamicForm";
|
||||
import { EmptyStateText } from "./EmptyStateText";
|
||||
|
||||
interface Props {
|
||||
model: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace;
|
||||
}
|
||||
|
||||
export function HttpAuthenticationEditor({ model }: Props) {
|
||||
const inheritedAuth = useInheritedAuthentication(model);
|
||||
const authConfig = useHttpAuthenticationConfig(
|
||||
model.authenticationType,
|
||||
model.authentication,
|
||||
model,
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
async (authentication: Record<string, unknown>) =>
|
||||
await patchModel(model, { authentication }),
|
||||
[model],
|
||||
);
|
||||
|
||||
if (model.authenticationType === "none") {
|
||||
return <EmptyStateText>No authentication</EmptyStateText>;
|
||||
}
|
||||
|
||||
if (model.authenticationType != null && authConfig.data == null) {
|
||||
return (
|
||||
<EmptyStateText>
|
||||
<p>
|
||||
Auth plugin not found for{" "}
|
||||
<InlineCode>{model.authenticationType}</InlineCode>
|
||||
</p>
|
||||
</EmptyStateText>
|
||||
);
|
||||
}
|
||||
|
||||
if (inheritedAuth == null) {
|
||||
if (model.model === "workspace" || model.model === "folder") {
|
||||
return (
|
||||
<EmptyStateText className="flex-col gap-3">
|
||||
<div className="not-italic flex flex-col items-center gap-3 text-center">
|
||||
<p className="max-w-md text-sm text-text-subtle">
|
||||
Choose an auth method to apply it to all requests in{" "}
|
||||
<strong className="font-semibold text-text-subtle">
|
||||
{resolvedModelName(model)}
|
||||
</strong>
|
||||
.
|
||||
</p>
|
||||
<AuthenticationTypeDropdown model={model} />
|
||||
<Link href="https://yaak.app/docs/using-yaak/request-inheritance">
|
||||
Documentation
|
||||
</Link>
|
||||
</div>
|
||||
</EmptyStateText>
|
||||
);
|
||||
}
|
||||
return <EmptyStateText>No authentication</EmptyStateText>;
|
||||
}
|
||||
|
||||
if (inheritedAuth.authenticationType === "none") {
|
||||
return <EmptyStateText>No authentication</EmptyStateText>;
|
||||
}
|
||||
|
||||
const wasAuthInherited = inheritedAuth?.id !== model.id;
|
||||
if (wasAuthInherited) {
|
||||
const name = resolvedModelName(inheritedAuth);
|
||||
const cta = inheritedAuth.model === "workspace" ? "Workspace" : name;
|
||||
return (
|
||||
<EmptyStateText>
|
||||
<p>
|
||||
Inherited from{" "}
|
||||
<button
|
||||
type="submit"
|
||||
className="underline hover:text-text"
|
||||
onClick={() => {
|
||||
if (inheritedAuth.model === "folder")
|
||||
openFolderSettings(inheritedAuth.id, "auth");
|
||||
else openWorkspaceSettings("auth");
|
||||
}}
|
||||
>
|
||||
{cta}
|
||||
</button>
|
||||
</p>
|
||||
</EmptyStateText>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)] gap-y-3">
|
||||
<div>
|
||||
<HStack space={2} alignItems="start">
|
||||
<SegmentedControl
|
||||
label="Enabled"
|
||||
hideLabel
|
||||
name="enabled"
|
||||
value={
|
||||
model.authentication.disabled === false ||
|
||||
model.authentication.disabled == null
|
||||
? "__TRUE__"
|
||||
: model.authentication.disabled === true
|
||||
? "__FALSE__"
|
||||
: "__DYNAMIC__"
|
||||
}
|
||||
options={[
|
||||
{ label: "Enabled", value: "__TRUE__" },
|
||||
{ label: "Disabled", value: "__FALSE__" },
|
||||
{ label: "Enabled when...", value: "__DYNAMIC__" },
|
||||
]}
|
||||
onChange={async (enabled) => {
|
||||
let disabled: boolean | string;
|
||||
if (enabled === "__TRUE__") {
|
||||
disabled = false;
|
||||
} else if (enabled === "__FALSE__") {
|
||||
disabled = true;
|
||||
} else {
|
||||
disabled = "";
|
||||
}
|
||||
await handleChange({ ...model.authentication, disabled });
|
||||
}}
|
||||
/>
|
||||
{authConfig.data?.actions && authConfig.data.actions.length > 0 && (
|
||||
<Dropdown
|
||||
items={authConfig.data.actions.map(
|
||||
(a): DropdownItem => ({
|
||||
label: a.label,
|
||||
leftSlot: a.icon ? <Icon icon={a.icon} /> : null,
|
||||
onSelect: () => a.call(model),
|
||||
}),
|
||||
)}
|
||||
>
|
||||
<IconButton
|
||||
title="Authentication Actions"
|
||||
icon="settings"
|
||||
size="xs"
|
||||
className="text-secondary!"
|
||||
/>
|
||||
</Dropdown>
|
||||
)}
|
||||
</HStack>
|
||||
{typeof model.authentication.disabled === "string" && (
|
||||
<div className="mt-3">
|
||||
<AuthenticationDisabledInput
|
||||
className="w-full"
|
||||
stateKey={`auth.${model.id}.dynamic`}
|
||||
value={model.authentication.disabled}
|
||||
onChange={(v) =>
|
||||
handleChange({ ...model.authentication, disabled: v })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DynamicForm
|
||||
disabled={model.authentication.disabled === true}
|
||||
autocompleteVariables
|
||||
autocompleteFunctions
|
||||
stateKey={`auth.${model.id}.${model.authenticationType}`}
|
||||
inputs={authConfig.data?.args ?? []}
|
||||
data={model.authentication}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthenticationTypeDropdown({ model }: Props) {
|
||||
const options = useAuthDropdownOptions(model);
|
||||
|
||||
if (options == null) return null;
|
||||
|
||||
return (
|
||||
<RadioDropdown
|
||||
items={options.items}
|
||||
itemsAfter={options.itemsAfter}
|
||||
itemsBefore={options.itemsBefore}
|
||||
value={options.value}
|
||||
onChange={options.onChange}
|
||||
>
|
||||
<Button
|
||||
color="secondary"
|
||||
variant="border"
|
||||
size="sm"
|
||||
rightSlot={
|
||||
<Icon icon="chevron_down" size="sm" className="text-text-subtle" />
|
||||
}
|
||||
>
|
||||
Select Auth
|
||||
</Button>
|
||||
</RadioDropdown>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthenticationDisabledInput({
|
||||
value,
|
||||
onChange,
|
||||
stateKey,
|
||||
className,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: InputProps["onChange"];
|
||||
stateKey: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const rendered = useRenderTemplate({
|
||||
template: value,
|
||||
enabled: true,
|
||||
purpose: "preview",
|
||||
refreshKey: value,
|
||||
});
|
||||
|
||||
return (
|
||||
<Input
|
||||
size="sm"
|
||||
className={className}
|
||||
label="Dynamic Disabled"
|
||||
hideLabel
|
||||
defaultValue={value}
|
||||
placeholder="Enabled when this renders a non-empty value"
|
||||
rightSlot={
|
||||
<div className="px-1 flex items-center">
|
||||
<div className="rounded-full bg-surface-highlight text-xs px-1.5 py-0.5 text-text-subtle whitespace-nowrap">
|
||||
{rendered.isPending
|
||||
? "loading"
|
||||
: rendered.data
|
||||
? "enabled"
|
||||
: "disabled"}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
autocompleteFunctions
|
||||
autocompleteVariables
|
||||
onChange={onChange}
|
||||
stateKey={stateKey}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,466 +0,0 @@
|
||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||
import { getModel, patchModel, patchModelDebounced } from "@yaakapp-internal/models";
|
||||
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
||||
import {
|
||||
CountBadge,
|
||||
type EditablePair,
|
||||
type ExtraPaneTab,
|
||||
HttpRequestPaneView,
|
||||
InlineCode,
|
||||
languageFromContentType,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { atom, useAtomValue } from "jotai";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { lazy, Suspense, useCallback, useMemo, useRef, useState } from "react";
|
||||
import { importCurl, looksLikeCurl } from "../commands/importCurl";
|
||||
import { allRequestUrlsAtom } from "../hooks/useAllRequests";
|
||||
import { useAuthTab } from "../hooks/useAuthTab";
|
||||
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
|
||||
import { useHeadersTab } from "../hooks/useHeadersTab";
|
||||
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 { fireAndForget } from "../lib/fireAndForget";
|
||||
import { httpRequestToView } from "../lib/httpViews";
|
||||
import {
|
||||
BODY_TYPE_BINARY,
|
||||
BODY_TYPE_FORM_MULTIPART,
|
||||
BODY_TYPE_FORM_URLENCODED,
|
||||
BODY_TYPE_GRAPHQL,
|
||||
BODY_TYPE_JSON,
|
||||
BODY_TYPE_NONE,
|
||||
BODY_TYPE_OTHER,
|
||||
BODY_TYPE_XML,
|
||||
getContentTypeFromHeaders,
|
||||
} from "../lib/model_util";
|
||||
import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
|
||||
import { prepareImportQuerystring } from "../lib/prepareImportQuerystring";
|
||||
import { convertRequestBody } from "../lib/requestBodyConversion";
|
||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
||||
import { showToast } from "../lib/toast";
|
||||
import { BinaryFileEditor } from "./BinaryFileEditor";
|
||||
import { ConfirmLargeRequestBody } from "./ConfirmLargeRequestBody";
|
||||
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
||||
import { getUrlCompletionConfig } from "./core/Editor/url/completion";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
|
||||
import { setActiveTab } from "./core/Tabs/Tabs";
|
||||
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
|
||||
import { MarkdownEditor } from "./MarkdownEditor";
|
||||
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
|
||||
import { RequestMethodDropdown } from "./RequestMethodDropdown";
|
||||
import { UrlParametersEditor } from "./UrlParameterEditor";
|
||||
import { generateId } from "@yaakapp-internal/lib/generateId";
|
||||
|
||||
const GraphQLEditor = lazy(() =>
|
||||
import("./graphql/GraphQLEditor").then((m) => ({ default: m.GraphQLEditor })),
|
||||
);
|
||||
|
||||
interface Props {
|
||||
style: CSSProperties;
|
||||
fullHeight: boolean;
|
||||
className?: string;
|
||||
activeRequest: HttpRequest;
|
||||
}
|
||||
|
||||
const TAB_BODY = "body";
|
||||
const TAB_PARAMS = "params";
|
||||
const TAB_HEADERS = "headers";
|
||||
const TAB_AUTH = "auth";
|
||||
const TAB_SETTINGS = "settings";
|
||||
const TAB_DESCRIPTION = "description";
|
||||
const TABS_STORAGE_KEY = "http_request_tabs";
|
||||
|
||||
// Derived from the identity-stable URL list so this only recomputes when a URL
|
||||
// actually changes. The active request's own URL is included, but exact matches
|
||||
// are filtered out at completion time by genericCompletion.
|
||||
const requestUrlOptionsAtom = atom((get): GenericCompletionOption[] =>
|
||||
get(allRequestUrlsAtom).map((url) => ({ type: "constant", label: url })),
|
||||
);
|
||||
|
||||
export function HttpRequestPane({ style, fullHeight, className, activeRequest }: Props) {
|
||||
const activeRequestId = activeRequest.id;
|
||||
const tabsRef = useRef<TabsRef>(null);
|
||||
const [forceUpdateHeaderEditorKey, setForceUpdateHeaderEditorKey] = useState<number>(0);
|
||||
const forceUpdateKey = useRequestUpdateKey(activeRequest.id ?? null);
|
||||
const [{ urlKey }, { forceUrlRefresh, forceParamsRefresh }] = useRequestEditor();
|
||||
const contentType = getContentTypeFromHeaders(activeRequest.headers);
|
||||
const authTab = useAuthTab(TAB_AUTH, activeRequest);
|
||||
const headersTab = useHeadersTab(TAB_HEADERS, activeRequest);
|
||||
const inheritedHeaders = useInheritedHeaders(activeRequest);
|
||||
const numSettingsOverrides = countOverriddenSettings(activeRequest);
|
||||
|
||||
// Listen for event to focus the params tab (e.g., when clicking a :param in the URL)
|
||||
useRequestEditorEvent(
|
||||
"request_pane.focus_tab",
|
||||
() => {
|
||||
tabsRef.current?.setActiveTab(TAB_PARAMS);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleContentTypeChange = useCallback(
|
||||
async (contentType: string | null, patch: Partial<Omit<HttpRequest, "headers">> = {}) => {
|
||||
if (activeRequest == null) {
|
||||
console.error("Failed to get active request to update", activeRequest);
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = activeRequest.headers.filter((h) => h.name.toLowerCase() !== "content-type");
|
||||
|
||||
if (contentType != null) {
|
||||
headers.push({
|
||||
name: "Content-Type",
|
||||
value: contentType,
|
||||
enabled: true,
|
||||
id: generateId(),
|
||||
});
|
||||
}
|
||||
await patchModel(activeRequest, { ...patch, headers });
|
||||
|
||||
// Force update header editor so any changed headers are reflected
|
||||
setTimeout(() => setForceUpdateHeaderEditorKey((u) => u + 1), 100);
|
||||
},
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
// Renaming a path placeholder has to rewrite the URL and rename the parameter together, or the
|
||||
// value detaches from the placeholder.
|
||||
// NOTE: Reads the request fresh rather than closing over `activeRequest`. The row that calls this
|
||||
// holds onto it until the URL's placeholders change, so a captured request would go stale and
|
||||
// patch its parameter list back over newer edits.
|
||||
const handleRenamePathPlaceholder = useCallback(
|
||||
(oldName: string, newName: string) => {
|
||||
const request = getModel("http_request", activeRequestId);
|
||||
if (request == null) return false;
|
||||
|
||||
const patch = renamePathPlaceholder(request, oldName, newName);
|
||||
if (patch == null) return false; // Unusable name, so the editor reverts the field
|
||||
void patchModel(request, patch);
|
||||
return true;
|
||||
},
|
||||
[activeRequestId],
|
||||
);
|
||||
|
||||
const { urlParameterPairs, urlParametersKey } = useMemo(
|
||||
() =>
|
||||
derivePathPlaceholderPairs(
|
||||
activeRequest.url,
|
||||
activeRequest.urlParameters,
|
||||
handleRenamePathPlaceholder,
|
||||
),
|
||||
[activeRequest.url, activeRequest.urlParameters, handleRenamePathPlaceholder],
|
||||
);
|
||||
|
||||
let numParams = 0;
|
||||
if (
|
||||
activeRequest.bodyType === BODY_TYPE_FORM_URLENCODED ||
|
||||
activeRequest.bodyType === BODY_TYPE_FORM_MULTIPART
|
||||
) {
|
||||
numParams = Array.isArray(activeRequest.body?.form)
|
||||
? activeRequest.body.form.filter((p) => p.name).length
|
||||
: 0;
|
||||
}
|
||||
|
||||
const hasDescription = activeRequest.description.trim().length > 0;
|
||||
|
||||
const bodyTab = useMemo<TabItem>(
|
||||
() =>
|
||||
({
|
||||
value: TAB_BODY,
|
||||
rightSlot: numParams > 0 ? <CountBadge count={numParams} /> : null,
|
||||
options: {
|
||||
value: activeRequest.bodyType,
|
||||
items: [
|
||||
{ type: "separator", label: "Form Data" },
|
||||
{ label: "Url Encoded", value: BODY_TYPE_FORM_URLENCODED },
|
||||
{ label: "Multi-Part", value: BODY_TYPE_FORM_MULTIPART },
|
||||
{ type: "separator", label: "Text Content" },
|
||||
{ label: "GraphQL", value: BODY_TYPE_GRAPHQL },
|
||||
{ label: "JSON", value: BODY_TYPE_JSON },
|
||||
{ label: "XML", value: BODY_TYPE_XML },
|
||||
{
|
||||
label: "Other",
|
||||
value: BODY_TYPE_OTHER,
|
||||
shortLabel: nameOfContentTypeOr(contentType, "Other"),
|
||||
},
|
||||
{ type: "separator", label: "Other" },
|
||||
{ label: "Binary File", value: BODY_TYPE_BINARY },
|
||||
{ label: "No Body", shortLabel: "Body", value: BODY_TYPE_NONE },
|
||||
],
|
||||
onChange: async (bodyType) => {
|
||||
if (bodyType === activeRequest.bodyType) return;
|
||||
|
||||
const showMethodToast = (newMethod: string) => {
|
||||
if (activeRequest.method.toLowerCase() === newMethod.toLowerCase()) return;
|
||||
showToast({
|
||||
id: "switched-method",
|
||||
message: (
|
||||
<>
|
||||
Request method switched to <InlineCode>POST</InlineCode>
|
||||
</>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const patch: Partial<HttpRequest> = {
|
||||
bodyType,
|
||||
body: convertRequestBody({
|
||||
body: activeRequest.body,
|
||||
fromBodyType: activeRequest.bodyType,
|
||||
toBodyType: bodyType,
|
||||
}),
|
||||
};
|
||||
let newContentType: string | null | undefined;
|
||||
if (bodyType === BODY_TYPE_NONE) {
|
||||
newContentType = null;
|
||||
} else if (
|
||||
bodyType === BODY_TYPE_FORM_URLENCODED ||
|
||||
bodyType === BODY_TYPE_FORM_MULTIPART ||
|
||||
bodyType === BODY_TYPE_JSON ||
|
||||
bodyType === BODY_TYPE_OTHER ||
|
||||
bodyType === BODY_TYPE_XML
|
||||
) {
|
||||
const isDefaultishRequest =
|
||||
activeRequest.bodyType === BODY_TYPE_NONE &&
|
||||
activeRequest.method.toLowerCase() === "get";
|
||||
const requiresPost = bodyType === BODY_TYPE_FORM_MULTIPART;
|
||||
if (isDefaultishRequest || requiresPost) {
|
||||
patch.method = "POST";
|
||||
showMethodToast(patch.method);
|
||||
}
|
||||
newContentType = bodyType === BODY_TYPE_OTHER ? "text/plain" : bodyType;
|
||||
} else if (bodyType === BODY_TYPE_GRAPHQL) {
|
||||
patch.method = "POST";
|
||||
newContentType = "application/json";
|
||||
showMethodToast(patch.method);
|
||||
}
|
||||
|
||||
if (newContentType !== undefined) {
|
||||
await handleContentTypeChange(newContentType, patch);
|
||||
} else {
|
||||
await patchModel(activeRequest, patch);
|
||||
}
|
||||
},
|
||||
},
|
||||
}) satisfies TabItem,
|
||||
[activeRequest, contentType, handleContentTypeChange, numParams],
|
||||
);
|
||||
|
||||
const { mutate: sendRequest } = useSendAnyHttpRequest();
|
||||
const { activeResponse } = usePinnedHttpResponse(activeRequestId);
|
||||
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
|
||||
const updateKey = useRequestUpdateKey(activeRequestId);
|
||||
|
||||
const handleBodyChange = useCallback(
|
||||
(body: HttpRequest["body"]) => patchModelDebounced(activeRequest, { body }),
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
const handleBodyTextChange = useCallback(
|
||||
(text: string) => patchModelDebounced(activeRequest, { body: { ...activeRequest.body, text } }),
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
const autocompleteUrls = useAtomValue(requestUrlOptionsAtom);
|
||||
|
||||
const autocomplete: GenericCompletionConfig = useMemo(
|
||||
() => getUrlCompletionConfig(autocompleteUrls),
|
||||
[autocompleteUrls],
|
||||
);
|
||||
|
||||
const handlePaste = useCallback(
|
||||
async (e: ClipboardEvent, text: string) => {
|
||||
if (looksLikeCurl(text)) {
|
||||
importCurl.mutate({ overwriteRequestId: activeRequestId, command: text });
|
||||
} else {
|
||||
const patch = prepareImportQuerystring(text);
|
||||
if (patch != null) {
|
||||
e.preventDefault(); // Prevent input onChange
|
||||
|
||||
await patchModel(activeRequest, patch);
|
||||
await setActiveTab({
|
||||
storageKey: TABS_STORAGE_KEY,
|
||||
activeTabKey: activeRequestId,
|
||||
value: TAB_PARAMS,
|
||||
});
|
||||
|
||||
// Wait for request to update, then refresh the UI
|
||||
// TODO: Somehow make this deterministic
|
||||
setTimeout(() => {
|
||||
forceUrlRefresh();
|
||||
forceParamsRefresh();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeRequest, activeRequestId, forceParamsRefresh, forceUrlRefresh],
|
||||
);
|
||||
const handleSend = useCallback(
|
||||
() => sendRequest(activeRequest.id ?? null),
|
||||
[activeRequest.id, sendRequest],
|
||||
);
|
||||
|
||||
const handleUrlChange = useCallback(
|
||||
(url: string) => patchModelDebounced(activeRequest, { url }),
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
const handleJsonOrBodyTextChange = useCallback(
|
||||
(text: string, bodyType: string | null) => {
|
||||
// JSON writes at once, as its editor always has; other text debounces
|
||||
if (bodyType === BODY_TYPE_JSON) {
|
||||
return patchModel(activeRequest, { body: { ...activeRequest.body, text } });
|
||||
}
|
||||
return handleBodyTextChange(text);
|
||||
},
|
||||
[activeRequest, handleBodyTextChange],
|
||||
);
|
||||
|
||||
const handleSendJsonCommentsChange = useCallback(
|
||||
(sendJsonComments: boolean) => {
|
||||
const newBody = { ...activeRequest.body };
|
||||
if (sendJsonComments) {
|
||||
newBody.sendJsonComments = true;
|
||||
} else {
|
||||
delete newBody.sendJsonComments;
|
||||
}
|
||||
fireAndForget(patchModel(activeRequest, { body: newBody }));
|
||||
},
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
const requestView = useMemo(() => httpRequestToView(activeRequest), [activeRequest]);
|
||||
|
||||
const extraTabs = useMemo<ExtraPaneTab[]>(
|
||||
() => [
|
||||
{
|
||||
tab: {
|
||||
value: TAB_SETTINGS,
|
||||
label: "Settings",
|
||||
rightSlot: <CountBadge count={numSettingsOverrides} />,
|
||||
},
|
||||
content: <ModelSettingsEditor model={activeRequest} />,
|
||||
},
|
||||
{
|
||||
tab: {
|
||||
value: TAB_DESCRIPTION,
|
||||
label: "Info",
|
||||
rightSlot: hasDescription && <CountBadge count={true} />,
|
||||
},
|
||||
content: (
|
||||
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full">
|
||||
<PlainInput
|
||||
label="Request Name"
|
||||
hideLabel
|
||||
forceUpdateKey={updateKey}
|
||||
defaultValue={activeRequest.name}
|
||||
className="font-sans text-xl! px-0!"
|
||||
containerClassName="border-0"
|
||||
placeholder={resolvedModelName(activeRequest)}
|
||||
onChange={(name) => patchModel(activeRequest, { name })}
|
||||
/>
|
||||
<MarkdownEditor
|
||||
name="request-description"
|
||||
placeholder="Request description"
|
||||
defaultValue={activeRequest.description}
|
||||
stateKey={`description.${activeRequest.id}`}
|
||||
forceUpdateKey={updateKey}
|
||||
onChange={(description) => patchModel(activeRequest, { description })}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[activeRequest, hasDescription, numSettingsOverrides, updateKey],
|
||||
);
|
||||
|
||||
const renderBody = useCallback(
|
||||
(sharedEditor: ReactNode) => (
|
||||
<ConfirmLargeRequestBody request={activeRequest}>
|
||||
{activeRequest.bodyType === BODY_TYPE_GRAPHQL ? (
|
||||
<Suspense>
|
||||
<GraphQLEditor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
baseRequest={activeRequest}
|
||||
request={activeRequest}
|
||||
onChange={handleBodyChange}
|
||||
/>
|
||||
</Suspense>
|
||||
) : activeRequest.bodyType === BODY_TYPE_BINARY ? (
|
||||
<BinaryFileEditor
|
||||
requestId={activeRequest.id}
|
||||
contentType={contentType}
|
||||
body={activeRequest.body}
|
||||
onChange={(body) => patchModelDebounced(activeRequest, { body })}
|
||||
onChangeContentType={handleContentTypeChange}
|
||||
/>
|
||||
) : (
|
||||
sharedEditor
|
||||
)}
|
||||
</ConfirmLargeRequestBody>
|
||||
),
|
||||
[activeRequest, contentType, forceUpdateKey, handleBodyChange, handleContentTypeChange],
|
||||
);
|
||||
|
||||
const renderUrlParameters = useCallback(
|
||||
(pairs: EditablePair[]) => (
|
||||
<UrlParametersEditor
|
||||
stateKey={`params.${activeRequest.id}`}
|
||||
forceUpdateKey={forceUpdateKey + urlParametersKey}
|
||||
pairs={pairs}
|
||||
onChange={(urlParameters) => patchModelDebounced(activeRequest, { urlParameters })}
|
||||
/>
|
||||
),
|
||||
[activeRequest, forceUpdateKey, urlParametersKey],
|
||||
);
|
||||
|
||||
return (
|
||||
<HttpRequestPaneView
|
||||
editable
|
||||
style={style}
|
||||
className={className}
|
||||
fullHeight={fullHeight}
|
||||
request={requestView}
|
||||
tabsRef={tabsRef}
|
||||
tabsStorageKey={TABS_STORAGE_KEY}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
urlBarKey={forceUpdateKey + urlKey}
|
||||
urlForceUpdateKey={updateKey}
|
||||
urlAutocomplete={autocomplete}
|
||||
onUrlChange={handleUrlChange}
|
||||
onUrlPasteOverwrite={handlePaste}
|
||||
onSend={handleSend}
|
||||
onCancel={cancelResponse}
|
||||
isSending={activeResponse != null && activeResponse.state !== "closed"}
|
||||
sendHotkeyAction="request.send"
|
||||
focusHotkeyAction="url_bar.focus"
|
||||
methodSlot={<RequestMethodDropdown request={activeRequest} className="ml-0.5 h-full!" />}
|
||||
bodyTab={bodyTab}
|
||||
renderBody={renderBody}
|
||||
onBodyChange={handleBodyChange}
|
||||
onBodyTextChange={handleJsonOrBodyTextChange}
|
||||
onSendJsonCommentsChange={handleSendJsonCommentsChange}
|
||||
jsonNoticeStorageKey={activeRequest.workspaceId}
|
||||
urlParameterPairs={urlParameterPairs}
|
||||
renderUrlParameters={renderUrlParameters}
|
||||
headersTabs={headersTab}
|
||||
inheritedHeaders={inheritedHeaders}
|
||||
headersForceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`}
|
||||
onHeadersChange={(headers) => patchModelDebounced(activeRequest, { headers })}
|
||||
authTabs={authTab}
|
||||
authContent={<HttpAuthenticationEditor model={activeRequest} />}
|
||||
extraTabs={extraTabs}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function nameOfContentTypeOr(contentType: string | null, fallback: string) {
|
||||
const language = languageFromContentType(contentType);
|
||||
if (language === "markdown") {
|
||||
return "Markdown";
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
import type { HttpResponse, HttpResponseEvent } from "@yaakapp-internal/models";
|
||||
import {
|
||||
CountBadge,
|
||||
type ExtraPaneTab,
|
||||
type HostBodyViewerArgs,
|
||||
HStack,
|
||||
HttpResponsePaneView,
|
||||
Icon,
|
||||
type ResponseTextViewerExtras,
|
||||
type TextFilterApi,
|
||||
Tooltip,
|
||||
VStack,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
|
||||
import { useCopyHttpResponse } from "../hooks/useCopyHttpResponse";
|
||||
import { useRequestBodyLoader, useResponseBodyLoader } from "../hooks/useHttpBodyLoaders";
|
||||
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
|
||||
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
|
||||
import { useRecentFilters } from "../hooks/useRecentFilters";
|
||||
import { useResponseViewMode } from "../hooks/useResponseViewMode";
|
||||
import { useSaveResponse } from "../hooks/useSaveResponse";
|
||||
import { useTimelineViewMode } from "../hooks/useTimelineViewMode";
|
||||
import { httpResponseToView } from "../lib/httpViews";
|
||||
import { getCookieCounts } from "../lib/model_util";
|
||||
import { CopyButton } from "./CopyButton";
|
||||
import { HotkeyList } from "./core/HotkeyList";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { PillButton } from "./core/PillButton";
|
||||
import { HttpResponseTimeline } from "./HttpResponseTimeline";
|
||||
import { RecentHttpResponsesDropdown } from "./RecentHttpResponsesDropdown";
|
||||
import { RequestVersionDropdown } from "./RequestVersionDropdown";
|
||||
import { EventStreamViewer } from "./responseViewers/EventStreamViewer";
|
||||
import { RecentFiltersDropdown } from "./responseViewers/RecentFiltersDropdown";
|
||||
|
||||
interface Props {
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
activeRequestId: string;
|
||||
}
|
||||
|
||||
const TAB_TIMELINE = "timeline";
|
||||
|
||||
export type TimelineViewMode = "timeline" | "text";
|
||||
|
||||
interface RedirectDropWarning {
|
||||
droppedBodyCount: number;
|
||||
droppedHeaders: string[];
|
||||
}
|
||||
|
||||
/** The shared response pane, bound to the pinned response of the active request */
|
||||
export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId);
|
||||
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
|
||||
const [timelineViewMode, setTimelineViewMode] = useTimelineViewMode();
|
||||
|
||||
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 saveResponse = useSaveResponse(activeResponse ?? null);
|
||||
const copyResponse = useCopyHttpResponse(activeResponse ?? null);
|
||||
const cancel = useCancelHttpResponse(activeResponse?.id ?? null);
|
||||
const body = useResponseBodyLoader(activeResponse ?? null);
|
||||
const requestBody = useRequestBodyLoader(activeResponse ?? null);
|
||||
const textViewer = useResponseTextViewerExtras(activeResponse ?? null);
|
||||
|
||||
const responseView = useMemo(
|
||||
() => (activeResponse == null ? null : httpResponseToView(activeResponse, responseEvents.data)),
|
||||
[activeResponse, responseEvents.data],
|
||||
);
|
||||
|
||||
const extraTabs = useMemo<ExtraPaneTab[]>(
|
||||
() =>
|
||||
activeResponse == null
|
||||
? []
|
||||
: [
|
||||
{
|
||||
tab: {
|
||||
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" },
|
||||
],
|
||||
},
|
||||
},
|
||||
content: (
|
||||
<HttpResponseTimeline response={activeResponse} viewMode={timelineViewMode} />
|
||||
),
|
||||
},
|
||||
],
|
||||
[activeResponse, responseEvents.data?.length, setTimelineViewMode, timelineViewMode],
|
||||
);
|
||||
|
||||
const renderBodyViewer = useCallback(
|
||||
({ mimeType, viewMode }: HostBodyViewerArgs) =>
|
||||
activeResponse != null && mimeType?.match(/^text\/event-stream/i) && viewMode === "pretty" ? (
|
||||
<EventStreamViewer response={activeResponse} />
|
||||
) : null,
|
||||
[activeResponse],
|
||||
);
|
||||
|
||||
return (
|
||||
<HttpResponsePaneView
|
||||
style={style}
|
||||
className={className}
|
||||
response={responseView}
|
||||
body={body}
|
||||
requestBody={requestBody}
|
||||
emptyState={
|
||||
<HotkeyList hotkeys={["request.send", "model.create", "sidebar.focus", "url_bar.focus"]} />
|
||||
}
|
||||
viewMode={viewMode === "raw" ? "raw" : "pretty"}
|
||||
onViewModeChange={setViewMode}
|
||||
activeTabKey={activeRequestId}
|
||||
onSaveBody={saveResponse.mutate}
|
||||
onCopyBody={copyResponse.mutate}
|
||||
onCancel={cancel.mutate}
|
||||
cookieCounts={cookieCounts}
|
||||
extraTabs={extraTabs}
|
||||
textViewer={textViewer}
|
||||
renderBodyViewer={renderBodyViewer}
|
||||
renderCopyButton={renderCopyButton}
|
||||
statusLineCenter={
|
||||
shouldShowRedirectDropWarning ? (
|
||||
<Tooltip
|
||||
tabIndex={0}
|
||||
className="my-auto pl-3 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 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>
|
||||
) : null
|
||||
}
|
||||
statusLineRight={
|
||||
activeResponse != null && (
|
||||
<HStack space={1}>
|
||||
<RequestVersionDropdown response={activeResponse} />
|
||||
<RecentHttpResponsesDropdown
|
||||
responses={responses}
|
||||
activeResponse={activeResponse}
|
||||
onPinnedResponseId={setPinnedResponseId}
|
||||
/>
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** The client's additions to the response text viewer: filter history, save and copy */
|
||||
function useResponseTextViewerExtras(
|
||||
response: HttpResponse | null,
|
||||
): ResponseTextViewerExtras | undefined {
|
||||
const filterStateKey = `response.body.${response?.requestId ?? ""}`;
|
||||
const { recentFilters, addFilter, removeFilter, togglePin, clearFilters } =
|
||||
useRecentFilters(filterStateKey);
|
||||
const saveResponse = useSaveResponse(response);
|
||||
const copyResponse = useCopyHttpResponse(response);
|
||||
const filterLeftSlot = useCallback(
|
||||
(filter: TextFilterApi) => (
|
||||
<div className="py-0.5 flex">
|
||||
<RecentFiltersDropdown
|
||||
recentFilters={recentFilters}
|
||||
activeFilter={filter.appliedFilter}
|
||||
onSelect={filter.replaceFilter}
|
||||
onRemove={removeFilter}
|
||||
onTogglePin={togglePin}
|
||||
onClear={clearFilters}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
[recentFilters, removeFilter, togglePin, clearFilters],
|
||||
);
|
||||
|
||||
if (response == null) return undefined;
|
||||
const actionsDisabled = response.state !== "closed" && response.status >= 100;
|
||||
return {
|
||||
filterStateKey,
|
||||
onFilterApplied: addFilter,
|
||||
filterLeftSlot,
|
||||
footerActions: [
|
||||
<IconButton
|
||||
key="save"
|
||||
size="sm"
|
||||
icon="save"
|
||||
title="Save response to file"
|
||||
disabled={actionsDisabled}
|
||||
onClick={() => saveResponse.mutate()}
|
||||
className="border !border-border-subtle"
|
||||
/>,
|
||||
<IconButton
|
||||
key="copy"
|
||||
size="sm"
|
||||
icon="copy"
|
||||
title="Copy response body"
|
||||
disabled={actionsDisabled}
|
||||
onClick={() => copyResponse.mutate()}
|
||||
className="border !border-border-subtle"
|
||||
/>,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function renderCopyButton(getText: () => Promise<string | null>) {
|
||||
return <CopyButton color="secondary" variant="border" size="xs" text={getText} />;
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
@@ -1,465 +0,0 @@
|
||||
import type {
|
||||
AnyModel,
|
||||
HttpResponse,
|
||||
HttpResponseEvent,
|
||||
HttpResponseEventData,
|
||||
} from "@yaakapp-internal/models";
|
||||
import { foldersAtom, workspacesAtom } from "@yaakapp-internal/models";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { type ReactNode, useMemo, useState } from "react";
|
||||
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
|
||||
import { useAllRequests } from "../hooks/useAllRequests";
|
||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
||||
import { Editor } from "./core/Editor/LazyEditor";
|
||||
import { type EventDetailAction, EventDetailHeader, EventViewer } from "./core/EventViewer";
|
||||
import { EventViewerRow } from "./core/EventViewerRow";
|
||||
import { Icon, type IconProps, HttpStatusTagRaw, KeyValueRows } from "@yaakapp-internal/ui";
|
||||
import { KeyValueRow } from "./core/KeyValueRow";
|
||||
import type { TimelineViewMode } from "./HttpResponsePane";
|
||||
|
||||
interface Props {
|
||||
response: HttpResponse;
|
||||
viewMode: TimelineViewMode;
|
||||
}
|
||||
|
||||
export function HttpResponseTimeline({ response, viewMode }: Props) {
|
||||
return <Inner key={response.id} response={response} viewMode={viewMode} />;
|
||||
}
|
||||
|
||||
function Inner({ response, viewMode }: Props) {
|
||||
const [showRaw, setShowRaw] = useState(false);
|
||||
const { data: events, error, isLoading } = useHttpResponseEvents(response);
|
||||
|
||||
// Generate plain text representation of all events (with prefixes for timeline view)
|
||||
const plainText = useMemo(() => {
|
||||
if (!events || events.length === 0) return "";
|
||||
return events.map((event) => formatEventText(event.event, true)).join("\n");
|
||||
}, [events]);
|
||||
|
||||
// Plain text view - show all events as text in an editor
|
||||
if (viewMode === "text") {
|
||||
if (isLoading) {
|
||||
return <div className="p-4 text-text-subtlest">Loading events...</div>;
|
||||
} else if (error) {
|
||||
return <div className="p-4 text-danger">{String(error)}</div>;
|
||||
} else if (!events || events.length === 0) {
|
||||
return <div className="p-4 text-text-subtlest">No events recorded</div>;
|
||||
} else {
|
||||
return (
|
||||
<Editor language="timeline" defaultValue={plainText} readOnly stateKey={null} hideGutter />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<EventViewer
|
||||
events={events ?? []}
|
||||
getEventKey={(event) => event.id}
|
||||
error={error ? String(error) : null}
|
||||
isLoading={isLoading}
|
||||
loadingMessage="Loading events..."
|
||||
emptyMessage="No events recorded"
|
||||
splitLayoutStorageKey="http_response_events"
|
||||
defaultRatio={0.25}
|
||||
renderRow={({ event, isActive, onClick }) => {
|
||||
const display = getEventDisplay(event.event);
|
||||
return (
|
||||
<EventViewerRow
|
||||
isActive={isActive}
|
||||
onClick={onClick}
|
||||
icon={<Icon color={display.color} icon={display.icon} size="sm" />}
|
||||
content={display.summary}
|
||||
timestamp={event.createdAt}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
renderDetail={({ event, onClose }) => (
|
||||
<EventDetails event={event} showRaw={showRaw} setShowRaw={setShowRaw} onClose={onClose} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function EventDetails({
|
||||
event,
|
||||
showRaw,
|
||||
setShowRaw,
|
||||
onClose,
|
||||
}: {
|
||||
event: HttpResponseEvent;
|
||||
showRaw: boolean;
|
||||
setShowRaw: (v: boolean) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { label } = getEventDisplay(event.event);
|
||||
const e = event.event;
|
||||
const settingSourceModels = useSettingSourceModels();
|
||||
|
||||
const actions: EventDetailAction[] = [
|
||||
{
|
||||
key: "toggle-raw",
|
||||
label: showRaw ? "Formatted" : "Text",
|
||||
onClick: () => setShowRaw(!showRaw),
|
||||
},
|
||||
];
|
||||
|
||||
// Determine the title based on event type
|
||||
const title = (() => {
|
||||
switch (e.type) {
|
||||
case "header_up":
|
||||
return "Header Sent";
|
||||
case "header_down":
|
||||
return "Header Received";
|
||||
case "send_url":
|
||||
return "Request";
|
||||
case "receive_url":
|
||||
return "Response";
|
||||
case "redirect":
|
||||
return "Redirect";
|
||||
case "setting":
|
||||
return "Apply Setting";
|
||||
case "chunk_sent":
|
||||
return "Data Sent";
|
||||
case "chunk_received":
|
||||
return "Data Received";
|
||||
case "dns_resolved":
|
||||
return e.overridden ? "DNS Override" : "DNS Resolution";
|
||||
default:
|
||||
return label;
|
||||
}
|
||||
})();
|
||||
|
||||
// Render content based on view mode and event type
|
||||
const renderContent = () => {
|
||||
// Raw view - show plaintext representation (without prefix)
|
||||
if (showRaw) {
|
||||
const rawText = formatEventText(event.event, false);
|
||||
return <Editor language="text" defaultValue={rawText} readOnly stateKey={null} hideGutter />;
|
||||
}
|
||||
|
||||
// Headers - show name and value
|
||||
if (e.type === "header_up" || e.type === "header_down") {
|
||||
return (
|
||||
<KeyValueRows>
|
||||
<KeyValueRow label="Header">{e.name}</KeyValueRow>
|
||||
<KeyValueRow label="Value">{e.value}</KeyValueRow>
|
||||
</KeyValueRows>
|
||||
);
|
||||
}
|
||||
|
||||
// Request URL - show all URL parts separately
|
||||
if (e.type === "send_url") {
|
||||
const auth = e.username || e.password ? `${e.username}:${e.password}@` : "";
|
||||
const isDefaultPort =
|
||||
(e.scheme === "http" && e.port === 80) || (e.scheme === "https" && e.port === 443);
|
||||
const portStr = isDefaultPort ? "" : `:${e.port}`;
|
||||
const query = e.query ? `?${e.query}` : "";
|
||||
const fragment = e.fragment ? `#${e.fragment}` : "";
|
||||
const fullUrl = `${e.scheme}://${auth}${e.host}${portStr}${e.path}${query}${fragment}`;
|
||||
return (
|
||||
<KeyValueRows>
|
||||
<KeyValueRow label="URL">{fullUrl}</KeyValueRow>
|
||||
<KeyValueRow label="Method">{e.method}</KeyValueRow>
|
||||
<KeyValueRow label="Scheme">{e.scheme}</KeyValueRow>
|
||||
{e.username ? <KeyValueRow label="Username">{e.username}</KeyValueRow> : null}
|
||||
{e.password ? <KeyValueRow label="Password">{e.password}</KeyValueRow> : null}
|
||||
<KeyValueRow label="Host">{e.host}</KeyValueRow>
|
||||
{!isDefaultPort ? <KeyValueRow label="Port">{e.port}</KeyValueRow> : null}
|
||||
<KeyValueRow label="Path">{e.path}</KeyValueRow>
|
||||
{e.query ? <KeyValueRow label="Query">{e.query}</KeyValueRow> : null}
|
||||
{e.fragment ? <KeyValueRow label="Fragment">{e.fragment}</KeyValueRow> : null}
|
||||
</KeyValueRows>
|
||||
);
|
||||
}
|
||||
|
||||
// Response status - show version and status separately
|
||||
if (e.type === "receive_url") {
|
||||
return (
|
||||
<KeyValueRows>
|
||||
<KeyValueRow label="HTTP Version">{e.version}</KeyValueRow>
|
||||
<KeyValueRow label="Status">
|
||||
<HttpStatusTagRaw status={e.status} />
|
||||
</KeyValueRow>
|
||||
</KeyValueRows>
|
||||
);
|
||||
}
|
||||
|
||||
// Redirect - show status, URL, and behavior
|
||||
if (e.type === "redirect") {
|
||||
const droppedHeaders = e.dropped_headers ?? [];
|
||||
return (
|
||||
<KeyValueRows>
|
||||
<KeyValueRow label="Status">
|
||||
<HttpStatusTagRaw status={e.status} />
|
||||
</KeyValueRow>
|
||||
<KeyValueRow label="Location">{e.url}</KeyValueRow>
|
||||
<KeyValueRow label="Behavior">
|
||||
{e.behavior === "drop_body" ? "Drop body, change to GET" : "Preserve method and body"}
|
||||
</KeyValueRow>
|
||||
<KeyValueRow label="Body Dropped">{e.dropped_body ? "Yes" : "No"}</KeyValueRow>
|
||||
<KeyValueRow label="Headers Dropped">
|
||||
{droppedHeaders.length > 0 ? droppedHeaders.join(", ") : "--"}
|
||||
</KeyValueRow>
|
||||
</KeyValueRows>
|
||||
);
|
||||
}
|
||||
|
||||
// Settings - show as key/value
|
||||
if (e.type === "setting") {
|
||||
return (
|
||||
<KeyValueRows>
|
||||
<KeyValueRow label="Setting">{e.name}</KeyValueRow>
|
||||
<KeyValueRow label="Value">{e.value}</KeyValueRow>
|
||||
{e.source_model != null ? (
|
||||
<KeyValueRow label="Source">{formatSettingSource(e, settingSourceModels)}</KeyValueRow>
|
||||
) : null}
|
||||
</KeyValueRows>
|
||||
);
|
||||
}
|
||||
|
||||
// Chunks - show formatted bytes
|
||||
if (e.type === "chunk_sent" || e.type === "chunk_received") {
|
||||
return <div className="font-mono text-editor">{formatBytes(e.bytes)}</div>;
|
||||
}
|
||||
|
||||
// DNS Resolution - show hostname, addresses, and timing
|
||||
if (e.type === "dns_resolved") {
|
||||
return (
|
||||
<KeyValueRows>
|
||||
<KeyValueRow label="Hostname">{e.hostname}</KeyValueRow>
|
||||
<KeyValueRow label="Addresses">{e.addresses.join(", ")}</KeyValueRow>
|
||||
<KeyValueRow label="Duration">
|
||||
{e.overridden ? (
|
||||
<span className="text-text-subtlest">--</span>
|
||||
) : (
|
||||
`${String(e.duration)}ms`
|
||||
)}
|
||||
</KeyValueRow>
|
||||
{e.overridden ? <KeyValueRow label="Source">Workspace Override</KeyValueRow> : null}
|
||||
</KeyValueRows>
|
||||
);
|
||||
}
|
||||
|
||||
// Default - use summary
|
||||
const { summary } = getEventDisplay(event.event);
|
||||
return <div className="font-mono text-editor">{summary}</div>;
|
||||
};
|
||||
return (
|
||||
<div className="flex flex-col gap-2 h-full">
|
||||
<EventDetailHeader
|
||||
title={title}
|
||||
timestamp={event.createdAt}
|
||||
actions={actions}
|
||||
onClose={onClose}
|
||||
/>
|
||||
{renderContent()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type EventTextParts = { prefix: ">" | "<" | "*"; text: string };
|
||||
|
||||
/** Get the prefix and text for an event */
|
||||
function getEventTextParts(event: HttpResponseEventData): EventTextParts {
|
||||
switch (event.type) {
|
||||
case "send_url":
|
||||
return {
|
||||
prefix: ">",
|
||||
text: `${event.method} ${event.path}${event.query ? `?${event.query}` : ""}${event.fragment ? `#${event.fragment}` : ""}`,
|
||||
};
|
||||
case "receive_url":
|
||||
return { prefix: "<", text: `${event.version} ${event.status}` };
|
||||
case "header_up":
|
||||
return { prefix: ">", text: `${event.name}: ${event.value}` };
|
||||
case "header_down":
|
||||
return { prefix: "<", text: `${event.name}: ${event.value}` };
|
||||
case "redirect": {
|
||||
const behavior = event.behavior === "drop_body" ? "drop body" : "preserve";
|
||||
const droppedHeaders = event.dropped_headers ?? [];
|
||||
const dropped = [
|
||||
event.dropped_body ? "body dropped" : null,
|
||||
droppedHeaders.length > 0 ? `headers dropped: ${droppedHeaders.join(", ")}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
return {
|
||||
prefix: "*",
|
||||
text: `Redirect ${event.status} -> ${event.url} (${behavior}${dropped ? `, ${dropped}` : ""})`,
|
||||
};
|
||||
}
|
||||
case "setting":
|
||||
return { prefix: "*", text: `Setting ${event.name}=${event.value}` };
|
||||
case "info":
|
||||
return { prefix: "*", text: event.message };
|
||||
case "chunk_sent":
|
||||
return { prefix: "*", text: `[${formatBytes(event.bytes)} sent]` };
|
||||
case "chunk_received":
|
||||
return { prefix: "*", text: `[${formatBytes(event.bytes)} received]` };
|
||||
case "dns_resolved":
|
||||
if (event.overridden) {
|
||||
return {
|
||||
prefix: "*",
|
||||
text: `DNS override ${event.hostname} -> ${event.addresses.join(", ")}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
prefix: "*",
|
||||
text: `DNS resolved ${event.hostname} to ${event.addresses.join(", ")} (${event.duration}ms)`,
|
||||
};
|
||||
default:
|
||||
return { prefix: "*", text: "[unknown event]" };
|
||||
}
|
||||
}
|
||||
|
||||
/** Format event as plaintext, optionally with curl-style prefix (> outgoing, < incoming, * info) */
|
||||
function formatEventText(event: HttpResponseEventData, includePrefix: boolean): string {
|
||||
const { prefix, text } = getEventTextParts(event);
|
||||
return includePrefix ? `${prefix} ${text}` : text;
|
||||
}
|
||||
|
||||
function useSettingSourceModels() {
|
||||
const requests = useAllRequests();
|
||||
const folders = useAtomValue(foldersAtom);
|
||||
const workspaces = useAtomValue(workspacesAtom);
|
||||
|
||||
return useMemo<AnyModel[]>(
|
||||
() => [...requests, ...folders, ...workspaces],
|
||||
[requests, folders, workspaces],
|
||||
);
|
||||
}
|
||||
|
||||
function formatSettingSource(
|
||||
event: Extract<HttpResponseEventData, { type: "setting" }>,
|
||||
models: AnyModel[],
|
||||
): string {
|
||||
const sourceModel = event.source_model;
|
||||
if (sourceModel == null || sourceModel === "default") {
|
||||
return "Default";
|
||||
}
|
||||
|
||||
const model =
|
||||
event.source_id == null
|
||||
? null
|
||||
: (models.find((m) => m.model === sourceModel && m.id === event.source_id) ?? null);
|
||||
const name = model == null ? event.source_name : resolvedModelName(model);
|
||||
const label = sourceModel.replaceAll("_", " ");
|
||||
return name == null || name.length === 0 ? label : `${name} (${label})`;
|
||||
}
|
||||
|
||||
function formatSettingSourceModel(event: Extract<HttpResponseEventData, { type: "setting" }>) {
|
||||
const sourceModel = event.source_model;
|
||||
if (sourceModel == null || sourceModel === "default" || sourceModel === "workspace") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sourceModel;
|
||||
}
|
||||
|
||||
type EventDisplay = {
|
||||
icon: IconProps["icon"];
|
||||
color: IconProps["color"];
|
||||
label: string;
|
||||
summary: ReactNode;
|
||||
};
|
||||
|
||||
function getEventDisplay(event: HttpResponseEventData): EventDisplay {
|
||||
switch (event.type) {
|
||||
case "setting":
|
||||
const sourceModel = formatSettingSourceModel(event);
|
||||
return {
|
||||
icon: "settings",
|
||||
color: "secondary",
|
||||
label: "Setting",
|
||||
summary: `${event.name} = ${event.value}${sourceModel == null ? "" : ` (${sourceModel})`}`,
|
||||
};
|
||||
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,112 +0,0 @@
|
||||
import { platform, useCapability } from "@yaakapp-internal/platform";
|
||||
import { Icon } from "@yaakapp-internal/ui";
|
||||
import * as m from "motion/react-m";
|
||||
import { useEffect, useState } from "react";
|
||||
import { importCurl, looksLikeCurl } from "../commands/importCurl";
|
||||
import { useWindowFocus } from "../hooks/useWindowFocus";
|
||||
import { showToast } from "../lib/toast";
|
||||
import { Button } from "./core/Button";
|
||||
|
||||
/**
|
||||
* Offers to create a request from a Curl command on the clipboard. A host that can read
|
||||
* the clipboard on its own offers it whenever the window is focused; one that would have
|
||||
* to prompt for permission first waits for the user to paste instead.
|
||||
*/
|
||||
export function ImportCurl() {
|
||||
return useCapability("clipboardRead") ? <ImportCurlButton /> : <ImportCurlOnPaste />;
|
||||
}
|
||||
|
||||
function ImportCurlButton() {
|
||||
const focused = useWindowFocus();
|
||||
const [clipboardText, setClipboardText] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// oxlint-disable-next-line react-hooks/exhaustive-deps -- none
|
||||
useEffect(() => {
|
||||
void platform.clipboard.readText().then(setClipboardText);
|
||||
}, [focused]);
|
||||
|
||||
if (!looksLikeCurl(clipboardText)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<m.div
|
||||
initial={{ opacity: 0, scale: 0 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
>
|
||||
<Button
|
||||
size="2xs"
|
||||
variant="border"
|
||||
color="success"
|
||||
className="rounded-full"
|
||||
rightSlot={<Icon icon="import" size="sm" />}
|
||||
isLoading={isLoading}
|
||||
title="Import Curl command from clipboard"
|
||||
onClick={async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await importCurl.mutateAsync({ command: clipboardText });
|
||||
setClipboardText(""); // Hide the button until the clipboard changes
|
||||
} catch (e) {
|
||||
console.log("Failed to import curl", e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Import Curl
|
||||
</Button>
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImportCurlOnPaste() {
|
||||
useEffect(() => {
|
||||
const handlePaste = (e: ClipboardEvent) => {
|
||||
const command = e.clipboardData?.getData("text/plain") ?? "";
|
||||
if (!looksLikeCurl(command)) return;
|
||||
|
||||
// Editable targets keep the text as text. The URL editor imports it itself.
|
||||
if (isEditable(e.target)) return;
|
||||
|
||||
showToast({
|
||||
id: "curl-paste",
|
||||
color: "success",
|
||||
message: (
|
||||
<div>
|
||||
<h2 className="font-semibold">Curl command detected</h2>
|
||||
<p className="text-text-subtle text-sm">Create a request from the pasted command?</p>
|
||||
</div>
|
||||
),
|
||||
action: ({ hide }) => (
|
||||
<Button
|
||||
size="xs"
|
||||
color="success"
|
||||
className="mr-auto min-w-20"
|
||||
rightSlot={<Icon icon="import" size="sm" />}
|
||||
onClick={() => {
|
||||
hide();
|
||||
importCurl.mutate({ command });
|
||||
}}
|
||||
>
|
||||
Create Request
|
||||
</Button>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener("paste", handlePaste);
|
||||
return () => document.removeEventListener("paste", handlePaste);
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isEditable(target: EventTarget | null) {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
if (target.isContentEditable) return true;
|
||||
if (target.closest(".cm-editor") != null) return true;
|
||||
return ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName);
|
||||
}
|
||||
@@ -1,769 +0,0 @@
|
||||
import {
|
||||
type ImportDestination,
|
||||
type ImportPlan,
|
||||
type ImportPlanItem,
|
||||
type ImportSource,
|
||||
type Workspace,
|
||||
} from "@yaakapp-internal/models";
|
||||
import {
|
||||
Banner,
|
||||
FormattedError,
|
||||
HStack,
|
||||
Icon,
|
||||
VStack,
|
||||
IconTooltip,
|
||||
SegmentedControl,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { type ComponentProps, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { errorMessage } from "../lib/errorMessage";
|
||||
import { CommercialUseBanner } from "./CommercialUseBanner";
|
||||
import {
|
||||
ImportSourceList,
|
||||
type ImportSourceDetection,
|
||||
type ImportSourcePath,
|
||||
InlineButton,
|
||||
toSourcePath,
|
||||
} from "./ImportSourceList";
|
||||
import type { CheckboxTreeNode } from "./core/CheckboxTree";
|
||||
import { CheckboxTree } from "./core/CheckboxTree";
|
||||
import { Chip } from "./core/Chip";
|
||||
import { DialogFooter } from "./core/Dialog";
|
||||
import { Select } from "./core/Select";
|
||||
import { pluralize } from "@yaakapp-internal/lib/pluralize";
|
||||
|
||||
interface Props {
|
||||
currentWorkspace: Workspace | null;
|
||||
workspaces: Workspace[];
|
||||
planSources: (sources: ImportSourcePath[], destination: ImportDestination) => Promise<ImportPlan>;
|
||||
detectSource: (source: ImportSourcePath) => Promise<string>;
|
||||
listSources: (workspaceId: string) => Promise<ImportSource[]>;
|
||||
findSourcesForOrigin: (args: { filePath?: string; url?: string }) => Promise<ImportSource[]>;
|
||||
commit: (plan: ImportPlan) => Promise<void>;
|
||||
cancel: () => void;
|
||||
onError: (err: unknown) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the current workspace's linked sources before rendering the dialog, so the inner
|
||||
* component can construct its initial state (prefilled path, destination) in one pass instead of
|
||||
* patching it in with effects after the first paint.
|
||||
*/
|
||||
export function ImportDataDialog(props: Props) {
|
||||
const [initialSources, setInitialSources] = useState<ImportSource[] | null>(null);
|
||||
const { currentWorkspace, listSources } = props;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = currentWorkspace == null ? Promise.resolve([]) : listSources(currentWorkspace.id);
|
||||
load
|
||||
.then((sources) => {
|
||||
if (!cancelled) setInitialSources(sources);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setInitialSources([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentWorkspace, listSources]);
|
||||
|
||||
if (initialSources == null) return null;
|
||||
return <LoadedImportDataDialog {...props} initialSources={initialSources} />;
|
||||
}
|
||||
|
||||
function LoadedImportDataDialog({
|
||||
currentWorkspace,
|
||||
workspaces,
|
||||
planSources,
|
||||
detectSource,
|
||||
findSourcesForOrigin,
|
||||
commit,
|
||||
cancel,
|
||||
onError,
|
||||
initialSources,
|
||||
}: Props & { initialSources: ImportSource[] }) {
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [sourceError, setSourceError] = useState<unknown>(null);
|
||||
const [plan, setPlan] = useState<ImportPlan | null>(null);
|
||||
const [items, setItems] = useState<ImportPlanItem[]>([]);
|
||||
// null means no explicit choice yet, so the default below applies
|
||||
const [destinationChoice, setDestinationChoice] = useState<"new" | "current" | "other" | null>(
|
||||
null,
|
||||
);
|
||||
const [otherWorkspaceId, setOtherWorkspaceId] = useState<string | null>(null);
|
||||
const [originSources, setOriginSources] = useState<ImportSource[]>(initialSources);
|
||||
const initialPaths = useMemo(
|
||||
() => [...new Set(initialSources.map((source) => source.origin))].map(toSourcePath),
|
||||
[initialSources],
|
||||
);
|
||||
const [sources, setSources] = useState<ImportSourcePath[]>(initialPaths);
|
||||
const knownPaths = useRef(new Set(initialPaths.map((source) => source.path)));
|
||||
const [detections, setDetections] = useState<Record<string, ImportSourceDetection>>({});
|
||||
const detect = useCallback(
|
||||
(added: ImportSourcePath[]) => {
|
||||
setDetections((prev) => ({
|
||||
...prev,
|
||||
...Object.fromEntries(added.map((s) => [s.path, { status: "loading" as const }])),
|
||||
}));
|
||||
for (const source of added) {
|
||||
const settle = (detection: ImportSourceDetection) =>
|
||||
setDetections((prev) =>
|
||||
source.path in prev ? { ...prev, [source.path]: detection } : prev,
|
||||
);
|
||||
detectSource(source).then(
|
||||
(importer) => settle({ status: "ok", importer }),
|
||||
(err) => settle({ status: "error", message: errorMessage(err) }),
|
||||
);
|
||||
}
|
||||
},
|
||||
[detectSource],
|
||||
);
|
||||
const allDetected = sources.every((source) => detections[source.path]?.status === "ok");
|
||||
useEffect(() => {
|
||||
detect(initialPaths);
|
||||
}, [detect, initialPaths]);
|
||||
|
||||
const addSources = useCallback(
|
||||
(added: ImportSourcePath[]) => {
|
||||
setSourceError(null);
|
||||
const fresh = added.filter(({ path }) => {
|
||||
if (knownPaths.current.has(path)) return false;
|
||||
knownPaths.current.add(path);
|
||||
return true;
|
||||
});
|
||||
if (fresh.length === 0) return;
|
||||
setSources((existing) => [...existing, ...fresh]);
|
||||
detect(fresh);
|
||||
},
|
||||
[detect],
|
||||
);
|
||||
const removeSource = (path: string) => {
|
||||
setSourceError(null);
|
||||
knownPaths.current.delete(path);
|
||||
setSources((current) => current.filter((source) => source.path !== path));
|
||||
setDetections(({ [path]: _removed, ...rest }) => rest);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (sources.length === 0) {
|
||||
setOriginSources([]);
|
||||
return;
|
||||
}
|
||||
Promise.all(
|
||||
sources.map(({ path, kind }) =>
|
||||
findSourcesForOrigin(kind === "url" ? { url: path } : { filePath: path }),
|
||||
),
|
||||
)
|
||||
.then((found) => {
|
||||
if (!cancelled) setOriginSources(found.flat());
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setOriginSources([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [sources, findSourcesForOrigin]);
|
||||
|
||||
// The one workspace these sources are linked to, if there is exactly one.
|
||||
const linkedWorkspace = useMemo(() => {
|
||||
const ids = [...new Set(originSources.map((s) => s.workspaceId))];
|
||||
if (ids.length !== 1) return null;
|
||||
return workspaces.find((w) => w.id === ids[0]) ?? null;
|
||||
}, [originSources, workspaces]);
|
||||
|
||||
// A file linked to the current workspace defaults back into it, so re-importing doesn't
|
||||
// accidentally create a duplicate workspace. A file linked elsewhere only gets a suggestion —
|
||||
// silently targeting a workspace that is neither new nor current is too surprising. An
|
||||
// explicit choice always wins.
|
||||
const destinationKind =
|
||||
destinationChoice ??
|
||||
(linkedWorkspace != null && linkedWorkspace.id === currentWorkspace?.id ? "current" : "new");
|
||||
|
||||
const destinationWorkspaceId =
|
||||
destinationKind === "current"
|
||||
? (currentWorkspace?.id ?? null)
|
||||
: destinationKind === "other"
|
||||
? otherWorkspaceId
|
||||
: null;
|
||||
|
||||
const destination = (): ImportDestination => {
|
||||
if (destinationWorkspaceId == null) {
|
||||
return { type: "new_workspace" };
|
||||
}
|
||||
return {
|
||||
type: "existing_workspace",
|
||||
workspaceId: destinationWorkspaceId,
|
||||
};
|
||||
};
|
||||
|
||||
const handlePreview = async () => {
|
||||
setIsLoading(true);
|
||||
setSourceError(null);
|
||||
try {
|
||||
const nextPlan = await planSources(sources, destination());
|
||||
setPlan(nextPlan);
|
||||
setItems(nextPlan.items);
|
||||
} catch (err) {
|
||||
setSourceError(err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCommit = async () => {
|
||||
if (plan == null) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await commit({ ...plan, items });
|
||||
} catch (err) {
|
||||
onError(err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// A folder row's checkbox carries everything beneath it, deletions included — the row labels
|
||||
// say which of those are destructive. Checking anything also brings back the folders it needs
|
||||
// to live in.
|
||||
const toggleNode = (node: CheckboxTreeNode<TreeRow>, checked: boolean) => {
|
||||
const targets = new Set(togglableItems(node).map((i) => i.modelId));
|
||||
if (checked && node.data.kind === "item") {
|
||||
const byId = new Map(items.map((i) => [i.modelId, i]));
|
||||
for (const ancestor of ancestorsOf(node.data.item, byId)) {
|
||||
if (isMissingFolder(ancestor)) targets.add(ancestor.modelId);
|
||||
}
|
||||
}
|
||||
setItems((prev) => prev.map((i) => (targets.has(i.modelId) ? { ...i, selected: checked } : i)));
|
||||
};
|
||||
|
||||
const resolveConflict = (modelId: string, resolution: "keep_mine" | "take_source") => {
|
||||
setItems((prev) =>
|
||||
prev.map((item) => (item.modelId === modelId ? { ...item, resolution } : item)),
|
||||
);
|
||||
};
|
||||
|
||||
// Deleting a folder takes its contents with it, so those rows have nothing left to decide.
|
||||
const disabledIds = useMemo(() => {
|
||||
const disabled = new Set<string>();
|
||||
const byId = new Map(items.map((i) => [i.modelId, i]));
|
||||
for (const item of items) {
|
||||
if (item.action !== "delete") continue;
|
||||
for (const parent of ancestorsOf(item, byId)) {
|
||||
if (parent.action === "delete" && parent.selected) {
|
||||
disabled.add(item.modelId);
|
||||
}
|
||||
}
|
||||
}
|
||||
return disabled;
|
||||
}, [items]);
|
||||
|
||||
if (plan != null) {
|
||||
const unchanged = items.filter((i) => i.action === "unchanged");
|
||||
const footerNote =
|
||||
unchanged.length > 0
|
||||
? `${unchanged.length} ${pluralize("resource", unchanged.length)} unchanged`
|
||||
: "";
|
||||
const changeCount = items.filter((item) => {
|
||||
if (disabledIds.has(item.modelId)) return false;
|
||||
if (item.action === "conflict") return item.resolution === "take_source";
|
||||
if (item.action === "unchanged") return false;
|
||||
return item.selected;
|
||||
}).length;
|
||||
|
||||
// The row's label carries what kind of destination it is, so the value can just be its name
|
||||
const [destinationLabel, destinationValue] = ((): [string, string] => {
|
||||
if (plan.destination.type === "new_workspace") {
|
||||
const names = plan.resources.workspaces.map((w) => w.name).filter((n) => n !== "");
|
||||
if (names.length === 0) return ["New workspace", "Untitled"];
|
||||
return [
|
||||
names.length === 1 ? "New workspace" : `${names.length} new workspaces`,
|
||||
names.join(", "),
|
||||
];
|
||||
}
|
||||
const { workspaceId } = plan.destination;
|
||||
const name = workspaces.find((w) => w.id === workspaceId)?.name ?? "Unknown workspace";
|
||||
return ["Destination", name];
|
||||
})();
|
||||
|
||||
// The destination workspace roots the tree. It is not a plan item — commit always applies
|
||||
// it — so its checkbox only aggregates the subtree.
|
||||
const workspaceRoots: CheckboxTreeNode<TreeRow>[] = (() => {
|
||||
const planDestination = plan.destination;
|
||||
const existing =
|
||||
planDestination.type === "existing_workspace"
|
||||
? workspaces.find((w) => w.id === planDestination.workspaceId)
|
||||
: null;
|
||||
const destinations = existing ? [existing] : plan.resources.workspaces;
|
||||
if (destinations.length === 1) {
|
||||
const workspace = destinations[0]!;
|
||||
return [
|
||||
{
|
||||
key: workspace.id,
|
||||
data: {
|
||||
kind: "destination",
|
||||
label: workspace.name,
|
||||
isNew: planDestination.type === "new_workspace",
|
||||
},
|
||||
children: buildItemTree(items),
|
||||
},
|
||||
];
|
||||
}
|
||||
const workspaceById = new Map(
|
||||
[
|
||||
...plan.resources.environments,
|
||||
...plan.resources.folders,
|
||||
...plan.resources.httpRequests,
|
||||
...plan.resources.grpcRequests,
|
||||
...plan.resources.websocketRequests,
|
||||
].map((resource) => [resource.id, resource.workspaceId]),
|
||||
);
|
||||
return destinations.map((workspace) => ({
|
||||
key: workspace.id,
|
||||
data: {
|
||||
kind: "destination",
|
||||
label: workspace.name,
|
||||
isNew: planDestination.type === "new_workspace",
|
||||
},
|
||||
children: buildItemTree(
|
||||
items.filter((item) => workspaceById.get(item.modelId) === workspace.id),
|
||||
),
|
||||
}));
|
||||
})();
|
||||
|
||||
return (
|
||||
<>
|
||||
<VStack space={4} className="pb-4">
|
||||
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
|
||||
<PreviewRow label="Detected format" value={plan.importer} />
|
||||
<PreviewRow label={destinationLabel} value={destinationValue} />
|
||||
</div>
|
||||
|
||||
{plan.warnings
|
||||
.filter(
|
||||
(warning, index, warnings) =>
|
||||
warnings.findIndex(
|
||||
(other) =>
|
||||
other.title === warning.title &&
|
||||
other.detail === warning.detail &&
|
||||
other.level === warning.level,
|
||||
) === index,
|
||||
)
|
||||
.map((warning) => (
|
||||
<Banner
|
||||
key={`${warning.level}:${warning.title}:${warning.detail}`}
|
||||
color={warning.level === "warning" ? "warning" : "info"}
|
||||
className="flex items-start gap-2.5"
|
||||
>
|
||||
<Icon
|
||||
icon={warning.level === "warning" ? "alert_triangle" : "info"}
|
||||
size="sm"
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">{warning.title}</div>
|
||||
<div className="text-xs text-text-subtle mt-0.5">{warning.detail}</div>
|
||||
</div>
|
||||
</Banner>
|
||||
))}
|
||||
|
||||
{workspaceRoots.map((root) => (
|
||||
<div key={root.key} className="rounded-lg border border-border-subtle px-3 py-2">
|
||||
<CheckboxTree
|
||||
node={root}
|
||||
checked={nodeCheckedStatus}
|
||||
onCheck={toggleNode}
|
||||
isCheckboxDisabled={(n) => disabledIds.has(n.key)}
|
||||
isCollapsedByDefault={(n) =>
|
||||
(n.data.kind === "destination" && workspaceRoots.length > 1) ||
|
||||
(n.data.kind === "item" && n.data.item.action === "ignored")
|
||||
}
|
||||
isRelevant={(n) =>
|
||||
n.data.kind === "destination" ||
|
||||
(n.data.kind === "item" && n.data.item.action !== "unchanged")
|
||||
}
|
||||
renderRow={(n) => (
|
||||
<ImportTreeRow row={n.data} onResolveConflict={resolveConflict} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</VStack>
|
||||
<DialogFooter
|
||||
leftSlot={
|
||||
footerNote !== "" ? (
|
||||
<div className="text-xs text-text-subtle">{footerNote}</div>
|
||||
) : undefined
|
||||
}
|
||||
actions={[
|
||||
{
|
||||
label: "Back",
|
||||
disabled: isLoading,
|
||||
onClick: () => {
|
||||
setPlan(null);
|
||||
setItems([]);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: isLoading
|
||||
? "Importing"
|
||||
: changeCount > 0
|
||||
? `Apply ${changeCount} ${changeCount === 1 ? "Change" : "Changes"}`
|
||||
: "Done",
|
||||
color: "primary",
|
||||
isLoading,
|
||||
onClick: handleCommit,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const destinationPicker = (
|
||||
<HStack
|
||||
space={2}
|
||||
alignItems="center"
|
||||
className={classNames(workspaces.length === 0 && "hidden")}
|
||||
>
|
||||
<Select
|
||||
name="import-destination-kind"
|
||||
label="Import into"
|
||||
labelPosition="left"
|
||||
size="sm"
|
||||
value={destinationKind}
|
||||
onChange={setDestinationChoice}
|
||||
options={[
|
||||
{ value: "new", label: "New Workspace" },
|
||||
...(currentWorkspace != null
|
||||
? [{ value: "current" as const, label: "Current Workspace" }]
|
||||
: []),
|
||||
{ value: "other", label: "Other Workspace" },
|
||||
]}
|
||||
/>
|
||||
{destinationKind === "other" && (
|
||||
<Select
|
||||
name="import-destination-workspace"
|
||||
label="Workspace"
|
||||
hideLabel
|
||||
size="sm"
|
||||
value={otherWorkspaceId ?? ""}
|
||||
onChange={(id) => setOtherWorkspaceId(id === "" ? null : id)}
|
||||
filterable
|
||||
options={[
|
||||
{ value: "", label: "Select a workspace" },
|
||||
...workspaces
|
||||
.filter((w) => w.id !== currentWorkspace?.id)
|
||||
.map((w) => ({ value: w.id, label: w.name })),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<VStack space={4} className="h-full pb-4">
|
||||
<CommercialUseBanner source="data-import" title="Importing work data?" />
|
||||
|
||||
<ImportSourceList
|
||||
sources={sources}
|
||||
detections={detections}
|
||||
disabled={isLoading}
|
||||
onAdd={addSources}
|
||||
onRemove={removeSource}
|
||||
onError={setSourceError}
|
||||
/>
|
||||
|
||||
{sourceError != null && (
|
||||
<Banner color="warning">
|
||||
<FormattedError>{errorMessage(sourceError)}</FormattedError>
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
{linkedWorkspace != null && linkedWorkspace.id !== destinationWorkspaceId && (
|
||||
<div className="text-xs text-text-subtle">
|
||||
{sources.length === 1 ? "This source was" : "These sources were"} last imported into{" "}
|
||||
<InlineButton
|
||||
onClick={() => {
|
||||
if (linkedWorkspace.id === currentWorkspace?.id) {
|
||||
setDestinationChoice("current");
|
||||
} else {
|
||||
setDestinationChoice("other");
|
||||
setOtherWorkspaceId(linkedWorkspace.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{linkedWorkspace.name}
|
||||
</InlineButton>
|
||||
</div>
|
||||
)}
|
||||
</VStack>
|
||||
<DialogFooter
|
||||
leftSlot={destinationPicker}
|
||||
actions={[
|
||||
{ label: "Cancel", disabled: isLoading, onClick: cancel },
|
||||
{
|
||||
label: isLoading ? "Analyzing" : "Preview Import",
|
||||
color: "primary",
|
||||
disabled:
|
||||
sources.length === 0 ||
|
||||
!allDetected ||
|
||||
isLoading ||
|
||||
(destinationKind === "other" && otherWorkspaceId == null),
|
||||
isLoading,
|
||||
onClick: handlePreview,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ImportTreeRow({
|
||||
row,
|
||||
onResolveConflict,
|
||||
}: {
|
||||
row: TreeRow;
|
||||
onResolveConflict: (modelId: string, resolution: "keep_mine" | "take_source") => void;
|
||||
}) {
|
||||
if (row.kind !== "item") {
|
||||
return (
|
||||
<>
|
||||
{row.kind === "destination" && (
|
||||
<Icon icon="box" size="sm" className="shrink-0 text-text-subtle" />
|
||||
)}
|
||||
<div className="truncate flex-1">{row.label}</div>
|
||||
{row.kind === "destination" && row.isNew && (
|
||||
<ActionChip label="new" help="Created by this import" color="success" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const { item } = row;
|
||||
const label = actionLabel(item);
|
||||
return (
|
||||
<>
|
||||
<div className="truncate flex-1">{item.name}</div>
|
||||
{item.action === "conflict" ? (
|
||||
<div className="shrink-0">
|
||||
<SegmentedControl
|
||||
name={`conflict-${item.modelId}`}
|
||||
label={`Resolve conflict for ${item.name}`}
|
||||
hideLabel
|
||||
size="2xs"
|
||||
help={actionHelp(item)}
|
||||
value={item.resolution ?? "keep_mine"}
|
||||
onChange={(v) => onResolveConflict(item.modelId, v)}
|
||||
options={[
|
||||
{ value: "keep_mine", label: "Keep mine" },
|
||||
{ value: "take_source", label: "Take source" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
label != null && (
|
||||
<ActionChip label={label} help={actionHelp(item)} color={actionColor(item)} />
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionChip({
|
||||
label,
|
||||
help,
|
||||
color,
|
||||
}: {
|
||||
label: string;
|
||||
help: string | null;
|
||||
color: ComponentProps<typeof Chip>["color"];
|
||||
}) {
|
||||
return (
|
||||
<Chip color={color}>
|
||||
{label}
|
||||
{help != null && <IconTooltip content={help} iconSize="xs" />}
|
||||
</Chip>
|
||||
);
|
||||
}
|
||||
|
||||
function actionColor(item: ImportPlanItem): ComponentProps<typeof Chip>["color"] {
|
||||
switch (item.action) {
|
||||
case "create":
|
||||
return "success";
|
||||
case "update":
|
||||
return "info";
|
||||
case "delete":
|
||||
return "danger";
|
||||
case "keep_local":
|
||||
return item.selected ? "warning" : "default";
|
||||
case "unchanged":
|
||||
case "conflict":
|
||||
case "ignored":
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
|
||||
function actionLabel(item: ImportPlanItem): string | null {
|
||||
switch (item.action) {
|
||||
case "create":
|
||||
return "new";
|
||||
case "update":
|
||||
return "updated";
|
||||
case "delete":
|
||||
return "removed";
|
||||
case "keep_local":
|
||||
return "edited";
|
||||
case "ignored":
|
||||
return "ignored";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function actionHelp(item: ImportPlanItem): string | null {
|
||||
const help = (text: string) =>
|
||||
item.changedFields.length > 0
|
||||
? `${text} · ${item.changedFields.map(fieldLabel).join(", ")}`
|
||||
: text;
|
||||
switch (item.action) {
|
||||
case "create":
|
||||
return "Not in this workspace yet";
|
||||
case "update":
|
||||
return item.reason === "moved_into_ignored_folder"
|
||||
? "Moved into a folder that isn't imported. Importing that folder brings it along"
|
||||
: help("Changed in the source since the last import");
|
||||
case "delete":
|
||||
return "Gone from the source since the last import. Checking it deletes it here";
|
||||
case "keep_local":
|
||||
return help("Changed here since the last import. Checking it reverts to the source");
|
||||
case "conflict":
|
||||
return help("Changed here and in the source since the last import");
|
||||
case "ignored":
|
||||
return "Not in this workspace. Imports leave it alone until you check it";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function fieldLabel(field: string): string {
|
||||
return field.replace(/([A-Z])/g, " $1").toLowerCase();
|
||||
}
|
||||
|
||||
/** Every plan item above `item`, nearest first. */
|
||||
function ancestorsOf(item: ImportPlanItem, byId: Map<string, ImportPlanItem>): ImportPlanItem[] {
|
||||
const ancestors: ImportPlanItem[] = [];
|
||||
const seen = new Set<string>();
|
||||
let parentId = item.parentId;
|
||||
while (parentId != null && !seen.has(parentId)) {
|
||||
seen.add(parentId);
|
||||
const parent = byId.get(parentId);
|
||||
if (parent == null) break;
|
||||
ancestors.push(parent);
|
||||
parentId = parent.parentId;
|
||||
}
|
||||
return ancestors;
|
||||
}
|
||||
|
||||
/**
|
||||
* A row of the preview tree. Most are plan items, but the destination workspace and the group the
|
||||
* workspace's environments sit in are headings: they aggregate their children and decide nothing
|
||||
* themselves.
|
||||
*/
|
||||
type TreeRow =
|
||||
| { kind: "destination"; label: string; isNew: boolean }
|
||||
| { kind: "group"; label: string }
|
||||
| { kind: "item"; item: ImportPlanItem };
|
||||
|
||||
function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<TreeRow>[] {
|
||||
const byId = new Map(items.map((i) => [i.modelId, i]));
|
||||
const childrenOf = new Map<string, ImportPlanItem[]>();
|
||||
const roots: ImportPlanItem[] = [];
|
||||
for (const item of items) {
|
||||
if (item.parentId != null && byId.has(item.parentId)) {
|
||||
const siblings = childrenOf.get(item.parentId) ?? [];
|
||||
siblings.push(item);
|
||||
childrenOf.set(item.parentId, siblings);
|
||||
} else {
|
||||
roots.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
const byKind = (list: ImportPlanItem[]) => [
|
||||
...list.filter((i) => i.model === "environment"),
|
||||
...list.filter((i) => i.model === "folder"),
|
||||
...list.filter((i) => i.model !== "environment" && i.model !== "folder"),
|
||||
];
|
||||
|
||||
const toNode = (item: ImportPlanItem, seen: Set<string>): CheckboxTreeNode<TreeRow> => ({
|
||||
key: item.modelId,
|
||||
data: { kind: "item", item },
|
||||
children: seen.has(item.modelId)
|
||||
? []
|
||||
: byKind(childrenOf.get(item.modelId) ?? []).map((c) =>
|
||||
toNode(c, new Set([...seen, item.modelId])),
|
||||
),
|
||||
});
|
||||
|
||||
// The workspace's environments have nothing to sit under — a sub-environment is a sibling of
|
||||
// the base one, not its child — so a heading groups them into one thing to turn on and off.
|
||||
const environments = roots.filter((i) => i.model === "environment");
|
||||
const others = byKind(roots.filter((i) => i.model !== "environment"));
|
||||
const nodes = others.map((r) => toNode(r, new Set()));
|
||||
if (environments.length === 0) return nodes;
|
||||
return [
|
||||
{
|
||||
key: "group:environments",
|
||||
data: { kind: "group", label: "Variables" },
|
||||
children: environments.map((e) => toNode(e, new Set())),
|
||||
},
|
||||
...nodes,
|
||||
];
|
||||
}
|
||||
|
||||
function collectRows(node: CheckboxTreeNode<TreeRow>): TreeRow[] {
|
||||
return [node.data, ...node.children.flatMap(collectRows)];
|
||||
}
|
||||
|
||||
/** A folder that isn't there yet, so anything inside it needs it brought in first. */
|
||||
function isMissingFolder(item: ImportPlanItem): boolean {
|
||||
return item.model === "folder" && (item.action === "create" || item.action === "ignored");
|
||||
}
|
||||
|
||||
/**
|
||||
* The plan item a row's checkbox decides, if it decides one. An unchanged resource has nothing to
|
||||
* decide and a conflict is decided by its own control, so neither takes a checkbox — nor rides
|
||||
* along with a parent's.
|
||||
*/
|
||||
function togglableItem(row: TreeRow): ImportPlanItem | null {
|
||||
if (row.kind !== "item") return null;
|
||||
const { item } = row;
|
||||
return item.action === "unchanged" || item.action === "conflict" ? null : item;
|
||||
}
|
||||
|
||||
function togglableItems(node: CheckboxTreeNode<TreeRow>): ImportPlanItem[] {
|
||||
return collectRows(node)
|
||||
.map(togglableItem)
|
||||
.filter((i) => i != null);
|
||||
}
|
||||
|
||||
function nodeCheckedStatus(node: CheckboxTreeNode<TreeRow>): boolean | "indeterminate" | "hidden" {
|
||||
const covered = togglableItems(node);
|
||||
if (covered.length === 0) return "hidden";
|
||||
const selected = covered.filter((i) => i.selected).length;
|
||||
if (selected === covered.length) return true;
|
||||
if (selected === 0) return false;
|
||||
return "indeterminate";
|
||||
}
|
||||
|
||||
function PreviewRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 px-3 py-2 text-sm">
|
||||
<span className="text-text-subtle">{label}</span>
|
||||
<span className="text-right font-medium">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { Icon, LoadingIcon } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { type KeyboardEvent, type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { Button } from "./core/Button";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { PathList, splitPath } from "./core/PathList";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
|
||||
export interface ImportSourcePath {
|
||||
path: string;
|
||||
kind?: "file" | "folder" | "url";
|
||||
}
|
||||
|
||||
export type ImportSourceDetection =
|
||||
| { status: "loading" }
|
||||
| { status: "ok"; importer: string }
|
||||
| { status: "error"; message: string };
|
||||
|
||||
interface Props {
|
||||
sources: ImportSourcePath[];
|
||||
detections: Record<string, ImportSourceDetection>;
|
||||
disabled?: boolean;
|
||||
onAdd: (sources: ImportSourcePath[]) => void;
|
||||
onRemove: (path: string) => void;
|
||||
onError: (err: unknown) => void;
|
||||
}
|
||||
|
||||
/** A bare hostname is a URL; local paths need an absolute or explicit relative prefix. */
|
||||
export function isImportFilePath(value: string): boolean {
|
||||
return (
|
||||
value.startsWith("/") ||
|
||||
value.startsWith("./") ||
|
||||
value.startsWith("../") ||
|
||||
value.startsWith("~/") ||
|
||||
value.startsWith("\\\\") ||
|
||||
/^[a-zA-Z]:[\\/]/.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
/** A typed or prefilled path is a URL unless it looks like a local path; kind is left for the host */
|
||||
export function toSourcePath(path: string): ImportSourcePath {
|
||||
return { path, kind: isImportFilePath(path) ? undefined : "url" };
|
||||
}
|
||||
|
||||
export function ImportSourceList({
|
||||
sources,
|
||||
detections,
|
||||
disabled,
|
||||
onAdd,
|
||||
onRemove,
|
||||
onError,
|
||||
}: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const [pathInput, setPathInput] = useState("");
|
||||
const [showPathInput, setShowPathInput] = useState(false);
|
||||
const pathInputRef = useRef<{ focus: () => void }>(null);
|
||||
const trimmedPath = pathInput.trim();
|
||||
|
||||
const openPathInput = () => {
|
||||
setShowPathInput(true);
|
||||
// The add-row menu hands focus back to its trigger as it closes, on a timer, so focus after it
|
||||
setTimeout(() => pathInputRef.current?.focus(), 50);
|
||||
};
|
||||
|
||||
const closePathInput = () => {
|
||||
setPathInput("");
|
||||
setShowPathInput(false);
|
||||
};
|
||||
|
||||
const addPath = () => {
|
||||
if (!trimmedPath || disabled) return;
|
||||
onAdd([toSourcePath(trimmedPath)]);
|
||||
closePathInput();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) return;
|
||||
return platform.window.onDragDrop((event) => {
|
||||
if (event.type === "leave") {
|
||||
setIsHovering(false);
|
||||
return;
|
||||
}
|
||||
const rect = ref.current?.getBoundingClientRect();
|
||||
const { x, y } = event.position;
|
||||
const isOver =
|
||||
rect != null && x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
|
||||
if (event.type === "drop") {
|
||||
if (isOver) onAdd(event.paths.map((path) => ({ path })));
|
||||
setIsHovering(false);
|
||||
} else {
|
||||
setIsHovering(isOver);
|
||||
}
|
||||
});
|
||||
}, [disabled, onAdd]);
|
||||
|
||||
const pick = async (directory: boolean) => {
|
||||
try {
|
||||
const selected = await platform.dialog.open({
|
||||
title: directory ? "Select Import Folder" : "Select Import Files",
|
||||
multiple: true,
|
||||
directory,
|
||||
});
|
||||
if (selected == null) return;
|
||||
onAdd(selected.map((path) => ({ path, kind: directory ? "folder" : "file" })));
|
||||
} catch (err) {
|
||||
onError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePathKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
addPath();
|
||||
} else if (event.key === "Escape") {
|
||||
event.stopPropagation();
|
||||
closePathInput();
|
||||
}
|
||||
};
|
||||
|
||||
const pathInputEl = (
|
||||
<PlainInput
|
||||
ref={pathInputRef}
|
||||
label="URL or file path"
|
||||
hideLabel
|
||||
size="sm"
|
||||
autoFocus
|
||||
disabled={disabled}
|
||||
placeholder="Paste a URL or a file path"
|
||||
onChange={setPathInput}
|
||||
onKeyDownCapture={handlePathKeyDown}
|
||||
rightSlot={
|
||||
<div className="flex items-center gap-1 my-1 mr-1">
|
||||
{trimmedPath !== "" && (
|
||||
<Button size="xs" variant="border" onClick={addPath}>
|
||||
Add
|
||||
</Button>
|
||||
)}
|
||||
<IconButton size="xs" icon="x" title="Cancel" onClick={closePathInput} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
if (sources.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<div
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
"flex-1 rounded-lg border min-h-28 flex flex-col items-center justify-center gap-2 px-4 py-5 text-center text-text-subtle",
|
||||
isHovering ? "border-notice bg-surface-highlight" : "border-dashed border-border",
|
||||
)}
|
||||
>
|
||||
<Icon icon="folder_input" className="text-text-subtlest w-10! h-10! mb-1" />
|
||||
{isHovering ? (
|
||||
<span>Drop to add</span>
|
||||
) : (
|
||||
<span>
|
||||
Choose{" "}
|
||||
<InlineButton disabled={disabled} onClick={() => pick(false)}>
|
||||
files
|
||||
</InlineButton>
|
||||
, a{" "}
|
||||
<InlineButton disabled={disabled} onClick={() => pick(true)}>
|
||||
folder
|
||||
</InlineButton>
|
||||
, or a{" "}
|
||||
<InlineButton disabled={disabled} onClick={openPathInput}>
|
||||
URL
|
||||
</InlineButton>
|
||||
, or drag them here
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-text-subtlest">
|
||||
Supports OpenAPI, Swagger, Postman, Insomnia, Bruno, curl, and Yaak exports
|
||||
</span>
|
||||
</div>
|
||||
{showPathInput && pathInputEl}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PathList
|
||||
ref={ref}
|
||||
hovering={isHovering}
|
||||
disabled={disabled}
|
||||
onRemove={onRemove}
|
||||
addLabel="Add files, a folder, or a URL"
|
||||
addHint="or drop them here"
|
||||
addItems={[
|
||||
{ label: "Files", icon: "file", onSelect: () => pick(false) },
|
||||
{ label: "Folder", icon: "folder", onSelect: () => pick(true) },
|
||||
{ label: "URL or file path", icon: "globe", onSelect: openPathInput },
|
||||
]}
|
||||
items={sources.map((source) => {
|
||||
const detection = detections[source.path];
|
||||
return {
|
||||
path: source.path,
|
||||
icon: sourceIcon(source),
|
||||
failed: detection?.status === "error",
|
||||
subtitle: <DetectionSubtitle path={source.path} detection={detection} />,
|
||||
};
|
||||
})}
|
||||
>
|
||||
{showPathInput && pathInputEl}
|
||||
</PathList>
|
||||
);
|
||||
}
|
||||
|
||||
function DetectionSubtitle({
|
||||
path,
|
||||
detection,
|
||||
}: {
|
||||
path: string;
|
||||
detection: ImportSourceDetection | undefined;
|
||||
}) {
|
||||
const { parent } = splitPath(path);
|
||||
if (detection?.status === "loading") {
|
||||
return (
|
||||
<>
|
||||
<LoadingIcon size="xs" />
|
||||
Detecting format
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (detection?.status === "error") return detection.message;
|
||||
return (
|
||||
<>
|
||||
{detection?.status === "ok" && <span>{detection.importer}</span>}
|
||||
{detection?.status === "ok" && parent !== "" && <span>·</span>}
|
||||
{parent !== "" && <span className="truncate font-mono">{parent}</span>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function sourceIcon({ kind }: ImportSourcePath) {
|
||||
switch (kind) {
|
||||
case "url":
|
||||
return "globe";
|
||||
case "folder":
|
||||
return "folder";
|
||||
case "file":
|
||||
return "file";
|
||||
case undefined:
|
||||
return "import";
|
||||
}
|
||||
}
|
||||
|
||||
export function InlineButton({
|
||||
children,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className="underline decoration-current/40 underline-offset-3 hocus:decoration-current disabled:opacity-disabled"
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { hotkeyActions } from "../hooks/useHotKey";
|
||||
import { HotkeyList } from "./core/HotkeyList";
|
||||
|
||||
export function KeyboardShortcutsDialog() {
|
||||
return (
|
||||
<div className="grid h-full">
|
||||
<HotkeyList hotkeys={hotkeyActions} className="pb-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
import { formatSize } from "@yaakapp-internal/lib/formatSize";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
HStack,
|
||||
LoadingIcon,
|
||||
AudioViewer,
|
||||
ImageViewer,
|
||||
SvgViewer,
|
||||
VideoViewer,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import type { ReactNode } from "react";
|
||||
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
||||
import type { SniffedValue } from "./core/Editor/sniffValue";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import { decodeValue, largeValueActions } from "../lib/largeValue";
|
||||
import { Dropdown } from "./core/Dropdown";
|
||||
|
||||
const PdfViewer = lazy(() =>
|
||||
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
|
||||
);
|
||||
|
||||
interface Props {
|
||||
/** The whole value, exactly as it reads in the document */
|
||||
text: string;
|
||||
sniffed: SniffedValue | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a value the editor collapsed, in whatever viewer its type calls for.
|
||||
*
|
||||
* The editor only ever read the value's head, so this is where it is decoded in full — on an
|
||||
* explicit click, behind a spinner, rather than during layout.
|
||||
*/
|
||||
export function LargeValueDialog({ text, sniffed }: Props) {
|
||||
const viewer = sniffed == null ? null : viewerFor(sniffed.mime);
|
||||
const decoded = useDecoded(text, viewer == null ? null : sniffed);
|
||||
|
||||
// Nothing recognised it, so there is nothing to decode it into
|
||||
if (sniffed == null || viewer == null) {
|
||||
return (
|
||||
<PagedText text={text}>
|
||||
{sniffed != null && (
|
||||
<Banner color="info" className="mb-3">
|
||||
{sniffed.label} content cannot be previewed, so it is shown as it appears in the
|
||||
response.
|
||||
</Banner>
|
||||
)}
|
||||
</PagedText>
|
||||
);
|
||||
}
|
||||
|
||||
if (decoded == null) {
|
||||
return (
|
||||
<HStack className="h-full" alignItems="center" justifyContent="center">
|
||||
<LoadingIcon />
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
if ("error" in decoded) {
|
||||
return (
|
||||
<PagedText text={text}>
|
||||
<Banner color="danger" className="mb-3">
|
||||
Failed to decode this {sniffed.label} value: {decoded.error}
|
||||
</Banner>
|
||||
</PagedText>
|
||||
);
|
||||
}
|
||||
|
||||
const { bytes } = decoded;
|
||||
|
||||
switch (viewer) {
|
||||
case "svg":
|
||||
return <DecodedSvg bytes={bytes} />;
|
||||
case "image":
|
||||
return (
|
||||
<div className="h-full overflow-auto flex items-center justify-center">
|
||||
<ImageViewer data={toArrayBuffer(bytes)} mimeType={sniffed.mime} />
|
||||
</div>
|
||||
);
|
||||
case "audio":
|
||||
return <AudioViewer data={bytes} mimeType={sniffed.mime} />;
|
||||
case "video":
|
||||
return <VideoViewer data={bytes} mimeType={sniffed.mime} />;
|
||||
case "pdf":
|
||||
return (
|
||||
<Suspense fallback={<LoadingIcon />}>
|
||||
<PdfViewer data={bytes} />
|
||||
</Suspense>
|
||||
);
|
||||
case "text":
|
||||
return <DecodedText bytes={bytes} />;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decoding megabytes is worth doing once, not on every render — and the viewers below key their
|
||||
* blob URLs off the string, so a fresh one each time would rebuild them for nothing.
|
||||
*/
|
||||
function DecodedSvg({ bytes }: { bytes: Uint8Array }) {
|
||||
const text = useMemo(() => new TextDecoder().decode(bytes), [bytes]);
|
||||
return <SvgViewer text={text} />;
|
||||
}
|
||||
|
||||
function DecodedText({ bytes }: { bytes: Uint8Array }) {
|
||||
const text = useMemo(() => new TextDecoder().decode(bytes), [bytes]);
|
||||
return <PagedText text={text} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* The same menu the tag in the editor carries, minus the one entry that would only reopen this.
|
||||
*
|
||||
* It lives in the dialog's title rather than above the content, which would cost a band of
|
||||
* whitespace the width of the dialog to hold one small button.
|
||||
*/
|
||||
function LargeValueActions({ text, sniffed }: Props) {
|
||||
const items = useMemo(
|
||||
() =>
|
||||
largeValueActions({
|
||||
value: () => text,
|
||||
sniffed,
|
||||
// The tag copies only what it hides; in here, what you see is the whole value
|
||||
copyText: () => text,
|
||||
}),
|
||||
[text, sniffed],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown items={items}>
|
||||
<Button size="xs" variant="border" forDropdown>
|
||||
Actions
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
type Viewer = "image" | "svg" | "audio" | "video" | "pdf" | "text";
|
||||
|
||||
/** Which viewer a media type calls for, or null if none of them can show it. */
|
||||
function viewerFor(mime: string): Viewer | null {
|
||||
if (mime.startsWith("image/svg")) return "svg";
|
||||
if (mime.startsWith("image/")) return "image";
|
||||
if (mime.startsWith("audio/")) return "audio";
|
||||
if (mime.startsWith("video/")) return "video";
|
||||
if (mime === "application/pdf") return "pdf";
|
||||
if (mime.startsWith("text/") || /\b(json|xml|javascript|csv)\b/.test(mime)) return "text";
|
||||
return null;
|
||||
}
|
||||
|
||||
type Decoded = { bytes: Uint8Array } | { error: string };
|
||||
|
||||
/**
|
||||
* The value's bytes, once they exist.
|
||||
*
|
||||
* Decoding a few megabytes takes long enough to be seen, so it happens in an effect rather than
|
||||
* during render — the dialog paints with a spinner first, instead of opening late.
|
||||
*/
|
||||
function useDecoded(text: string, sniffed: SniffedValue | null): Decoded | null {
|
||||
const [decoded, setDecoded] = useState<Decoded | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (sniffed == null) return;
|
||||
|
||||
setDecoded(null);
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(() => {
|
||||
let result: Decoded;
|
||||
try {
|
||||
result = { bytes: decodeValue(text, sniffed) };
|
||||
} catch (err) {
|
||||
result = { error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
if (!cancelled) setDecoded(result);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [text, sniffed]);
|
||||
|
||||
return decoded;
|
||||
}
|
||||
|
||||
/** A copy, since a `Uint8Array` may be a view onto a larger buffer */
|
||||
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||
return bytes.slice().buffer;
|
||||
}
|
||||
|
||||
/** Characters shown at once. A page this size lays out instantly once its lines are short. */
|
||||
const PAGE_CHARS = 50_000;
|
||||
|
||||
/** Where a line is broken. The long-line cost this whole feature avoids is per line. */
|
||||
const WRAP_CHARS = 120;
|
||||
|
||||
/**
|
||||
* The raw text, in pages of short lines.
|
||||
*
|
||||
* What is left when nothing can render the value. Handing it to an editor whole would walk
|
||||
* straight back into the layout stall that collapsed it in the first place, so it is paged and
|
||||
* hard-wrapped instead: no line is ever long, and no page is ever big.
|
||||
*/
|
||||
function PagedText({ text, children }: { text: string; children?: ReactNode }) {
|
||||
const [page, setPage] = useState(0);
|
||||
const pages = Math.max(1, Math.ceil(text.length / PAGE_CHARS));
|
||||
const from = page * PAGE_CHARS;
|
||||
const to = Math.min(from + PAGE_CHARS, text.length);
|
||||
const body = useMemo(() => wrap(text.slice(from, to)), [text, from, to]);
|
||||
|
||||
return (
|
||||
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)_auto] gap-3">
|
||||
<div>{children}</div>
|
||||
<pre className="overflow-auto font-mono text-sm text-text-subtle select-text">{body}</pre>
|
||||
<HStack space={2} alignItems="center" className="text-sm text-text-subtle">
|
||||
<span>
|
||||
{(to - from).toLocaleString()} of {text.length.toLocaleString()} characters
|
||||
</span>
|
||||
{pages > 1 && (
|
||||
<HStack space={2} alignItems="center" className="ml-auto">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="border"
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<span>
|
||||
Page {page + 1} of {pages.toLocaleString()}
|
||||
</span>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="border"
|
||||
disabled={page >= pages - 1}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</HStack>
|
||||
)}
|
||||
</HStack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Breaks every line at {@link WRAP_CHARS}, leaving the ones already shorter alone. */
|
||||
function wrap(text: string): string {
|
||||
const lines: string[] = [];
|
||||
for (const line of text.split("\n")) {
|
||||
if (line.length <= WRAP_CHARS) {
|
||||
lines.push(line);
|
||||
continue;
|
||||
}
|
||||
for (let i = 0; i < line.length; i += WRAP_CHARS) {
|
||||
lines.push(line.slice(i, i + WRAP_CHARS));
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function showLargeValueDialog({ text, sniffed }: Props) {
|
||||
showDialog({
|
||||
id: "large-value",
|
||||
size: "lg",
|
||||
className: "h-[calc(100vh-10rem)]",
|
||||
// Everything in one line: the same words the tag uses, and the same menu it carries. A
|
||||
// separate description and a row of its own for the button cost three bands to say this.
|
||||
title: (
|
||||
<HStack space={3} alignItems="center">
|
||||
<span>
|
||||
{sniffed == null ? "Hidden Value" : sniffed.label} · {formatSize(text.length)}
|
||||
</span>
|
||||
<LargeValueActions text={text} sniffed={sniffed} />
|
||||
</HStack>
|
||||
),
|
||||
render: () => <LargeValueDialog text={text} sniffed={sniffed} />,
|
||||
});
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import classNames from "classnames";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
|
||||
interface Props {
|
||||
src: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LocalImage({ src: srcPath, className }: Props) {
|
||||
const src = useQuery({
|
||||
queryKey: ["local-image", srcPath],
|
||||
queryFn: async () => {
|
||||
const p = await platform.files.resolveResource(srcPath);
|
||||
return platform.files.url(p);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src.data}
|
||||
alt="Response preview"
|
||||
className={classNames(
|
||||
className,
|
||||
"transition-opacity",
|
||||
src.data == null ? "opacity-0" : "opacity-100",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import ReactMarkdown, { type Components } from "react-markdown";
|
||||
import { PrismLight as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { ErrorBoundary } from "./ErrorBoundary";
|
||||
import { Prose } from "./Prose";
|
||||
|
||||
interface Props {
|
||||
children: string | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Markdown({ children, className }: Props) {
|
||||
if (children == null) return null;
|
||||
|
||||
return (
|
||||
<Prose className={className}>
|
||||
<ErrorBoundary name="Markdown">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
</ErrorBoundary>
|
||||
</Prose>
|
||||
);
|
||||
}
|
||||
|
||||
const prismTheme = {
|
||||
'pre[class*="language-"]': {
|
||||
// Needs to be here, so the lib doesn't add its own
|
||||
},
|
||||
|
||||
// Syntax tokens
|
||||
comment: { color: "var(--textSubtle)" },
|
||||
prolog: { color: "var(--textSubtle)" },
|
||||
doctype: { color: "var(--textSubtle)" },
|
||||
cdata: { color: "var(--textSubtle)" },
|
||||
|
||||
punctuation: { color: "var(--textSubtle)" },
|
||||
|
||||
property: { color: "var(--primary)" },
|
||||
"attr-name": { color: "var(--primary)" },
|
||||
|
||||
string: { color: "var(--notice)" },
|
||||
char: { color: "var(--notice)" },
|
||||
|
||||
number: { color: "var(--info)" },
|
||||
constant: { color: "var(--info)" },
|
||||
symbol: { color: "var(--info)" },
|
||||
|
||||
boolean: { color: "var(--warning)" },
|
||||
"attr-value": { color: "var(--warning)" },
|
||||
|
||||
variable: { color: "var(--success)" },
|
||||
|
||||
tag: { color: "var(--info)" },
|
||||
operator: { color: "var(--danger)" },
|
||||
keyword: { color: "var(--danger)" },
|
||||
function: { color: "var(--success)" },
|
||||
"class-name": { color: "var(--primary)" },
|
||||
builtin: { color: "var(--danger)" },
|
||||
selector: { color: "var(--danger)" },
|
||||
inserted: { color: "var(--success)" },
|
||||
deleted: { color: "var(--danger)" },
|
||||
regex: { color: "var(--warning)" },
|
||||
|
||||
important: { color: "var(--danger)", fontWeight: "bold" },
|
||||
italic: { fontStyle: "italic" },
|
||||
bold: { fontWeight: "bold" },
|
||||
entity: { cursor: "help" },
|
||||
};
|
||||
|
||||
const lineStyle: CSSProperties = {
|
||||
paddingRight: "1.5em",
|
||||
paddingLeft: "0",
|
||||
opacity: 0.5,
|
||||
};
|
||||
|
||||
const markdownComponents: Partial<Components> = {
|
||||
// Ensure links open in external browser by adding target="_blank"
|
||||
a: ({ href, children, ...rest }) => {
|
||||
if (href && !href.match(/https?:\/\//)) {
|
||||
href = `http://${href}`;
|
||||
}
|
||||
return (
|
||||
<a target="_blank" rel="noreferrer noopener" href={href} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
code(props) {
|
||||
const { children, className, ref, ...extraProps } = props;
|
||||
extraProps.node = undefined;
|
||||
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
return match ? (
|
||||
<SyntaxHighlighter
|
||||
{...extraProps}
|
||||
CodeTag="code"
|
||||
showLineNumbers
|
||||
PreTag="div"
|
||||
lineNumberStyle={lineStyle}
|
||||
language={match[1]}
|
||||
style={prismTheme}
|
||||
>
|
||||
{String(children as string).replace(/\n$/, "")}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<code {...extraProps} ref={ref} className={className}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -1,660 +0,0 @@
|
||||
import type {
|
||||
Folder,
|
||||
GrpcRequest,
|
||||
HttpRequest,
|
||||
HttpVersion,
|
||||
InheritedBoolSetting,
|
||||
InheritedHttpVersionSetting,
|
||||
InheritedIntSetting,
|
||||
WebsocketRequest,
|
||||
Workspace,
|
||||
} from "@yaakapp-internal/models";
|
||||
import { patchModel } from "@yaakapp-internal/models";
|
||||
import { useModelAncestors } from "../hooks/useModelAncestors";
|
||||
import {
|
||||
modelSupportsSetting,
|
||||
type RequestSettingDefinition,
|
||||
SETTING_FOLLOW_REDIRECTS,
|
||||
SETTING_HTTP_VERSION,
|
||||
SETTING_REQUEST_MESSAGE_SIZE,
|
||||
SETTING_REQUEST_TIMEOUT,
|
||||
SETTING_SEND_COOKIES,
|
||||
SETTING_STORE_COOKIES,
|
||||
SETTING_VALIDATE_CERTIFICATES,
|
||||
} from "../lib/requestSettings";
|
||||
import { Checkbox } from "./core/Checkbox";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import { Select } from "./core/Select";
|
||||
import {
|
||||
SettingOverrideRow,
|
||||
SettingRow,
|
||||
SettingRowBoolean,
|
||||
SettingsList,
|
||||
SettingsSection,
|
||||
} from "./core/SettingRow";
|
||||
|
||||
const BYTES_PER_MB = 1024 * 1024;
|
||||
const MAX_REQUEST_MESSAGE_SIZE_BYTES = 2_147_483_647;
|
||||
const MAX_MESSAGE_SIZE_MB = MAX_REQUEST_MESSAGE_SIZE_BYTES / BYTES_PER_MB;
|
||||
|
||||
interface Props {
|
||||
showSectionTitles?: boolean;
|
||||
model: ModelWithSettings;
|
||||
}
|
||||
|
||||
type ModelWithSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest;
|
||||
type ModelWithHttpSettings = Workspace | Folder | HttpRequest;
|
||||
type ModelWithTlsSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest;
|
||||
type ModelWithCookieSettings = Workspace | Folder | HttpRequest | WebsocketRequest;
|
||||
type ModelWithMessageSizeSettings = Workspace | Folder | WebsocketRequest | GrpcRequest;
|
||||
type BooleanSetting = boolean | InheritedBoolSetting;
|
||||
type IntegerSetting = number | InheritedIntSetting;
|
||||
type HttpVersionSetting = HttpVersion | InheritedHttpVersionSetting;
|
||||
type CookieSettingsPatch = {
|
||||
settingSendCookies?: ModelWithCookieSettings["settingSendCookies"];
|
||||
settingStoreCookies?: ModelWithCookieSettings["settingStoreCookies"];
|
||||
};
|
||||
type HttpSettingsPatch = {
|
||||
settingFollowRedirects?: ModelWithHttpSettings["settingFollowRedirects"];
|
||||
settingHttpVersion?: ModelWithHttpSettings["settingHttpVersion"];
|
||||
settingRequestTimeout?: ModelWithHttpSettings["settingRequestTimeout"];
|
||||
};
|
||||
type TlsSettingsPatch = {
|
||||
settingValidateCertificates?: ModelWithTlsSettings["settingValidateCertificates"];
|
||||
};
|
||||
type MessageSizeSettingsPatch = {
|
||||
settingRequestMessageSize?: ModelWithMessageSizeSettings["settingRequestMessageSize"];
|
||||
};
|
||||
|
||||
export function ModelSettingsEditor({ model, showSectionTitles = false }: Props) {
|
||||
const ancestors = useModelAncestors(model);
|
||||
const supportsHttpSettings = modelSupportsHttpSettings(model);
|
||||
const supportsCookieSettings = modelSupportsCookieSettings(model);
|
||||
const supportsTlsSettings = modelSupportsTlsSettings(model);
|
||||
const supportsMessageSizeSettings = modelSupportsMessageSizeSettings(model);
|
||||
|
||||
return (
|
||||
<SettingsList className="space-y-8">
|
||||
{supportsTlsSettings && (
|
||||
<SettingsSection title={showSectionTitles ? "Requests" : null}>
|
||||
{supportsHttpSettings && (
|
||||
<IntegerSettingRow
|
||||
settingDefinition={SETTING_REQUEST_TIMEOUT}
|
||||
setting={model.settingRequestTimeout}
|
||||
inheritedValue={resolveInheritedValue(
|
||||
ancestors,
|
||||
SETTING_REQUEST_TIMEOUT.modelKey,
|
||||
model.settingRequestTimeout,
|
||||
)}
|
||||
onChange={(settingRequestTimeout) =>
|
||||
patchHttpSettings(model, {
|
||||
settingRequestTimeout,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{supportsMessageSizeSettings && (
|
||||
<MessageSizeSettingRow
|
||||
settingDefinition={SETTING_REQUEST_MESSAGE_SIZE}
|
||||
setting={model.settingRequestMessageSize}
|
||||
inheritedValue={resolveInheritedValue(
|
||||
ancestors,
|
||||
SETTING_REQUEST_MESSAGE_SIZE.modelKey,
|
||||
model.settingRequestMessageSize,
|
||||
)}
|
||||
onChange={(settingRequestMessageSize) =>
|
||||
patchMessageSizeSettings(model, {
|
||||
settingRequestMessageSize,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<BooleanSettingRow
|
||||
settingDefinition={SETTING_VALIDATE_CERTIFICATES}
|
||||
setting={model.settingValidateCertificates}
|
||||
inheritedValue={resolveInheritedValue(
|
||||
ancestors,
|
||||
SETTING_VALIDATE_CERTIFICATES.modelKey,
|
||||
model.settingValidateCertificates,
|
||||
)}
|
||||
onChange={(settingValidateCertificates) =>
|
||||
patchTlsSettings(model, {
|
||||
settingValidateCertificates,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{supportsHttpSettings && (
|
||||
<BooleanSettingRow
|
||||
settingDefinition={SETTING_FOLLOW_REDIRECTS}
|
||||
setting={model.settingFollowRedirects}
|
||||
inheritedValue={resolveInheritedValue(
|
||||
ancestors,
|
||||
SETTING_FOLLOW_REDIRECTS.modelKey,
|
||||
model.settingFollowRedirects,
|
||||
)}
|
||||
onChange={(settingFollowRedirects) =>
|
||||
patchHttpSettings(model, {
|
||||
settingFollowRedirects,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{supportsHttpSettings && (
|
||||
<HttpVersionSettingRow
|
||||
settingDefinition={SETTING_HTTP_VERSION}
|
||||
setting={model.settingHttpVersion}
|
||||
inheritedValue={resolveInheritedValue(
|
||||
ancestors,
|
||||
SETTING_HTTP_VERSION.modelKey,
|
||||
model.settingHttpVersion,
|
||||
)}
|
||||
onChange={(settingHttpVersion) =>
|
||||
patchHttpSettings(model, {
|
||||
settingHttpVersion,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SettingsSection>
|
||||
)}
|
||||
{supportsCookieSettings && (
|
||||
<SettingsSection title={supportsTlsSettings || showSectionTitles ? "Cookies" : null}>
|
||||
<BooleanSettingRow
|
||||
settingDefinition={SETTING_SEND_COOKIES}
|
||||
setting={model.settingSendCookies}
|
||||
inheritedValue={resolveInheritedValue(
|
||||
ancestors,
|
||||
SETTING_SEND_COOKIES.modelKey,
|
||||
model.settingSendCookies,
|
||||
)}
|
||||
onChange={(settingSendCookies) =>
|
||||
patchCookieSettings(model, {
|
||||
settingSendCookies,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<BooleanSettingRow
|
||||
settingDefinition={SETTING_STORE_COOKIES}
|
||||
setting={model.settingStoreCookies}
|
||||
inheritedValue={resolveInheritedValue(
|
||||
ancestors,
|
||||
SETTING_STORE_COOKIES.modelKey,
|
||||
model.settingStoreCookies,
|
||||
)}
|
||||
onChange={(settingStoreCookies) =>
|
||||
patchCookieSettings(model, {
|
||||
settingStoreCookies,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
)}
|
||||
</SettingsList>
|
||||
);
|
||||
}
|
||||
|
||||
export function countOverriddenSettings(model: ModelWithSettings) {
|
||||
const settings: (BooleanSetting | IntegerSetting | HttpVersionSetting)[] = [];
|
||||
|
||||
if (modelSupportsCookieSettings(model)) {
|
||||
settings.push(model.settingSendCookies, model.settingStoreCookies);
|
||||
}
|
||||
|
||||
settings.push(model.settingValidateCertificates);
|
||||
|
||||
if (modelSupportsHttpSettings(model)) {
|
||||
settings.push(
|
||||
model.settingFollowRedirects,
|
||||
model.settingRequestTimeout,
|
||||
model.settingHttpVersion,
|
||||
);
|
||||
}
|
||||
|
||||
if (modelSupportsMessageSizeSettings(model)) {
|
||||
settings.push(model.settingRequestMessageSize);
|
||||
}
|
||||
|
||||
return settings.filter((setting) => isInheritedSetting(setting) && setting.enabled === true)
|
||||
.length;
|
||||
}
|
||||
|
||||
function patchCookieSettings(model: ModelWithCookieSettings, patch: Partial<CookieSettingsPatch>) {
|
||||
switch (model.model) {
|
||||
case "workspace":
|
||||
return patchModel(model, patch as Partial<Workspace>);
|
||||
case "folder":
|
||||
return patchModel(model, patch as Partial<Folder>);
|
||||
case "http_request":
|
||||
return patchModel(model, patch as Partial<HttpRequest>);
|
||||
case "websocket_request":
|
||||
return patchModel(model, patch as Partial<WebsocketRequest>);
|
||||
}
|
||||
}
|
||||
|
||||
function patchHttpSettings(model: ModelWithHttpSettings, patch: Partial<HttpSettingsPatch>) {
|
||||
switch (model.model) {
|
||||
case "workspace":
|
||||
return patchModel(model, patch as Partial<Workspace>);
|
||||
case "folder":
|
||||
return patchModel(model, patch as Partial<Folder>);
|
||||
case "http_request":
|
||||
return patchModel(model, patch as Partial<HttpRequest>);
|
||||
}
|
||||
}
|
||||
|
||||
function patchTlsSettings(model: ModelWithTlsSettings, patch: Partial<TlsSettingsPatch>) {
|
||||
switch (model.model) {
|
||||
case "workspace":
|
||||
return patchModel(model, patch as Partial<Workspace>);
|
||||
case "folder":
|
||||
return patchModel(model, patch as Partial<Folder>);
|
||||
case "http_request":
|
||||
return patchModel(model, patch as Partial<HttpRequest>);
|
||||
case "websocket_request":
|
||||
return patchModel(model, patch as Partial<WebsocketRequest>);
|
||||
case "grpc_request":
|
||||
return patchModel(model, patch as Partial<GrpcRequest>);
|
||||
}
|
||||
}
|
||||
|
||||
function patchMessageSizeSettings(
|
||||
model: ModelWithMessageSizeSettings,
|
||||
patch: Partial<MessageSizeSettingsPatch>,
|
||||
) {
|
||||
switch (model.model) {
|
||||
case "workspace":
|
||||
return patchModel(model, patch as Partial<Workspace>);
|
||||
case "folder":
|
||||
return patchModel(model, patch as Partial<Folder>);
|
||||
case "websocket_request":
|
||||
return patchModel(model, patch as Partial<WebsocketRequest>);
|
||||
case "grpc_request":
|
||||
return patchModel(model, patch as Partial<GrpcRequest>);
|
||||
}
|
||||
}
|
||||
|
||||
function modelSupportsHttpSettings(model: ModelWithSettings): model is ModelWithHttpSettings {
|
||||
return modelSupportsSetting(model, SETTING_REQUEST_TIMEOUT);
|
||||
}
|
||||
|
||||
function modelSupportsCookieSettings(model: ModelWithSettings): model is ModelWithCookieSettings {
|
||||
return modelSupportsSetting(model, SETTING_SEND_COOKIES);
|
||||
}
|
||||
|
||||
function modelSupportsTlsSettings(model: ModelWithSettings): model is ModelWithTlsSettings {
|
||||
return modelSupportsSetting(model, SETTING_VALIDATE_CERTIFICATES);
|
||||
}
|
||||
|
||||
function modelSupportsMessageSizeSettings(
|
||||
model: ModelWithSettings,
|
||||
): model is ModelWithMessageSizeSettings {
|
||||
return modelSupportsSetting(model, SETTING_REQUEST_MESSAGE_SIZE);
|
||||
}
|
||||
|
||||
function BooleanSettingRow({
|
||||
inheritedValue,
|
||||
setting,
|
||||
settingDefinition,
|
||||
onChange,
|
||||
}: {
|
||||
inheritedValue: boolean;
|
||||
setting: BooleanSetting;
|
||||
settingDefinition: RequestSettingDefinition;
|
||||
onChange: (setting: BooleanSetting) => void;
|
||||
}) {
|
||||
const inherited = isInheritedSetting(setting);
|
||||
const overridden = inherited ? setting.enabled === true : false;
|
||||
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
|
||||
|
||||
if (!inherited) {
|
||||
return (
|
||||
<SettingRowBoolean
|
||||
checked={value}
|
||||
title={settingDefinition.title}
|
||||
description={settingDefinition.description}
|
||||
onChange={(value) => onChange(value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingOverrideRow
|
||||
title={settingDefinition.title}
|
||||
description={settingDefinition.description}
|
||||
overridden={overridden}
|
||||
onResetOverride={() => onChange({ ...setting, enabled: false })}
|
||||
>
|
||||
<Checkbox
|
||||
hideLabel
|
||||
size="md"
|
||||
title={settingDefinition.title}
|
||||
checked={value}
|
||||
onChange={(value) => onChange({ ...setting, enabled: true, value })}
|
||||
/>
|
||||
</SettingOverrideRow>
|
||||
);
|
||||
}
|
||||
|
||||
const HTTP_VERSION_OPTIONS: { label: string; value: HttpVersion }[] = [
|
||||
{ label: "Automatic", value: "auto" },
|
||||
{ label: "HTTP/1.1", value: "http1" },
|
||||
{ label: "HTTP/2", value: "http2" },
|
||||
];
|
||||
|
||||
function HttpVersionSettingRow({
|
||||
inheritedValue,
|
||||
setting,
|
||||
settingDefinition,
|
||||
onChange,
|
||||
}: {
|
||||
inheritedValue: HttpVersion;
|
||||
setting: HttpVersionSetting;
|
||||
settingDefinition: RequestSettingDefinition<"settingHttpVersion">;
|
||||
onChange: (setting: HttpVersionSetting) => void;
|
||||
}) {
|
||||
const inherited = isInheritedSetting(setting);
|
||||
const overridden = inherited ? setting.enabled === true : false;
|
||||
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
|
||||
|
||||
if (!inherited) {
|
||||
return (
|
||||
<SettingRow title={settingDefinition.title} description={settingDefinition.description}>
|
||||
<Select
|
||||
hideLabel
|
||||
name={settingDefinition.modelKey}
|
||||
label={settingDefinition.title}
|
||||
size="sm"
|
||||
value={value}
|
||||
options={HTTP_VERSION_OPTIONS}
|
||||
onChange={(value) => onChange(value)}
|
||||
/>
|
||||
</SettingRow>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingOverrideRow
|
||||
title={settingDefinition.title}
|
||||
description={settingDefinition.description}
|
||||
overridden={overridden}
|
||||
onResetOverride={() => onChange({ ...setting, enabled: false })}
|
||||
>
|
||||
<Select
|
||||
hideLabel
|
||||
name={settingDefinition.modelKey}
|
||||
label={settingDefinition.title}
|
||||
size="sm"
|
||||
value={value}
|
||||
options={HTTP_VERSION_OPTIONS}
|
||||
onChange={(value) => onChange({ ...setting, enabled: true, value })}
|
||||
/>
|
||||
</SettingOverrideRow>
|
||||
);
|
||||
}
|
||||
|
||||
function IntegerSettingRow({
|
||||
inheritedValue,
|
||||
setting,
|
||||
settingDefinition,
|
||||
onChange,
|
||||
}: {
|
||||
inheritedValue: number;
|
||||
setting: IntegerSetting;
|
||||
settingDefinition: RequestSettingDefinition<"settingRequestTimeout">;
|
||||
onChange: (setting: IntegerSetting) => void;
|
||||
}) {
|
||||
const inherited = isInheritedSetting(setting);
|
||||
const overridden = inherited ? setting.enabled === true : false;
|
||||
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
|
||||
|
||||
if (!inherited) {
|
||||
return (
|
||||
<SettingRow title={settingDefinition.title} description={settingDefinition.description}>
|
||||
<NumberUnitInput
|
||||
name={settingDefinition.modelKey}
|
||||
label={settingDefinition.title}
|
||||
unit="ms"
|
||||
value={`${value}`}
|
||||
placeholder={`${settingDefinition.defaultValue}`}
|
||||
validate={isValidInteger}
|
||||
onChange={(value) => onChange(parseInteger(value))}
|
||||
/>
|
||||
</SettingRow>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingOverrideRow
|
||||
title={settingDefinition.title}
|
||||
description={settingDefinition.description}
|
||||
overridden={overridden}
|
||||
onResetOverride={() => onChange({ ...setting, enabled: false })}
|
||||
>
|
||||
<NumberUnitInput
|
||||
name={settingDefinition.modelKey}
|
||||
label={settingDefinition.title}
|
||||
unit="ms"
|
||||
value={`${value}`}
|
||||
placeholder={`${settingDefinition.defaultValue}`}
|
||||
validate={isValidInteger}
|
||||
onChange={(value) =>
|
||||
onChange({
|
||||
...setting,
|
||||
enabled: true,
|
||||
value: parseInteger(value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SettingOverrideRow>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageSizeSettingRow({
|
||||
inheritedValue,
|
||||
setting,
|
||||
settingDefinition,
|
||||
onChange,
|
||||
}: {
|
||||
inheritedValue: number;
|
||||
setting: IntegerSetting;
|
||||
settingDefinition: RequestSettingDefinition<"settingRequestMessageSize">;
|
||||
onChange: (setting: IntegerSetting) => void;
|
||||
}) {
|
||||
const inherited = isInheritedSetting(setting);
|
||||
const overridden = inherited ? setting.enabled === true : false;
|
||||
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
|
||||
const displayValue = formatMegabytes(value);
|
||||
const placeholder = "0";
|
||||
|
||||
if (!inherited) {
|
||||
return (
|
||||
<SettingRow title={settingDefinition.title} description={settingDefinition.description}>
|
||||
<MessageSizeInput
|
||||
name={settingDefinition.modelKey}
|
||||
label={settingDefinition.title}
|
||||
value={displayValue}
|
||||
placeholder={placeholder}
|
||||
onChange={(value) => onChange(parseMegabytes(value))}
|
||||
/>
|
||||
</SettingRow>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingOverrideRow
|
||||
title={settingDefinition.title}
|
||||
description={settingDefinition.description}
|
||||
overridden={overridden}
|
||||
onResetOverride={() => onChange({ ...setting, enabled: false })}
|
||||
>
|
||||
<MessageSizeInput
|
||||
name={settingDefinition.modelKey}
|
||||
label={settingDefinition.title}
|
||||
value={displayValue}
|
||||
placeholder={placeholder}
|
||||
onChange={(value) =>
|
||||
onChange({
|
||||
...setting,
|
||||
enabled: true,
|
||||
value: parseMegabytes(value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SettingOverrideRow>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageSizeInput({
|
||||
label,
|
||||
name,
|
||||
onChange,
|
||||
placeholder,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
name: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<NumberUnitInput
|
||||
name={name}
|
||||
label={label}
|
||||
unit="MB"
|
||||
value={value}
|
||||
inputMode="decimal"
|
||||
step="any"
|
||||
placeholder={placeholder}
|
||||
validate={isValidMegabytes}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberUnitInput({
|
||||
inputMode,
|
||||
label,
|
||||
name,
|
||||
onChange,
|
||||
placeholder,
|
||||
step,
|
||||
unit,
|
||||
validate,
|
||||
value,
|
||||
}: {
|
||||
inputMode?: "decimal" | "numeric";
|
||||
label: string;
|
||||
name: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
step?: number | "any";
|
||||
unit: string;
|
||||
validate: (value: string) => boolean;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<PlainInput
|
||||
hideLabel
|
||||
name={name}
|
||||
label={label}
|
||||
size="sm"
|
||||
type="number"
|
||||
inputMode={inputMode}
|
||||
step={step}
|
||||
placeholder={placeholder}
|
||||
defaultValue={value}
|
||||
className="[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
containerClassName="w-48!"
|
||||
validate={validate}
|
||||
rightSlot={
|
||||
<span className="flex self-stretch items-center border-l border-border-subtle px-2 text-xs font-medium text-text-subtle">
|
||||
{unit}
|
||||
</span>
|
||||
}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function isInheritedSetting<T>(
|
||||
setting: T | { enabled?: boolean; value: T },
|
||||
): setting is { enabled?: boolean; value: T } {
|
||||
return typeof setting === "object" && setting != null && "value" in setting;
|
||||
}
|
||||
|
||||
function resolveInheritedValue(
|
||||
ancestors: (Folder | Workspace)[],
|
||||
key: "settingRequestTimeout" | "settingRequestMessageSize",
|
||||
fallback: IntegerSetting,
|
||||
): number;
|
||||
function resolveInheritedValue(
|
||||
ancestors: (Folder | Workspace)[],
|
||||
key: BooleanWorkspaceSettingKey,
|
||||
fallback: BooleanSetting,
|
||||
): boolean;
|
||||
function resolveInheritedValue(
|
||||
ancestors: (Folder | Workspace)[],
|
||||
key: "settingHttpVersion",
|
||||
fallback: HttpVersionSetting,
|
||||
): HttpVersion;
|
||||
function resolveInheritedValue(
|
||||
ancestors: (Folder | Workspace)[],
|
||||
key: keyof WorkspaceSettings,
|
||||
fallback: BooleanSetting | IntegerSetting | HttpVersionSetting,
|
||||
) {
|
||||
for (const ancestor of ancestors) {
|
||||
const setting = ancestor[key] as BooleanSetting | IntegerSetting | HttpVersionSetting;
|
||||
if (isInheritedSetting(setting)) {
|
||||
if (setting.enabled === true) {
|
||||
return setting.value;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
return setting;
|
||||
}
|
||||
|
||||
return isInheritedSetting(fallback) ? fallback.value : fallback;
|
||||
}
|
||||
|
||||
type WorkspaceSettings = Pick<
|
||||
Workspace,
|
||||
| "settingFollowRedirects"
|
||||
| "settingHttpVersion"
|
||||
| "settingRequestMessageSize"
|
||||
| "settingRequestTimeout"
|
||||
| "settingSendCookies"
|
||||
| "settingStoreCookies"
|
||||
| "settingValidateCertificates"
|
||||
>;
|
||||
|
||||
type BooleanWorkspaceSettingKey = Exclude<
|
||||
keyof WorkspaceSettings,
|
||||
"settingRequestTimeout" | "settingRequestMessageSize" | "settingHttpVersion"
|
||||
>;
|
||||
|
||||
function formatMegabytes(bytes: number) {
|
||||
const megabytes = bytes / BYTES_PER_MB;
|
||||
return Number.isInteger(megabytes) ? `${megabytes}` : megabytes.toFixed(3).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
function parseMegabytes(value: string) {
|
||||
const megabytes = Number(value);
|
||||
return Number.isFinite(megabytes) ? Math.round(megabytes * BYTES_PER_MB) : 0;
|
||||
}
|
||||
|
||||
function parseInteger(value: string) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? Math.trunc(parsed) : 0;
|
||||
}
|
||||
|
||||
function isValidInteger(value: string) {
|
||||
const parsed = Number(value);
|
||||
return value === "" || (Number.isInteger(parsed) && parsed >= 0);
|
||||
}
|
||||
|
||||
function isValidMegabytes(value: string) {
|
||||
if (value === "") return true;
|
||||
const megabytes = Number(value);
|
||||
return Number.isFinite(megabytes) && megabytes >= 0 && megabytes <= MAX_MESSAGE_SIZE_MB;
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import type { GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-internal/models";
|
||||
import { patchModel, workspacesAtom } from "@yaakapp-internal/models";
|
||||
import { InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useState } from "react";
|
||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
||||
import { router } from "../lib/router";
|
||||
import { showToast } from "../lib/toast";
|
||||
import { Button } from "./core/Button";
|
||||
import { Select } from "./core/Select";
|
||||
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
|
||||
|
||||
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-20"
|
||||
onClick={async () => {
|
||||
await router.navigate({
|
||||
to: "/workspaces/$workspaceId",
|
||||
params: { workspaceId: selectedWorkspaceId },
|
||||
});
|
||||
hide();
|
||||
}}
|
||||
>
|
||||
Switch to Workspace
|
||||
</Button>
|
||||
),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{requests.length === 1 ? "Move" : `Move ${pluralizeCount("Request", requests.length)}`}
|
||||
</Button>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
import type { Color } from "@yaakapp-internal/plugins";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { settingsAtom } from "@yaakapp-internal/models";
|
||||
import { HeaderSize, Heading, Icon, type IconProps, LoadingIcon } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useState } from "react";
|
||||
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import { importData } from "../lib/importData";
|
||||
import {
|
||||
createExampleWorkspace,
|
||||
createFreshWorkspace,
|
||||
type OnboardingChoice,
|
||||
recordOnboardingChoice,
|
||||
} from "../lib/onboarding";
|
||||
import { showErrorToast } from "../lib/toast";
|
||||
import { CloneGitRepositoryDialog } from "./CloneGitRepositoryDialog";
|
||||
import { Button } from "./core/Button";
|
||||
|
||||
/** Shown instead of a workspace when there are none. */
|
||||
export function Onboarding() {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const { capabilities } = platform;
|
||||
const canImport = capabilities.plugins;
|
||||
const showAlreadyUsing = capabilities.localFiles || capabilities.git || canImport;
|
||||
const [busy, setBusy] = useState<OnboardingChoice | null>(null);
|
||||
|
||||
const choose = (choice: OnboardingChoice, run: () => Promise<void> | void) => async () => {
|
||||
recordOnboardingChoice(choice);
|
||||
setBusy(choice);
|
||||
try {
|
||||
await run();
|
||||
} catch (err) {
|
||||
showErrorToast({
|
||||
id: "onboarding-failed",
|
||||
title: "Something went wrong",
|
||||
message: String(err),
|
||||
});
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full w-full">
|
||||
<HeaderSize
|
||||
data-tauri-drag-region
|
||||
size="lg"
|
||||
className="x-theme-appHeader bg-surface"
|
||||
osType={platform.osType()}
|
||||
hideWindowControls={settings.hideWindowControls}
|
||||
useNativeTitlebar={settings.useNativeTitlebar}
|
||||
interfaceScale={settings.interfaceScale}
|
||||
/>
|
||||
<div className="overflow-auto px-6 py-10 grid">
|
||||
<div className="m-auto w-full max-w-lg flex flex-col gap-7">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Heading>How would you like to get started?</Heading>
|
||||
<p className="text-text-subtle">
|
||||
{canImport
|
||||
? "Bring over existing work, try a real API, or start fresh."
|
||||
: "Try a real API, or start fresh."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{canImport && (
|
||||
<StartOption
|
||||
color="primary"
|
||||
icon="folder_input"
|
||||
title="Migrate from another tool"
|
||||
description="Postman, Insomnia, Bruno, OpenAPI, or curl"
|
||||
busy={busy === "import"}
|
||||
disabled={busy != null}
|
||||
onClick={choose("import", () => importData.mutateAsync())}
|
||||
/>
|
||||
)}
|
||||
<StartOption
|
||||
color="info"
|
||||
icon="flask"
|
||||
title="Try Yaak with a real API"
|
||||
description="Ready-made requests you can send right away"
|
||||
busy={busy === "example"}
|
||||
disabled={busy != null}
|
||||
onClick={choose("example", createExampleWorkspace)}
|
||||
/>
|
||||
<StartOption
|
||||
color="success"
|
||||
icon="plus"
|
||||
title="Start fresh"
|
||||
description="An empty workspace for your first request"
|
||||
busy={busy === "fresh"}
|
||||
disabled={busy != null}
|
||||
onClick={choose("fresh", createFreshWorkspace)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showAlreadyUsing && (
|
||||
<div className="pt-5 border-t border-dashed border-border-subtle flex flex-col items-start gap-2.5">
|
||||
<span className="text-sm text-text-subtle">Already using Yaak?</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{capabilities.localFiles && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="border"
|
||||
color="secondary"
|
||||
leftSlot={<Icon icon="folder_open" size="sm" />}
|
||||
disabled={busy != null}
|
||||
onClick={choose("open_folder", async () => {
|
||||
const dir = await platform.dialog.open({
|
||||
title: "Select Workspace Directory",
|
||||
directory: true,
|
||||
multiple: false,
|
||||
});
|
||||
if (dir == null) return;
|
||||
await openWorkspaceFromSyncDir.mutateAsync(dir);
|
||||
})}
|
||||
>
|
||||
Open folder
|
||||
</Button>
|
||||
)}
|
||||
{capabilities.git && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="border"
|
||||
color="secondary"
|
||||
leftSlot={<Icon icon="git_branch" size="sm" />}
|
||||
disabled={busy != null}
|
||||
onClick={choose("clone_git", () => {
|
||||
showDialog({
|
||||
id: "clone-git-repository",
|
||||
size: "md",
|
||||
title: "Clone Git Repository",
|
||||
render: ({ hide }) => <CloneGitRepositoryDialog hide={hide} />,
|
||||
});
|
||||
})}
|
||||
>
|
||||
Clone repository
|
||||
</Button>
|
||||
)}
|
||||
{canImport && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="border"
|
||||
color="secondary"
|
||||
leftSlot={<Icon icon="import" size="sm" />}
|
||||
disabled={busy != null}
|
||||
onClick={choose("import_yaak", () => importData.mutateAsync())}
|
||||
>
|
||||
Import
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StartOption({
|
||||
color,
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
onClick,
|
||||
busy,
|
||||
disabled,
|
||||
}: {
|
||||
color: Color;
|
||||
icon: IconProps["icon"];
|
||||
title: string;
|
||||
description: string;
|
||||
onClick: () => void;
|
||||
busy: boolean;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={classNames(
|
||||
"group w-full text-left rounded-lg px-3 py-2.5 border border-border-subtle",
|
||||
"grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3.5",
|
||||
"enabled:hocus:bg-surface-highlight/50 enabled:hocus:border-border",
|
||||
"outline-border-focus focus-visible:outline-2",
|
||||
disabled && !busy && "opacity-disabled",
|
||||
)}
|
||||
>
|
||||
{/* The banner theme tints this tile from the theme's own color for each option */}
|
||||
<div
|
||||
className={classNames(
|
||||
`x-theme-banner--${color}`,
|
||||
"size-10 rounded-md bg-surface-highlight grid place-items-center",
|
||||
)}
|
||||
>
|
||||
{busy ? <LoadingIcon size="sm" /> : <Icon icon={icon} color={color} size="md" />}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-text">{title}</div>
|
||||
<div className="text-sm text-text-subtle">{description}</div>
|
||||
</div>
|
||||
<Icon
|
||||
icon="chevron_right"
|
||||
className="text-text-subtlest group-enabled:group-hover:text-text-subtle group-enabled:group-focus-visible:text-text-subtle"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import classNames from "classnames";
|
||||
import type { ReactNode } from "react";
|
||||
import "./Prose.css";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Prose({ className, ...props }: Props) {
|
||||
return <div className={classNames("prose", className)} {...props} />;
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
import type { GrpcConnection } from "@yaakapp-internal/models";
|
||||
import { deleteModel } from "@yaakapp-internal/models";
|
||||
import { HStack, Icon, formatMillis } from "@yaakapp-internal/ui";
|
||||
import {
|
||||
differenceInHours,
|
||||
differenceInMinutes,
|
||||
format,
|
||||
isToday,
|
||||
isYesterday,
|
||||
} from "date-fns";
|
||||
import { useDeleteGrpcConnections } from "../hooks/useDeleteGrpcConnections";
|
||||
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
|
||||
|
||||
interface Props {
|
||||
connections: GrpcConnection[];
|
||||
activeConnection: GrpcConnection;
|
||||
onPinnedConnectionId: (id: string) => void;
|
||||
}
|
||||
|
||||
export function RecentGrpcConnectionsDropdown({
|
||||
activeConnection,
|
||||
connections,
|
||||
onPinnedConnectionId,
|
||||
}: Props) {
|
||||
const deleteAllConnections = useDeleteGrpcConnections(activeConnection?.requestId);
|
||||
const latestConnectionId = connections[0]?.id ?? "n/a";
|
||||
const connectionHistoryItems: DropdownItem[] = [];
|
||||
let lastHistoryGroup: string | null = null;
|
||||
let hasRecentConnections = false;
|
||||
let hasShownRecentEmptyState = false;
|
||||
const now = new Date();
|
||||
|
||||
for (const c of connections) {
|
||||
const createdAt = `${c.createdAt}Z`;
|
||||
const createdAtDate = new Date(createdAt);
|
||||
const minutesAgo = differenceInMinutes(now, createdAtDate);
|
||||
const hoursAgo = differenceInHours(now, createdAtDate);
|
||||
let historyGroup = format(createdAtDate, "MMM d, yyyy");
|
||||
if (minutesAgo < 5) historyGroup = "Just now";
|
||||
else if (minutesAgo < 15) historyGroup = "5 minutes ago";
|
||||
else if (minutesAgo < 60) historyGroup = "15 minutes ago";
|
||||
else if (hoursAgo < 3) historyGroup = "1 hour ago";
|
||||
else if (hoursAgo < 6) historyGroup = "3 hours ago";
|
||||
else if (isToday(createdAtDate)) historyGroup = "Today";
|
||||
else if (isYesterday(createdAtDate)) historyGroup = "Yesterday";
|
||||
else if (createdAtDate.getFullYear() === now.getFullYear()) historyGroup = format(createdAtDate, "MMM d");
|
||||
const absoluteTime = format(createdAt, "MMM d, yyyy, h:mm:ss a O");
|
||||
|
||||
if (historyGroup === "Just now") {
|
||||
hasRecentConnections = true;
|
||||
} else if (!hasRecentConnections && !hasShownRecentEmptyState) {
|
||||
connectionHistoryItems.push({
|
||||
type: "content",
|
||||
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent connections</span>,
|
||||
});
|
||||
hasShownRecentEmptyState = true;
|
||||
}
|
||||
|
||||
if (historyGroup !== "Just now" && historyGroup !== lastHistoryGroup) {
|
||||
connectionHistoryItems.push({
|
||||
type: "separator",
|
||||
label: <span title={absoluteTime}>{historyGroup}</span>,
|
||||
});
|
||||
lastHistoryGroup = historyGroup;
|
||||
}
|
||||
|
||||
connectionHistoryItems.push({
|
||||
label: (
|
||||
<HStack space={2} className="text-sm" title={absoluteTime}>
|
||||
<span className="font-mono">{formatMillis(c.elapsed)}</span>
|
||||
</HStack>
|
||||
),
|
||||
leftSlot: activeConnection?.id === c.id ? <Icon icon="check" /> : <Icon icon="empty" />,
|
||||
onSelect: () => onPinnedConnectionId(c.id),
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasRecentConnections && !hasShownRecentEmptyState) {
|
||||
connectionHistoryItems.push({
|
||||
type: "content",
|
||||
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent connections</span>,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: "Clear Connection",
|
||||
onSelect: () => deleteModel(activeConnection),
|
||||
disabled: connections.length === 0,
|
||||
},
|
||||
{
|
||||
label: `Clear ${pluralizeCount("Connection", connections.length)}`,
|
||||
onSelect: deleteAllConnections.mutate,
|
||||
hidden: connections.length <= 1,
|
||||
disabled: connections.length === 0,
|
||||
},
|
||||
{ type: "separator", label: "History" },
|
||||
...connectionHistoryItems,
|
||||
]}
|
||||
>
|
||||
<IconButton
|
||||
title="Show connection history"
|
||||
icon={activeConnection?.id === latestConnectionId ? "history" : "pin"}
|
||||
className="m-0.5 text-text-subtle"
|
||||
size="sm"
|
||||
iconSize="md"
|
||||
/>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||
import { deleteModel } from "@yaakapp-internal/models";
|
||||
import { HStack, Icon, formatMillis, HttpStatusTag, SizeTag } from "@yaakapp-internal/ui";
|
||||
import {
|
||||
differenceInHours,
|
||||
differenceInMinutes,
|
||||
format,
|
||||
isToday,
|
||||
isYesterday,
|
||||
} from "date-fns";
|
||||
import { useDeleteHttpResponses } from "../hooks/useDeleteHttpResponses";
|
||||
import { useKeyValue } from "../hooks/useKeyValue";
|
||||
import { DismissibleBanner } from "./core/DismissibleBanner";
|
||||
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
|
||||
interface Props {
|
||||
responses: HttpResponse[];
|
||||
activeResponse: HttpResponse;
|
||||
onPinnedResponseId: (id: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const RecentHttpResponsesDropdown = function ResponsePane({
|
||||
activeResponse,
|
||||
responses,
|
||||
onPinnedResponseId,
|
||||
}: Props) {
|
||||
const deleteAllResponses = useDeleteHttpResponses(activeResponse?.requestId);
|
||||
const movedActionsBannerId = "response-actions-moved-to-response-menu-2026-07-02-v2";
|
||||
const { value: dismissedMovedActions } = useKeyValue<boolean>({
|
||||
namespace: "global",
|
||||
key: ["dismiss-banner", movedActionsBannerId],
|
||||
fallback: false,
|
||||
});
|
||||
const latestResponseId = responses[0]?.id ?? "n/a";
|
||||
const responseHistoryItems: DropdownItem[] = [];
|
||||
let lastHistoryGroup: string | null = null;
|
||||
let hasRecentResponses = false;
|
||||
let hasShownRecentEmptyState = false;
|
||||
const now = new Date();
|
||||
|
||||
for (const r of responses) {
|
||||
const createdAt = `${r.createdAt}Z`;
|
||||
const createdAtDate = new Date(createdAt);
|
||||
const minutesAgo = differenceInMinutes(now, createdAtDate);
|
||||
const hoursAgo = differenceInHours(now, createdAtDate);
|
||||
let historyGroup = format(createdAtDate, "MMM d, yyyy");
|
||||
if (minutesAgo < 5) historyGroup = "Just now";
|
||||
else if (minutesAgo < 15) historyGroup = "5 minutes ago";
|
||||
else if (minutesAgo < 60) historyGroup = "15 minutes ago";
|
||||
else if (hoursAgo < 3) historyGroup = "1 hour ago";
|
||||
else if (hoursAgo < 6) historyGroup = "3 hours ago";
|
||||
else if (isToday(createdAtDate)) historyGroup = "Today";
|
||||
else if (isYesterday(createdAtDate)) historyGroup = "Yesterday";
|
||||
else if (createdAtDate.getFullYear() === now.getFullYear()) historyGroup = format(createdAtDate, "MMM d");
|
||||
const absoluteTime = format(createdAt, "MMM d, yyyy, h:mm:ss a O");
|
||||
|
||||
if (historyGroup === "Just now") {
|
||||
hasRecentResponses = true;
|
||||
} else if (!hasRecentResponses && !hasShownRecentEmptyState) {
|
||||
responseHistoryItems.push({
|
||||
type: "content",
|
||||
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent requests</span>,
|
||||
});
|
||||
hasShownRecentEmptyState = true;
|
||||
}
|
||||
|
||||
if (historyGroup !== "Just now" && historyGroup !== lastHistoryGroup) {
|
||||
responseHistoryItems.push({
|
||||
type: "separator",
|
||||
label: <span title={absoluteTime}>{historyGroup}</span>,
|
||||
});
|
||||
lastHistoryGroup = historyGroup;
|
||||
}
|
||||
|
||||
responseHistoryItems.push({
|
||||
label: (
|
||||
<HStack space={2} className="text-sm" title={absoluteTime}>
|
||||
<HttpStatusTag short className="text-xs" response={r} />
|
||||
<span className="text-text-subtlest">•</span>
|
||||
<span className="font-mono">{r.elapsed >= 0 ? formatMillis(r.elapsed) : "n/a"}</span>
|
||||
<span className="text-text-subtlest">•</span>
|
||||
<SizeTag
|
||||
className="text-xs"
|
||||
contentLength={r.contentLength ?? 0}
|
||||
contentLengthCompressed={r.contentLengthCompressed}
|
||||
/>
|
||||
</HStack>
|
||||
),
|
||||
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <Icon icon="empty" />,
|
||||
onSelect: () => {
|
||||
onPinnedResponseId(r.id);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasRecentResponses && !hasShownRecentEmptyState) {
|
||||
responseHistoryItems.push({
|
||||
type: "content",
|
||||
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent requests</span>,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: "Delete",
|
||||
leftSlot: <Icon icon="trash" />,
|
||||
onSelect: () => deleteModel(activeResponse),
|
||||
},
|
||||
{
|
||||
label: "Delete all",
|
||||
leftSlot: <Icon icon="trash" />,
|
||||
onSelect: deleteAllResponses.mutate,
|
||||
disabled: responses.length === 0,
|
||||
},
|
||||
{
|
||||
label: "Unpin Response",
|
||||
onSelect: () => onPinnedResponseId(activeResponse.id),
|
||||
leftSlot: <Icon icon="unpin" />,
|
||||
hidden: latestResponseId === activeResponse.id,
|
||||
disabled: responses.length === 0,
|
||||
},
|
||||
{
|
||||
type: "content",
|
||||
hidden: dismissedMovedActions === true,
|
||||
label: (
|
||||
<DismissibleBanner
|
||||
id={movedActionsBannerId}
|
||||
color="info"
|
||||
size="xs"
|
||||
className="max-w-72"
|
||||
>
|
||||
<p>Copy and save actions moved to the Response tab menu.</p>
|
||||
</DismissibleBanner>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
label: "Recent",
|
||||
},
|
||||
...responseHistoryItems,
|
||||
]}
|
||||
>
|
||||
<IconButton
|
||||
title="Show response history"
|
||||
icon={activeResponse?.id === latestResponseId ? "history" : "pin"}
|
||||
className="m-0.5 text-text-subtle"
|
||||
size="sm"
|
||||
iconSize="md"
|
||||
/>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
@@ -1,118 +0,0 @@
|
||||
import type { WebsocketConnection } from "@yaakapp-internal/models";
|
||||
import { deleteModel, getModel } from "@yaakapp-internal/models";
|
||||
import { HStack, Icon, formatMillis } from "@yaakapp-internal/ui";
|
||||
import {
|
||||
differenceInHours,
|
||||
differenceInMinutes,
|
||||
format,
|
||||
isToday,
|
||||
isYesterday,
|
||||
} from "date-fns";
|
||||
import { deleteWebsocketConnections } from "../commands/deleteWebsocketConnections";
|
||||
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
|
||||
|
||||
interface Props {
|
||||
connections: WebsocketConnection[];
|
||||
activeConnection: WebsocketConnection;
|
||||
onPinnedConnectionId: (id: string) => void;
|
||||
}
|
||||
|
||||
export function RecentWebsocketConnectionsDropdown({
|
||||
activeConnection,
|
||||
connections,
|
||||
onPinnedConnectionId,
|
||||
}: Props) {
|
||||
const latestConnectionId = connections[0]?.id ?? "n/a";
|
||||
const connectionHistoryItems: DropdownItem[] = [];
|
||||
let lastHistoryGroup: string | null = null;
|
||||
let hasRecentConnections = false;
|
||||
let hasShownRecentEmptyState = false;
|
||||
const now = new Date();
|
||||
|
||||
for (const c of connections) {
|
||||
const createdAt = `${c.createdAt}Z`;
|
||||
const createdAtDate = new Date(createdAt);
|
||||
const minutesAgo = differenceInMinutes(now, createdAtDate);
|
||||
const hoursAgo = differenceInHours(now, createdAtDate);
|
||||
let historyGroup = format(createdAtDate, "MMM d, yyyy");
|
||||
if (minutesAgo < 5) historyGroup = "Just now";
|
||||
else if (minutesAgo < 15) historyGroup = "5 minutes ago";
|
||||
else if (minutesAgo < 60) historyGroup = "15 minutes ago";
|
||||
else if (hoursAgo < 3) historyGroup = "1 hour ago";
|
||||
else if (hoursAgo < 6) historyGroup = "3 hours ago";
|
||||
else if (isToday(createdAtDate)) historyGroup = "Today";
|
||||
else if (isYesterday(createdAtDate)) historyGroup = "Yesterday";
|
||||
else if (createdAtDate.getFullYear() === now.getFullYear()) historyGroup = format(createdAtDate, "MMM d");
|
||||
const absoluteTime = format(createdAt, "MMM d, yyyy, h:mm:ss a O");
|
||||
|
||||
if (historyGroup === "Just now") {
|
||||
hasRecentConnections = true;
|
||||
} else if (!hasRecentConnections && !hasShownRecentEmptyState) {
|
||||
connectionHistoryItems.push({
|
||||
type: "content",
|
||||
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent connections</span>,
|
||||
});
|
||||
hasShownRecentEmptyState = true;
|
||||
}
|
||||
|
||||
if (historyGroup !== "Just now" && historyGroup !== lastHistoryGroup) {
|
||||
connectionHistoryItems.push({
|
||||
type: "separator",
|
||||
label: <span title={absoluteTime}>{historyGroup}</span>,
|
||||
});
|
||||
lastHistoryGroup = historyGroup;
|
||||
}
|
||||
|
||||
connectionHistoryItems.push({
|
||||
label: (
|
||||
<HStack space={2} className="text-sm" title={absoluteTime}>
|
||||
<span className="font-mono">{formatMillis(c.elapsed)}</span>
|
||||
</HStack>
|
||||
),
|
||||
leftSlot: activeConnection?.id === c.id ? <Icon icon="check" /> : <Icon icon="empty" />,
|
||||
onSelect: () => onPinnedConnectionId(c.id),
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasRecentConnections && !hasShownRecentEmptyState) {
|
||||
connectionHistoryItems.push({
|
||||
type: "content",
|
||||
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent connections</span>,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: "Clear Connection",
|
||||
onSelect: () => deleteModel(activeConnection),
|
||||
disabled: connections.length === 0,
|
||||
},
|
||||
{
|
||||
label: `Clear ${pluralizeCount("Connection", connections.length)}`,
|
||||
onSelect: () => {
|
||||
const request = getModel("websocket_request", activeConnection.requestId);
|
||||
if (request != null) {
|
||||
deleteWebsocketConnections.mutate(request);
|
||||
}
|
||||
},
|
||||
hidden: connections.length <= 1,
|
||||
disabled: connections.length === 0,
|
||||
},
|
||||
{ type: "separator", label: "History" },
|
||||
...connectionHistoryItems,
|
||||
]}
|
||||
>
|
||||
<IconButton
|
||||
title="Show connection history"
|
||||
icon={activeConnection?.id === latestConnectionId ? "history" : "pin"}
|
||||
className="m-0.5 text-text-subtle"
|
||||
size="sm"
|
||||
iconSize="md"
|
||||
/>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { workspacesAtom } from "@yaakapp-internal/models";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useEffect } from "react";
|
||||
import { getRecentCookieJars } from "../hooks/useRecentCookieJars";
|
||||
import { getRecentEnvironments } from "../hooks/useRecentEnvironments";
|
||||
import { getRecentRequests } from "../hooks/useRecentRequests";
|
||||
import { useRecentWorkspaces } from "../hooks/useRecentWorkspaces";
|
||||
import { fireAndForget } from "../lib/fireAndForget";
|
||||
import { router } from "../lib/router";
|
||||
import { Onboarding } from "./Onboarding";
|
||||
|
||||
export function RedirectToLatestWorkspace() {
|
||||
const workspaces = useAtomValue(workspacesAtom);
|
||||
const recentWorkspaces = useRecentWorkspaces();
|
||||
|
||||
useEffect(() => {
|
||||
if (workspaces.length === 0 || recentWorkspaces == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
fireAndForget(
|
||||
(async () => {
|
||||
const workspaceId = recentWorkspaces[0] ?? workspaces[0]?.id ?? "n/a";
|
||||
const environmentId = (await getRecentEnvironments(workspaceId))[0] ?? null;
|
||||
const cookieJarId = (await getRecentCookieJars(workspaceId))[0] ?? null;
|
||||
const requestId = (await getRecentRequests(workspaceId))[0] ?? null;
|
||||
const params = { workspaceId };
|
||||
const search = {
|
||||
cookie_jar_id: cookieJarId,
|
||||
environment_id: environmentId,
|
||||
request_id: requestId,
|
||||
};
|
||||
|
||||
// Whatever created the first workspace may have already navigated into it
|
||||
const currentId = router.history.location.pathname.match(/^\/workspaces\/([^/]+)/)?.[1];
|
||||
if (workspaces.some((w) => w.id === currentId)) return;
|
||||
|
||||
console.log("Redirecting to workspace", params, search);
|
||||
await router.navigate({ to: "/workspaces/$workspaceId", params, search });
|
||||
})(),
|
||||
);
|
||||
}, [recentWorkspaces, workspaces, workspaces.length]);
|
||||
|
||||
// The global models are loaded before the router mounts, so an empty list is a real
|
||||
// first launch (or the last workspace was just deleted), not a store still loading
|
||||
if (workspaces.length === 0) {
|
||||
return <Onboarding />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
||||
import { RequestMethodDropdown as BaseRequestMethodDropdown } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { memo, useCallback } from "react";
|
||||
|
||||
type Props = {
|
||||
request: HttpRequest;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/** The shared method menu, writing to the request and coloured by the user's setting */
|
||||
export const RequestMethodDropdown = memo(function RequestMethodDropdown({
|
||||
request,
|
||||
className,
|
||||
}: Props) {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const handleChange = useCallback(
|
||||
async (method: string) => {
|
||||
await patchModel(request, { method });
|
||||
},
|
||||
[request],
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseRequestMethodDropdown
|
||||
method={request.method}
|
||||
colored={settings.coloredMethods}
|
||||
onChange={handleChange}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
import type { HttpResponse, RequestVersionComparison } from "@yaakapp-internal/models";
|
||||
import { Icon } from "@yaakapp-internal/ui";
|
||||
import { stringify } from "yaml";
|
||||
import { useRequestVersion } from "../hooks/useRequestVersion";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import { restoreRequestVersion } from "../lib/restoreRequestVersion";
|
||||
import { Button } from "./core/Button";
|
||||
import { DiffViewer } from "./core/Editor/DiffViewer";
|
||||
import { Dropdown } from "./core/Dropdown";
|
||||
|
||||
interface Props {
|
||||
response: Pick<HttpResponse, "requestId" | "versionId">;
|
||||
}
|
||||
|
||||
/**
|
||||
* Offers the request a response was sent from, when that is no longer the
|
||||
* request you have.
|
||||
*
|
||||
* Hidden while the two agree, which is the overwhelmingly common case and the
|
||||
* one where there is nothing to say. Responses recorded before versioning
|
||||
* existed have no version and stay quiet forever.
|
||||
*/
|
||||
export function RequestVersionDropdown({ response }: Props) {
|
||||
const comparison = useRequestVersion(response.versionId, response.requestId);
|
||||
if (comparison == null || !comparison.differs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: "View Diff",
|
||||
leftSlot: <Icon icon="git_branch" />,
|
||||
onSelect: () => showRequestVersionDiff(comparison),
|
||||
},
|
||||
{
|
||||
label: "Restore This Version",
|
||||
leftSlot: <Icon icon="history" />,
|
||||
onSelect: () => restoreRequestVersion(comparison.version),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Button
|
||||
size="2xs"
|
||||
variant="border"
|
||||
color="notice"
|
||||
className="font-sans"
|
||||
title="This request has changed since this response was sent"
|
||||
forDropdown
|
||||
>
|
||||
Request Changed
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
function showRequestVersionDiff(comparison: RequestVersionComparison) {
|
||||
showDialog({
|
||||
id: "request-version-diff",
|
||||
title: "Request Changes Since This Response",
|
||||
size: "full",
|
||||
noPadding: true,
|
||||
render: () => (
|
||||
<div className="h-full flex flex-col px-4 pb-4">
|
||||
<DiffViewer
|
||||
original={toYaml(comparison.version.document)}
|
||||
modified={toYaml(comparison.currentDocument)}
|
||||
className="flex-1 min-h-0"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
/** Matches how the Git dialog renders a model for diffing. */
|
||||
function toYaml(document: unknown): string {
|
||||
return stringify(document, { indent: 2, lineWidth: 0 });
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { HStack, IconTooltip, Label } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import mime from "mime";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ButtonProps } from "./core/Button";
|
||||
import { Button } from "./core/Button";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
|
||||
type Props = Omit<ButtonProps, "type"> & {
|
||||
onChange: (value: { filePath: string | null; contentType: string | null }) => void;
|
||||
filePath: string | null;
|
||||
nameOverride?: string | null;
|
||||
directory?: boolean;
|
||||
inline?: boolean;
|
||||
noun?: string;
|
||||
help?: ReactNode;
|
||||
hideLabel?: boolean;
|
||||
label?: ReactNode;
|
||||
};
|
||||
|
||||
// Special character to insert ltr text in rtl element
|
||||
const rtlEscapeChar = <>‎</>;
|
||||
|
||||
export function SelectFile({
|
||||
onChange,
|
||||
filePath,
|
||||
inline,
|
||||
className,
|
||||
directory,
|
||||
noun,
|
||||
nameOverride,
|
||||
size = "sm",
|
||||
label,
|
||||
help,
|
||||
hideLabel,
|
||||
...props
|
||||
}: Props) {
|
||||
const handleClick = async () => {
|
||||
const filePath = await platform.dialog.open({
|
||||
title: directory ? "Select Folder" : "Select File",
|
||||
multiple: false,
|
||||
directory,
|
||||
});
|
||||
if (filePath == null) return;
|
||||
const contentType = filePath ? mime.getType(filePath) : null;
|
||||
onChange({ filePath, contentType });
|
||||
};
|
||||
|
||||
const handleClear = async () => {
|
||||
onChange({ filePath: null, contentType: null });
|
||||
};
|
||||
|
||||
const itemLabel = noun ?? (directory ? "Folder" : "File");
|
||||
const selectOrChange = (filePath ? "Change " : "Select ") + itemLabel;
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Listen for dropped files on the element
|
||||
// NOTE: This doesn't work for Windows since native drag-n-drop can't work at the same tmie
|
||||
// as browser drag-n-drop.
|
||||
useEffect(() => {
|
||||
return platform.window.onDragDrop((event) => {
|
||||
if (event.type === "over") {
|
||||
const p = event.position;
|
||||
const r = ref.current?.getBoundingClientRect();
|
||||
if (r == null) return;
|
||||
const isOver = p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom;
|
||||
setIsHovering(isOver);
|
||||
} else if (event.type === "drop" && isHovering) {
|
||||
const p = event.paths[0];
|
||||
if (p) onChange({ filePath: p, contentType: null });
|
||||
setIsHovering(false);
|
||||
} else {
|
||||
setIsHovering(false);
|
||||
}
|
||||
});
|
||||
}, [isHovering, onChange]);
|
||||
|
||||
const filePathWithNameOverride = nameOverride ? `${filePath} (${nameOverride})` : filePath;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="w-full">
|
||||
{label && (
|
||||
<Label htmlFor={null} help={help} visuallyHidden={hideLabel}>
|
||||
{label}
|
||||
</Label>
|
||||
)}
|
||||
<HStack className="relative justify-stretch overflow-hidden">
|
||||
<Button
|
||||
className={classNames(
|
||||
className,
|
||||
"rtl mr-1.5",
|
||||
inline && "w-full",
|
||||
filePath && inline && "font-mono text-xs",
|
||||
isHovering && "border-notice!",
|
||||
)}
|
||||
color={isHovering ? "primary" : "secondary"}
|
||||
onClick={handleClick}
|
||||
size={size}
|
||||
{...props}
|
||||
>
|
||||
{rtlEscapeChar}
|
||||
{inline ? filePathWithNameOverride || selectOrChange : selectOrChange}
|
||||
</Button>
|
||||
|
||||
{!inline && (
|
||||
<>
|
||||
{filePath && (
|
||||
<IconButton
|
||||
size={size === "auto" ? "md" : size}
|
||||
variant="border"
|
||||
icon="x"
|
||||
title={`Unset ${itemLabel}`}
|
||||
onClick={handleClear}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={classNames(
|
||||
"truncate rtl pl-1.5 pr-3 text-text",
|
||||
filePath && "font-mono",
|
||||
size === "xs" && filePath && "text-xs",
|
||||
size === "sm" && filePath && "text-sm",
|
||||
)}
|
||||
>
|
||||
{rtlEscapeChar}
|
||||
{filePath ?? `No ${itemLabel.toLowerCase()} selected`}
|
||||
</div>
|
||||
{filePath == null && help && !label && <IconTooltip content={help} />}
|
||||
</>
|
||||
)}
|
||||
</HStack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { useLicense } from "@yaakapp-internal/license";
|
||||
import { pluginsAtom, settingsAtom } from "@yaakapp-internal/models";
|
||||
import {
|
||||
CountBadge,
|
||||
SETTINGS_TAB_GENERAL,
|
||||
SETTINGS_TAB_INTERFACE,
|
||||
SETTINGS_TAB_LICENSE,
|
||||
SETTINGS_TAB_THEME,
|
||||
SettingsDialog,
|
||||
type SettingsTabDef,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useKeyPressEvent } from "react-use";
|
||||
import { appInfo } from "../../lib/appInfo";
|
||||
import { capitalize } from "../../lib/capitalize";
|
||||
import { SettingsAgents } from "./SettingsAgents";
|
||||
import { SettingsCertificates } from "./SettingsCertificates";
|
||||
import { SettingsGeneral } from "./SettingsGeneral";
|
||||
import { SettingsHotkeys } from "./SettingsHotkeys";
|
||||
import { SettingsInterface } from "./SettingsInterface";
|
||||
import { SettingsLicense } from "./SettingsLicense";
|
||||
import { SettingsPlugins } from "./SettingsPlugins";
|
||||
import { SettingsProxy } from "./SettingsProxy";
|
||||
import { SettingsTheme } from "./SettingsTheme";
|
||||
|
||||
interface Props {
|
||||
tab?: SettingsTabWithSubtab | null;
|
||||
/** Set when Settings is in a dialog rather than owning a window. */
|
||||
hide?: () => void;
|
||||
}
|
||||
|
||||
const TAB_GENERAL = SETTINGS_TAB_GENERAL;
|
||||
const TAB_INTERFACE = SETTINGS_TAB_INTERFACE;
|
||||
const TAB_THEME = SETTINGS_TAB_THEME;
|
||||
const TAB_SHORTCUTS = "shortcuts";
|
||||
const TAB_PROXY = "proxy";
|
||||
const TAB_CERTIFICATES = "certificates";
|
||||
const TAB_PLUGINS = "plugins";
|
||||
const TAB_AGENTS = "agents";
|
||||
const TAB_LICENSE = SETTINGS_TAB_LICENSE;
|
||||
const tabs = [
|
||||
TAB_GENERAL,
|
||||
TAB_THEME,
|
||||
TAB_INTERFACE,
|
||||
TAB_SHORTCUTS,
|
||||
TAB_PLUGINS,
|
||||
TAB_AGENTS,
|
||||
TAB_CERTIFICATES,
|
||||
TAB_PROXY,
|
||||
TAB_LICENSE,
|
||||
] as const;
|
||||
export type SettingsTab = (typeof tabs)[number];
|
||||
export type SettingsTabWithSubtab = SettingsTab | `${SettingsTab}:${string}`;
|
||||
|
||||
export default function Settings({ tab, hide }: Props) {
|
||||
// Parse tab and subtab (e.g., "plugins:installed")
|
||||
const [mainTab, subtab] = tab?.split(":") ?? [];
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const plugins = useAtomValue(pluginsAtom);
|
||||
const licenseCheck = useLicense();
|
||||
|
||||
// Close settings window on escape. In a dialog, the dialog handles Escape itself.
|
||||
// TODO: Could this be put in a better place? Eg. in Rust key listener when creating the window
|
||||
useKeyPressEvent("Escape", async () => {
|
||||
if (hide == null) await platform.window.close();
|
||||
});
|
||||
|
||||
const tabDefs: Record<SettingsTab, Omit<SettingsTabDef, "value" | "label">> = {
|
||||
[TAB_GENERAL]: { icon: "settings", content: <SettingsGeneral /> },
|
||||
[TAB_THEME]: { icon: "palette", content: <SettingsTheme /> },
|
||||
[TAB_INTERFACE]: { icon: "columns_2", content: <SettingsInterface /> },
|
||||
[TAB_SHORTCUTS]: { icon: "keyboard", content: <SettingsHotkeys /> },
|
||||
[TAB_PLUGINS]: {
|
||||
icon: "puzzle",
|
||||
rightSlot: <CountBadge count={plugins.filter((p) => p.source !== "bundled").length} />,
|
||||
content: <SettingsPlugins defaultSubtab={mainTab === TAB_PLUGINS ? subtab : undefined} />,
|
||||
contentClassName: "h-full grid grid-rows-1",
|
||||
},
|
||||
[TAB_AGENTS]: {
|
||||
icon: "sparkles",
|
||||
hidden: !platform.capabilities.agents,
|
||||
content: <SettingsAgents />,
|
||||
},
|
||||
[TAB_CERTIFICATES]: {
|
||||
icon: "shield_check",
|
||||
rightSlot: <CountBadge count={settings.clientCertificates.length} />,
|
||||
content: <SettingsCertificates />,
|
||||
},
|
||||
[TAB_PROXY]: {
|
||||
icon: "wifi",
|
||||
rightSlot: settings.proxy?.type === "enabled" ? <CountBadge count /> : null,
|
||||
content: <SettingsProxy />,
|
||||
},
|
||||
[TAB_LICENSE]: {
|
||||
icon: "key_round",
|
||||
hidden: !appInfo.featureLicense,
|
||||
rightSlot:
|
||||
licenseCheck.check.data?.status === "personal_use" ? (
|
||||
<CountBadge count color="notice" />
|
||||
) : null,
|
||||
content: <SettingsLicense />,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsDialog
|
||||
defaultTab={mainTab}
|
||||
titlebar={
|
||||
hide
|
||||
? null
|
||||
: {
|
||||
osType: platform.osType(),
|
||||
hideWindowControls: settings.hideWindowControls,
|
||||
useNativeTitlebar: settings.useNativeTitlebar,
|
||||
interfaceScale: settings.interfaceScale,
|
||||
}
|
||||
}
|
||||
tabs={tabs.map((value) => ({ value, label: capitalize(value), ...tabDefs[value] }))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import type { AgentSetupStatus, AppMetaData } from "@yaakapp-internal/rpc-schema";
|
||||
import { Banner, Heading, Icon, InlineCode, SegmentedControl, VStack } from "@yaakapp-internal/ui";
|
||||
import { useEffect } from "react";
|
||||
import { useKeyValue } from "../../hooks/useKeyValue";
|
||||
import { usePluginInfos } from "../../hooks/usePluginInfo";
|
||||
import {
|
||||
AGENT_CLIENTS,
|
||||
type AgentClient,
|
||||
cliSetup,
|
||||
skillInstallCommand,
|
||||
} from "../../lib/agentSetup";
|
||||
import { appInfo } from "../../lib/appInfo";
|
||||
import { rpc } from "../../lib/rpc";
|
||||
import { Link } from "../core/Link";
|
||||
import {
|
||||
SettingCommand,
|
||||
SettingSnippet,
|
||||
SettingsList,
|
||||
SettingsSection,
|
||||
} from "../core/SettingRow";
|
||||
import { DeprecatedPluginBanner } from "./SettingsPlugins";
|
||||
|
||||
const CLI_INSTALL_COMMAND = "npm install -g @yaakapp/cli";
|
||||
const MCP_SERVER_PLUGIN = "@yaak/mcp-server";
|
||||
|
||||
export function SettingsAgents() {
|
||||
const queryClient = useQueryClient();
|
||||
const { value: storedClient, set: setClient } = useKeyValue<AgentClient>({
|
||||
key: "agents_setup_client",
|
||||
fallback: "claude_code",
|
||||
});
|
||||
const client = AGENT_CLIENTS.find((c) => c.value === storedClient)?.value ?? "claude_code";
|
||||
const clientLabel = AGENT_CLIENTS.find((c) => c.value === client)?.label ?? "your agent";
|
||||
const cliVersion = useQuery({
|
||||
queryKey: ["cli_version"],
|
||||
queryFn: async () => (await rpc<AppMetaData>("cmd_metadata")).cliVersion,
|
||||
initialData: appInfo.cliVersion,
|
||||
});
|
||||
const setupStatus = useQuery({
|
||||
queryKey: ["agent_setup_status", client],
|
||||
queryFn: () => rpc<AgentSetupStatus>("cmd_agent_setup_status", { agent: client }),
|
||||
});
|
||||
const mcpServerPlugin = usePluginInfos().find(({ info }) => info.name === MCP_SERVER_PLUGIN);
|
||||
|
||||
useEffect(() => {
|
||||
return platform.window.onFocusChanged((focused) => {
|
||||
if (!focused) return;
|
||||
void queryClient.invalidateQueries({ queryKey: ["cli_version"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["agent_setup_status"] });
|
||||
});
|
||||
}, [queryClient]);
|
||||
|
||||
const cliInstalled = cliVersion.data != null;
|
||||
const cli = cliSetup(client);
|
||||
|
||||
return (
|
||||
<VStack space={1.5} className="mb-4">
|
||||
<div className="mb-3">
|
||||
<Heading>Agents</Heading>
|
||||
<p className="text-text-subtle">
|
||||
Connect AI coding agents like Claude Code and Cursor to Yaak.
|
||||
</p>
|
||||
</div>
|
||||
<SettingsList>
|
||||
<SettingsSection title={null}>
|
||||
<div className="pb-4 border-b border-border-subtle">
|
||||
<SegmentedControl
|
||||
name="agents_setup_client"
|
||||
label="Agent"
|
||||
hideLabel
|
||||
value={client}
|
||||
onChange={setClient}
|
||||
options={AGENT_CLIENTS}
|
||||
/>
|
||||
</div>
|
||||
{cliInstalled ? (
|
||||
<div className="flex items-center gap-2 py-3 text-sm text-text-subtle border-b border-border-subtle">
|
||||
<Icon icon="check" size="sm" color="success" />
|
||||
<span>
|
||||
Yaak CLI <span className="text-text">{cliVersion.data}</span> is installed.
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-4 border-b border-border-subtle">
|
||||
<Banner color="info">
|
||||
<div className="flex items-center gap-2 text text-text">
|
||||
<Icon icon="square_terminal" color="info" />
|
||||
Install the Yaak CLI first
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-text-subtle">
|
||||
Agents launch it to reach Yaak, even with the app closed.
|
||||
</p>
|
||||
<SettingSnippet className="mt-3" language="shell" snippet={CLI_INSTALL_COMMAND} />
|
||||
<div className="mt-3 mb-1 flex items-center gap-4 text-sm">
|
||||
<Link href="https://yaak.app/docs/cli">Docs</Link>
|
||||
<span className="text-text-subtlest">
|
||||
Checks again when you come back to Yaak.
|
||||
</span>
|
||||
</div>
|
||||
</Banner>
|
||||
</div>
|
||||
)}
|
||||
{mcpServerPlugin != null && (
|
||||
<div className="py-4 border-b border-border-subtle">
|
||||
<DeprecatedPluginBanner
|
||||
plugin={mcpServerPlugin.plugin}
|
||||
name={mcpServerPlugin.info.displayName}
|
||||
>
|
||||
The two steps below replace it. Uninstall it once your agent is connected.
|
||||
</DeprecatedPluginBanner>
|
||||
</div>
|
||||
)}
|
||||
<SettingCommand
|
||||
title="Add the MCP server"
|
||||
description={cli.where}
|
||||
snippet={cli.snippet}
|
||||
language={cli.language}
|
||||
disabled={!cliInstalled}
|
||||
done={setupStatus.data?.mcpServer ? "Added" : null}
|
||||
/>
|
||||
<SettingCommand
|
||||
title="Install the skill"
|
||||
description={
|
||||
<>
|
||||
Lets {clientLabel} run the <InlineCode>yaak</InlineCode> command directly.
|
||||
</>
|
||||
}
|
||||
snippet={skillInstallCommand(client)}
|
||||
language="shell"
|
||||
disabled={!cliInstalled}
|
||||
done={setupStatus.data?.skill ? "Installed" : null}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</SettingsList>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { settingsAtom } from "@yaakapp-internal/models";
|
||||
import { SettingsGeneral as SharedSettingsGeneral } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useSettingsView } from "../../hooks/useSettingsView";
|
||||
import { CommercialUseBanner } from "../CommercialUseBanner";
|
||||
import { DismissibleBanner } from "../core/DismissibleBanner";
|
||||
|
||||
const WORKSPACE_SETTINGS_MOVED_AT = "2026-06-30";
|
||||
|
||||
export function SettingsGeneral() {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const bound = useSettingsView();
|
||||
|
||||
if (settings == null || bound == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const showWorkspaceSettingsMovedBanner =
|
||||
settings.createdAt.slice(0, 10) < WORKSPACE_SETTINGS_MOVED_AT;
|
||||
|
||||
return (
|
||||
<SharedSettingsGeneral
|
||||
view={bound.view}
|
||||
callbacks={bound.callbacks}
|
||||
banner={<CommercialUseBanner source="settings-general" title="Using Yaak for work?" />}
|
||||
extraSections={
|
||||
showWorkspaceSettingsMovedBanner && (
|
||||
<DismissibleBanner
|
||||
id="workspace-settings-moved-2026-06-30"
|
||||
color="info"
|
||||
className="w-full p-4 max-w-xl mr-auto"
|
||||
>
|
||||
<p>
|
||||
Workspace specific settings have moved to <b>Workspace Settings</b>, accessible from
|
||||
the workspace switcher menu.
|
||||
</p>
|
||||
</DismissibleBanner>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,365 +0,0 @@
|
||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
||||
import {
|
||||
Heading,
|
||||
HStack,
|
||||
Icon,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
VStack,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { fuzzyMatch } from "fuzzbunny";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
defaultHotkeys,
|
||||
formatHotkeyString,
|
||||
getHotkeyScope,
|
||||
type HotkeyAction,
|
||||
hotkeyActions,
|
||||
hotkeysAtom,
|
||||
useHotkeyLabel,
|
||||
} from "../../hooks/useHotKey";
|
||||
import { capitalize } from "../../lib/capitalize";
|
||||
import { showDialog } from "../../lib/dialog";
|
||||
import { Button } from "../core/Button";
|
||||
import { Dropdown, type DropdownItem } from "../core/Dropdown";
|
||||
import { HotkeyRaw } from "../core/Hotkey";
|
||||
import { IconButton } from "../core/IconButton";
|
||||
import { PlainInput } from "../core/PlainInput";
|
||||
|
||||
const HOLD_KEYS = ["Shift", "Control", "Alt", "Meta"];
|
||||
const LAYOUT_INSENSITIVE_KEYS = [
|
||||
"Equal",
|
||||
"Minus",
|
||||
"BracketLeft",
|
||||
"BracketRight",
|
||||
"Backquote",
|
||||
"Space",
|
||||
];
|
||||
|
||||
/** Convert a KeyboardEvent to a hotkey string like "Meta+Shift+k" or "Control+Shift+k" */
|
||||
function eventToHotkeyString(e: KeyboardEvent): string | null {
|
||||
// Don't capture modifier-only key presses
|
||||
if (HOLD_KEYS.includes(e.key)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
// Add modifiers in consistent order (Meta, Control, Alt, Shift)
|
||||
if (e.metaKey) {
|
||||
parts.push("Meta");
|
||||
}
|
||||
if (e.ctrlKey) {
|
||||
parts.push("Control");
|
||||
}
|
||||
if (e.altKey) {
|
||||
parts.push("Alt");
|
||||
}
|
||||
if (e.shiftKey) {
|
||||
parts.push("Shift");
|
||||
}
|
||||
|
||||
// Get the main key - use the same logic as useHotKey.ts
|
||||
const key = LAYOUT_INSENSITIVE_KEYS.includes(e.code) ? e.code : e.key;
|
||||
parts.push(key);
|
||||
|
||||
return parts.join("+");
|
||||
}
|
||||
|
||||
export function SettingsHotkeys() {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const hotkeys = useAtomValue(hotkeysAtom);
|
||||
const [filter, setFilter] = useState("");
|
||||
|
||||
const filteredActions = useMemo(() => {
|
||||
if (!filter.trim()) {
|
||||
return hotkeyActions;
|
||||
}
|
||||
return hotkeyActions.filter((action) => {
|
||||
const scope = getHotkeyScope(action).replace(/_/g, " ");
|
||||
const label = action.replace(/[_.]/g, " ");
|
||||
const searchText = `${scope} ${label}`;
|
||||
return fuzzyMatch(searchText, filter) != null;
|
||||
});
|
||||
}, [filter]);
|
||||
|
||||
if (settings == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack space={3} className="mb-4">
|
||||
<div className="mb-3">
|
||||
<Heading>Keyboard Shortcuts</Heading>
|
||||
<p className="text-text-subtle">
|
||||
Click the menu button to add, remove, or reset keyboard shortcuts.
|
||||
</p>
|
||||
</div>
|
||||
<PlainInput
|
||||
label="Filter"
|
||||
placeholder="Filter shortcuts..."
|
||||
defaultValue={filter}
|
||||
onChange={setFilter}
|
||||
hideLabel
|
||||
containerClassName="max-w-xs"
|
||||
/>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Scope</TableHeaderCell>
|
||||
<TableHeaderCell>Action</TableHeaderCell>
|
||||
<TableHeaderCell>Shortcut</TableHeaderCell>
|
||||
<TableHeaderCell></TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
{/* key={filter} forces re-render on filter change to fix Safari table rendering bug */}
|
||||
<TableBody key={filter}>
|
||||
{filteredActions.map((action) => (
|
||||
<HotkeyRow
|
||||
key={action}
|
||||
action={action}
|
||||
currentKeys={hotkeys[action] ?? []}
|
||||
defaultKeys={defaultHotkeys[action]}
|
||||
onSave={async (keys) => {
|
||||
const newHotkeys = { ...settings.hotkeys };
|
||||
if (arraysEqual(keys, defaultHotkeys[action])) {
|
||||
// Remove from settings if it matches default (use default)
|
||||
delete newHotkeys[action];
|
||||
} else {
|
||||
// Store the keys (including empty array to disable)
|
||||
newHotkeys[action] = keys;
|
||||
}
|
||||
await patchModel(settings, { hotkeys: newHotkeys });
|
||||
}}
|
||||
onReset={async () => {
|
||||
const newHotkeys = { ...settings.hotkeys };
|
||||
delete newHotkeys[action];
|
||||
await patchModel(settings, { hotkeys: newHotkeys });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
interface HotkeyRowProps {
|
||||
action: HotkeyAction;
|
||||
currentKeys: string[];
|
||||
defaultKeys: string[];
|
||||
onSave: (keys: string[]) => Promise<void>;
|
||||
onReset: () => Promise<void>;
|
||||
}
|
||||
|
||||
function HotkeyRow({ action, currentKeys, defaultKeys, onSave, onReset }: HotkeyRowProps) {
|
||||
const label = useHotkeyLabel(action);
|
||||
const scope = capitalize(getHotkeyScope(action).replace(/_/g, " "));
|
||||
const isCustomized = !arraysEqual(currentKeys, defaultKeys);
|
||||
const isDisabled = currentKeys.length === 0;
|
||||
|
||||
const handleStartRecording = useCallback(() => {
|
||||
showDialog({
|
||||
id: `record-hotkey-${action}`,
|
||||
title: label,
|
||||
size: "sm",
|
||||
render: ({ hide }) => (
|
||||
<RecordHotkeyDialog
|
||||
label={label}
|
||||
onSave={async (key) => {
|
||||
await onSave([...currentKeys, key]);
|
||||
hide();
|
||||
}}
|
||||
onCancel={hide}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}, [action, label, currentKeys, onSave]);
|
||||
|
||||
const handleRemove = useCallback(
|
||||
async (keyToRemove: string) => {
|
||||
const newKeys = currentKeys.filter((k) => k !== keyToRemove);
|
||||
await onSave(newKeys);
|
||||
},
|
||||
[currentKeys, onSave],
|
||||
);
|
||||
|
||||
const handleClearAll = useCallback(async () => {
|
||||
await onSave([]);
|
||||
}, [onSave]);
|
||||
|
||||
// Build dropdown items dynamically
|
||||
const dropdownItems: DropdownItem[] = [
|
||||
{
|
||||
label: "Add Keyboard Shortcut",
|
||||
leftSlot: <Icon icon="plus" />,
|
||||
onSelect: handleStartRecording,
|
||||
},
|
||||
];
|
||||
|
||||
// Add remove options for each existing shortcut
|
||||
if (!isDisabled) {
|
||||
currentKeys.forEach((key) => {
|
||||
dropdownItems.push({
|
||||
label: (
|
||||
<HStack space={1.5}>
|
||||
<span>Remove</span>
|
||||
<HotkeyRaw labelParts={formatHotkeyString(key)} variant="with-bg" className="text-xs" />
|
||||
</HStack>
|
||||
),
|
||||
leftSlot: <Icon icon="trash" />,
|
||||
onSelect: () => handleRemove(key),
|
||||
});
|
||||
});
|
||||
|
||||
if (currentKeys.length > 1) {
|
||||
dropdownItems.push(
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
label: "Remove All Shortcuts",
|
||||
leftSlot: <Icon icon="trash" />,
|
||||
onSelect: handleClearAll,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isCustomized) {
|
||||
dropdownItems.push({
|
||||
type: "separator",
|
||||
});
|
||||
dropdownItems.push({
|
||||
label: "Reset to Default",
|
||||
leftSlot: <Icon icon="refresh" />,
|
||||
onSelect: onReset,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<span className="text-sm text-text-subtlest">{scope}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm">{label}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<HStack space={1.5} className="py-1">
|
||||
{isDisabled ? (
|
||||
<span className="text-text-subtlest">Disabled</span>
|
||||
) : (
|
||||
currentKeys.map((k) => (
|
||||
<HotkeyRaw key={k} labelParts={formatHotkeyString(k)} variant="with-bg" />
|
||||
))
|
||||
)}
|
||||
</HStack>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Dropdown items={dropdownItems}>
|
||||
<IconButton
|
||||
icon="ellipsis_vertical"
|
||||
size="sm"
|
||||
title="Hotkey actions"
|
||||
className="ml-auto text-text-subtlest"
|
||||
/>
|
||||
</Dropdown>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function arraysEqual(a: string[], b: string[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
const sortedA = [...a].sort();
|
||||
const sortedB = [...b].sort();
|
||||
return sortedA.every((v, i) => v === sortedB[i]);
|
||||
}
|
||||
|
||||
interface RecordHotkeyDialogProps {
|
||||
label: string;
|
||||
onSave: (key: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function RecordHotkeyDialog({ label, onSave, onCancel }: RecordHotkeyDialogProps) {
|
||||
const [recordedKey, setRecordedKey] = useState<string | null>(null);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFocused) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (e.key === "Escape") {
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
const hotkeyString = eventToHotkeyString(e);
|
||||
if (hotkeyString) {
|
||||
setRecordedKey(hotkeyString);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown, { capture: true });
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown, { capture: true });
|
||||
};
|
||||
}, [isFocused, onCancel]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (recordedKey) {
|
||||
onSave(recordedKey);
|
||||
}
|
||||
}, [recordedKey, onSave]);
|
||||
|
||||
return (
|
||||
<VStack space={4}>
|
||||
<div>
|
||||
<p className="text-text-subtle mb-2">
|
||||
Record a key combination for <span className="font-semibold">{label}</span>
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
data-disable-hotkey
|
||||
aria-label="Keyboard shortcut input"
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.focus();
|
||||
}}
|
||||
className={classNames(
|
||||
"flex items-center justify-center",
|
||||
"px-4 py-2 rounded-lg bg-surface-highlight border outline-hidden cursor-default w-full",
|
||||
"border-border-subtle focus:border-border-focus",
|
||||
)}
|
||||
>
|
||||
{recordedKey ? (
|
||||
<HotkeyRaw labelParts={formatHotkeyString(recordedKey)} />
|
||||
) : (
|
||||
<span className="text-text-subtlest">Press keys...</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<HStack space={2} justifyContent="end">
|
||||
<Button color="secondary" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="primary" onClick={handleSave} disabled={!recordedKey}>
|
||||
Save
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import { useLicense } from "@yaakapp-internal/license";
|
||||
import type { Settings } from "@yaakapp-internal/models";
|
||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
||||
import { SettingsInterface as SharedSettingsInterface, VStack } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
|
||||
import { useSettingsView } from "../../hooks/useSettingsView";
|
||||
import { showConfirm } from "../../lib/confirm";
|
||||
import { pricingUrl } from "../../lib/pricingUrl";
|
||||
import { CargoFeature } from "../CargoFeature";
|
||||
import { Link } from "../core/Link";
|
||||
import { SettingRowBoolean, SettingRowSelect, SettingsSection } from "../core/SettingRow";
|
||||
|
||||
export function SettingsInterface() {
|
||||
const workspace = useAtomValue(activeWorkspaceAtom);
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const bound = useSettingsView();
|
||||
|
||||
if (settings == null || workspace == null || bound == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SharedSettingsInterface
|
||||
view={bound.view}
|
||||
callbacks={bound.callbacks}
|
||||
leadingSections={
|
||||
<SettingsSection title="Workspaces">
|
||||
<SettingRowSelect
|
||||
title="Open workspace behavior"
|
||||
description="Choose what happens when opening another workspace."
|
||||
name="switchWorkspaceBehavior"
|
||||
value={
|
||||
settings.openWorkspaceNewWindow === true
|
||||
? "new"
|
||||
: settings.openWorkspaceNewWindow === false
|
||||
? "current"
|
||||
: "ask"
|
||||
}
|
||||
onChange={async (v) => {
|
||||
if (v === "current") await patchModel(settings, { openWorkspaceNewWindow: false });
|
||||
else if (v === "new") await patchModel(settings, { openWorkspaceNewWindow: true });
|
||||
else await patchModel(settings, { openWorkspaceNewWindow: null });
|
||||
}}
|
||||
options={[
|
||||
{ label: "Always ask", value: "ask" },
|
||||
{ label: "Open in current window", value: "current" },
|
||||
{ label: "Open in new window", value: "new" },
|
||||
]}
|
||||
/>
|
||||
</SettingsSection>
|
||||
}
|
||||
trailingSections={
|
||||
<CargoFeature feature="license">
|
||||
<LicenseSettings settings={settings} />
|
||||
</CargoFeature>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function LicenseSettings({ settings }: { settings: Settings }) {
|
||||
const license = useLicense();
|
||||
if (license.check.data?.status !== "personal_use") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection title="License">
|
||||
<SettingRowBoolean
|
||||
checked={settings.hideLicenseBadge}
|
||||
title="Hide personal use badge"
|
||||
description="Hide the personal-use badge from the interface."
|
||||
onChange={async (hideLicenseBadge) => {
|
||||
if (hideLicenseBadge) {
|
||||
const confirmed = await showConfirm({
|
||||
id: "hide-license-badge",
|
||||
title: "Confirm Personal Use",
|
||||
confirmText: "Confirm",
|
||||
description: (
|
||||
<VStack space={3}>
|
||||
<p>Hey there 👋🏼</p>
|
||||
<p>
|
||||
Yaak is free for personal projects and learning.{" "}
|
||||
<strong>If you’re using Yaak at work, a license is required.</strong>
|
||||
</p>
|
||||
<p>
|
||||
Licenses help keep Yaak independent and sustainable.{" "}
|
||||
<Link href={pricingUrl("app.license.badge-hide-confirm")}>
|
||||
Purchase a License →
|
||||
</Link>
|
||||
</p>
|
||||
</VStack>
|
||||
),
|
||||
requireTyping: "Personal Use",
|
||||
color: "info",
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await patchModel(settings, { hideLicenseBadge });
|
||||
}}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { SettingsLicense as SharedSettingsLicense } from "@yaakapp-internal/ui";
|
||||
import { useSettingsView } from "../../hooks/useSettingsView";
|
||||
import { CargoFeature } from "../CargoFeature";
|
||||
|
||||
export function SettingsLicense() {
|
||||
return (
|
||||
<CargoFeature feature="license">
|
||||
<SettingsLicenseCmp />
|
||||
</CargoFeature>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsLicenseCmp() {
|
||||
const bound = useSettingsView();
|
||||
if (bound == null) return null;
|
||||
return <SharedSettingsLicense view={bound.view} callbacks={bound.callbacks} />;
|
||||
}
|
||||
@@ -1,454 +0,0 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import type { Plugin } from "@yaakapp-internal/models";
|
||||
import { patchModel, pluginsAtom } from "@yaakapp-internal/models";
|
||||
import type { PluginVersion } from "@yaakapp-internal/plugins";
|
||||
import { checkPluginUpdates, installPlugin, searchPlugins } from "@yaakapp-internal/plugins";
|
||||
import {
|
||||
Banner,
|
||||
HStack,
|
||||
Icon,
|
||||
InlineCode,
|
||||
LoadingIcon,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
useDebouncedValue,
|
||||
CountBadge,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { useInstallPlugin } from "../../hooks/useInstallPlugin";
|
||||
import { usePluginInfo, usePluginInfos } from "../../hooks/usePluginInfo";
|
||||
import { usePluginsKey, useRefreshPlugins } from "../../hooks/usePlugins";
|
||||
import { usePromptUninstallPlugin } from "../../hooks/useUninstallPlugin";
|
||||
import { Button } from "../core/Button";
|
||||
import { Checkbox } from "../core/Checkbox";
|
||||
import { IconButton } from "../core/IconButton";
|
||||
import { Link } from "../core/Link";
|
||||
import { PlainInput } from "../core/PlainInput";
|
||||
import { TabContent, Tabs } from "../core/Tabs/Tabs";
|
||||
import { EmptyStateText } from "../EmptyStateText";
|
||||
import { SelectFile } from "../SelectFile";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
|
||||
interface SettingsPluginsProps {
|
||||
defaultSubtab?: string;
|
||||
}
|
||||
|
||||
export function SettingsPlugins({ defaultSubtab }: SettingsPluginsProps) {
|
||||
const [directory, setDirectory] = useState<string | null>(null);
|
||||
const plugins = useAtomValue(pluginsAtom);
|
||||
const bundledPlugins = plugins.filter((p) => p.source === "bundled");
|
||||
const installedPlugins = plugins.filter((p) => p.source !== "bundled");
|
||||
const createPlugin = useInstallPlugin();
|
||||
const refreshPlugins = useRefreshPlugins();
|
||||
return (
|
||||
<div className="h-full">
|
||||
<Tabs
|
||||
defaultValue={defaultSubtab}
|
||||
label="Plugins"
|
||||
addBorders
|
||||
tabListClassName="px-6 pt-2"
|
||||
tabs={[
|
||||
{ label: "Discover", value: "search" },
|
||||
{
|
||||
label: "Installed",
|
||||
value: "installed",
|
||||
rightSlot: <CountBadge count={installedPlugins.length} />,
|
||||
},
|
||||
{
|
||||
label: "Bundled",
|
||||
value: "bundled",
|
||||
rightSlot: <CountBadge count={bundledPlugins.length} />,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TabContent value="search" className="px-6">
|
||||
<PluginSearch />
|
||||
</TabContent>
|
||||
<TabContent value="installed" className="pb-0">
|
||||
<div className="h-full grid grid-rows-[minmax(0,1fr)_auto]">
|
||||
<InstalledPlugins plugins={installedPlugins} className="px-6" />
|
||||
<footer className="grid grid-cols-[minmax(0,1fr)_auto] py-2 px-4 border-t bg-surface-highlight border-border-subtle min-w-0">
|
||||
<SelectFile
|
||||
size="xs"
|
||||
noun="Plugin"
|
||||
directory
|
||||
onChange={({ filePath }) => setDirectory(filePath)}
|
||||
filePath={directory}
|
||||
/>
|
||||
<HStack>
|
||||
{directory && (
|
||||
<Button
|
||||
size="xs"
|
||||
color="primary"
|
||||
className="ml-auto"
|
||||
onClick={() => {
|
||||
if (directory == null) return;
|
||||
createPlugin.mutate(directory);
|
||||
setDirectory(null);
|
||||
}}
|
||||
>
|
||||
Add Plugin
|
||||
</Button>
|
||||
)}
|
||||
<IconButton
|
||||
size="sm"
|
||||
icon="refresh"
|
||||
title="Reload plugins"
|
||||
spin={refreshPlugins.isPending}
|
||||
onClick={() => refreshPlugins.mutate()}
|
||||
/>
|
||||
<IconButton
|
||||
size="sm"
|
||||
icon="help"
|
||||
title="View documentation"
|
||||
onClick={() =>
|
||||
platform.openUrl("https://yaak.app/docs/plugin-development/plugins-quick-start")
|
||||
}
|
||||
/>
|
||||
</HStack>
|
||||
</footer>
|
||||
</div>
|
||||
</TabContent>
|
||||
<TabContent value="bundled" className="pb-0 px-6">
|
||||
<BundledPlugins plugins={bundledPlugins} />
|
||||
</TabContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginTableRowForInstalledPlugin({ plugin }: { plugin: Plugin }) {
|
||||
const info = usePluginInfo(plugin.id).data;
|
||||
if (info == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PluginTableRow
|
||||
plugin={plugin}
|
||||
version={info.version}
|
||||
name={info.name}
|
||||
displayName={info.displayName}
|
||||
url={plugin.url}
|
||||
showCheckbox={true}
|
||||
showUninstall={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginTableRowForBundledPlugin({ plugin }: { plugin: Plugin }) {
|
||||
const info = usePluginInfo(plugin.id).data;
|
||||
if (info == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PluginTableRow
|
||||
plugin={plugin}
|
||||
version={info.version}
|
||||
name={info.name}
|
||||
displayName={info.displayName}
|
||||
url={plugin.url}
|
||||
showCheckbox={true}
|
||||
showUninstall={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginTableRowForRemotePluginVersion({ pluginVersion }: { pluginVersion: PluginVersion }) {
|
||||
const plugin = useAtomValue(pluginsAtom).find((p) => p.id === pluginVersion.id);
|
||||
const pluginInfo = usePluginInfo(plugin?.id ?? null).data;
|
||||
|
||||
return (
|
||||
<PluginTableRow
|
||||
plugin={plugin ?? null}
|
||||
version={pluginInfo?.version ?? pluginVersion.version}
|
||||
name={pluginVersion.name}
|
||||
displayName={pluginVersion.displayName}
|
||||
url={pluginVersion.url}
|
||||
showCheckbox={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginTableRow({
|
||||
plugin,
|
||||
name,
|
||||
version,
|
||||
displayName,
|
||||
url,
|
||||
showCheckbox = true,
|
||||
showUninstall = true,
|
||||
}: {
|
||||
plugin: Plugin | null;
|
||||
name: string;
|
||||
version: string;
|
||||
displayName: string;
|
||||
url: string | null;
|
||||
showCheckbox?: boolean;
|
||||
showUninstall?: boolean;
|
||||
}) {
|
||||
const updates = usePluginUpdates();
|
||||
const latestVersion = updates.data?.plugins.find((u) => u.name === name)?.version;
|
||||
const installPluginMutation = useMutation({
|
||||
mutationKey: ["install_plugin", name],
|
||||
mutationFn: (name: string) => installPlugin(name, null),
|
||||
});
|
||||
const uninstall = usePromptUninstallPlugin(plugin?.id ?? null, displayName);
|
||||
const refreshPlugins = useRefreshPlugins();
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
{showCheckbox && (
|
||||
<TableCell className="py-0!">
|
||||
<Checkbox
|
||||
hideLabel
|
||||
title={plugin?.enabled ? "Disable plugin" : "Enable plugin"}
|
||||
checked={plugin?.enabled ?? false}
|
||||
disabled={plugin == null}
|
||||
onChange={async (enabled) => {
|
||||
if (plugin) {
|
||||
await patchModel(plugin, { enabled });
|
||||
refreshPlugins.mutate();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className="font-semibold">
|
||||
{url ? (
|
||||
<Link noUnderline href={url}>
|
||||
{displayName}
|
||||
</Link>
|
||||
) : (
|
||||
displayName
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<InlineCode>{name}</InlineCode>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<HStack space={1.5}>
|
||||
<InlineCode>{version}</InlineCode>
|
||||
{latestVersion != null && (
|
||||
<InlineCode className="text-success flex items-center gap-1">
|
||||
<Icon icon="arrow_up" size="sm" />
|
||||
{latestVersion}
|
||||
</InlineCode>
|
||||
)}
|
||||
</HStack>
|
||||
</TableCell>
|
||||
<TableCell className="py-0!">
|
||||
<HStack justifyContent="end" space={1.5}>
|
||||
{plugin != null && latestVersion != null ? (
|
||||
<Button
|
||||
variant="border"
|
||||
color="success"
|
||||
title={`Update to ${latestVersion}`}
|
||||
size="xs"
|
||||
isLoading={installPluginMutation.isPending}
|
||||
onClick={() => installPluginMutation.mutate(name)}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
) : plugin == null ? (
|
||||
<Button
|
||||
variant="border"
|
||||
color="primary"
|
||||
title={`Install ${version}`}
|
||||
size="xs"
|
||||
isLoading={installPluginMutation.isPending}
|
||||
onClick={() => installPluginMutation.mutate(name)}
|
||||
>
|
||||
Install
|
||||
</Button>
|
||||
) : null}
|
||||
{showUninstall && uninstall != null && (
|
||||
<Button
|
||||
size="xs"
|
||||
title="Uninstall plugin"
|
||||
variant="border"
|
||||
isLoading={uninstall.isPending}
|
||||
onClick={() => uninstall.mutate()}
|
||||
>
|
||||
Uninstall
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginSearch() {
|
||||
const [query, setQuery] = useState<string>("");
|
||||
const debouncedQuery = useDebouncedValue(query);
|
||||
const results = useQuery({
|
||||
queryKey: ["plugins", debouncedQuery],
|
||||
queryFn: async () => {
|
||||
const { plugins } = await searchPlugins(query);
|
||||
return { plugins: plugins.filter((p) => p.deprecated == null) };
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)] gap-3">
|
||||
<HStack space={1.5}>
|
||||
<PlainInput
|
||||
hideLabel
|
||||
label="Search"
|
||||
placeholder="Search plugins..."
|
||||
onChange={setQuery}
|
||||
defaultValue={query}
|
||||
/>
|
||||
</HStack>
|
||||
<div className="w-full h-full">
|
||||
{results.data == null ? (
|
||||
<EmptyStateText>
|
||||
<LoadingIcon size="xl" className="text-text-subtlest" />
|
||||
</EmptyStateText>
|
||||
) : (results.data.plugins ?? []).length === 0 ? (
|
||||
<EmptyStateText>No plugins found</EmptyStateText>
|
||||
) : (
|
||||
<Table scrollable>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Display Name</TableHeaderCell>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Version</TableHeaderCell>
|
||||
<TableHeaderCell />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{results.data.plugins.map((p) => (
|
||||
<PluginTableRowForRemotePluginVersion key={p.id} pluginVersion={p} />
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InstalledPlugins({ plugins, className }: { plugins: Plugin[]; className?: string }) {
|
||||
return plugins.length === 0 ? (
|
||||
<div className={classNames(className, "pb-4")}>
|
||||
<EmptyStateText className="text-center">
|
||||
Plugins extend the functionality of Yaak.
|
||||
<br />
|
||||
Add your first plugin to get started.
|
||||
</EmptyStateText>
|
||||
</div>
|
||||
) : (
|
||||
<div className={classNames(className, "h-full grid grid-rows-[auto_minmax(0,1fr)]")}>
|
||||
<DeprecatedPluginBanners />
|
||||
<Table scrollable>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell className="w-0" />
|
||||
<TableHeaderCell>Display Name</TableHeaderCell>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Version</TableHeaderCell>
|
||||
<TableHeaderCell />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<tbody className="divide-y divide-surface-highlight">
|
||||
{plugins.map((p) => (
|
||||
<PluginTableRowForInstalledPlugin key={p.id} plugin={p} />
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeprecatedPluginBanners() {
|
||||
const deprecated = usePluginInfos().filter(
|
||||
({ plugin, info }) => plugin.source !== "bundled" && info.deprecated != null,
|
||||
);
|
||||
return (
|
||||
<div className={classNames("flex flex-col gap-2", deprecated.length > 0 && "pb-3")}>
|
||||
{deprecated.map(({ plugin, info }) => (
|
||||
<DeprecatedPluginBanner key={plugin.id} plugin={plugin} name={info.displayName}>
|
||||
{withInlineCode(info.deprecated ?? "")}
|
||||
</DeprecatedPluginBanner>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Backtick spans in a package.json string, set as code. */
|
||||
function withInlineCode(text: string): ReactNode {
|
||||
return text
|
||||
.split("`")
|
||||
.map((part, i) => (i % 2 === 1 ? <InlineCode key={i}>{part}</InlineCode> : part));
|
||||
}
|
||||
|
||||
export function DeprecatedPluginBanner({
|
||||
children,
|
||||
name,
|
||||
plugin,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
name: string;
|
||||
plugin: Plugin;
|
||||
}) {
|
||||
const uninstall = usePromptUninstallPlugin(plugin.id, name);
|
||||
return (
|
||||
<Banner color="info" className="flex items-center gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text text-text">The {name} plugin is deprecated.</div>
|
||||
<p className="mt-1 text-sm text-text-subtle">{children}</p>
|
||||
</div>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="border"
|
||||
title="Uninstall plugin"
|
||||
className="shrink-0"
|
||||
isLoading={uninstall?.isPending}
|
||||
onClick={() => uninstall?.mutate()}
|
||||
>
|
||||
Uninstall
|
||||
</Button>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
|
||||
function BundledPlugins({ plugins }: { plugins: Plugin[] }) {
|
||||
return plugins.length === 0 ? (
|
||||
<div className="pb-4">
|
||||
<EmptyStateText className="text-center">No bundled plugins found.</EmptyStateText>
|
||||
</div>
|
||||
) : (
|
||||
<Table scrollable>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell className="w-0" />
|
||||
<TableHeaderCell>Display Name</TableHeaderCell>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Version</TableHeaderCell>
|
||||
<TableHeaderCell />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<tbody className="divide-y divide-surface-highlight">
|
||||
{plugins.map((p) => (
|
||||
<PluginTableRowForBundledPlugin key={p.id} plugin={p} />
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
function usePluginUpdates() {
|
||||
return useQuery({
|
||||
queryKey: ["plugin_updates", usePluginsKey()],
|
||||
queryFn: () => checkPluginUpdates(),
|
||||
});
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
||||
import type { ProxySetting } from "@yaakapp-internal/models";
|
||||
import { Heading, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { CommercialUseBanner } from "../CommercialUseBanner";
|
||||
import {
|
||||
SettingRowBoolean,
|
||||
SettingRowSelect,
|
||||
SettingRowText,
|
||||
SettingsList,
|
||||
SettingsSection,
|
||||
} from "../core/SettingRow";
|
||||
|
||||
export function SettingsProxy() {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const proxy = enabledProxyOrDefault(settings.proxy);
|
||||
|
||||
const patchProxy = async (patch: Partial<EnabledProxySetting>) => {
|
||||
await patchModel(settings, {
|
||||
proxy: {
|
||||
...proxy,
|
||||
...patch,
|
||||
auth: Object.hasOwn(patch, "auth") ? (patch.auth ?? null) : proxy.auth,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack space={1.5} className="mb-4">
|
||||
<div className="mb-3">
|
||||
<Heading>Proxy</Heading>
|
||||
<p className="text-text-subtle">
|
||||
Configure a proxy server for HTTP requests. Useful for corporate firewalls, debugging
|
||||
traffic, or routing through specific infrastructure.
|
||||
</p>
|
||||
</div>
|
||||
<CommercialUseBanner source="proxy-settings" title="Using a proxy for work?" />
|
||||
<SettingsList className="space-y-8">
|
||||
<SettingsSection title="Proxy">
|
||||
<SettingRowSelect
|
||||
title="Proxy"
|
||||
description="Choose how Yaak should discover or use proxy settings."
|
||||
name="proxy"
|
||||
value={settings.proxy?.type ?? "automatic"}
|
||||
onChange={async (v) => {
|
||||
if (v === "automatic") {
|
||||
await patchModel(settings, { proxy: undefined });
|
||||
} else if (v === "enabled") {
|
||||
await patchModel(settings, { proxy });
|
||||
} else {
|
||||
await patchModel(settings, { proxy: { type: "disabled" } });
|
||||
}
|
||||
}}
|
||||
options={[
|
||||
{ label: "Automatic proxy detection", value: "automatic" },
|
||||
{ label: "Custom proxy configuration", value: "enabled" },
|
||||
{ label: "No proxy", value: "disabled" },
|
||||
]}
|
||||
selectClassName="w-64!"
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
{settings.proxy?.type === "enabled" && (
|
||||
<>
|
||||
<SettingsSection title="Custom Proxy">
|
||||
<SettingRowBoolean
|
||||
checked={!settings.proxy.disabled}
|
||||
title="Enable proxy"
|
||||
description="Temporarily disable the proxy without losing the configuration."
|
||||
onChange={(enabled) => patchProxy({ disabled: !enabled })}
|
||||
/>
|
||||
<SettingRowText
|
||||
name="proxyHttp"
|
||||
title={
|
||||
<>
|
||||
Proxy for <InlineCode>http://</InlineCode> traffic
|
||||
</>
|
||||
}
|
||||
description="Proxy host used for unencrypted HTTP traffic."
|
||||
value={settings.proxy.http}
|
||||
placeholder="localhost:9090"
|
||||
onChange={(http) => patchProxy({ http })}
|
||||
/>
|
||||
<SettingRowText
|
||||
name="proxyHttps"
|
||||
title={
|
||||
<>
|
||||
Proxy for <InlineCode>https://</InlineCode> traffic
|
||||
</>
|
||||
}
|
||||
description="Proxy host used for HTTPS traffic."
|
||||
value={settings.proxy.https}
|
||||
placeholder="localhost:9090"
|
||||
onChange={(https) => patchProxy({ https })}
|
||||
/>
|
||||
<SettingRowText
|
||||
name="proxyBypass"
|
||||
title="Proxy Bypass"
|
||||
description="Comma-separated list of hosts that should bypass the proxy."
|
||||
value={settings.proxy.bypass}
|
||||
placeholder="127.0.0.1, *.example.com, localhost:3000"
|
||||
inputWidthClassName="w-96!"
|
||||
onChange={(bypass) => patchProxy({ bypass })}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Authentication">
|
||||
<SettingRowBoolean
|
||||
checked={settings.proxy.auth != null}
|
||||
title="Enable authentication"
|
||||
description="Send proxy credentials with proxied requests."
|
||||
onChange={(enabled) =>
|
||||
patchProxy({ auth: enabled ? { user: "", password: "" } : null })
|
||||
}
|
||||
/>
|
||||
|
||||
{settings.proxy.auth != null && (
|
||||
<>
|
||||
<SettingRowText
|
||||
required
|
||||
name="proxyUser"
|
||||
title="User"
|
||||
description="Username for proxy authentication."
|
||||
value={settings.proxy.auth.user}
|
||||
placeholder="myUser"
|
||||
onChange={(user) =>
|
||||
patchProxy({
|
||||
auth: {
|
||||
user,
|
||||
password:
|
||||
settings.proxy?.type === "enabled"
|
||||
? (settings.proxy.auth?.password ?? "")
|
||||
: "",
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<SettingRowText
|
||||
name="proxyPassword"
|
||||
title="Password"
|
||||
description="Password for proxy authentication."
|
||||
value={settings.proxy.auth.password}
|
||||
placeholder="s3cretPassw0rd"
|
||||
type="password"
|
||||
onChange={(password) =>
|
||||
patchProxy({
|
||||
auth: {
|
||||
user:
|
||||
settings.proxy?.type === "enabled"
|
||||
? (settings.proxy.auth?.user ?? "")
|
||||
: "",
|
||||
password,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</SettingsSection>
|
||||
</>
|
||||
)}
|
||||
</SettingsList>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
type EnabledProxySetting = Extract<ProxySetting, { type: "enabled" }>;
|
||||
|
||||
function enabledProxyOrDefault(proxy: ProxySetting | null): EnabledProxySetting {
|
||||
if (proxy?.type === "enabled") return proxy;
|
||||
|
||||
return {
|
||||
disabled: false,
|
||||
type: "enabled",
|
||||
http: "",
|
||||
https: "",
|
||||
auth: { user: "", password: "" },
|
||||
bypass: "",
|
||||
};
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { SettingsTheme as SharedSettingsTheme } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
|
||||
import { useSettingsView } from "../../hooks/useSettingsView";
|
||||
|
||||
export function SettingsTheme() {
|
||||
const workspace = useAtomValue(activeWorkspaceAtom);
|
||||
const bound = useSettingsView();
|
||||
|
||||
if (workspace == null || bound == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <SharedSettingsTheme view={bound.view} callbacks={bound.callbacks} />;
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import { useLicense } from "@yaakapp-internal/license";
|
||||
import { useRef } from "react";
|
||||
import { openSettings } from "../commands/openSettings";
|
||||
import { useCheckForUpdates } from "../hooks/useCheckForUpdates";
|
||||
import { useExportData } from "../hooks/useExportData";
|
||||
import { appInfo } from "../lib/appInfo";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import { importData } from "../lib/importData";
|
||||
import { pricingUrl } from "../lib/pricingUrl";
|
||||
import type { DropdownRef } from "./core/Dropdown";
|
||||
import { Dropdown } from "./core/Dropdown";
|
||||
import { Icon } from "@yaakapp-internal/ui";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { KeyboardShortcutsDialog } from "./KeyboardShortcutsDialog";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
|
||||
export function SettingsDropdown() {
|
||||
const exportData = useExportData();
|
||||
const dropdownRef = useRef<DropdownRef>(null);
|
||||
const checkForUpdates = useCheckForUpdates();
|
||||
const { check } = useLicense();
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
ref={dropdownRef}
|
||||
items={[
|
||||
{
|
||||
label: "Settings",
|
||||
hotKeyAction: "settings.show",
|
||||
leftSlot: <Icon icon="settings" />,
|
||||
onSelect: () => openSettings.mutate(null),
|
||||
},
|
||||
{
|
||||
label: "Keyboard shortcuts",
|
||||
hotKeyAction: "hotkeys.showHelp",
|
||||
leftSlot: <Icon icon="keyboard" />,
|
||||
onSelect: () => {
|
||||
showDialog({
|
||||
id: "hotkey",
|
||||
title: "Keyboard Shortcuts",
|
||||
size: "dynamic",
|
||||
render: () => <KeyboardShortcutsDialog />,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Plugins",
|
||||
leftSlot: <Icon icon="puzzle" />,
|
||||
onSelect: () => openSettings.mutate("plugins"),
|
||||
},
|
||||
{ type: "separator", label: "Share Workspace(s)" },
|
||||
{
|
||||
label: "Import Data",
|
||||
leftSlot: <Icon icon="folder_input" />,
|
||||
onSelect: () => importData.mutate(),
|
||||
},
|
||||
{
|
||||
label: "Export Data",
|
||||
leftSlot: <Icon icon="folder_output" />,
|
||||
onSelect: () => exportData.mutate(),
|
||||
},
|
||||
{
|
||||
label: "Create Run Button",
|
||||
leftSlot: <Icon icon="rocket" />,
|
||||
onSelect: () => platform.openUrl("https://yaak.app/button/new"),
|
||||
},
|
||||
{ type: "separator", label: `Yaak v${appInfo.version}` },
|
||||
{
|
||||
label: "Check for Updates",
|
||||
leftSlot: <Icon icon="update" />,
|
||||
hidden: !appInfo.featureUpdater,
|
||||
onSelect: () => checkForUpdates.mutate(),
|
||||
},
|
||||
{
|
||||
label: "Purchase License",
|
||||
color: "success",
|
||||
hidden: check.data == null || check.data.status === "active",
|
||||
leftSlot: <Icon icon="circle_dollar_sign" />,
|
||||
rightSlot: <Icon icon="external_link" color="success" className="opacity-60" />,
|
||||
onSelect: () =>
|
||||
platform.openUrl(pricingUrl(`app.menu.purchase.${check.data?.status ?? "unknown"}`)),
|
||||
},
|
||||
{
|
||||
label: "Install CLI",
|
||||
hidden: appInfo.cliVersion != null || !platform.capabilities.agents,
|
||||
leftSlot: <Icon icon="square_terminal" />,
|
||||
onSelect: () => openSettings.mutate("agents"),
|
||||
},
|
||||
{
|
||||
label: "Feedback",
|
||||
leftSlot: <Icon icon="chat" />,
|
||||
rightSlot: <Icon icon="external_link" color="secondary" />,
|
||||
onSelect: () => platform.openUrl("https://yaak.app/feedback"),
|
||||
},
|
||||
{
|
||||
label: "Changelog",
|
||||
leftSlot: <Icon icon="cake" />,
|
||||
rightSlot: <Icon icon="external_link" color="secondary" />,
|
||||
onSelect: () => platform.openUrl(`https://yaak.app/changelog/${appInfo.version}`),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<IconButton
|
||||
size="sm"
|
||||
title="Main Menu"
|
||||
icon="settings"
|
||||
iconColor="secondary"
|
||||
className="pointer-events-auto"
|
||||
/>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 2244 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user