mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-17 09:02:05 +02:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e6523f477 | ||
|
|
0c8d180928 |
@@ -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,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
|
||||
+2
-7
@@ -1,7 +1,2 @@
|
||||
crates-tauri/yaak-app-client/vendored/**/* linguist-generated=true
|
||||
crates-tauri/yaak-app-client/gen/schemas/**/* linguist-generated=true
|
||||
**/bindings/* linguist-generated=true
|
||||
crates/yaak-templates/pkg/* linguist-generated=true
|
||||
|
||||
# Ensure consistent line endings for test files that check exact content
|
||||
crates/yaak-http/tests/test.txt text eol=lf
|
||||
src-tauri/vendored/**/* linguist-generated=true
|
||||
src-tauri/gen/schemas/**/* linguist-generated=true
|
||||
|
||||
@@ -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,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,
|
||||
};
|
||||
@@ -14,20 +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
|
||||
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,234 +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: 60
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
- 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:
|
||||
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 }}"
|
||||
|
||||
- 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" .
|
||||
gh release upload "${{ github.ref_name }}" "$tarball" --clobber
|
||||
|
||||
# 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"}}}}'
|
||||
Pop-Location
|
||||
$setup = Get-ChildItem -Recurse -Path target -Filter "*setup*.exe" | Select-Object -First 1
|
||||
$setupSig = "$($setup.FullName).sig"
|
||||
$dest = $setup.FullName -replace '-setup\.exe$', '-setup-machine.exe'
|
||||
$destSig = "$dest.sig"
|
||||
Copy-Item $setup.FullName $dest
|
||||
Copy-Item $setupSig $destSig
|
||||
gh release upload "${{ github.ref_name }}" "$dest" --clobber
|
||||
gh release upload "${{ github.ref_name }}" "$destSig" --clobber
|
||||
@@ -1,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
|
||||
@@ -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
@@ -25,7 +25,6 @@ dist-ssr
|
||||
*.sln
|
||||
*.sw?
|
||||
.eslintcache
|
||||
out
|
||||
|
||||
*.sqlite
|
||||
*.sqlite-*
|
||||
@@ -34,28 +33,3 @@ out
|
||||
|
||||
.tmp
|
||||
tmp
|
||||
.zed
|
||||
codebook.toml
|
||||
target
|
||||
|
||||
# Per-worktree Tauri config (generated by post-checkout hook)
|
||||
crates-tauri/yaak-app-client/tauri.worktree.conf.json
|
||||
crates-tauri/yaak-app-proxy/tauri.worktree.conf.json
|
||||
|
||||
# Tauri auto-generated permission files
|
||||
**/permissions/autogenerated
|
||||
**/permissions/schemas
|
||||
|
||||
# Flatpak build artifacts
|
||||
flatpak-repo/
|
||||
.flatpak-builder/
|
||||
flatpak/flatpak-builder-tools/
|
||||
flatpak/cargo-sources.json
|
||||
flatpak/node-sources.json
|
||||
|
||||
# Local Codex desktop env state
|
||||
.codex/environments/environment.toml
|
||||
|
||||
# Claude Code local settings
|
||||
.claude/settings.local.json
|
||||
.claude/worktrees/
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
24.14.0
|
||||
@@ -1,2 +0,0 @@
|
||||
# vite-plugin-wasm has not yet declared Vite 8 in its peerDependencies
|
||||
legacy-peer-deps=true
|
||||
@@ -1,3 +0,0 @@
|
||||
**/bindings/**
|
||||
**/routeTree.gen.ts
|
||||
crates/yaak-templates/pkg/**
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"ignorePatterns": [
|
||||
"**/bindings/**",
|
||||
"crates/yaak-templates/pkg/**",
|
||||
"apps/yaak-client/routeTree.gen.ts"
|
||||
]
|
||||
}
|
||||
@@ -1 +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).
|
||||
-106
@@ -1,106 +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-git",
|
||||
"crates/yaak-grpc",
|
||||
"crates/yaak-http",
|
||||
"crates/yaak-models",
|
||||
"crates/yaak-plugins",
|
||||
"crates/yaak-sse",
|
||||
"crates/yaak-sync",
|
||||
"crates/yaak-templates",
|
||||
"crates/yaak-tls",
|
||||
"crates/yaak-ws",
|
||||
"crates/yaak-web",
|
||||
"crates/yaak-api",
|
||||
"crates/yaak-proxy",
|
||||
# Proxy-specific crates
|
||||
"crates-proxy/yaak-proxy-lib",
|
||||
# CLI crates
|
||||
"crates-cli/yaak-cli",
|
||||
# Tauri-specific crates
|
||||
"crates-tauri/yaak-app-client",
|
||||
"crates-tauri/yaak-app-proxy",
|
||||
"crates-tauri/yaak-fonts",
|
||||
"crates-tauri/yaak-license",
|
||||
"crates-tauri/yaak-mac-window",
|
||||
"crates-tauri/yaak-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 = "0.12.20"
|
||||
rustls = { version = "0.23.34", default-features = false }
|
||||
rustls-platform-verifier = "0.6.2"
|
||||
schemars = { version = "0.8.22", features = ["chrono"] }
|
||||
serde = "1.0.228"
|
||||
serde_json = "1.0.145"
|
||||
sha2 = "0.10.9"
|
||||
tauri = { 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-git = { path = "crates/yaak-git" }
|
||||
yaak-grpc = { path = "crates/yaak-grpc" }
|
||||
yaak-http = { path = "crates/yaak-http" }
|
||||
yaak-models = { path = "crates/yaak-models" }
|
||||
yaak-plugins = { path = "crates/yaak-plugins" }
|
||||
yaak-sse = { path = "crates/yaak-sse" }
|
||||
yaak-sync = { path = "crates/yaak-sync" }
|
||||
yaak-templates = { path = "crates/yaak-templates" }
|
||||
yaak-tls = { path = "crates/yaak-tls" }
|
||||
yaak-ws = { path = "crates/yaak-ws" }
|
||||
yaak-api = { path = "crates/yaak-api" }
|
||||
yaak-proxy = { path = "crates/yaak-proxy" }
|
||||
|
||||
# Internal crates - proxy
|
||||
yaak-proxy-lib = { path = "crates-proxy/yaak-proxy-lib" }
|
||||
|
||||
# Internal crates - Tauri-specific
|
||||
yaak-fonts = { path = "crates-tauri/yaak-fonts" }
|
||||
yaak-license = { path = "crates-tauri/yaak-license" }
|
||||
yaak-mac-window = { path = "crates-tauri/yaak-mac-window" }
|
||||
yaak-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,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,19 +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/"><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>
|
||||
|
||||

|
||||
|
||||
|
||||
## 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
|
||||
|
||||
@@ -37,34 +41,30 @@ Built with [Tauri](https://tauri.app), Rust, and React, it’s fast, lightweight
|
||||
- Filter and inspect responses with JSONPath or XPath.
|
||||
|
||||
### 🔐 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)
|
||||
|
||||
@@ -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 { pluralizeCount } from "../lib/pluralize";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import { jotaiStore } from "../lib/jotai";
|
||||
|
||||
export const moveToWorkspace = createFastMutation({
|
||||
mutationKey: ["move_workspace"],
|
||||
mutationFn: async (requests: (HttpRequest | GrpcRequest | WebsocketRequest)[]) => {
|
||||
const activeWorkspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||
if (activeWorkspaceId == null) return;
|
||||
if (requests.length === 0) return;
|
||||
|
||||
const title =
|
||||
requests.length === 1 ? "Move Request" : `Move ${pluralizeCount("Request", requests.length)}`;
|
||||
|
||||
showDialog({
|
||||
id: "change-workspace",
|
||||
title,
|
||||
size: "sm",
|
||||
render: ({ hide }) => (
|
||||
<MoveToWorkspaceDialog
|
||||
onDone={hide}
|
||||
requests={requests}
|
||||
activeWorkspaceId={activeWorkspaceId}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import { getModel } from "@yaakapp-internal/models";
|
||||
import type { FolderSettingsTab } from "../components/FolderSettingsDialog";
|
||||
import { FolderSettingsDialog } from "../components/FolderSettingsDialog";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
|
||||
export function openFolderSettings(folderId: string, tab?: FolderSettingsTab) {
|
||||
const folder = getModel("folder", folderId);
|
||||
if (folder == null) return;
|
||||
showDialog({
|
||||
id: "folder-settings",
|
||||
title: null,
|
||||
size: "lg",
|
||||
className: "h-200",
|
||||
noPadding: true,
|
||||
render: () => <FolderSettingsDialog folderId={folderId} tab={tab} />,
|
||||
});
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import type { SettingsTab } from "../components/Settings/Settings";
|
||||
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
||||
import { createFastMutation } from "../hooks/useFastMutation";
|
||||
import { jotaiStore } from "../lib/jotai";
|
||||
import { router } from "../lib/router";
|
||||
import { rpc } from "../lib/rpc";
|
||||
|
||||
// Allow tab with optional subtab (e.g., "plugins:installed")
|
||||
type SettingsTabWithSubtab = SettingsTab | `${SettingsTab}:${string}` | null;
|
||||
|
||||
export const openSettings = createFastMutation<void, string, SettingsTabWithSubtab>({
|
||||
mutationKey: ["open_settings"],
|
||||
mutationFn: async (tab) => {
|
||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||
if (workspaceId == null) return;
|
||||
|
||||
const to = "/workspaces/$workspaceId/settings" as const;
|
||||
const params = { workspaceId };
|
||||
const search = { tab: (tab ?? undefined) as SettingsTab | undefined };
|
||||
|
||||
// Settings is its own window where the host has windows to give. Where it
|
||||
// doesn't — a browser tab — the same route opens in place, which is the
|
||||
// whole difference: it is already a route, not a separate app.
|
||||
if (!platform.capabilities.multiWindow) {
|
||||
await router.navigate({ to, params, search });
|
||||
return;
|
||||
}
|
||||
|
||||
const location = router.buildLocation({ to, params, search });
|
||||
|
||||
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);
|
||||
|
||||
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,163 +0,0 @@
|
||||
import { gitClone } from "@yaakapp-internal/git";
|
||||
import { Banner, VStack } from "@yaakapp-internal/ui";
|
||||
import { useState } from "react";
|
||||
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
|
||||
import { appInfo } from "../lib/appInfo";
|
||||
import { CommercialUseBanner } from "./CommercialUseBanner";
|
||||
import { showErrorToast } from "../lib/toast";
|
||||
import { Button } from "./core/Button";
|
||||
import { Checkbox } from "./core/Checkbox";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import { promptCredentials } from "./git/credentials";
|
||||
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 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" space={3} alignItems="start" className="pb-3" 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}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
color="primary"
|
||||
className="w-full mt-3"
|
||||
disabled={!url || !directory || isCloning}
|
||||
isLoading={isCloning}
|
||||
>
|
||||
{isCloning ? "Cloning..." : "Clone Repository"}
|
||||
</Button>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function extractRepoName(url: string): string {
|
||||
// Handle various Git URL formats:
|
||||
// https://github.com/user/repo.git
|
||||
// git@github.com:user/repo.git
|
||||
// https://github.com/user/repo
|
||||
const match = url.match(/\/([^/]+?)(\.git)?$/);
|
||||
if (match?.[1]) {
|
||||
return match[1];
|
||||
}
|
||||
// Fallback for SSH-style URLs
|
||||
const sshMatch = url.match(/:([^/]+?)(\.git)?$/);
|
||||
if (sshMatch?.[1]) {
|
||||
return sshMatch[1];
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -1,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,56 +0,0 @@
|
||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||
import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import { getRequestBodyText as getHttpResponseRequestBodyText } from "../hooks/useHttpRequestBody";
|
||||
import { useToggle } from "../hooks/useToggle";
|
||||
import { isProbablyTextContentType } from "../lib/contentType";
|
||||
import { getContentTypeFromHeaders } from "../lib/model_util";
|
||||
import { CopyButton } from "./CopyButton";
|
||||
import { Button } from "./core/Button";
|
||||
import { SizeTag } from "./core/SizeTag";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
response: HttpResponse;
|
||||
}
|
||||
|
||||
const LARGE_BYTES = 2 * 1000 * 1000;
|
||||
|
||||
export function ConfirmLargeResponseRequest({ children, response }: Props) {
|
||||
const [showLargeResponse, toggleShowLargeResponse] = useToggle();
|
||||
const isProbablyText = useMemo(() => {
|
||||
const contentType = getContentTypeFromHeaders(response.headers);
|
||||
return isProbablyTextContentType(contentType);
|
||||
}, [response.headers]);
|
||||
|
||||
const contentLength = response.requestContentLength ?? 0;
|
||||
const isLarge = contentLength > LARGE_BYTES;
|
||||
if (!showLargeResponse && isLarge) {
|
||||
return (
|
||||
<Banner color="primary" className="flex flex-col gap-3">
|
||||
<p>
|
||||
Showing content over{" "}
|
||||
<InlineCode>
|
||||
<SizeTag contentLength={LARGE_BYTES} />
|
||||
</InlineCode>{" "}
|
||||
may impact performance
|
||||
</p>
|
||||
<HStack wrap space={2}>
|
||||
<Button color="primary" size="xs" onClick={toggleShowLargeResponse}>
|
||||
Reveal Request Body
|
||||
</Button>
|
||||
{isProbablyText && (
|
||||
<CopyButton
|
||||
color="secondary"
|
||||
variant="border"
|
||||
size="xs"
|
||||
text={() => getHttpResponseRequestBodyText(response).then((d) => d?.bodyText ?? "")}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,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,731 +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,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { Checkbox } from "./core/Checkbox";
|
||||
import classNames from "classnames";
|
||||
import { EventDetailHeader } from "./core/EventViewer";
|
||||
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
|
||||
import { EmptyStateText } from "./EmptyStateText";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import { Select } from "./core/Select";
|
||||
import { showAlert } from "../lib/alert";
|
||||
|
||||
interface Props {
|
||||
cookieJarId: string | null;
|
||||
}
|
||||
|
||||
export const CookieDialog = ({ cookieJarId }: Props) => {
|
||||
const cookieJars = useAtomValue(cookieJarsAtom);
|
||||
const cookieJar = cookieJars?.find((c) => c.id === cookieJarId);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [filterUpdateKey, setFilterUpdateKey] = useState(0);
|
||||
const [selectedCookieKey, setSelectedCookieKey] = useState<string | null>(null);
|
||||
const [editingCookieKey, setEditingCookieKey] = useState<string | null>(null);
|
||||
const [draftCookie, setDraftCookie] = useState<Cookie | null>(null);
|
||||
const [draftExpiresInput, setDraftExpiresInput] = useState("");
|
||||
const 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,31 +0,0 @@
|
||||
import { IconButton, type IconButtonProps, useTimedBoolean } from "@yaakapp-internal/ui";
|
||||
import { copyToClipboard } from "../lib/copy";
|
||||
import { showToast } from "../lib/toast";
|
||||
|
||||
interface Props extends Omit<IconButtonProps, "onClick" | "icon"> {
|
||||
text: string | (() => Promise<string | null>);
|
||||
}
|
||||
|
||||
export function CopyIconButton({ text, ...props }: Props) {
|
||||
const [copied, setCopied] = useTimedBoolean();
|
||||
return (
|
||||
<IconButton
|
||||
{...props}
|
||||
icon={copied ? "check" : "copy"}
|
||||
showConfirm
|
||||
onClick={async () => {
|
||||
const content = typeof text === "function" ? await text() : text;
|
||||
if (content == null) {
|
||||
showToast({
|
||||
id: "failed-to-copy",
|
||||
color: "danger",
|
||||
message: "Failed to copy",
|
||||
});
|
||||
} else {
|
||||
copyToClipboard(content, { disableToast: true });
|
||||
setCopied();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
import type { DnsOverride, Workspace } from "@yaakapp-internal/models";
|
||||
import { patchModel } from "@yaakapp-internal/models";
|
||||
import { fireAndForget } from "../lib/fireAndForget";
|
||||
import {
|
||||
HStack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
VStack,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { useCallback, useId, useMemo } from "react";
|
||||
import { Button } from "./core/Button";
|
||||
import { Checkbox } from "./core/Checkbox";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
|
||||
interface Props {
|
||||
workspace: Workspace;
|
||||
}
|
||||
|
||||
interface DnsOverrideWithId extends DnsOverride {
|
||||
_id: string;
|
||||
}
|
||||
|
||||
export function DnsOverridesEditor({ workspace }: Props) {
|
||||
const reactId = useId();
|
||||
|
||||
// Ensure each override has an internal ID for React keys
|
||||
const overridesWithIds = useMemo<DnsOverrideWithId[]>(() => {
|
||||
return workspace.settingDnsOverrides.map((override, index) => ({
|
||||
...override,
|
||||
_id: `${reactId}-${index}`,
|
||||
}));
|
||||
}, [workspace.settingDnsOverrides, reactId]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(overrides: DnsOverride[]) => {
|
||||
fireAndForget(patchModel(workspace, { settingDnsOverrides: overrides }));
|
||||
},
|
||||
[workspace],
|
||||
);
|
||||
|
||||
const handleAdd = useCallback(() => {
|
||||
const newOverride: DnsOverride = {
|
||||
hostname: "",
|
||||
ipv4: [""],
|
||||
ipv6: [],
|
||||
enabled: true,
|
||||
};
|
||||
handleChange([...workspace.settingDnsOverrides, newOverride]);
|
||||
}, [workspace.settingDnsOverrides, handleChange]);
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(index: number, update: Partial<DnsOverride>) => {
|
||||
const updated = workspace.settingDnsOverrides.map((o, i) =>
|
||||
i === index ? { ...o, ...update } : o,
|
||||
);
|
||||
handleChange(updated);
|
||||
},
|
||||
[workspace.settingDnsOverrides, handleChange],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(index: number) => {
|
||||
const updated = workspace.settingDnsOverrides.filter((_, i) => i !== index);
|
||||
handleChange(updated);
|
||||
},
|
||||
[workspace.settingDnsOverrides, handleChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<VStack space={3} className="pb-3">
|
||||
<div className="text-text-subtle text-sm">
|
||||
Override DNS resolution for specific hostnames. This works like{" "}
|
||||
<code className="text-text-subtlest bg-surface-highlight px-1 rounded-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,24 +0,0 @@
|
||||
import classNames from "classnames";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
wrapperClassName?: string;
|
||||
}
|
||||
|
||||
export function EmptyStateText({ children, className, wrapperClassName }: Props) {
|
||||
return (
|
||||
<div className={classNames("w-full h-full pb-2", wrapperClassName)}>
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
"rounded-lg border border-dashed border-border-subtle",
|
||||
"h-full py-2 text-text-subtlest flex items-center justify-center italic",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</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,209 +0,0 @@
|
||||
import { createWorkspaceModel, foldersAtom, patchModel } from "@yaakapp-internal/models";
|
||||
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { Fragment, useMemo } from "react";
|
||||
import { useAuthTab } from "../hooks/useAuthTab";
|
||||
import { useEnvironmentsBreakdown } from "../hooks/useEnvironmentsBreakdown";
|
||||
import { useHeadersTab } from "../hooks/useHeadersTab";
|
||||
import { useInheritedHeaders } from "../hooks/useInheritedHeaders";
|
||||
import { useModelAncestors } from "../hooks/useModelAncestors";
|
||||
import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
|
||||
import { hideDialog } from "../lib/dialog";
|
||||
import { CopyIconButton } from "./CopyIconButton";
|
||||
import { Button } from "./core/Button";
|
||||
import { CountBadge } from "./core/CountBadge";
|
||||
import { Input } from "./core/Input";
|
||||
import { Link } from "./core/Link";
|
||||
import type { TabItem } from "./core/Tabs/Tabs";
|
||||
import { TabContent, Tabs } from "./core/Tabs/Tabs";
|
||||
import { EmptyStateText } from "./EmptyStateText";
|
||||
import { EnvironmentEditor } from "./EnvironmentEditor";
|
||||
import { HeadersEditor } from "./HeadersEditor";
|
||||
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
|
||||
import { MarkdownEditor } from "./MarkdownEditor";
|
||||
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
|
||||
|
||||
interface Props {
|
||||
folderId: string | null;
|
||||
tab?: FolderSettingsTab;
|
||||
}
|
||||
|
||||
const TAB_AUTH = "auth";
|
||||
const TAB_HEADERS = "headers";
|
||||
const TAB_SETTINGS = "settings";
|
||||
const TAB_VARIABLES = "variables";
|
||||
const TAB_GENERAL = "general";
|
||||
|
||||
export type FolderSettingsTab =
|
||||
| typeof TAB_AUTH
|
||||
| typeof TAB_HEADERS
|
||||
| typeof TAB_GENERAL
|
||||
| typeof TAB_SETTINGS
|
||||
| typeof TAB_VARIABLES;
|
||||
|
||||
export function FolderSettingsDialog({ folderId, tab }: Props) {
|
||||
const folders = useAtomValue(foldersAtom);
|
||||
const folder = folders.find((f) => f.id === folderId) ?? null;
|
||||
const ancestors = useModelAncestors(folder);
|
||||
const breadcrumbs = useMemo(() => ancestors.toReversed(), [ancestors]);
|
||||
const authTab = useAuthTab(TAB_AUTH, folder);
|
||||
const headersTab = useHeadersTab(TAB_HEADERS, folder);
|
||||
const inheritedHeaders = useInheritedHeaders(folder);
|
||||
const environments = useEnvironmentsBreakdown();
|
||||
const folderEnvironment = environments.allEnvironments.find(
|
||||
(e) => e.parentModel === "folder" && e.parentId === folderId,
|
||||
);
|
||||
const numVars = (folderEnvironment?.variables ?? []).filter((v) => v.name).length;
|
||||
const numSettingsOverrides = folder == null ? 0 : countOverriddenSettings(folder);
|
||||
|
||||
const tabs = useMemo<TabItem[]>(() => {
|
||||
if (folder == null) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
value: TAB_GENERAL,
|
||||
label: "General",
|
||||
},
|
||||
{
|
||||
value: TAB_SETTINGS,
|
||||
label: "Settings",
|
||||
rightSlot: <CountBadge count={numSettingsOverrides} />,
|
||||
},
|
||||
...headersTab,
|
||||
...authTab,
|
||||
{
|
||||
value: TAB_VARIABLES,
|
||||
label: "Variables",
|
||||
rightSlot: numVars > 0 ? <CountBadge count={numVars} /> : null,
|
||||
},
|
||||
];
|
||||
}, [authTab, folder, headersTab, numSettingsOverrides, numVars]);
|
||||
|
||||
if (folder == null) return null;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex items-center gap-3 px-6 pr-10 mt-4 mb-2 min-w-0 text-xl">
|
||||
<Icon icon="folder_cog" size="lg" color="secondary" className="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,38 +0,0 @@
|
||||
import { activeRequestAtom } from "../hooks/useActiveRequest";
|
||||
import { useSubscribeActiveWorkspaceId } from "../hooks/useActiveWorkspace";
|
||||
import { useActiveWorkspaceChangedToast } from "../hooks/useActiveWorkspaceChangedToast";
|
||||
import { useHotKey, useSubscribeHotKeys } from "../hooks/useHotKey";
|
||||
import { useSubscribeHttpAuthentication } from "../hooks/useHttpAuthentication";
|
||||
import { useSyncFontSizeSetting } from "../hooks/useSyncFontSizeSetting";
|
||||
import { useSyncWorkspaceChildModels } from "../hooks/useSyncWorkspaceChildModels";
|
||||
import { useSyncZoomSetting } from "../hooks/useSyncZoomSetting";
|
||||
import { useSubscribeTemplateFunctions } from "../hooks/useTemplateFunctions";
|
||||
import { jotaiStore } from "../lib/jotai";
|
||||
import { renameModelWithPrompt } from "../lib/renameModelWithPrompt";
|
||||
|
||||
export function GlobalHooks() {
|
||||
useSyncZoomSetting();
|
||||
useSyncFontSizeSetting();
|
||||
|
||||
useSubscribeActiveWorkspaceId();
|
||||
|
||||
useSyncWorkspaceChildModels();
|
||||
useSubscribeTemplateFunctions();
|
||||
useSubscribeHttpAuthentication();
|
||||
|
||||
// Other useful things
|
||||
useActiveWorkspaceChangedToast();
|
||||
useSubscribeHotKeys();
|
||||
|
||||
useHotKey(
|
||||
"request.rename",
|
||||
async () => {
|
||||
const model = jotaiStore.get(activeRequestAtom);
|
||||
if (model == null) return;
|
||||
await renameModelWithPrompt(model);
|
||||
},
|
||||
{ allowDefault: true },
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
import type { GrpcEvent, GrpcRequest } from "@yaakapp-internal/models";
|
||||
import { HStack, Icon, type IconProps, LoadingIcon, VStack } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
activeGrpcConnectionAtom,
|
||||
activeGrpcConnections,
|
||||
pinnedGrpcConnectionIdAtom,
|
||||
useGrpcEvents,
|
||||
} from "../hooks/usePinnedGrpcConnection";
|
||||
import { useStateWithDeps } from "../hooks/useStateWithDeps";
|
||||
import { Button } from "./core/Button";
|
||||
import { Editor } from "./core/Editor/LazyEditor";
|
||||
import { EventDetailHeader, EventViewer } from "./core/EventViewer";
|
||||
import { EventViewerRow } from "./core/EventViewerRow";
|
||||
import { HotkeyList } from "./core/HotkeyList";
|
||||
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
|
||||
import { EmptyStateText } from "./EmptyStateText";
|
||||
import { ErrorBoundary } from "./ErrorBoundary";
|
||||
import { RecentGrpcConnectionsDropdown } from "./RecentGrpcConnectionsDropdown";
|
||||
|
||||
interface Props {
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
activeRequest: GrpcRequest;
|
||||
methodType:
|
||||
| "unary"
|
||||
| "client_streaming"
|
||||
| "server_streaming"
|
||||
| "streaming"
|
||||
| "no-schema"
|
||||
| "no-method";
|
||||
}
|
||||
|
||||
export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
|
||||
const [activeEventIndex, setActiveEventIndex] = useState<number | null>(null);
|
||||
const [showLarge, setShowLarge] = useStateWithDeps<boolean>(false, [activeRequest.id]);
|
||||
const [showingLarge, setShowingLarge] = useState<boolean>(false);
|
||||
const connections = useAtomValue(activeGrpcConnections);
|
||||
const activeConnection = useAtomValue(activeGrpcConnectionAtom);
|
||||
const events = useGrpcEvents(activeConnection?.id ?? null);
|
||||
const setPinnedGrpcConnectionId = useSetAtom(pinnedGrpcConnectionIdAtom);
|
||||
|
||||
const activeEvent = useMemo(
|
||||
() => (activeEventIndex != null ? events[activeEventIndex] : null),
|
||||
[activeEventIndex, events],
|
||||
);
|
||||
|
||||
// Set the active message to the first message received if unary
|
||||
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => {
|
||||
if (events.length === 0 || activeEvent != null || methodType !== "unary") {
|
||||
return;
|
||||
}
|
||||
const firstServerMessageIndex = events.findIndex((m) => m.eventType === "server_message");
|
||||
if (firstServerMessageIndex !== -1) {
|
||||
setActiveEventIndex(firstServerMessageIndex);
|
||||
}
|
||||
}, [events.length]);
|
||||
|
||||
if (activeConnection == null) {
|
||||
return (
|
||||
<HotkeyList hotkeys={["request.send", "model.create", "sidebar.focus", "url_bar.focus"]} />
|
||||
);
|
||||
}
|
||||
|
||||
const header = (
|
||||
<HStack className="pl-3 mb-1 font-mono text-sm text-text-subtle overflow-x-auto hide-scrollbars">
|
||||
<HStack space={2}>
|
||||
<span className="whitespace-nowrap">{events.length} Messages</span>
|
||||
{activeConnection.state !== "closed" && (
|
||||
<LoadingIcon size="sm" className="text-text-subtlest" />
|
||||
)}
|
||||
</HStack>
|
||||
<div className="ml-auto">
|
||||
<RecentGrpcConnectionsDropdown
|
||||
connections={connections}
|
||||
activeConnection={activeConnection}
|
||||
onPinnedConnectionId={setPinnedGrpcConnectionId}
|
||||
/>
|
||||
</div>
|
||||
</HStack>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={style} className="h-full">
|
||||
<ErrorBoundary name="GRPC Events">
|
||||
<EventViewer
|
||||
events={events}
|
||||
getEventKey={(event) => event.id}
|
||||
error={activeConnection.error}
|
||||
header={header}
|
||||
splitLayoutStorageKey="grpc_events"
|
||||
defaultRatio={0.4}
|
||||
renderRow={({ event, isActive, onClick }) => (
|
||||
<GrpcEventRow event={event} isActive={isActive} onClick={onClick} />
|
||||
)}
|
||||
renderDetail={({ event, onClose }) => (
|
||||
<GrpcEventDetail
|
||||
event={event}
|
||||
showLarge={showLarge}
|
||||
showingLarge={showingLarge}
|
||||
setShowLarge={setShowLarge}
|
||||
setShowingLarge={setShowingLarge}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GrpcEventRow({
|
||||
event,
|
||||
isActive,
|
||||
onClick,
|
||||
}: {
|
||||
event: GrpcEvent;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const { eventType, status, content, error } = event;
|
||||
const display = getEventDisplay(eventType, status);
|
||||
|
||||
return (
|
||||
<EventViewerRow
|
||||
isActive={isActive}
|
||||
onClick={onClick}
|
||||
icon={<Icon color={display.color} title={display.title} icon={display.icon} />}
|
||||
content={
|
||||
<span className="text-xs">
|
||||
{content.slice(0, 1000)}
|
||||
{error && <span className="text-warning"> ({error})</span>}
|
||||
</span>
|
||||
}
|
||||
timestamp={event.createdAt}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function GrpcEventDetail({
|
||||
event,
|
||||
showLarge,
|
||||
showingLarge,
|
||||
setShowLarge,
|
||||
setShowingLarge,
|
||||
onClose,
|
||||
}: {
|
||||
event: GrpcEvent;
|
||||
showLarge: boolean;
|
||||
showingLarge: boolean;
|
||||
setShowLarge: (v: boolean) => void;
|
||||
setShowingLarge: (v: boolean) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
if (event.eventType === "client_message" || event.eventType === "server_message") {
|
||||
const title = `Message ${event.eventType === "client_message" ? "Sent" : "Received"}`;
|
||||
|
||||
return (
|
||||
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
|
||||
<EventDetailHeader
|
||||
title={title}
|
||||
timestamp={event.createdAt}
|
||||
copyText={event.content}
|
||||
onClose={onClose}
|
||||
/>
|
||||
{!showLarge && event.content.length > 1000 * 1000 ? (
|
||||
<VStack space={2} className="italic text-text-subtlest">
|
||||
Message previews larger than 1MB are hidden
|
||||
<div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowingLarge(true);
|
||||
setTimeout(() => {
|
||||
setShowLarge(true);
|
||||
setShowingLarge(false);
|
||||
}, 500);
|
||||
}}
|
||||
isLoading={showingLarge}
|
||||
color="secondary"
|
||||
variant="border"
|
||||
size="xs"
|
||||
>
|
||||
Try Showing
|
||||
</Button>
|
||||
</div>
|
||||
</VStack>
|
||||
) : (
|
||||
<Editor
|
||||
language="json"
|
||||
defaultValue={event.content ?? ""}
|
||||
wrapLines={false}
|
||||
readOnly={true}
|
||||
stateKey={null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error or connection_end - show metadata/trailers
|
||||
return (
|
||||
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
|
||||
<EventDetailHeader title={event.content} timestamp={event.createdAt} onClose={onClose} />
|
||||
{event.error && (
|
||||
<div className="select-text cursor-text text-sm font-mono py-1 text-warning">
|
||||
{event.error}
|
||||
</div>
|
||||
)}
|
||||
<div className="py-2 h-full">
|
||||
{Object.keys(event.metadata).length === 0 ? (
|
||||
<EmptyStateText>
|
||||
No {event.eventType === "connection_end" ? "trailers" : "metadata"}
|
||||
</EmptyStateText>
|
||||
) : (
|
||||
<KeyValueRows>
|
||||
{Object.entries(event.metadata).map(([key, value]) => (
|
||||
<KeyValueRow key={key} label={key}>
|
||||
{value}
|
||||
</KeyValueRow>
|
||||
))}
|
||||
</KeyValueRows>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getEventDisplay(
|
||||
eventType: GrpcEvent["eventType"],
|
||||
status: GrpcEvent["status"],
|
||||
): { icon: IconProps["icon"]; color: IconProps["color"]; title: string } {
|
||||
if (eventType === "server_message") {
|
||||
return { icon: "arrow_big_down_dash", color: "info", title: "Server message" };
|
||||
}
|
||||
if (eventType === "client_message") {
|
||||
return { icon: "arrow_big_up_dash", color: "primary", title: "Client message" };
|
||||
}
|
||||
if (eventType === "error" || (status != null && status > 0)) {
|
||||
return { icon: "alert_triangle", color: "danger", title: "Error" };
|
||||
}
|
||||
if (eventType === "connection_end") {
|
||||
return { icon: "check", color: "success", title: "Connection response" };
|
||||
}
|
||||
return { icon: "info", color: undefined, title: "Event" };
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
import type { HttpRequestHeader } from "@yaakapp-internal/models";
|
||||
import { HStack } from "@yaakapp-internal/ui";
|
||||
import { acceptLanguages } from "../lib/data/acceptLanguages";
|
||||
import { cacheControlDirectives } from "../lib/data/cacheControl";
|
||||
import { charsets } from "../lib/data/charsets";
|
||||
import { connections } from "../lib/data/connections";
|
||||
import { encodings } from "../lib/data/encodings";
|
||||
import type { HeaderValuePreset } from "../lib/data/headerValuePresets";
|
||||
import { headerNames } from "../lib/data/headerNames";
|
||||
import { mimeTypes } from "../lib/data/mimetypes";
|
||||
import { userAgents } from "../lib/data/userAgents";
|
||||
import { CountBadge } from "./core/CountBadge";
|
||||
import { DetailsBanner } from "./core/DetailsBanner";
|
||||
import type { GenericCompletion, GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
||||
import type { InputProps } from "./core/Input";
|
||||
import type { Pair, PairEditorProps } from "./core/PairEditor";
|
||||
import { PairEditorRow } from "./core/PairEditor";
|
||||
import { ensurePairId } from "./core/PairEditor.util";
|
||||
import { PairOrBulkEditor } from "./core/PairOrBulkEditor";
|
||||
|
||||
type Props = {
|
||||
forceUpdateKey: string;
|
||||
headers: HttpRequestHeader[];
|
||||
inheritedHeaders?: HttpRequestHeader[];
|
||||
inheritedHeadersLabel?: string;
|
||||
stateKey: string;
|
||||
onChange: (headers: HttpRequestHeader[]) => void;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export function HeadersEditor({
|
||||
stateKey,
|
||||
headers,
|
||||
inheritedHeaders,
|
||||
inheritedHeadersLabel = "Inherited",
|
||||
onChange,
|
||||
forceUpdateKey,
|
||||
}: Props) {
|
||||
// Get header names defined at current level (case-insensitive)
|
||||
const currentHeaderNames = new Set(
|
||||
headers.filter((h) => h.name).map((h) => h.name.toLowerCase()),
|
||||
);
|
||||
// Filter inherited headers: must be enabled, have content, and not be overridden by current level
|
||||
const validInheritedHeaders =
|
||||
inheritedHeaders?.filter(
|
||||
(pair) =>
|
||||
pair.enabled &&
|
||||
(pair.name || pair.value) &&
|
||||
!currentHeaderNames.has(pair.name.toLowerCase()),
|
||||
) ?? [];
|
||||
const hasInheritedHeaders = validInheritedHeaders.length > 0;
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
hasInheritedHeaders
|
||||
? "@container w-full h-full grid grid-rows-[auto_minmax(0,1fr)] gap-y-1.5"
|
||||
: "@container w-full h-full"
|
||||
}
|
||||
>
|
||||
{hasInheritedHeaders && (
|
||||
<DetailsBanner
|
||||
color="secondary"
|
||||
className="text-sm"
|
||||
summary={
|
||||
<HStack>
|
||||
{inheritedHeadersLabel} <CountBadge count={validInheritedHeaders.length} />
|
||||
</HStack>
|
||||
}
|
||||
>
|
||||
<div className="pb-2">
|
||||
{validInheritedHeaders?.map((pair, i) => (
|
||||
<PairEditorRow
|
||||
key={`${pair.id}.${i}`}
|
||||
index={i}
|
||||
disabled
|
||||
disableDrag
|
||||
className="py-1"
|
||||
pair={ensurePairId(pair)}
|
||||
stateKey={null}
|
||||
nameAutocompleteFunctions
|
||||
nameAutocompleteVariables
|
||||
valueAutocompleteFunctions
|
||||
valueAutocompleteVariables
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</DetailsBanner>
|
||||
)}
|
||||
<PairOrBulkEditor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
nameAutocomplete={nameAutocomplete}
|
||||
nameAutocompleteFunctions
|
||||
nameAutocompleteVariables
|
||||
namePlaceholder="Header-Name"
|
||||
nameValidate={validateHttpHeader}
|
||||
onChange={onChange}
|
||||
pairs={headers}
|
||||
preferenceName="headers"
|
||||
stateKey={stateKey}
|
||||
valueType={valueType}
|
||||
valueAutocomplete={valueAutocomplete}
|
||||
valueAutocompleteFunctions
|
||||
valueAutocompleteVariables
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const MIN_MATCH = 3;
|
||||
|
||||
const headerOptionsMap: Record<string, HeaderValuePreset[]> = {
|
||||
"content-type": mimeTypes,
|
||||
accept: ["*/*", ...mimeTypes],
|
||||
"accept-encoding": encodings,
|
||||
"content-encoding": encodings,
|
||||
connection: connections,
|
||||
"accept-charset": charsets,
|
||||
"accept-language": acceptLanguages,
|
||||
"cache-control": cacheControlDirectives,
|
||||
"user-agent": userAgents,
|
||||
pragma: ["no-cache"],
|
||||
te: ["trailers", "compress", "deflate", "gzip"],
|
||||
dnt: ["1", "0"],
|
||||
"upgrade-insecure-requests": ["1"],
|
||||
"x-requested-with": ["XMLHttpRequest"],
|
||||
};
|
||||
|
||||
const valueType = (pair: Pair): InputProps["type"] => {
|
||||
const name = pair.name.toLowerCase().trim();
|
||||
if (
|
||||
name.includes("authorization") ||
|
||||
name.includes("api-key") ||
|
||||
name.includes("access-token") ||
|
||||
name.includes("auth") ||
|
||||
name.includes("secret") ||
|
||||
name.includes("token") ||
|
||||
name === "cookie" ||
|
||||
name === "set-cookie"
|
||||
) {
|
||||
return "password";
|
||||
}
|
||||
return "text";
|
||||
};
|
||||
|
||||
const valueAutocomplete = (headerName: string): GenericCompletionConfig | undefined => {
|
||||
const name = headerName.toLowerCase().trim();
|
||||
const options: GenericCompletion[] =
|
||||
headerOptionsMap[name]?.map((o) =>
|
||||
typeof o === "string"
|
||||
? {
|
||||
label: o,
|
||||
type: "constant",
|
||||
boost: 1, // Put above other completions
|
||||
}
|
||||
: {
|
||||
// Show the short label but insert the full value (e.g. User-Agent)
|
||||
label: o.label,
|
||||
apply: o.value,
|
||||
type: "constant",
|
||||
boost: 1,
|
||||
},
|
||||
) ?? [];
|
||||
return { minMatch: MIN_MATCH, options };
|
||||
};
|
||||
|
||||
const nameAutocomplete: PairEditorProps["nameAutocomplete"] = {
|
||||
minMatch: MIN_MATCH,
|
||||
options: headerNames.map((t) =>
|
||||
typeof t === "string"
|
||||
? {
|
||||
label: t,
|
||||
type: "constant",
|
||||
boost: 1, // Put above other completions
|
||||
}
|
||||
: {
|
||||
...t,
|
||||
boost: 1, // Put above other completions
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
const validateHttpHeader = (v: string) => {
|
||||
if (v === "") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Template strings are not allowed so we replace them with a valid example string
|
||||
const withoutTemplateStrings = v.replace(/\$\{\[\s*[^\]\s]+\s*]}/gi, "123");
|
||||
return withoutTemplateStrings.match(/^[a-zA-Z0-9-_]+$/) !== null;
|
||||
};
|
||||
@@ -1,260 +0,0 @@
|
||||
import type {
|
||||
Folder,
|
||||
GrpcRequest,
|
||||
HttpRequest,
|
||||
WebsocketRequest,
|
||||
Workspace,
|
||||
} from "@yaakapp-internal/models";
|
||||
import { patchModel } from "@yaakapp-internal/models";
|
||||
import { HStack, Icon, InlineCode } from "@yaakapp-internal/ui";
|
||||
import { useCallback } from "react";
|
||||
import { openFolderSettings } from "../commands/openFolderSettings";
|
||||
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
|
||||
import { 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 { SegmentedControl } from "./core/SegmentedControl";
|
||||
import { DynamicForm } from "./DynamicForm";
|
||||
import { EmptyStateText } from "./EmptyStateText";
|
||||
|
||||
interface Props {
|
||||
model: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace;
|
||||
}
|
||||
|
||||
export function HttpAuthenticationEditor({ model }: Props) {
|
||||
const inheritedAuth = useInheritedAuthentication(model);
|
||||
const authConfig = useHttpAuthenticationConfig(
|
||||
model.authenticationType,
|
||||
model.authentication,
|
||||
model,
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
async (authentication: Record<string, unknown>) =>
|
||||
await patchModel(model, { authentication }),
|
||||
[model],
|
||||
);
|
||||
|
||||
if (model.authenticationType === "none") {
|
||||
return <EmptyStateText>No authentication</EmptyStateText>;
|
||||
}
|
||||
|
||||
if (model.authenticationType != null && authConfig.data == null) {
|
||||
return (
|
||||
<EmptyStateText>
|
||||
<p>
|
||||
Auth plugin not found for{" "}
|
||||
<InlineCode>{model.authenticationType}</InlineCode>
|
||||
</p>
|
||||
</EmptyStateText>
|
||||
);
|
||||
}
|
||||
|
||||
if (inheritedAuth == null) {
|
||||
if (model.model === "workspace" || model.model === "folder") {
|
||||
return (
|
||||
<EmptyStateText className="flex-col gap-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,461 +0,0 @@
|
||||
import type { HttpResponse, HttpResponseEvent } from "@yaakapp-internal/models";
|
||||
import { Banner, HStack, Icon, LoadingIcon, VStack } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import type { ComponentType, CSSProperties } from "react";
|
||||
import { lazy, Suspense, useMemo } from "react";
|
||||
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
|
||||
import { useCopyHttpResponse } from "../hooks/useCopyHttpResponse";
|
||||
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
|
||||
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
|
||||
import { useResponseBodyBytes, useResponseBodyText } from "../hooks/useResponseBodyText";
|
||||
import { useResponseBodyUrl } from "../hooks/useResponseBodyUrl";
|
||||
import { useResponseViewMode } from "../hooks/useResponseViewMode";
|
||||
import { useSaveResponse } from "../hooks/useSaveResponse";
|
||||
import { useTimelineViewMode } from "../hooks/useTimelineViewMode";
|
||||
import { getMimeTypeFromContentType } from "../lib/contentType";
|
||||
import { getContentTypeFromHeaders, getCookieCounts } from "../lib/model_util";
|
||||
import { ConfirmLargeResponse } from "./ConfirmLargeResponse";
|
||||
import { ConfirmLargeResponseRequest } from "./ConfirmLargeResponseRequest";
|
||||
import { Button } from "./core/Button";
|
||||
import { CountBadge } from "./core/CountBadge";
|
||||
import { HotkeyList } from "./core/HotkeyList";
|
||||
import { HttpResponseDurationTag } from "./core/HttpResponseDurationTag";
|
||||
import { HttpStatusTag } from "./core/HttpStatusTag";
|
||||
import { PillButton } from "./core/PillButton";
|
||||
import { SizeTag } from "./core/SizeTag";
|
||||
import type { TabItem } from "./core/Tabs/Tabs";
|
||||
import { TabContent, Tabs } from "./core/Tabs/Tabs";
|
||||
import { Tooltip } from "./core/Tooltip";
|
||||
import { EmptyStateText } from "./EmptyStateText";
|
||||
import { ErrorBoundary } from "./ErrorBoundary";
|
||||
import { HttpResponseTimeline } from "./HttpResponseTimeline";
|
||||
import { RecentHttpResponsesDropdown } from "./RecentHttpResponsesDropdown";
|
||||
import { RequestBodyViewer } from "./RequestBodyViewer";
|
||||
import { ResponseCookies } from "./ResponseCookies";
|
||||
import { ResponseHeaders } from "./ResponseHeaders";
|
||||
import { AudioViewer } from "./responseViewers/AudioViewer";
|
||||
import { CsvViewer } from "./responseViewers/CsvViewer";
|
||||
import { EventStreamViewer } from "./responseViewers/EventStreamViewer";
|
||||
import { HTMLOrTextViewer } from "./responseViewers/HTMLOrTextViewer";
|
||||
import { ImageViewer } from "./responseViewers/ImageViewer";
|
||||
import { MultipartViewer } from "./responseViewers/MultipartViewer";
|
||||
import { SvgViewer } from "./responseViewers/SvgViewer";
|
||||
import { VideoViewer } from "./responseViewers/VideoViewer";
|
||||
|
||||
const PdfViewer = lazy(() =>
|
||||
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
|
||||
);
|
||||
|
||||
interface Props {
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
activeRequestId: string;
|
||||
}
|
||||
|
||||
const TAB_BODY = "body";
|
||||
const TAB_REQUEST = "request";
|
||||
const TAB_HEADERS = "headers";
|
||||
const TAB_COOKIES = "cookies";
|
||||
const TAB_TIMELINE = "timeline";
|
||||
|
||||
export type TimelineViewMode = "timeline" | "text";
|
||||
|
||||
interface RedirectDropWarning {
|
||||
droppedBodyCount: number;
|
||||
droppedHeaders: string[];
|
||||
}
|
||||
|
||||
export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId);
|
||||
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
|
||||
const [timelineViewMode, setTimelineViewMode] = useTimelineViewMode();
|
||||
const contentType = getContentTypeFromHeaders(activeResponse?.headers ?? null);
|
||||
const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence;
|
||||
|
||||
const responseEvents = useHttpResponseEvents(activeResponse);
|
||||
const redirectDropWarning = useMemo(
|
||||
() => getRedirectDropWarning(responseEvents.data),
|
||||
[responseEvents.data],
|
||||
);
|
||||
const shouldShowRedirectDropWarning =
|
||||
activeResponse?.state === "closed" && redirectDropWarning != null;
|
||||
|
||||
const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]);
|
||||
const saveResponse = useSaveResponse(activeResponse ?? null);
|
||||
const copyResponse = useCopyHttpResponse(activeResponse ?? null);
|
||||
|
||||
const tabs = useMemo<TabItem[]>(
|
||||
() => [
|
||||
{
|
||||
value: TAB_BODY,
|
||||
label: "Response",
|
||||
options: {
|
||||
value: viewMode,
|
||||
onChange: setViewMode,
|
||||
items: [
|
||||
{ label: "Response", value: "pretty" },
|
||||
...(mimeType?.startsWith("image")
|
||||
? []
|
||||
: [{ label: "Response (Raw)", shortLabel: "Raw", value: "raw" }]),
|
||||
],
|
||||
itemsAfter: [
|
||||
{
|
||||
label: "Save to File",
|
||||
onSelect: saveResponse.mutate,
|
||||
leftSlot: <Icon icon="save" />,
|
||||
hidden: activeResponse == null || !!activeResponse.error,
|
||||
disabled: activeResponse?.state !== "closed" && (activeResponse?.status ?? 0) >= 100,
|
||||
},
|
||||
{
|
||||
label: "Copy Body",
|
||||
onSelect: copyResponse.mutate,
|
||||
leftSlot: <Icon icon="copy" />,
|
||||
hidden: activeResponse == null || !!activeResponse.error,
|
||||
disabled: activeResponse?.state !== "closed" && (activeResponse?.status ?? 0) >= 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
value: TAB_REQUEST,
|
||||
label: "Request",
|
||||
rightSlot:
|
||||
(activeResponse?.requestContentLength ?? 0) > 0 ? <CountBadge count={true} /> : null,
|
||||
},
|
||||
{
|
||||
value: TAB_HEADERS,
|
||||
label: "Headers",
|
||||
rightSlot: (
|
||||
<CountBadge
|
||||
count={activeResponse?.requestHeaders.length ?? 0}
|
||||
count2={activeResponse?.headers.length ?? 0}
|
||||
showZero
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: TAB_COOKIES,
|
||||
label: "Cookies",
|
||||
rightSlot:
|
||||
cookieCounts.sent > 0 || cookieCounts.received > 0 ? (
|
||||
<CountBadge count={cookieCounts.sent} count2={cookieCounts.received} showZero />
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
value: TAB_TIMELINE,
|
||||
rightSlot: <CountBadge count={responseEvents.data?.length ?? 0} />,
|
||||
options: {
|
||||
value: timelineViewMode,
|
||||
onChange: (v) => setTimelineViewMode((v as TimelineViewMode) ?? "timeline"),
|
||||
items: [
|
||||
{ label: "Timeline", value: "timeline" },
|
||||
{ label: "Timeline (Text)", shortLabel: "Timeline", value: "text" },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
activeResponse?.headers,
|
||||
activeResponse,
|
||||
activeResponse?.error,
|
||||
activeResponse?.requestContentLength,
|
||||
activeResponse?.requestHeaders.length,
|
||||
activeResponse?.state,
|
||||
activeResponse?.status,
|
||||
cookieCounts.sent,
|
||||
cookieCounts.received,
|
||||
copyResponse.mutate,
|
||||
mimeType,
|
||||
responseEvents.data?.length,
|
||||
saveResponse.mutate,
|
||||
setViewMode,
|
||||
viewMode,
|
||||
timelineViewMode,
|
||||
setTimelineViewMode,
|
||||
],
|
||||
);
|
||||
|
||||
const cancel = useCancelHttpResponse(activeResponse?.id ?? null);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
className={classNames(
|
||||
className,
|
||||
"x-theme-responsePane",
|
||||
"max-h-full h-full",
|
||||
"bg-surface rounded-md border border-border-subtle overflow-hidden",
|
||||
"relative",
|
||||
)}
|
||||
>
|
||||
{activeResponse == null ? (
|
||||
<HotkeyList hotkeys={["request.send", "model.create", "sidebar.focus", "url_bar.focus"]} />
|
||||
) : (
|
||||
<div className="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1">
|
||||
<HStack
|
||||
className={classNames(
|
||||
"text-text-subtle w-full shrink-0",
|
||||
// Remove a bit of space because the tabs have lots too
|
||||
"-mb-1.5",
|
||||
)}
|
||||
>
|
||||
{activeResponse && (
|
||||
<div
|
||||
className={classNames(
|
||||
"grid grid-cols-[auto_minmax(4rem,1fr)_auto]",
|
||||
"cursor-default select-none",
|
||||
"whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars",
|
||||
)}
|
||||
>
|
||||
<HStack space={2} className="w-full shrink-0">
|
||||
{activeResponse.state !== "closed" && <LoadingIcon size="sm" />}
|
||||
<HttpStatusTag showReason response={activeResponse} />
|
||||
<span>•</span>
|
||||
<HttpResponseDurationTag response={activeResponse} />
|
||||
<span>•</span>
|
||||
<SizeTag
|
||||
contentLength={activeResponse.contentLength ?? 0}
|
||||
contentLengthCompressed={activeResponse.contentLengthCompressed}
|
||||
/>
|
||||
</HStack>
|
||||
{shouldShowRedirectDropWarning ? (
|
||||
<Tooltip
|
||||
tabIndex={0}
|
||||
className="my-auto pl-3 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>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className="justify-self-end shrink-0">
|
||||
<RecentHttpResponsesDropdown
|
||||
responses={responses}
|
||||
activeResponse={activeResponse}
|
||||
onPinnedResponseId={setPinnedResponseId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
<div className="overflow-hidden flex flex-col min-h-0">
|
||||
{activeResponse?.error && (
|
||||
<Banner color="danger" className="mx-3 mt-1 shrink-0">
|
||||
{activeResponse.error}
|
||||
</Banner>
|
||||
)}
|
||||
{/* Show tabs if we have any data (headers, body, etc.) even if there's an error */}
|
||||
<Tabs
|
||||
tabs={tabs}
|
||||
label="Response"
|
||||
className="ml-3 mr-3 mb-3 min-h-0 flex-1"
|
||||
tabListClassName="mt-0.5 -mb-1.5"
|
||||
storageKey="http_response_tabs"
|
||||
activeTabKey={activeRequestId}
|
||||
>
|
||||
<TabContent value={TAB_BODY}>
|
||||
<ErrorBoundary name="Http Response Viewer">
|
||||
<Suspense>
|
||||
<ConfirmLargeResponse response={activeResponse}>
|
||||
{activeResponse.state === "initialized" ? (
|
||||
<EmptyStateText>
|
||||
<VStack space={3}>
|
||||
<HStack space={3}>
|
||||
<LoadingIcon className="text-text-subtlest" />
|
||||
Sending Request
|
||||
</HStack>
|
||||
<Button size="sm" variant="border" onClick={() => cancel.mutate()}>
|
||||
Cancel
|
||||
</Button>
|
||||
</VStack>
|
||||
</EmptyStateText>
|
||||
) : activeResponse.state === "closed" &&
|
||||
(activeResponse.contentLength ?? 0) === 0 ? (
|
||||
<EmptyStateText>Empty</EmptyStateText>
|
||||
) : mimeType?.match(/^text\/event-stream/i) && viewMode === "pretty" ? (
|
||||
<EventStreamViewer response={activeResponse} />
|
||||
) : mimeType?.match(/^image\/svg/) ? (
|
||||
<HttpSvgViewer response={activeResponse} />
|
||||
) : mimeType?.match(/^image/i) ? (
|
||||
<EnsureCompleteResponse response={activeResponse} Component={ImageViewer} />
|
||||
) : mimeType?.match(/^audio/i) ? (
|
||||
<EnsureCompleteResponse response={activeResponse} Component={AudioViewer} />
|
||||
) : mimeType?.match(/^video/i) ? (
|
||||
<EnsureCompleteResponse response={activeResponse} Component={VideoViewer} />
|
||||
) : mimeType?.match(/^multipart/i) && viewMode === "pretty" ? (
|
||||
<HttpMultipartViewer response={activeResponse} />
|
||||
) : mimeType?.match(/pdf/i) ? (
|
||||
<EnsureCompleteResponse response={activeResponse} Component={PdfViewer} />
|
||||
) : mimeType?.match(/csv|tab-separated/i) && viewMode === "pretty" ? (
|
||||
<HttpCsvViewer className="pb-2" response={activeResponse} />
|
||||
) : (
|
||||
<HTMLOrTextViewer
|
||||
textViewerClassName="-mr-2 bg-surface" // Pull to the right
|
||||
response={activeResponse}
|
||||
pretty={viewMode === "pretty"}
|
||||
/>
|
||||
)}
|
||||
</ConfirmLargeResponse>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_REQUEST}>
|
||||
<ConfirmLargeResponseRequest response={activeResponse}>
|
||||
<RequestBodyViewer response={activeResponse} />
|
||||
</ConfirmLargeResponseRequest>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_HEADERS}>
|
||||
<ResponseHeaders response={activeResponse} />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_COOKIES}>
|
||||
<ResponseCookies response={activeResponse} />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_TIMELINE}>
|
||||
<HttpResponseTimeline response={activeResponse} viewMode={timelineViewMode} />
|
||||
</TabContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getRedirectDropWarning(
|
||||
events: HttpResponseEvent[] | undefined,
|
||||
): RedirectDropWarning | null {
|
||||
if (events == null || events.length === 0) return null;
|
||||
|
||||
let droppedBodyCount = 0;
|
||||
const droppedHeaders = new Set<string>();
|
||||
for (const e of events) {
|
||||
const event = e.event;
|
||||
if (event.type !== "redirect") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.dropped_body) {
|
||||
droppedBodyCount += 1;
|
||||
}
|
||||
for (const headerName of event.dropped_headers ?? []) {
|
||||
pushHeaderName(droppedHeaders, headerName);
|
||||
}
|
||||
}
|
||||
|
||||
if (droppedBodyCount === 0 && droppedHeaders.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
droppedBodyCount,
|
||||
droppedHeaders: Array.from(droppedHeaders).sort(),
|
||||
};
|
||||
}
|
||||
|
||||
function pushHeaderName(headers: Set<string>, headerName: string): void {
|
||||
const existing = Array.from(headers).find((h) => h.toLowerCase() === headerName.toLowerCase());
|
||||
if (existing == null) {
|
||||
headers.add(headerName);
|
||||
}
|
||||
}
|
||||
|
||||
function getRedirectWarningLabel(warning: RedirectDropWarning): string {
|
||||
if (warning.droppedBodyCount > 0 && warning.droppedHeaders.length > 0) {
|
||||
return "Dropped body and headers";
|
||||
}
|
||||
if (warning.droppedBodyCount > 0) {
|
||||
return "Dropped body";
|
||||
}
|
||||
return "Dropped headers";
|
||||
}
|
||||
|
||||
function EnsureCompleteResponse({
|
||||
response,
|
||||
Component,
|
||||
}: {
|
||||
response: HttpResponse;
|
||||
Component: ComponentType<{ bodyUrl: string }>;
|
||||
}) {
|
||||
// Wait until the response has been fully-downloaded before asking for it
|
||||
const complete = response.state === "closed";
|
||||
const bodyUrl = useResponseBodyUrl(complete ? response : null);
|
||||
|
||||
if (!complete || bodyUrl.isPending) {
|
||||
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 <Component bodyUrl={bodyUrl.data} />;
|
||||
}
|
||||
|
||||
function HttpSvgViewer({ response }: { response: HttpResponse }) {
|
||||
const body = useResponseBodyText({ response, filter: null });
|
||||
|
||||
if (!body.data) return null;
|
||||
|
||||
return <SvgViewer text={body.data} />;
|
||||
}
|
||||
|
||||
function HttpCsvViewer({ response, className }: { response: HttpResponse; className?: string }) {
|
||||
const body = useResponseBodyText({ response, filter: null });
|
||||
|
||||
return <CsvViewer text={body.data ?? null} className={className} />;
|
||||
}
|
||||
|
||||
function HttpMultipartViewer({ response }: { response: HttpResponse }) {
|
||||
const body = useResponseBodyBytes({ response });
|
||||
|
||||
if (body.data == null) return null;
|
||||
|
||||
const contentTypeHeader = getContentTypeFromHeaders(response.headers);
|
||||
const boundary = contentTypeHeader?.split("boundary=")[1] ?? "unknown";
|
||||
|
||||
return <MultipartViewer data={body.data} boundary={boundary} idPrefix={response.id} />;
|
||||
}
|
||||
@@ -1,466 +0,0 @@
|
||||
import type {
|
||||
AnyModel,
|
||||
HttpResponse,
|
||||
HttpResponseEvent,
|
||||
HttpResponseEventData,
|
||||
} from "@yaakapp-internal/models";
|
||||
import { foldersAtom, workspacesAtom } from "@yaakapp-internal/models";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { type ReactNode, useMemo, useState } from "react";
|
||||
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
|
||||
import { useAllRequests } from "../hooks/useAllRequests";
|
||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
||||
import { Editor } from "./core/Editor/LazyEditor";
|
||||
import { type EventDetailAction, EventDetailHeader, EventViewer } from "./core/EventViewer";
|
||||
import { EventViewerRow } from "./core/EventViewerRow";
|
||||
import { HttpStatusTagRaw } from "./core/HttpStatusTag";
|
||||
import { Icon, type IconProps } from "@yaakapp-internal/ui";
|
||||
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
|
||||
import type { TimelineViewMode } from "./HttpResponsePane";
|
||||
|
||||
interface Props {
|
||||
response: HttpResponse;
|
||||
viewMode: TimelineViewMode;
|
||||
}
|
||||
|
||||
export function HttpResponseTimeline({ response, viewMode }: Props) {
|
||||
return <Inner key={response.id} response={response} viewMode={viewMode} />;
|
||||
}
|
||||
|
||||
function Inner({ response, viewMode }: Props) {
|
||||
const [showRaw, setShowRaw] = useState(false);
|
||||
const { data: events, error, isLoading } = useHttpResponseEvents(response);
|
||||
|
||||
// Generate plain text representation of all events (with prefixes for timeline view)
|
||||
const plainText = useMemo(() => {
|
||||
if (!events || events.length === 0) return "";
|
||||
return events.map((event) => formatEventText(event.event, true)).join("\n");
|
||||
}, [events]);
|
||||
|
||||
// Plain text view - show all events as text in an editor
|
||||
if (viewMode === "text") {
|
||||
if (isLoading) {
|
||||
return <div className="p-4 text-text-subtlest">Loading events...</div>;
|
||||
} else if (error) {
|
||||
return <div className="p-4 text-danger">{String(error)}</div>;
|
||||
} else if (!events || events.length === 0) {
|
||||
return <div className="p-4 text-text-subtlest">No events recorded</div>;
|
||||
} else {
|
||||
return (
|
||||
<Editor language="timeline" defaultValue={plainText} readOnly stateKey={null} hideGutter />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<EventViewer
|
||||
events={events ?? []}
|
||||
getEventKey={(event) => event.id}
|
||||
error={error ? String(error) : null}
|
||||
isLoading={isLoading}
|
||||
loadingMessage="Loading events..."
|
||||
emptyMessage="No events recorded"
|
||||
splitLayoutStorageKey="http_response_events"
|
||||
defaultRatio={0.25}
|
||||
renderRow={({ event, isActive, onClick }) => {
|
||||
const display = getEventDisplay(event.event);
|
||||
return (
|
||||
<EventViewerRow
|
||||
isActive={isActive}
|
||||
onClick={onClick}
|
||||
icon={<Icon color={display.color} icon={display.icon} size="sm" />}
|
||||
content={display.summary}
|
||||
timestamp={event.createdAt}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
renderDetail={({ event, onClose }) => (
|
||||
<EventDetails event={event} showRaw={showRaw} setShowRaw={setShowRaw} onClose={onClose} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function EventDetails({
|
||||
event,
|
||||
showRaw,
|
||||
setShowRaw,
|
||||
onClose,
|
||||
}: {
|
||||
event: HttpResponseEvent;
|
||||
showRaw: boolean;
|
||||
setShowRaw: (v: boolean) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { label } = getEventDisplay(event.event);
|
||||
const e = event.event;
|
||||
const settingSourceModels = useSettingSourceModels();
|
||||
|
||||
const actions: EventDetailAction[] = [
|
||||
{
|
||||
key: "toggle-raw",
|
||||
label: showRaw ? "Formatted" : "Text",
|
||||
onClick: () => setShowRaw(!showRaw),
|
||||
},
|
||||
];
|
||||
|
||||
// Determine the title based on event type
|
||||
const title = (() => {
|
||||
switch (e.type) {
|
||||
case "header_up":
|
||||
return "Header Sent";
|
||||
case "header_down":
|
||||
return "Header Received";
|
||||
case "send_url":
|
||||
return "Request";
|
||||
case "receive_url":
|
||||
return "Response";
|
||||
case "redirect":
|
||||
return "Redirect";
|
||||
case "setting":
|
||||
return "Apply Setting";
|
||||
case "chunk_sent":
|
||||
return "Data Sent";
|
||||
case "chunk_received":
|
||||
return "Data Received";
|
||||
case "dns_resolved":
|
||||
return e.overridden ? "DNS Override" : "DNS Resolution";
|
||||
default:
|
||||
return label;
|
||||
}
|
||||
})();
|
||||
|
||||
// Render content based on view mode and event type
|
||||
const renderContent = () => {
|
||||
// Raw view - show plaintext representation (without prefix)
|
||||
if (showRaw) {
|
||||
const rawText = formatEventText(event.event, false);
|
||||
return <Editor language="text" defaultValue={rawText} readOnly stateKey={null} hideGutter />;
|
||||
}
|
||||
|
||||
// Headers - show name and value
|
||||
if (e.type === "header_up" || e.type === "header_down") {
|
||||
return (
|
||||
<KeyValueRows>
|
||||
<KeyValueRow label="Header">{e.name}</KeyValueRow>
|
||||
<KeyValueRow label="Value">{e.value}</KeyValueRow>
|
||||
</KeyValueRows>
|
||||
);
|
||||
}
|
||||
|
||||
// Request URL - show all URL parts separately
|
||||
if (e.type === "send_url") {
|
||||
const auth = e.username || e.password ? `${e.username}:${e.password}@` : "";
|
||||
const isDefaultPort =
|
||||
(e.scheme === "http" && e.port === 80) || (e.scheme === "https" && e.port === 443);
|
||||
const portStr = isDefaultPort ? "" : `:${e.port}`;
|
||||
const query = e.query ? `?${e.query}` : "";
|
||||
const fragment = e.fragment ? `#${e.fragment}` : "";
|
||||
const fullUrl = `${e.scheme}://${auth}${e.host}${portStr}${e.path}${query}${fragment}`;
|
||||
return (
|
||||
<KeyValueRows>
|
||||
<KeyValueRow label="URL">{fullUrl}</KeyValueRow>
|
||||
<KeyValueRow label="Method">{e.method}</KeyValueRow>
|
||||
<KeyValueRow label="Scheme">{e.scheme}</KeyValueRow>
|
||||
{e.username ? <KeyValueRow label="Username">{e.username}</KeyValueRow> : null}
|
||||
{e.password ? <KeyValueRow label="Password">{e.password}</KeyValueRow> : null}
|
||||
<KeyValueRow label="Host">{e.host}</KeyValueRow>
|
||||
{!isDefaultPort ? <KeyValueRow label="Port">{e.port}</KeyValueRow> : null}
|
||||
<KeyValueRow label="Path">{e.path}</KeyValueRow>
|
||||
{e.query ? <KeyValueRow label="Query">{e.query}</KeyValueRow> : null}
|
||||
{e.fragment ? <KeyValueRow label="Fragment">{e.fragment}</KeyValueRow> : null}
|
||||
</KeyValueRows>
|
||||
);
|
||||
}
|
||||
|
||||
// Response status - show version and status separately
|
||||
if (e.type === "receive_url") {
|
||||
return (
|
||||
<KeyValueRows>
|
||||
<KeyValueRow label="HTTP Version">{e.version}</KeyValueRow>
|
||||
<KeyValueRow label="Status">
|
||||
<HttpStatusTagRaw status={e.status} />
|
||||
</KeyValueRow>
|
||||
</KeyValueRows>
|
||||
);
|
||||
}
|
||||
|
||||
// Redirect - show status, URL, and behavior
|
||||
if (e.type === "redirect") {
|
||||
const droppedHeaders = e.dropped_headers ?? [];
|
||||
return (
|
||||
<KeyValueRows>
|
||||
<KeyValueRow label="Status">
|
||||
<HttpStatusTagRaw status={e.status} />
|
||||
</KeyValueRow>
|
||||
<KeyValueRow label="Location">{e.url}</KeyValueRow>
|
||||
<KeyValueRow label="Behavior">
|
||||
{e.behavior === "drop_body" ? "Drop body, change to GET" : "Preserve method and body"}
|
||||
</KeyValueRow>
|
||||
<KeyValueRow label="Body Dropped">{e.dropped_body ? "Yes" : "No"}</KeyValueRow>
|
||||
<KeyValueRow label="Headers Dropped">
|
||||
{droppedHeaders.length > 0 ? droppedHeaders.join(", ") : "--"}
|
||||
</KeyValueRow>
|
||||
</KeyValueRows>
|
||||
);
|
||||
}
|
||||
|
||||
// Settings - show as key/value
|
||||
if (e.type === "setting") {
|
||||
return (
|
||||
<KeyValueRows>
|
||||
<KeyValueRow label="Setting">{e.name}</KeyValueRow>
|
||||
<KeyValueRow label="Value">{e.value}</KeyValueRow>
|
||||
{e.source_model != null ? (
|
||||
<KeyValueRow label="Source">{formatSettingSource(e, settingSourceModels)}</KeyValueRow>
|
||||
) : null}
|
||||
</KeyValueRows>
|
||||
);
|
||||
}
|
||||
|
||||
// Chunks - show formatted bytes
|
||||
if (e.type === "chunk_sent" || e.type === "chunk_received") {
|
||||
return <div className="font-mono text-editor">{formatBytes(e.bytes)}</div>;
|
||||
}
|
||||
|
||||
// DNS Resolution - show hostname, addresses, and timing
|
||||
if (e.type === "dns_resolved") {
|
||||
return (
|
||||
<KeyValueRows>
|
||||
<KeyValueRow label="Hostname">{e.hostname}</KeyValueRow>
|
||||
<KeyValueRow label="Addresses">{e.addresses.join(", ")}</KeyValueRow>
|
||||
<KeyValueRow label="Duration">
|
||||
{e.overridden ? (
|
||||
<span className="text-text-subtlest">--</span>
|
||||
) : (
|
||||
`${String(e.duration)}ms`
|
||||
)}
|
||||
</KeyValueRow>
|
||||
{e.overridden ? <KeyValueRow label="Source">Workspace Override</KeyValueRow> : null}
|
||||
</KeyValueRows>
|
||||
);
|
||||
}
|
||||
|
||||
// Default - use summary
|
||||
const { summary } = getEventDisplay(event.event);
|
||||
return <div className="font-mono text-editor">{summary}</div>;
|
||||
};
|
||||
return (
|
||||
<div className="flex flex-col gap-2 h-full">
|
||||
<EventDetailHeader
|
||||
title={title}
|
||||
timestamp={event.createdAt}
|
||||
actions={actions}
|
||||
onClose={onClose}
|
||||
/>
|
||||
{renderContent()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type EventTextParts = { prefix: ">" | "<" | "*"; text: string };
|
||||
|
||||
/** Get the prefix and text for an event */
|
||||
function getEventTextParts(event: HttpResponseEventData): EventTextParts {
|
||||
switch (event.type) {
|
||||
case "send_url":
|
||||
return {
|
||||
prefix: ">",
|
||||
text: `${event.method} ${event.path}${event.query ? `?${event.query}` : ""}${event.fragment ? `#${event.fragment}` : ""}`,
|
||||
};
|
||||
case "receive_url":
|
||||
return { prefix: "<", text: `${event.version} ${event.status}` };
|
||||
case "header_up":
|
||||
return { prefix: ">", text: `${event.name}: ${event.value}` };
|
||||
case "header_down":
|
||||
return { prefix: "<", text: `${event.name}: ${event.value}` };
|
||||
case "redirect": {
|
||||
const behavior = event.behavior === "drop_body" ? "drop body" : "preserve";
|
||||
const droppedHeaders = event.dropped_headers ?? [];
|
||||
const dropped = [
|
||||
event.dropped_body ? "body dropped" : null,
|
||||
droppedHeaders.length > 0 ? `headers dropped: ${droppedHeaders.join(", ")}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
return {
|
||||
prefix: "*",
|
||||
text: `Redirect ${event.status} -> ${event.url} (${behavior}${dropped ? `, ${dropped}` : ""})`,
|
||||
};
|
||||
}
|
||||
case "setting":
|
||||
return { prefix: "*", text: `Setting ${event.name}=${event.value}` };
|
||||
case "info":
|
||||
return { prefix: "*", text: event.message };
|
||||
case "chunk_sent":
|
||||
return { prefix: "*", text: `[${formatBytes(event.bytes)} sent]` };
|
||||
case "chunk_received":
|
||||
return { prefix: "*", text: `[${formatBytes(event.bytes)} received]` };
|
||||
case "dns_resolved":
|
||||
if (event.overridden) {
|
||||
return {
|
||||
prefix: "*",
|
||||
text: `DNS override ${event.hostname} -> ${event.addresses.join(", ")}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
prefix: "*",
|
||||
text: `DNS resolved ${event.hostname} to ${event.addresses.join(", ")} (${event.duration}ms)`,
|
||||
};
|
||||
default:
|
||||
return { prefix: "*", text: "[unknown event]" };
|
||||
}
|
||||
}
|
||||
|
||||
/** Format event as plaintext, optionally with curl-style prefix (> outgoing, < incoming, * info) */
|
||||
function formatEventText(event: HttpResponseEventData, includePrefix: boolean): string {
|
||||
const { prefix, text } = getEventTextParts(event);
|
||||
return includePrefix ? `${prefix} ${text}` : text;
|
||||
}
|
||||
|
||||
function useSettingSourceModels() {
|
||||
const requests = useAllRequests();
|
||||
const folders = useAtomValue(foldersAtom);
|
||||
const workspaces = useAtomValue(workspacesAtom);
|
||||
|
||||
return useMemo<AnyModel[]>(
|
||||
() => [...requests, ...folders, ...workspaces],
|
||||
[requests, folders, workspaces],
|
||||
);
|
||||
}
|
||||
|
||||
function formatSettingSource(
|
||||
event: Extract<HttpResponseEventData, { type: "setting" }>,
|
||||
models: AnyModel[],
|
||||
): string {
|
||||
const sourceModel = event.source_model;
|
||||
if (sourceModel == null || sourceModel === "default") {
|
||||
return "Default";
|
||||
}
|
||||
|
||||
const model =
|
||||
event.source_id == null
|
||||
? null
|
||||
: (models.find((m) => m.model === sourceModel && m.id === event.source_id) ?? null);
|
||||
const name = model == null ? event.source_name : resolvedModelName(model);
|
||||
const label = sourceModel.replaceAll("_", " ");
|
||||
return name == null || name.length === 0 ? label : `${name} (${label})`;
|
||||
}
|
||||
|
||||
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,139 +0,0 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { Icon, VStack } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useLocalStorage } from "react-use";
|
||||
import { CommercialUseBanner } from "./CommercialUseBanner";
|
||||
import { Button } from "./core/Button";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
|
||||
interface Props {
|
||||
importFile: (filePath: string) => Promise<void>;
|
||||
importUrl: (url: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* An absolute or relative path is unambiguously a file. Everything else is treated as a URL, so a
|
||||
* bare host like `example.com/openapi.json` still works (the backend defaults it to https).
|
||||
*/
|
||||
function isFilePath(value: string): boolean {
|
||||
return (
|
||||
value.startsWith("/") ||
|
||||
value.startsWith("./") ||
|
||||
value.startsWith("../") ||
|
||||
value.startsWith("~/") ||
|
||||
value.startsWith("\\\\") ||
|
||||
/^[a-zA-Z]:[\\/]/.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
function fileName(path: string): string {
|
||||
return path.split(/[/\\]/).at(-1) || path;
|
||||
}
|
||||
|
||||
export function ImportDataDialog({ importFile, importUrl }: Props) {
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
// A file path or a URL. Both inputs write here, so there is only ever one thing to import
|
||||
const [source, setSource] = useLocalStorage<string | null>("importPathOrUrl", null);
|
||||
const [forceUpdateKey, setForceUpdateKey] = useState<number>(0);
|
||||
const [isHovering, setIsHovering] = useState<boolean>(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const trimmedSource = source?.trim() ?? "";
|
||||
const filePath = isFilePath(trimmedSource) ? trimmedSource : null;
|
||||
|
||||
const selectSource = (value: string) => {
|
||||
setSource(value);
|
||||
// Remount the input so it shows the path of the newly-picked file
|
||||
setForceUpdateKey((k) => k + 1);
|
||||
};
|
||||
|
||||
// Accept a file dropped anywhere on the dialog, the way SelectFile does for its button
|
||||
useEffect(() => {
|
||||
return platform.window.onDragDrop((event) => {
|
||||
if (event.type === "over") {
|
||||
const p = event.position;
|
||||
const r = ref.current?.getBoundingClientRect();
|
||||
if (r == null) return;
|
||||
setIsHovering(p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom);
|
||||
} else if (event.type === "drop" && isHovering) {
|
||||
const p = event.paths[0];
|
||||
if (p) selectSource(p);
|
||||
setIsHovering(false);
|
||||
} else {
|
||||
setIsHovering(false);
|
||||
}
|
||||
});
|
||||
}, [isHovering, setSource]);
|
||||
|
||||
const handleSelectFile = async () => {
|
||||
const selected = await platform.dialog.open({ title: "Select File", multiple: false });
|
||||
if (selected == null) return;
|
||||
selectSource(selected);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (filePath != null) {
|
||||
await importFile(filePath);
|
||||
} else {
|
||||
await importUrl(trimmedSource);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack ref={ref} space={4} className="pb-4">
|
||||
<CommercialUseBanner source="data-import" title="Importing work data?" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSelectFile}
|
||||
className={classNames(
|
||||
"w-full rounded-lg border border-dashed px-4 py-6",
|
||||
"flex flex-col items-center gap-1 text-center",
|
||||
isHovering ? "border-notice bg-surface-highlight" : "border-border hover:border-text",
|
||||
)}
|
||||
>
|
||||
<Icon icon="folder_input" className="text-text-subtlest w-8! h-8! mb-2" />
|
||||
{/* Fixed height so the region doesn't resize between the empty and selected states */}
|
||||
<div className="h-6 w-full flex items-center justify-center">
|
||||
{filePath == null ? (
|
||||
<div className="text-text">
|
||||
<strong className="font-semibold">Choose a file</strong> or drag it here
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-text font-mono text-xs max-w-full truncate" title={filePath}>
|
||||
{fileName(filePath)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-text-subtlest">
|
||||
Supports OpenAPI, Swagger, Postman, Insomnia, and curl
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<VStack space={2}>
|
||||
<PlainInput
|
||||
label="Or enter a file path or URL"
|
||||
size="sm"
|
||||
placeholder="https://example.com/openapi.json"
|
||||
defaultValue={source ?? ""}
|
||||
forceUpdateKey={String(forceUpdateKey)}
|
||||
onChange={setSource}
|
||||
/>
|
||||
<Button
|
||||
color="primary"
|
||||
disabled={trimmedSource === "" || isLoading}
|
||||
isLoading={isLoading}
|
||||
size="sm"
|
||||
onClick={handleImport}
|
||||
>
|
||||
{isLoading ? "Importing" : "Import"}
|
||||
</Button>
|
||||
</VStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import { linter } from "@codemirror/lint";
|
||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||
import { patchModel } from "@yaakapp-internal/models";
|
||||
import { Banner, Icon } from "@yaakapp-internal/ui";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useKeyValue } from "../hooks/useKeyValue";
|
||||
import { fireAndForget } from "../lib/fireAndForget";
|
||||
import { textLikelyContainsJsonComments } from "../lib/jsonComments";
|
||||
import type { DropdownItem } from "./core/Dropdown";
|
||||
import { Dropdown } from "./core/Dropdown";
|
||||
import type { EditorProps } from "./core/Editor/Editor";
|
||||
import { jsonParseLinter } from "./core/Editor/json-lint";
|
||||
import { Editor } from "./core/Editor/LazyEditor";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { IconTooltip } from "./core/IconTooltip";
|
||||
|
||||
interface Props {
|
||||
forceUpdateKey: string;
|
||||
heightMode: EditorProps["heightMode"];
|
||||
request: HttpRequest;
|
||||
}
|
||||
|
||||
export function JsonBodyEditor({ forceUpdateKey, heightMode, request }: Props) {
|
||||
const handleChange = useCallback(
|
||||
(text: string) => patchModel(request, { body: { ...request.body, text } }),
|
||||
[request],
|
||||
);
|
||||
|
||||
const autoFix = request.body?.sendJsonComments !== true;
|
||||
|
||||
const lintExtension = useMemo(
|
||||
() =>
|
||||
linter(
|
||||
jsonParseLinter(
|
||||
autoFix
|
||||
? { allowComments: true, allowTrailingCommas: true }
|
||||
: { allowComments: false, allowTrailingCommas: false },
|
||||
),
|
||||
),
|
||||
[autoFix],
|
||||
);
|
||||
|
||||
const hasComments = useMemo(
|
||||
() => textLikelyContainsJsonComments(request.body?.text ?? ""),
|
||||
[request.body?.text],
|
||||
);
|
||||
|
||||
const { value: bannerDismissed, set: setBannerDismissed } = useKeyValue<boolean>({
|
||||
namespace: "no_sync",
|
||||
key: ["json-fix-3", request.workspaceId],
|
||||
fallback: false,
|
||||
});
|
||||
|
||||
const handleToggleAutoFix = useCallback(() => {
|
||||
const newBody = { ...request.body };
|
||||
if (autoFix) {
|
||||
newBody.sendJsonComments = true;
|
||||
} else {
|
||||
delete newBody.sendJsonComments;
|
||||
}
|
||||
fireAndForget(patchModel(request, { body: newBody }));
|
||||
}, [request, autoFix]);
|
||||
|
||||
const handleDropdownOpen = useCallback(() => {
|
||||
if (!bannerDismissed) {
|
||||
fireAndForget(setBannerDismissed(true));
|
||||
}
|
||||
}, [bannerDismissed, setBannerDismissed]);
|
||||
|
||||
const showBanner = hasComments && autoFix && !bannerDismissed;
|
||||
|
||||
const stripMessage = "Automatically strip comments and trailing commas before sending";
|
||||
const actions = useMemo<EditorProps["actions"]>(
|
||||
() => [
|
||||
showBanner && (
|
||||
<Banner color="notice" className="opacity-100! h-sm py-0! px-2! flex items-center text-xs">
|
||||
<p className="inline-flex items-center gap-1 min-w-0">
|
||||
<span className="truncate">Auto-fix enabled</span>
|
||||
<Icon icon="arrow_right" size="sm" className="opacity-disabled" />
|
||||
</p>
|
||||
</Banner>
|
||||
),
|
||||
<div key="settings" className="opacity-100! shadow!">
|
||||
<Dropdown
|
||||
onOpen={handleDropdownOpen}
|
||||
items={
|
||||
[
|
||||
{
|
||||
label: "Automatically Fix JSON",
|
||||
keepOpenOnSelect: true,
|
||||
onSelect: handleToggleAutoFix,
|
||||
rightSlot: <IconTooltip content={stripMessage} />,
|
||||
leftSlot: (
|
||||
<Icon icon={autoFix ? "check_square_checked" : "check_square_unchecked"} />
|
||||
),
|
||||
},
|
||||
] satisfies DropdownItem[]
|
||||
}
|
||||
>
|
||||
<IconButton size="sm" variant="border" icon="settings" title="JSON Settings" />
|
||||
</Dropdown>
|
||||
</div>,
|
||||
],
|
||||
[handleDropdownOpen, handleToggleAutoFix, autoFix, showBanner],
|
||||
);
|
||||
|
||||
return (
|
||||
<Editor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
autocompleteFunctions
|
||||
autocompleteVariables
|
||||
placeholder="..."
|
||||
heightMode={heightMode}
|
||||
defaultValue={`${request.body?.text ?? ""}`}
|
||||
language="json"
|
||||
onChange={handleChange}
|
||||
stateKey={`json.${request.id}`}
|
||||
actions={actions}
|
||||
lintExtension={lintExtension}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { hotkeyActions } from "../hooks/useHotKey";
|
||||
import { HotkeyList } from "./core/HotkeyList";
|
||||
|
||||
export function KeyboardShortcutsDialog() {
|
||||
return (
|
||||
<div className="grid h-full">
|
||||
<HotkeyList hotkeys={hotkeyActions} className="pb-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
import { formatSize } from "@yaakapp-internal/lib/formatSize";
|
||||
import { Banner, Button, HStack, LoadingIcon } 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";
|
||||
import { AudioViewer } from "./responseViewers/AudioViewer";
|
||||
import { ImageViewer } from "./responseViewers/ImageViewer";
|
||||
import { SvgViewer } from "./responseViewers/SvgViewer";
|
||||
import { VideoViewer } from "./responseViewers/VideoViewer";
|
||||
|
||||
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,634 +0,0 @@
|
||||
import type {
|
||||
Folder,
|
||||
GrpcRequest,
|
||||
HttpRequest,
|
||||
InheritedBoolSetting,
|
||||
InheritedIntSetting,
|
||||
WebsocketRequest,
|
||||
Workspace,
|
||||
} from "@yaakapp-internal/models";
|
||||
import { patchModel } from "@yaakapp-internal/models";
|
||||
import { useModelAncestors } from "../hooks/useModelAncestors";
|
||||
import {
|
||||
modelSupportsSetting,
|
||||
type RequestSettingDefinition,
|
||||
SETTING_FOLLOW_REDIRECTS,
|
||||
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 {
|
||||
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 CookieSettingsPatch = {
|
||||
settingSendCookies?: ModelWithCookieSettings["settingSendCookies"];
|
||||
settingStoreCookies?: ModelWithCookieSettings["settingStoreCookies"];
|
||||
};
|
||||
type HttpSettingsPatch = {
|
||||
settingFollowRedirects?: ModelWithHttpSettings["settingFollowRedirects"];
|
||||
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,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</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)[] = [];
|
||||
|
||||
if (modelSupportsCookieSettings(model)) {
|
||||
settings.push(model.settingSendCookies, model.settingStoreCookies);
|
||||
}
|
||||
|
||||
settings.push(model.settingValidateCertificates);
|
||||
|
||||
if (modelSupportsHttpSettings(model)) {
|
||||
settings.push(model.settingFollowRedirects, model.settingRequestTimeout);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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: keyof WorkspaceSettings,
|
||||
fallback: BooleanSetting | IntegerSetting,
|
||||
) {
|
||||
for (const ancestor of ancestors) {
|
||||
const setting = ancestor[key] as BooleanSetting | IntegerSetting;
|
||||
if (isInheritedSetting(setting)) {
|
||||
if (setting.enabled === true) {
|
||||
return setting.value;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
return setting;
|
||||
}
|
||||
|
||||
return isInheritedSetting(fallback) ? fallback.value : fallback;
|
||||
}
|
||||
|
||||
type WorkspaceSettings = Pick<
|
||||
Workspace,
|
||||
| "settingFollowRedirects"
|
||||
| "settingRequestMessageSize"
|
||||
| "settingRequestTimeout"
|
||||
| "settingSendCookies"
|
||||
| "settingStoreCookies"
|
||||
| "settingValidateCertificates"
|
||||
>;
|
||||
|
||||
type BooleanWorkspaceSettingKey = Exclude<
|
||||
keyof WorkspaceSettings,
|
||||
"settingRequestTimeout" | "settingRequestMessageSize"
|
||||
>;
|
||||
|
||||
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 { pluralizeCount } from "../lib/pluralize";
|
||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
||||
import { router } from "../lib/router";
|
||||
import { showToast } from "../lib/toast";
|
||||
import { Button } from "./core/Button";
|
||||
import { Select } from "./core/Select";
|
||||
|
||||
interface Props {
|
||||
activeWorkspaceId: string;
|
||||
requests: (HttpRequest | GrpcRequest | WebsocketRequest)[];
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
export function MoveToWorkspaceDialog({ onDone, requests, activeWorkspaceId }: Props) {
|
||||
const workspaces = useAtomValue(workspacesAtom);
|
||||
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string>(activeWorkspaceId);
|
||||
|
||||
const targetWorkspace = workspaces.find((w) => w.id === selectedWorkspaceId);
|
||||
const isSameWorkspace = selectedWorkspaceId === activeWorkspaceId;
|
||||
|
||||
return (
|
||||
<VStack space={4} className="mb-4">
|
||||
<Select
|
||||
label="Target Workspace"
|
||||
name="workspace"
|
||||
value={selectedWorkspaceId}
|
||||
onChange={setSelectedWorkspaceId}
|
||||
options={workspaces.map((w) => ({
|
||||
label: w.id === activeWorkspaceId ? `${w.name} (current)` : w.name,
|
||||
value: w.id,
|
||||
}))}
|
||||
/>
|
||||
<Button
|
||||
color="primary"
|
||||
disabled={isSameWorkspace}
|
||||
onClick={async () => {
|
||||
const patch = {
|
||||
workspaceId: selectedWorkspaceId,
|
||||
folderId: null,
|
||||
};
|
||||
|
||||
await Promise.all(requests.map((r) => patchModel(r, patch)));
|
||||
|
||||
// Hide after a moment, to give time for requests to disappear
|
||||
setTimeout(onDone, 100);
|
||||
showToast({
|
||||
id: "workspace-moved",
|
||||
message:
|
||||
requests.length === 1 && requests[0] != null ? (
|
||||
<>
|
||||
<InlineCode>{resolvedModelName(requests[0])}</InlineCode> moved to{" "}
|
||||
<InlineCode>{targetWorkspace?.name ?? "unknown"}</InlineCode>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{pluralizeCount("request", requests.length)} moved to{" "}
|
||||
<InlineCode>{targetWorkspace?.name ?? "unknown"}</InlineCode>
|
||||
</>
|
||||
),
|
||||
action: ({ hide }) => (
|
||||
<Button
|
||||
size="xs"
|
||||
color="secondary"
|
||||
className="mr-auto min-w-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,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,115 +0,0 @@
|
||||
import type { GrpcConnection } from "@yaakapp-internal/models";
|
||||
import { deleteModel } from "@yaakapp-internal/models";
|
||||
import { HStack, Icon } from "@yaakapp-internal/ui";
|
||||
import {
|
||||
differenceInHours,
|
||||
differenceInMinutes,
|
||||
format,
|
||||
isToday,
|
||||
isYesterday,
|
||||
} from "date-fns";
|
||||
import { useDeleteGrpcConnections } from "../hooks/useDeleteGrpcConnections";
|
||||
import { pluralizeCount } from "../lib/pluralize";
|
||||
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
||||
import { formatMillis } from "./core/HttpResponseDurationTag";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
|
||||
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,159 +0,0 @@
|
||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||
import { deleteModel } from "@yaakapp-internal/models";
|
||||
import { HStack, Icon } 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 { formatMillis } from "./core/HttpResponseDurationTag";
|
||||
import { HttpStatusTag } from "./core/HttpStatusTag";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { SizeTag } from "./core/SizeTag";
|
||||
|
||||
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,119 +0,0 @@
|
||||
import type { WebsocketConnection } from "@yaakapp-internal/models";
|
||||
import { deleteModel, getModel } from "@yaakapp-internal/models";
|
||||
import { HStack, Icon } from "@yaakapp-internal/ui";
|
||||
import {
|
||||
differenceInHours,
|
||||
differenceInMinutes,
|
||||
format,
|
||||
isToday,
|
||||
isYesterday,
|
||||
} from "date-fns";
|
||||
import { deleteWebsocketConnections } from "../commands/deleteWebsocketConnections";
|
||||
import { pluralizeCount } from "../lib/pluralize";
|
||||
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
||||
import { formatMillis } from "./core/HttpResponseDurationTag";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
|
||||
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,44 +0,0 @@
|
||||
import { workspacesAtom } from "@yaakapp-internal/models";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useEffect } from "react";
|
||||
import { getRecentCookieJars } from "../hooks/useRecentCookieJars";
|
||||
import { getRecentEnvironments } from "../hooks/useRecentEnvironments";
|
||||
import { getRecentRequests } from "../hooks/useRecentRequests";
|
||||
import { useRecentWorkspaces } from "../hooks/useRecentWorkspaces";
|
||||
import { fireAndForget } from "../lib/fireAndForget";
|
||||
import { router } from "../lib/router";
|
||||
|
||||
export function RedirectToLatestWorkspace() {
|
||||
const workspaces = useAtomValue(workspacesAtom);
|
||||
const recentWorkspaces = useRecentWorkspaces();
|
||||
|
||||
useEffect(() => {
|
||||
if (workspaces.length === 0 || recentWorkspaces == null) {
|
||||
console.log("No workspaces found to redirect to. Skipping.", {
|
||||
workspaces,
|
||||
recentWorkspaces,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
fireAndForget(
|
||||
(async () => {
|
||||
const workspaceId = recentWorkspaces[0] ?? workspaces[0]?.id ?? "n/a";
|
||||
const environmentId = (await getRecentEnvironments(workspaceId))[0] ?? null;
|
||||
const cookieJarId = (await getRecentCookieJars(workspaceId))[0] ?? null;
|
||||
const requestId = (await getRecentRequests(workspaceId))[0] ?? null;
|
||||
const params = { workspaceId };
|
||||
const search = {
|
||||
cookie_jar_id: cookieJarId,
|
||||
environment_id: environmentId,
|
||||
request_id: requestId,
|
||||
};
|
||||
|
||||
console.log("Redirecting to workspace", params, search);
|
||||
await router.navigate({ to: "/workspaces/$workspaceId", params, search });
|
||||
})(),
|
||||
);
|
||||
}, [recentWorkspaces, workspaces, workspaces.length]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||
import { lazy, Suspense } from "react";
|
||||
import { useHttpRequestBody } from "../hooks/useHttpRequestBody";
|
||||
import { getMimeTypeFromContentType, languageFromContentType } from "../lib/contentType";
|
||||
import { LoadingIcon } from "@yaakapp-internal/ui";
|
||||
import { EmptyStateText } from "./EmptyStateText";
|
||||
import { AudioViewer } from "./responseViewers/AudioViewer";
|
||||
import { CsvViewer } from "./responseViewers/CsvViewer";
|
||||
import { ImageViewer } from "./responseViewers/ImageViewer";
|
||||
import { MultipartViewer } from "./responseViewers/MultipartViewer";
|
||||
import { SvgViewer } from "./responseViewers/SvgViewer";
|
||||
import { TextViewer } from "./responseViewers/TextViewer";
|
||||
import { VideoViewer } from "./responseViewers/VideoViewer";
|
||||
import { WebPageViewer } from "./responseViewers/WebPageViewer";
|
||||
|
||||
const PdfViewer = lazy(() =>
|
||||
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
|
||||
);
|
||||
|
||||
interface Props {
|
||||
response: HttpResponse;
|
||||
}
|
||||
|
||||
export function RequestBodyViewer({ response }: Props) {
|
||||
return <RequestBodyViewerInner key={response.id} response={response} />;
|
||||
}
|
||||
|
||||
function RequestBodyViewerInner({ response }: Props) {
|
||||
const { data, isLoading, error } = useHttpRequestBody(response);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<EmptyStateText>
|
||||
<LoadingIcon />
|
||||
</EmptyStateText>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <EmptyStateText>Error loading request body: {error.message}</EmptyStateText>;
|
||||
}
|
||||
|
||||
if (data?.bodyText == null || data.bodyText.length === 0) {
|
||||
return <EmptyStateText>No request body</EmptyStateText>;
|
||||
}
|
||||
|
||||
const { bodyText, body } = data;
|
||||
|
||||
// Try to detect language from content-type header that was sent
|
||||
const contentTypeHeader = response.requestHeaders.find(
|
||||
(h) => h.name.toLowerCase() === "content-type",
|
||||
);
|
||||
const contentType = contentTypeHeader?.value ?? null;
|
||||
const mimeType = contentType ? getMimeTypeFromContentType(contentType).essence : null;
|
||||
const language = languageFromContentType(contentType, bodyText);
|
||||
|
||||
// Route to appropriate viewer based on content type
|
||||
if (mimeType?.match(/^multipart/i)) {
|
||||
const boundary = contentType?.split("boundary=")[1] ?? "unknown";
|
||||
// Create a copy because parseMultipart may detach the buffer
|
||||
const bodyCopy = new Uint8Array(body);
|
||||
return (
|
||||
<MultipartViewer data={bodyCopy} boundary={boundary} idPrefix={`request.${response.id}`} />
|
||||
);
|
||||
}
|
||||
|
||||
if (mimeType?.match(/^image\/svg/i)) {
|
||||
return <SvgViewer text={bodyText} />;
|
||||
}
|
||||
|
||||
if (mimeType?.match(/^image/i)) {
|
||||
return <ImageViewer data={body.buffer} />;
|
||||
}
|
||||
|
||||
if (mimeType?.match(/^audio/i)) {
|
||||
return <AudioViewer data={body} />;
|
||||
}
|
||||
|
||||
if (mimeType?.match(/^video/i)) {
|
||||
return <VideoViewer data={body} />;
|
||||
}
|
||||
|
||||
if (mimeType?.match(/csv|tab-separated/i)) {
|
||||
return <CsvViewer text={bodyText} />;
|
||||
}
|
||||
|
||||
if (mimeType?.match(/^text\/html/i)) {
|
||||
return <WebPageViewer html={bodyText} />;
|
||||
}
|
||||
|
||||
if (mimeType?.match(/pdf/i)) {
|
||||
return (
|
||||
<Suspense fallback={<LoadingIcon />}>
|
||||
<PdfViewer data={body} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TextViewer text={bodyText} language={language} stateKey={`request.body.${response.id}`} />
|
||||
);
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||
import { patchModel } from "@yaakapp-internal/models";
|
||||
import classNames from "classnames";
|
||||
import { memo, useCallback, useMemo } from "react";
|
||||
import { showPrompt } from "../lib/prompt";
|
||||
import { Button } from "./core/Button";
|
||||
import type { DropdownItem } from "./core/Dropdown";
|
||||
import { HttpMethodTag, HttpMethodTagRaw } from "./core/HttpMethodTag";
|
||||
import { Icon } from "@yaakapp-internal/ui";
|
||||
import type { RadioDropdownItem } from "./core/RadioDropdown";
|
||||
import { RadioDropdown } from "./core/RadioDropdown";
|
||||
|
||||
type Props = {
|
||||
request: HttpRequest;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const radioItems: RadioDropdownItem<string>[] = [
|
||||
"GET",
|
||||
"PUT",
|
||||
"POST",
|
||||
"PATCH",
|
||||
"DELETE",
|
||||
"OPTIONS",
|
||||
"QUERY",
|
||||
"HEAD",
|
||||
].map((m) => ({
|
||||
value: m,
|
||||
label: <HttpMethodTagRaw method={m} />,
|
||||
}));
|
||||
|
||||
export const RequestMethodDropdown = memo(function RequestMethodDropdown({
|
||||
request,
|
||||
className,
|
||||
}: Props) {
|
||||
const handleChange = useCallback(
|
||||
async (method: string) => {
|
||||
await patchModel(request, { method });
|
||||
},
|
||||
[request],
|
||||
);
|
||||
|
||||
const itemsAfter = useMemo<DropdownItem[]>(
|
||||
() => [
|
||||
{
|
||||
key: "custom",
|
||||
label: "CUSTOM",
|
||||
leftSlot: <Icon icon="sparkles" />,
|
||||
onSelect: async () => {
|
||||
const newMethod = await showPrompt({
|
||||
id: "custom-method",
|
||||
label: "Http Method",
|
||||
title: "Custom Method",
|
||||
confirmText: "Save",
|
||||
description: "Enter a custom method name",
|
||||
placeholder: "CUSTOM",
|
||||
});
|
||||
if (newMethod == null) return;
|
||||
await handleChange(newMethod);
|
||||
},
|
||||
},
|
||||
],
|
||||
[handleChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<RadioDropdown
|
||||
value={request.method}
|
||||
items={radioItems}
|
||||
itemsAfter={itemsAfter}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<Button size="xs" className={classNames(className, "text-text-subtle hover:text")}>
|
||||
<HttpMethodTag request={request} noAlias />
|
||||
</Button>
|
||||
</RadioDropdown>
|
||||
);
|
||||
});
|
||||
@@ -1,225 +0,0 @@
|
||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||
import classNames from "classnames";
|
||||
import { useMemo } from "react";
|
||||
import type { JSX } from "react/jsx-runtime";
|
||||
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
|
||||
import { CountBadge } from "./core/CountBadge";
|
||||
import { DetailsBanner } from "./core/DetailsBanner";
|
||||
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
|
||||
|
||||
interface Props {
|
||||
response: HttpResponse;
|
||||
}
|
||||
|
||||
interface ParsedCookie {
|
||||
name: string;
|
||||
value: string;
|
||||
domain?: string;
|
||||
path?: string;
|
||||
expires?: string;
|
||||
maxAge?: string;
|
||||
secure?: boolean;
|
||||
httpOnly?: boolean;
|
||||
sameSite?: string;
|
||||
isDeleted?: boolean;
|
||||
}
|
||||
|
||||
function parseCookieHeader(cookieHeader: string): Array<{ name: string; value: string }> {
|
||||
// Parse "Cookie: name=value; name2=value2" format
|
||||
return cookieHeader.split(";").map((pair) => {
|
||||
const [name = "", ...valueParts] = pair.split("=");
|
||||
return {
|
||||
name: name.trim(),
|
||||
value: valueParts.join("=").trim(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function parseSetCookieHeader(setCookieHeader: string): ParsedCookie {
|
||||
// Parse "Set-Cookie: name=value; Domain=...; Path=..." format
|
||||
const parts = setCookieHeader.split(";").map((p) => p.trim());
|
||||
const [nameValue = "", ...attributes] = parts;
|
||||
const [name = "", ...valueParts] = nameValue.split("=");
|
||||
|
||||
const cookie: ParsedCookie = {
|
||||
name: name.trim(),
|
||||
value: valueParts.join("=").trim(),
|
||||
};
|
||||
|
||||
for (const attr of attributes) {
|
||||
const [key = "", val] = attr.split("=").map((s) => s.trim());
|
||||
const lowerKey = key.toLowerCase();
|
||||
|
||||
if (lowerKey === "domain") cookie.domain = val;
|
||||
else if (lowerKey === "path") cookie.path = val;
|
||||
else if (lowerKey === "expires") cookie.expires = val;
|
||||
else if (lowerKey === "max-age") cookie.maxAge = val;
|
||||
else if (lowerKey === "secure") cookie.secure = true;
|
||||
else if (lowerKey === "httponly") cookie.httpOnly = true;
|
||||
else if (lowerKey === "samesite") cookie.sameSite = val;
|
||||
}
|
||||
|
||||
// Detect if cookie is being deleted
|
||||
if (cookie.maxAge !== undefined) {
|
||||
const maxAgeNum = Number.parseInt(cookie.maxAge, 10);
|
||||
if (!Number.isNaN(maxAgeNum) && maxAgeNum <= 0) {
|
||||
cookie.isDeleted = true;
|
||||
}
|
||||
} else if (cookie.expires !== undefined) {
|
||||
// Check if expires date is in the past
|
||||
try {
|
||||
const expiresDate = new Date(cookie.expires);
|
||||
if (expiresDate.getTime() < Date.now()) {
|
||||
cookie.isDeleted = true;
|
||||
}
|
||||
} catch {
|
||||
// Invalid date, ignore
|
||||
}
|
||||
}
|
||||
|
||||
return cookie;
|
||||
}
|
||||
|
||||
export function ResponseCookies({ response }: Props) {
|
||||
const { data: events } = useHttpResponseEvents(response);
|
||||
|
||||
const { sentCookies, receivedCookies } = useMemo(() => {
|
||||
if (!events) return { sentCookies: [], receivedCookies: [] };
|
||||
|
||||
// Use Maps to deduplicate by cookie name (latest value wins)
|
||||
const sentMap = new Map<string, { name: string; value: string }>();
|
||||
const receivedMap = new Map<string, ParsedCookie>();
|
||||
|
||||
for (const event of events) {
|
||||
const e = event.event;
|
||||
|
||||
// Cookie headers sent (header_up with name=cookie)
|
||||
if (e.type === "header_up" && e.name.toLowerCase() === "cookie") {
|
||||
const cookies = parseCookieHeader(e.value);
|
||||
for (const cookie of cookies) {
|
||||
sentMap.set(cookie.name, cookie);
|
||||
}
|
||||
}
|
||||
|
||||
// Set-Cookie headers received (header_down with name=set-cookie)
|
||||
if (e.type === "header_down" && e.name.toLowerCase() === "set-cookie") {
|
||||
const cookie = parseSetCookieHeader(e.value);
|
||||
receivedMap.set(cookie.name, cookie);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sentCookies: Array.from(sentMap.values()),
|
||||
receivedCookies: Array.from(receivedMap.values()),
|
||||
};
|
||||
}, [events]);
|
||||
|
||||
return (
|
||||
<div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5">
|
||||
<DetailsBanner
|
||||
defaultOpen
|
||||
storageKey={`${response.requestId}.sent_cookies`}
|
||||
summary={
|
||||
<h2 className="flex items-center">
|
||||
Sent Cookies <CountBadge showZero count={sentCookies.length} />
|
||||
</h2>
|
||||
}
|
||||
>
|
||||
{sentCookies.length === 0 ? (
|
||||
<NoCookies />
|
||||
) : (
|
||||
<KeyValueRows>
|
||||
{sentCookies.map((cookie, i) => (
|
||||
// oxlint-disable-next-line react/no-array-index-key
|
||||
<KeyValueRow labelColor="primary" key={i} label={cookie.name}>
|
||||
{cookie.value}
|
||||
</KeyValueRow>
|
||||
))}
|
||||
</KeyValueRows>
|
||||
)}
|
||||
</DetailsBanner>
|
||||
|
||||
<DetailsBanner
|
||||
defaultOpen
|
||||
storageKey={`${response.requestId}.received_cookies`}
|
||||
summary={
|
||||
<h2 className="flex items-center">
|
||||
Received Cookies <CountBadge showZero count={receivedCookies.length} />
|
||||
</h2>
|
||||
}
|
||||
>
|
||||
{receivedCookies.length === 0 ? (
|
||||
<NoCookies />
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{receivedCookies.map((cookie, i) => (
|
||||
// oxlint-disable-next-line react/no-array-index-key
|
||||
<div key={i} className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2 my-1">
|
||||
<span
|
||||
className={classNames(
|
||||
"font-mono text-editor select-auto cursor-auto",
|
||||
cookie.isDeleted ? "line-through opacity-60 text-text-subtle" : "text-text",
|
||||
)}
|
||||
>
|
||||
{cookie.name}
|
||||
<span className="text-text-subtlest select-auto cursor-auto mx-0.5">=</span>
|
||||
{cookie.value}
|
||||
</span>
|
||||
{cookie.isDeleted && (
|
||||
<span className="text-xs font-sans text-danger bg-danger/10 px-1.5 py-0.5 rounded-sm">
|
||||
Deleted
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<KeyValueRows>
|
||||
{[
|
||||
cookie.domain && (
|
||||
<KeyValueRow labelColor="info" label="Domain" key="domain">
|
||||
{cookie.domain}
|
||||
</KeyValueRow>
|
||||
),
|
||||
cookie.path && (
|
||||
<KeyValueRow labelColor="info" label="Path" key="path">
|
||||
{cookie.path}
|
||||
</KeyValueRow>
|
||||
),
|
||||
cookie.expires && (
|
||||
<KeyValueRow labelColor="info" label="Expires" key="expires">
|
||||
{cookie.expires}
|
||||
</KeyValueRow>
|
||||
),
|
||||
cookie.maxAge && (
|
||||
<KeyValueRow labelColor="info" label="Max-Age" key="maxAge">
|
||||
{cookie.maxAge}
|
||||
</KeyValueRow>
|
||||
),
|
||||
cookie.secure && (
|
||||
<KeyValueRow labelColor="info" label="Secure" key="secure">
|
||||
true
|
||||
</KeyValueRow>
|
||||
),
|
||||
cookie.httpOnly && (
|
||||
<KeyValueRow labelColor="info" label="HttpOnly" key="httpOnly">
|
||||
true
|
||||
</KeyValueRow>
|
||||
),
|
||||
cookie.sameSite && (
|
||||
<KeyValueRow labelColor="info" label="SameSite" key="sameSite">
|
||||
{cookie.sameSite}
|
||||
</KeyValueRow>
|
||||
),
|
||||
].filter((item): item is JSX.Element => Boolean(item))}
|
||||
</KeyValueRows>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</DetailsBanner>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NoCookies() {
|
||||
return <span className="text-text-subtlest text-sm italic">No Cookies</span>;
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||
import { format, formatDistanceToNowStrict } from "date-fns";
|
||||
import { useMemo } from "react";
|
||||
import { CountBadge } from "./core/CountBadge";
|
||||
import { DetailsBanner } from "./core/DetailsBanner";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
|
||||
interface Props {
|
||||
response: HttpResponse;
|
||||
}
|
||||
|
||||
export function ResponseHeaders({ response }: Props) {
|
||||
const responseHeaders = useMemo(
|
||||
() =>
|
||||
[...response.headers].sort((a, b) =>
|
||||
a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()),
|
||||
),
|
||||
[response.headers],
|
||||
);
|
||||
const requestHeaders = useMemo(
|
||||
() =>
|
||||
[...response.requestHeaders].sort((a, b) =>
|
||||
a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()),
|
||||
),
|
||||
[response.requestHeaders],
|
||||
);
|
||||
return (
|
||||
<div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5">
|
||||
<DetailsBanner storageKey={`${response.requestId}.general`} summary={<h2>Info</h2>}>
|
||||
<KeyValueRows>
|
||||
<KeyValueRow labelColor="secondary" label="Sent">
|
||||
<time
|
||||
dateTime={new Date(`${response.createdAt}Z`).toISOString()}
|
||||
title={formatDistanceToNowStrict(`${response.createdAt}Z`, { addSuffix: true })}
|
||||
>
|
||||
{format(`${response.createdAt}Z`, "MMM d, yyyy, h:mm:ss a O")}
|
||||
</time>
|
||||
</KeyValueRow>
|
||||
<KeyValueRow labelColor="secondary" label="Request URL">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="select-text cursor-text">{response.url}</span>
|
||||
<IconButton
|
||||
iconSize="sm"
|
||||
className="inline-block w-auto h-auto! opacity-50 hover:opacity-100"
|
||||
icon="external_link"
|
||||
onClick={() => platform.openUrl(response.url)}
|
||||
title="Open in browser"
|
||||
/>
|
||||
</div>
|
||||
</KeyValueRow>
|
||||
<KeyValueRow labelColor="secondary" label="Remote Address">
|
||||
{response.remoteAddr ?? <span className="text-text-subtlest">--</span>}
|
||||
</KeyValueRow>
|
||||
<KeyValueRow labelColor="secondary" label="Version">
|
||||
{response.version ?? <span className="text-text-subtlest">--</span>}
|
||||
</KeyValueRow>
|
||||
</KeyValueRows>
|
||||
</DetailsBanner>
|
||||
<DetailsBanner
|
||||
storageKey={`${response.requestId}.request_headers`}
|
||||
summary={
|
||||
<h2 className="flex items-center">
|
||||
Request Headers <CountBadge showZero count={requestHeaders.length} />
|
||||
</h2>
|
||||
}
|
||||
>
|
||||
{requestHeaders.length === 0 ? (
|
||||
<NoHeaders />
|
||||
) : (
|
||||
<KeyValueRows>
|
||||
{requestHeaders.map((h, i) => (
|
||||
// oxlint-disable-next-line react/no-array-index-key
|
||||
<KeyValueRow labelColor="primary" key={i} label={h.name}>
|
||||
{h.value}
|
||||
</KeyValueRow>
|
||||
))}
|
||||
</KeyValueRows>
|
||||
)}
|
||||
</DetailsBanner>
|
||||
<DetailsBanner
|
||||
defaultOpen
|
||||
storageKey={`${response.requestId}.response_headers`}
|
||||
summary={
|
||||
<h2 className="flex items-center">
|
||||
Response Headers <CountBadge showZero count={responseHeaders.length} />
|
||||
</h2>
|
||||
}
|
||||
>
|
||||
{responseHeaders.length === 0 ? (
|
||||
<NoHeaders />
|
||||
) : (
|
||||
<KeyValueRows>
|
||||
{responseHeaders.map((h, i) => (
|
||||
// oxlint-disable-next-line react/no-array-index-key
|
||||
<KeyValueRow labelColor="info" key={i} label={h.name}>
|
||||
{h.value}
|
||||
</KeyValueRow>
|
||||
))}
|
||||
</KeyValueRows>
|
||||
)}
|
||||
</DetailsBanner>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NoHeaders() {
|
||||
return <span className="text-text-subtlest text-sm italic">No Headers</span>;
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { HStack } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import mime from "mime";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ButtonProps } from "./core/Button";
|
||||
import { Button } from "./core/Button";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { IconTooltip } from "./core/IconTooltip";
|
||||
import { Label } from "./core/Label";
|
||||
|
||||
type Props = Omit<ButtonProps, "type"> & {
|
||||
onChange: (value: { filePath: string | null; contentType: string | null }) => void;
|
||||
filePath: string | null;
|
||||
nameOverride?: string | null;
|
||||
directory?: boolean;
|
||||
inline?: boolean;
|
||||
noun?: string;
|
||||
help?: ReactNode;
|
||||
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,160 +0,0 @@
|
||||
import { useSearch } from "@tanstack/react-router";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { useLicense } from "@yaakapp-internal/license";
|
||||
import { pluginsAtom, settingsAtom } from "@yaakapp-internal/models";
|
||||
import { HeaderSize, HStack, Icon } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useKeyPressEvent } from "react-use";
|
||||
import { appInfo } from "../../lib/appInfo";
|
||||
import { capitalize } from "../../lib/capitalize";
|
||||
import { CountBadge } from "../core/CountBadge";
|
||||
import { TabContent, type TabItem, Tabs } from "../core/Tabs/Tabs";
|
||||
import { SettingsCertificates } from "./SettingsCertificates";
|
||||
import { SettingsGeneral } from "./SettingsGeneral";
|
||||
import { SettingsHotkeys } from "./SettingsHotkeys";
|
||||
import { SettingsInterface } from "./SettingsInterface";
|
||||
import { SettingsLicense } from "./SettingsLicense";
|
||||
import { SettingsPlugins } from "./SettingsPlugins";
|
||||
import { SettingsProxy } from "./SettingsProxy";
|
||||
import { SettingsTheme } from "./SettingsTheme";
|
||||
|
||||
interface Props {
|
||||
hide?: () => void;
|
||||
}
|
||||
|
||||
const TAB_GENERAL = "general";
|
||||
const TAB_INTERFACE = "interface";
|
||||
const TAB_THEME = "theme";
|
||||
const TAB_SHORTCUTS = "shortcuts";
|
||||
const TAB_PROXY = "proxy";
|
||||
const TAB_CERTIFICATES = "certificates";
|
||||
const TAB_PLUGINS = "plugins";
|
||||
const TAB_LICENSE = "license";
|
||||
const tabs = [
|
||||
TAB_GENERAL,
|
||||
TAB_THEME,
|
||||
TAB_INTERFACE,
|
||||
TAB_SHORTCUTS,
|
||||
TAB_PLUGINS,
|
||||
TAB_CERTIFICATES,
|
||||
TAB_PROXY,
|
||||
TAB_LICENSE,
|
||||
] as const;
|
||||
export type SettingsTab = (typeof tabs)[number];
|
||||
|
||||
export default function Settings({ hide }: Props) {
|
||||
const { tab: tabFromQuery } = useSearch({ from: "/workspaces/$workspaceId/settings" });
|
||||
// Parse tab and subtab (e.g., "plugins:installed")
|
||||
const [mainTab, subtab] = tabFromQuery?.split(":") ?? [];
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const plugins = useAtomValue(pluginsAtom);
|
||||
const licenseCheck = useLicense();
|
||||
|
||||
// Close settings window on escape
|
||||
// TODO: Could this be put in a better place? Eg. in Rust key listener when creating the window
|
||||
useKeyPressEvent("Escape", async () => {
|
||||
if (hide != null) {
|
||||
// It's being shown in a dialog, so close the dialog
|
||||
hide();
|
||||
} else {
|
||||
// It's being shown in a window, so close the window
|
||||
await platform.window.close();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={classNames("grid grid-rows-[auto_minmax(0,1fr)] h-full")}>
|
||||
{hide ? (
|
||||
<span />
|
||||
) : (
|
||||
<HeaderSize
|
||||
data-tauri-drag-region
|
||||
ignoreControlsSpacing
|
||||
onlyXWindowControl
|
||||
size="md"
|
||||
className="x-theme-appHeader bg-surface text-text-subtle flex items-center justify-center border-b border-border-subtle text-sm font-semibold"
|
||||
osType={platform.osType()}
|
||||
hideWindowControls={settings.hideWindowControls}
|
||||
useNativeTitlebar={settings.useNativeTitlebar}
|
||||
interfaceScale={settings.interfaceScale}
|
||||
>
|
||||
<HStack
|
||||
space={2}
|
||||
justifyContent="center"
|
||||
className="w-full h-full grid grid-cols-[1fr_auto] pointer-events-none"
|
||||
>
|
||||
<div className={classNames(platform.osType() === "macos" ? "text-center" : "pl-2")}>Settings</div>
|
||||
</HStack>
|
||||
</HeaderSize>
|
||||
)}
|
||||
<Tabs
|
||||
layout="horizontal"
|
||||
defaultValue={mainTab || tabFromQuery}
|
||||
addBorders
|
||||
tabListClassName="min-w-40 bg-surface x-theme-sidebar border-r border-border pl-3"
|
||||
label="Settings"
|
||||
tabs={tabs.map(
|
||||
(value): TabItem => ({
|
||||
value,
|
||||
label: capitalize(value),
|
||||
hidden: !appInfo.featureLicense && value === TAB_LICENSE,
|
||||
leftSlot:
|
||||
value === TAB_GENERAL ? (
|
||||
<Icon icon="settings" className="text-secondary" />
|
||||
) : value === TAB_THEME ? (
|
||||
<Icon icon="palette" className="text-secondary" />
|
||||
) : value === TAB_INTERFACE ? (
|
||||
<Icon icon="columns_2" className="text-secondary" />
|
||||
) : value === TAB_SHORTCUTS ? (
|
||||
<Icon icon="keyboard" className="text-secondary" />
|
||||
) : value === TAB_CERTIFICATES ? (
|
||||
<Icon icon="shield_check" className="text-secondary" />
|
||||
) : value === TAB_PROXY ? (
|
||||
<Icon icon="wifi" className="text-secondary" />
|
||||
) : value === TAB_PLUGINS ? (
|
||||
<Icon icon="puzzle" className="text-secondary" />
|
||||
) : value === TAB_LICENSE ? (
|
||||
<Icon icon="key_round" className="text-secondary" />
|
||||
) : null,
|
||||
rightSlot:
|
||||
value === TAB_CERTIFICATES ? (
|
||||
<CountBadge count={settings.clientCertificates.length} />
|
||||
) : value === TAB_PLUGINS ? (
|
||||
<CountBadge count={plugins.filter((p) => p.source !== "bundled").length} />
|
||||
) : value === TAB_PROXY && settings.proxy?.type === "enabled" ? (
|
||||
<CountBadge count />
|
||||
) : value === TAB_LICENSE && licenseCheck.check.data?.status === "personal_use" ? (
|
||||
<CountBadge count color="notice" />
|
||||
) : null,
|
||||
}),
|
||||
)}
|
||||
>
|
||||
<TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-6 py-4!">
|
||||
<SettingsGeneral />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_INTERFACE} className="overflow-y-auto h-full px-6 py-4!">
|
||||
<SettingsInterface />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_THEME} className="overflow-y-auto h-full px-6 py-4!">
|
||||
<SettingsTheme />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_SHORTCUTS} className="overflow-y-auto h-full px-6 py-4!">
|
||||
<SettingsHotkeys />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_PLUGINS} className="h-full grid grid-rows-1">
|
||||
<SettingsPlugins defaultSubtab={mainTab === TAB_PLUGINS ? subtab : undefined} />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_PROXY} className="overflow-y-auto h-full px-6 py-4!">
|
||||
<SettingsProxy />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_CERTIFICATES} className="overflow-y-auto h-full px-6 py-4!">
|
||||
<SettingsCertificates />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_LICENSE} className="overflow-y-auto h-full px-6 py-4!">
|
||||
<SettingsLicense />
|
||||
</TabContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
import type { ClientCertificate } from "@yaakapp-internal/models";
|
||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
||||
import { Heading, HStack, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useRef } from "react";
|
||||
import { showConfirmDelete } from "../../lib/confirm";
|
||||
import { CommercialUseBanner } from "../CommercialUseBanner";
|
||||
import { Button } from "../core/Button";
|
||||
import { Checkbox } from "../core/Checkbox";
|
||||
import { DetailsBanner } from "../core/DetailsBanner";
|
||||
import { IconButton } from "../core/IconButton";
|
||||
import { PlainInput } from "../core/PlainInput";
|
||||
import { Separator } from "../core/Separator";
|
||||
import { SelectFile } from "../SelectFile";
|
||||
|
||||
function createEmptyCertificate(): ClientCertificate {
|
||||
return {
|
||||
host: "",
|
||||
port: null,
|
||||
crtFile: null,
|
||||
keyFile: null,
|
||||
pfxFile: null,
|
||||
passphrase: null,
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
interface CertificateEditorProps {
|
||||
certificate: ClientCertificate;
|
||||
index: number;
|
||||
onUpdate: (index: number, cert: ClientCertificate) => void;
|
||||
onRemove: (index: number) => void;
|
||||
}
|
||||
|
||||
function CertificateEditor({ certificate, index, onUpdate, onRemove }: CertificateEditorProps) {
|
||||
const updateField = <K extends keyof ClientCertificate>(
|
||||
field: K,
|
||||
value: ClientCertificate[K],
|
||||
) => {
|
||||
onUpdate(index, { ...certificate, [field]: value });
|
||||
};
|
||||
|
||||
const hasPfx = Boolean(certificate.pfxFile && certificate.pfxFile.length > 0);
|
||||
const hasCrtKey = Boolean(
|
||||
(certificate.crtFile && certificate.crtFile.length > 0) ||
|
||||
(certificate.keyFile && certificate.keyFile.length > 0),
|
||||
);
|
||||
|
||||
// Determine certificate type for display
|
||||
const certType = hasPfx ? "PFX" : hasCrtKey ? "CERT" : null;
|
||||
const defaultOpen = useRef<boolean>(!certificate.host);
|
||||
|
||||
return (
|
||||
<DetailsBanner
|
||||
defaultOpen={defaultOpen.current}
|
||||
summary={
|
||||
<HStack alignItems="center" justifyContent="between" space={2} className="w-full">
|
||||
<HStack space={1.5}>
|
||||
<Checkbox
|
||||
className="ml-1"
|
||||
checked={certificate.enabled ?? true}
|
||||
title={certificate.enabled ? "Disable certificate" : "Enable certificate"}
|
||||
hideLabel
|
||||
onChange={(enabled) => updateField("enabled", enabled)}
|
||||
/>
|
||||
|
||||
{certificate.host ? (
|
||||
<InlineCode>
|
||||
{certificate.host || <> </>}
|
||||
{certificate.port != null && `:${certificate.port}`}
|
||||
</InlineCode>
|
||||
) : (
|
||||
<span className="italic text-sm text-text-subtlest">Configure Certificate</span>
|
||||
)}
|
||||
{certType && <InlineCode>{certType}</InlineCode>}
|
||||
</HStack>
|
||||
<IconButton
|
||||
icon="trash"
|
||||
size="sm"
|
||||
title="Remove certificate"
|
||||
className="text-text-subtlest -mr-2"
|
||||
onClick={() => onRemove(index)}
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
>
|
||||
<VStack space={3} className="mt-2">
|
||||
<HStack space={2} alignItems="end">
|
||||
<PlainInput
|
||||
leftSlot={
|
||||
<div className="bg-surface-highlight flex items-center text-editor font-mono px-2 text-text-subtle mr-1">
|
||||
https://
|
||||
</div>
|
||||
}
|
||||
validate={(value) => {
|
||||
if (!value) return false;
|
||||
if (!/^[a-zA-Z0-9_.-]+$/.test(value)) return false;
|
||||
return true;
|
||||
}}
|
||||
label="Host"
|
||||
placeholder="example.com"
|
||||
size="sm"
|
||||
required
|
||||
defaultValue={certificate.host}
|
||||
onChange={(host) => updateField("host", host)}
|
||||
/>
|
||||
<PlainInput
|
||||
label="Port"
|
||||
hideLabel
|
||||
validate={(value) => {
|
||||
if (!value) return true;
|
||||
if (Number.isNaN(parseInt(value, 10))) return false;
|
||||
return true;
|
||||
}}
|
||||
placeholder="443"
|
||||
leftSlot={
|
||||
<div className="bg-surface-highlight flex items-center text-editor font-mono px-2 text-text-subtle mr-1">
|
||||
:
|
||||
</div>
|
||||
}
|
||||
size="sm"
|
||||
className="w-24"
|
||||
defaultValue={certificate.port?.toString() ?? ""}
|
||||
onChange={(port) => updateField("port", port ? parseInt(port, 10) : null)}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
<Separator className="my-3" />
|
||||
|
||||
<VStack space={2}>
|
||||
<SelectFile
|
||||
label="CRT File"
|
||||
noun="Cert"
|
||||
filePath={certificate.crtFile ?? null}
|
||||
size="sm"
|
||||
disabled={hasPfx}
|
||||
onChange={({ filePath }) => updateField("crtFile", filePath)}
|
||||
/>
|
||||
<SelectFile
|
||||
label="KEY File"
|
||||
noun="Key"
|
||||
filePath={certificate.keyFile ?? null}
|
||||
size="sm"
|
||||
disabled={hasPfx}
|
||||
onChange={({ filePath }) => updateField("keyFile", filePath)}
|
||||
/>
|
||||
</VStack>
|
||||
|
||||
<Separator className="my-3" />
|
||||
|
||||
<SelectFile
|
||||
label="PFX File"
|
||||
noun="Key"
|
||||
filePath={certificate.pfxFile ?? null}
|
||||
size="sm"
|
||||
disabled={hasCrtKey}
|
||||
onChange={({ filePath }) => updateField("pfxFile", filePath)}
|
||||
/>
|
||||
|
||||
<PlainInput
|
||||
label="Passphrase"
|
||||
size="sm"
|
||||
type="password"
|
||||
defaultValue={certificate.passphrase ?? ""}
|
||||
onChange={(passphrase) => updateField("passphrase", passphrase || null)}
|
||||
/>
|
||||
</VStack>
|
||||
</DetailsBanner>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsCertificates() {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const certificates = settings.clientCertificates ?? [];
|
||||
|
||||
const updateCertificates = async (newCertificates: ClientCertificate[]) => {
|
||||
await patchModel(settings, { clientCertificates: newCertificates });
|
||||
};
|
||||
|
||||
const handleAdd = async () => {
|
||||
const newCert = createEmptyCertificate();
|
||||
await updateCertificates([...certificates, newCert]);
|
||||
};
|
||||
|
||||
const handleUpdate = async (index: number, cert: ClientCertificate) => {
|
||||
const newCertificates = [...certificates];
|
||||
newCertificates[index] = cert;
|
||||
await updateCertificates(newCertificates);
|
||||
};
|
||||
|
||||
const handleRemove = async (index: number) => {
|
||||
const cert = certificates[index];
|
||||
if (cert == null) return;
|
||||
|
||||
const host = cert.host || "this certificate";
|
||||
const port = cert.port != null ? `:${cert.port}` : "";
|
||||
|
||||
const confirmed = await showConfirmDelete({
|
||||
id: "confirm-remove-certificate",
|
||||
title: "Delete Certificate",
|
||||
description: (
|
||||
<>
|
||||
Permanently delete certificate for{" "}
|
||||
<InlineCode>
|
||||
{host}
|
||||
{port}
|
||||
</InlineCode>
|
||||
?
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
const newCertificates = certificates.filter((_, i) => i !== index);
|
||||
|
||||
await updateCertificates(newCertificates);
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack space={3}>
|
||||
<div className="mb-3">
|
||||
<HStack justifyContent="between" alignItems="start">
|
||||
<div>
|
||||
<Heading>Client Certificates</Heading>
|
||||
<p className="text-text-subtle">
|
||||
Add and manage TLS certificates on a per domain basis
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="border" size="sm" color="secondary" onClick={handleAdd}>
|
||||
Add Certificate
|
||||
</Button>
|
||||
</HStack>
|
||||
</div>
|
||||
|
||||
<CommercialUseBanner source="client-certificates" title="Using certificates for work?" />
|
||||
|
||||
{certificates.length > 0 && (
|
||||
<VStack space={3}>
|
||||
{certificates.map((cert, index) => (
|
||||
<CertificateEditor
|
||||
// oxlint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
certificate={cert}
|
||||
index={index}
|
||||
onUpdate={handleUpdate}
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
||||
import { Heading, VStack } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCheckForUpdates } from "../../hooks/useCheckForUpdates";
|
||||
import { appInfo } from "../../lib/appInfo";
|
||||
import { revealInFinderText } from "../../lib/reveal";
|
||||
import { CargoFeature } from "../CargoFeature";
|
||||
import { CommercialUseBanner } from "../CommercialUseBanner";
|
||||
import { DismissibleBanner } from "../core/DismissibleBanner";
|
||||
import { IconButton } from "../core/IconButton";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import {
|
||||
ModelSettingRowBoolean,
|
||||
ModelSettingSelectControl,
|
||||
SettingValue,
|
||||
SettingRow,
|
||||
SettingRowBoolean,
|
||||
SettingRowSelect,
|
||||
SettingsList,
|
||||
SettingsSection,
|
||||
} from "../core/SettingRow";
|
||||
|
||||
const WORKSPACE_SETTINGS_MOVED_AT = "2026-06-30";
|
||||
|
||||
export function SettingsGeneral() {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const checkForUpdates = useCheckForUpdates();
|
||||
|
||||
if (settings == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const showWorkspaceSettingsMovedBanner =
|
||||
settings.createdAt.slice(0, 10) < WORKSPACE_SETTINGS_MOVED_AT;
|
||||
|
||||
return (
|
||||
<VStack space={1.5} className="mb-4">
|
||||
<div>
|
||||
<Heading>General</Heading>
|
||||
<p className="text-text-subtle">
|
||||
Configure general settings for update behavior and more.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-3 mb-5">
|
||||
<CommercialUseBanner source="settings-general" title="Using Yaak for work?" />
|
||||
</div>
|
||||
<SettingsList className="space-y-8">
|
||||
<CargoFeature feature="updater">
|
||||
<SettingsSection title="Updates">
|
||||
<SettingRow
|
||||
title="Update Channel"
|
||||
description="Choose whether Yaak should use stable releases or beta releases."
|
||||
>
|
||||
<div className="grid grid-cols-[12rem_auto] gap-1">
|
||||
<ModelSettingSelectControl
|
||||
model={settings}
|
||||
modelKey="updateChannel"
|
||||
label="Update Channel"
|
||||
selectClassName="w-full!"
|
||||
options={[
|
||||
{ label: "Stable", value: "stable" },
|
||||
{ label: "Beta", value: "beta" },
|
||||
]}
|
||||
/>
|
||||
<IconButton
|
||||
variant="border"
|
||||
size="sm"
|
||||
title="Check for updates"
|
||||
icon="refresh"
|
||||
spin={checkForUpdates.isPending}
|
||||
onClick={() => checkForUpdates.mutateAsync()}
|
||||
/>
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRowSelect
|
||||
title="Update Behavior"
|
||||
description="Choose whether updates are installed automatically or manually."
|
||||
name="autoupdate"
|
||||
value={settings.autoupdate ? "auto" : "manual"}
|
||||
onChange={(v) =>
|
||||
patchModel(settings, { autoupdate: v === "auto" })
|
||||
}
|
||||
options={[
|
||||
{ label: "Automatic", value: "auto" },
|
||||
{ label: "Manual", value: "manual" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<ModelSettingRowBoolean
|
||||
model={settings}
|
||||
modelKey="autoDownloadUpdates"
|
||||
title="Automatically download updates"
|
||||
description="Download Yaak updates in the background so they are ready to install."
|
||||
disabled={!settings.autoupdate}
|
||||
/>
|
||||
|
||||
<ModelSettingRowBoolean
|
||||
model={settings}
|
||||
modelKey="checkNotifications"
|
||||
title="Check for notifications"
|
||||
description="Periodically ping Yaak servers to check for relevant notifications."
|
||||
/>
|
||||
|
||||
<SettingRowBoolean
|
||||
title="Send anonymous usage statistics"
|
||||
description="Yaak is local-first and does not collect analytics or usage data."
|
||||
disabled
|
||||
checked={false}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</CargoFeature>
|
||||
|
||||
<CargoFeature feature="license">
|
||||
<SettingsSection title="Feedback">
|
||||
<SettingRowBoolean
|
||||
title="Prompt for feedback"
|
||||
description="Show rare one-time prompts asking how new features are working."
|
||||
checked={settings.promptFeedback}
|
||||
onChange={(promptFeedback) => patchModel(settings, { promptFeedback })}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</CargoFeature>
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
<SettingsSection title="App Info">
|
||||
<SettingRow title="Version" description="Current Yaak version.">
|
||||
<SettingValue value={appInfo.version} />
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
title="Data Directory"
|
||||
description="Where Yaak stores application data."
|
||||
controlClassName="min-w-0 max-w-[min(42rem,55vw)] gap-2"
|
||||
>
|
||||
<SettingValue
|
||||
value={appInfo.appDataDir}
|
||||
actions={[
|
||||
{
|
||||
title: revealInFinderText,
|
||||
icon: "folder_open",
|
||||
onClick: () => platform.revealItemInDir(appInfo.appDataDir),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
title="Logs Directory"
|
||||
description="Where Yaak writes application logs."
|
||||
controlClassName="min-w-0 max-w-[min(42rem,55vw)] gap-2"
|
||||
>
|
||||
<SettingValue
|
||||
value={appInfo.appLogDir}
|
||||
actions={[
|
||||
{
|
||||
title: revealInFinderText,
|
||||
icon: "folder_open",
|
||||
onClick: () => platform.revealItemInDir(appInfo.appLogDir),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingsSection>
|
||||
</SettingsList>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -1,365 +0,0 @@
|
||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
||||
import {
|
||||
Heading,
|
||||
HStack,
|
||||
Icon,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
VStack,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { fuzzyMatch } from "fuzzbunny";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
defaultHotkeys,
|
||||
formatHotkeyString,
|
||||
getHotkeyScope,
|
||||
type HotkeyAction,
|
||||
hotkeyActions,
|
||||
hotkeysAtom,
|
||||
useHotkeyLabel,
|
||||
} from "../../hooks/useHotKey";
|
||||
import { capitalize } from "../../lib/capitalize";
|
||||
import { showDialog } from "../../lib/dialog";
|
||||
import { Button } from "../core/Button";
|
||||
import { Dropdown, type DropdownItem } from "../core/Dropdown";
|
||||
import { HotkeyRaw } from "../core/Hotkey";
|
||||
import { IconButton } from "../core/IconButton";
|
||||
import { PlainInput } from "../core/PlainInput";
|
||||
|
||||
const HOLD_KEYS = ["Shift", "Control", "Alt", "Meta"];
|
||||
const LAYOUT_INSENSITIVE_KEYS = [
|
||||
"Equal",
|
||||
"Minus",
|
||||
"BracketLeft",
|
||||
"BracketRight",
|
||||
"Backquote",
|
||||
"Space",
|
||||
];
|
||||
|
||||
/** Convert a KeyboardEvent to a hotkey string like "Meta+Shift+k" or "Control+Shift+k" */
|
||||
function eventToHotkeyString(e: KeyboardEvent): string | null {
|
||||
// Don't capture modifier-only key presses
|
||||
if (HOLD_KEYS.includes(e.key)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
// Add modifiers in consistent order (Meta, Control, Alt, Shift)
|
||||
if (e.metaKey) {
|
||||
parts.push("Meta");
|
||||
}
|
||||
if (e.ctrlKey) {
|
||||
parts.push("Control");
|
||||
}
|
||||
if (e.altKey) {
|
||||
parts.push("Alt");
|
||||
}
|
||||
if (e.shiftKey) {
|
||||
parts.push("Shift");
|
||||
}
|
||||
|
||||
// Get the main key - use the same logic as useHotKey.ts
|
||||
const key = LAYOUT_INSENSITIVE_KEYS.includes(e.code) ? e.code : e.key;
|
||||
parts.push(key);
|
||||
|
||||
return parts.join("+");
|
||||
}
|
||||
|
||||
export function SettingsHotkeys() {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const hotkeys = useAtomValue(hotkeysAtom);
|
||||
const [filter, setFilter] = useState("");
|
||||
|
||||
const filteredActions = useMemo(() => {
|
||||
if (!filter.trim()) {
|
||||
return hotkeyActions;
|
||||
}
|
||||
return hotkeyActions.filter((action) => {
|
||||
const scope = getHotkeyScope(action).replace(/_/g, " ");
|
||||
const label = action.replace(/[_.]/g, " ");
|
||||
const searchText = `${scope} ${label}`;
|
||||
return fuzzyMatch(searchText, filter) != null;
|
||||
});
|
||||
}, [filter]);
|
||||
|
||||
if (settings == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack space={3} className="mb-4">
|
||||
<div className="mb-3">
|
||||
<Heading>Keyboard Shortcuts</Heading>
|
||||
<p className="text-text-subtle">
|
||||
Click the menu button to add, remove, or reset keyboard shortcuts.
|
||||
</p>
|
||||
</div>
|
||||
<PlainInput
|
||||
label="Filter"
|
||||
placeholder="Filter shortcuts..."
|
||||
defaultValue={filter}
|
||||
onChange={setFilter}
|
||||
hideLabel
|
||||
containerClassName="max-w-xs"
|
||||
/>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Scope</TableHeaderCell>
|
||||
<TableHeaderCell>Action</TableHeaderCell>
|
||||
<TableHeaderCell>Shortcut</TableHeaderCell>
|
||||
<TableHeaderCell></TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
{/* key={filter} forces re-render on filter change to fix Safari table rendering bug */}
|
||||
<TableBody key={filter}>
|
||||
{filteredActions.map((action) => (
|
||||
<HotkeyRow
|
||||
key={action}
|
||||
action={action}
|
||||
currentKeys={hotkeys[action]}
|
||||
defaultKeys={defaultHotkeys[action]}
|
||||
onSave={async (keys) => {
|
||||
const newHotkeys = { ...settings.hotkeys };
|
||||
if (arraysEqual(keys, defaultHotkeys[action])) {
|
||||
// Remove from settings if it matches default (use default)
|
||||
delete newHotkeys[action];
|
||||
} else {
|
||||
// Store the keys (including empty array to disable)
|
||||
newHotkeys[action] = keys;
|
||||
}
|
||||
await patchModel(settings, { hotkeys: newHotkeys });
|
||||
}}
|
||||
onReset={async () => {
|
||||
const newHotkeys = { ...settings.hotkeys };
|
||||
delete newHotkeys[action];
|
||||
await patchModel(settings, { hotkeys: newHotkeys });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
interface HotkeyRowProps {
|
||||
action: HotkeyAction;
|
||||
currentKeys: string[];
|
||||
defaultKeys: string[];
|
||||
onSave: (keys: string[]) => Promise<void>;
|
||||
onReset: () => Promise<void>;
|
||||
}
|
||||
|
||||
function HotkeyRow({ action, currentKeys, defaultKeys, onSave, onReset }: HotkeyRowProps) {
|
||||
const label = useHotkeyLabel(action);
|
||||
const scope = capitalize(getHotkeyScope(action).replace(/_/g, " "));
|
||||
const isCustomized = !arraysEqual(currentKeys, defaultKeys);
|
||||
const isDisabled = currentKeys.length === 0;
|
||||
|
||||
const handleStartRecording = useCallback(() => {
|
||||
showDialog({
|
||||
id: `record-hotkey-${action}`,
|
||||
title: label,
|
||||
size: "sm",
|
||||
render: ({ hide }) => (
|
||||
<RecordHotkeyDialog
|
||||
label={label}
|
||||
onSave={async (key) => {
|
||||
await onSave([...currentKeys, key]);
|
||||
hide();
|
||||
}}
|
||||
onCancel={hide}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}, [action, label, currentKeys, onSave]);
|
||||
|
||||
const handleRemove = useCallback(
|
||||
async (keyToRemove: string) => {
|
||||
const newKeys = currentKeys.filter((k) => k !== keyToRemove);
|
||||
await onSave(newKeys);
|
||||
},
|
||||
[currentKeys, onSave],
|
||||
);
|
||||
|
||||
const handleClearAll = useCallback(async () => {
|
||||
await onSave([]);
|
||||
}, [onSave]);
|
||||
|
||||
// Build dropdown items dynamically
|
||||
const dropdownItems: DropdownItem[] = [
|
||||
{
|
||||
label: "Add Keyboard Shortcut",
|
||||
leftSlot: <Icon icon="plus" />,
|
||||
onSelect: handleStartRecording,
|
||||
},
|
||||
];
|
||||
|
||||
// Add remove options for each existing shortcut
|
||||
if (!isDisabled) {
|
||||
currentKeys.forEach((key) => {
|
||||
dropdownItems.push({
|
||||
label: (
|
||||
<HStack space={1.5}>
|
||||
<span>Remove</span>
|
||||
<HotkeyRaw labelParts={formatHotkeyString(key)} variant="with-bg" className="text-xs" />
|
||||
</HStack>
|
||||
),
|
||||
leftSlot: <Icon icon="trash" />,
|
||||
onSelect: () => handleRemove(key),
|
||||
});
|
||||
});
|
||||
|
||||
if (currentKeys.length > 1) {
|
||||
dropdownItems.push(
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
label: "Remove All Shortcuts",
|
||||
leftSlot: <Icon icon="trash" />,
|
||||
onSelect: handleClearAll,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isCustomized) {
|
||||
dropdownItems.push({
|
||||
type: "separator",
|
||||
});
|
||||
dropdownItems.push({
|
||||
label: "Reset to Default",
|
||||
leftSlot: <Icon icon="refresh" />,
|
||||
onSelect: onReset,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<span className="text-sm text-text-subtlest">{scope}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm">{label}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<HStack space={1.5} className="py-1">
|
||||
{isDisabled ? (
|
||||
<span className="text-text-subtlest">Disabled</span>
|
||||
) : (
|
||||
currentKeys.map((k) => (
|
||||
<HotkeyRaw key={k} labelParts={formatHotkeyString(k)} variant="with-bg" />
|
||||
))
|
||||
)}
|
||||
</HStack>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Dropdown items={dropdownItems}>
|
||||
<IconButton
|
||||
icon="ellipsis_vertical"
|
||||
size="sm"
|
||||
title="Hotkey actions"
|
||||
className="ml-auto text-text-subtlest"
|
||||
/>
|
||||
</Dropdown>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function arraysEqual(a: string[], b: string[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
const sortedA = [...a].sort();
|
||||
const sortedB = [...b].sort();
|
||||
return sortedA.every((v, i) => v === sortedB[i]);
|
||||
}
|
||||
|
||||
interface RecordHotkeyDialogProps {
|
||||
label: string;
|
||||
onSave: (key: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function RecordHotkeyDialog({ label, onSave, onCancel }: RecordHotkeyDialogProps) {
|
||||
const [recordedKey, setRecordedKey] = useState<string | null>(null);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFocused) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (e.key === "Escape") {
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
const hotkeyString = eventToHotkeyString(e);
|
||||
if (hotkeyString) {
|
||||
setRecordedKey(hotkeyString);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown, { capture: true });
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown, { capture: true });
|
||||
};
|
||||
}, [isFocused, onCancel]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (recordedKey) {
|
||||
onSave(recordedKey);
|
||||
}
|
||||
}, [recordedKey, onSave]);
|
||||
|
||||
return (
|
||||
<VStack space={4}>
|
||||
<div>
|
||||
<p className="text-text-subtle mb-2">
|
||||
Record a key combination for <span className="font-semibold">{label}</span>
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
data-disable-hotkey
|
||||
aria-label="Keyboard shortcut input"
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.focus();
|
||||
}}
|
||||
className={classNames(
|
||||
"flex items-center justify-center",
|
||||
"px-4 py-2 rounded-lg bg-surface-highlight border outline-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,274 +0,0 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { useFonts } from "@yaakapp-internal/fonts";
|
||||
import { useLicense } from "@yaakapp-internal/license";
|
||||
import type { EditorKeymap, Settings } from "@yaakapp-internal/models";
|
||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
||||
import { clamp, Heading, VStack } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useState } from "react";
|
||||
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
|
||||
import { showConfirm } from "../../lib/confirm";
|
||||
import { pricingUrl } from "../../lib/pricingUrl";
|
||||
import { rpc } from "../../lib/rpc";
|
||||
import { CargoFeature } from "../CargoFeature";
|
||||
import { Button } from "../core/Button";
|
||||
import { Checkbox } from "../core/Checkbox";
|
||||
import { Link } from "../core/Link";
|
||||
import {
|
||||
ModelSettingRowBoolean,
|
||||
ModelSettingRowSelect,
|
||||
SettingRow,
|
||||
SettingRowBoolean,
|
||||
SettingRowSelect,
|
||||
SettingSelectControl,
|
||||
SettingsList,
|
||||
SettingsSection,
|
||||
} from "../core/SettingRow";
|
||||
|
||||
const NULL_FONT_VALUE = "__NULL_FONT__";
|
||||
|
||||
const fontSizeOptions = [
|
||||
8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30,
|
||||
].map((n) => ({ label: `${n}`, value: `${n}` }));
|
||||
|
||||
const keymaps: { value: EditorKeymap; label: string }[] = [
|
||||
{ value: "default", label: "Default" },
|
||||
{ value: "vim", label: "Vim" },
|
||||
{ value: "vscode", label: "VSCode" },
|
||||
{ value: "emacs", label: "Emacs" },
|
||||
];
|
||||
|
||||
export function SettingsInterface() {
|
||||
const workspace = useAtomValue(activeWorkspaceAtom);
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const fonts = useFonts();
|
||||
|
||||
if (settings == null || workspace == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack space={1.5} className="mb-4">
|
||||
<div className="mb-3">
|
||||
<Heading>Interface</Heading>
|
||||
<p className="text-text-subtle">Tweak settings related to the user interface.</p>
|
||||
</div>
|
||||
<SettingsList className="space-y-8">
|
||||
<SettingsSection title="Workspaces">
|
||||
<SettingRowSelect
|
||||
title="Open workspace behavior"
|
||||
description="Choose what happens when opening another workspace."
|
||||
name="switchWorkspaceBehavior"
|
||||
value={
|
||||
settings.openWorkspaceNewWindow === true
|
||||
? "new"
|
||||
: settings.openWorkspaceNewWindow === false
|
||||
? "current"
|
||||
: "ask"
|
||||
}
|
||||
onChange={async (v) => {
|
||||
if (v === "current") await patchModel(settings, { openWorkspaceNewWindow: false });
|
||||
else if (v === "new") await patchModel(settings, { openWorkspaceNewWindow: true });
|
||||
else await patchModel(settings, { openWorkspaceNewWindow: null });
|
||||
}}
|
||||
options={[
|
||||
{ label: "Always ask", value: "ask" },
|
||||
{ label: "Open in current window", value: "current" },
|
||||
{ label: "Open in new window", value: "new" },
|
||||
]}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Fonts">
|
||||
<SettingRow
|
||||
title="Interface font"
|
||||
description="Font used for Yaak interface controls."
|
||||
controlClassName="gap-1"
|
||||
>
|
||||
{fonts.data && (
|
||||
<SettingSelectControl
|
||||
name="uiFont"
|
||||
label="Interface font"
|
||||
selectClassName="w-72!"
|
||||
value={settings.interfaceFont ?? NULL_FONT_VALUE}
|
||||
defaultValue={NULL_FONT_VALUE}
|
||||
options={[
|
||||
{ label: "System default", value: NULL_FONT_VALUE },
|
||||
...fonts.data.uiFonts.map((f) => ({ label: f, value: f })),
|
||||
...fonts.data.editorFonts.map((f) => ({ label: f, value: f })),
|
||||
]}
|
||||
onChange={async (v) => {
|
||||
const interfaceFont = v === NULL_FONT_VALUE ? null : v;
|
||||
await patchModel(settings, { interfaceFont });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<SettingSelectControl
|
||||
name="interfaceFontSize"
|
||||
label="Interface Font Size"
|
||||
selectClassName="w-20!"
|
||||
value={`${settings.interfaceFontSize}`}
|
||||
defaultValue="14"
|
||||
options={fontSizeOptions}
|
||||
onChange={(v) => patchModel(settings, { interfaceFontSize: Number.parseInt(v, 10) })}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Editor font"
|
||||
description="Font used in request and response editors."
|
||||
controlClassName="gap-1"
|
||||
>
|
||||
{fonts.data && (
|
||||
<SettingSelectControl
|
||||
name="editorFont"
|
||||
label="Editor font"
|
||||
selectClassName="w-72!"
|
||||
value={settings.editorFont ?? NULL_FONT_VALUE}
|
||||
defaultValue={NULL_FONT_VALUE}
|
||||
options={[
|
||||
{ label: "System default", value: NULL_FONT_VALUE },
|
||||
...fonts.data.editorFonts.map((f) => ({ label: f, value: f })),
|
||||
]}
|
||||
onChange={async (v) => {
|
||||
const editorFont = v === NULL_FONT_VALUE ? null : v;
|
||||
await patchModel(settings, { editorFont });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<SettingSelectControl
|
||||
name="editorFontSize"
|
||||
label="Editor Font Size"
|
||||
selectClassName="w-20!"
|
||||
value={`${settings.editorFontSize}`}
|
||||
defaultValue="12"
|
||||
options={fontSizeOptions}
|
||||
onChange={(v) =>
|
||||
patchModel(settings, {
|
||||
editorFontSize: clamp(Number.parseInt(v, 10) || 14, 8, 30),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Editor">
|
||||
<ModelSettingRowSelect
|
||||
model={settings}
|
||||
modelKey="editorKeymap"
|
||||
title="Editor keymap"
|
||||
description="Keyboard shortcut preset used by text editors."
|
||||
options={keymaps}
|
||||
/>
|
||||
<ModelSettingRowBoolean
|
||||
model={settings}
|
||||
modelKey="editorSoftWrap"
|
||||
title="Wrap editor lines"
|
||||
description="Wrap long lines in request and response editors."
|
||||
/>
|
||||
<ModelSettingRowBoolean
|
||||
model={settings}
|
||||
modelKey="coloredMethods"
|
||||
title="Colorize request methods"
|
||||
description="Use method-specific colors for HTTP request methods."
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Window">
|
||||
<NativeTitlebarSetting settings={settings} />
|
||||
{platform.osType() !== "macos" && (
|
||||
<ModelSettingRowBoolean
|
||||
model={settings}
|
||||
modelKey="hideWindowControls"
|
||||
title="Hide window controls"
|
||||
description="Hide the close, maximize, and minimize controls on Windows or Linux."
|
||||
/>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<CargoFeature feature="license">
|
||||
<LicenseSettings settings={settings} />
|
||||
</CargoFeature>
|
||||
</SettingsList>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function NativeTitlebarSetting({ settings }: { settings: Settings }) {
|
||||
const [nativeTitlebar, setNativeTitlebar] = useState(settings.useNativeTitlebar);
|
||||
|
||||
return (
|
||||
<SettingRow
|
||||
title="Native title bar"
|
||||
description="Use the operating system's standard title bar and window controls."
|
||||
controlClassName="gap-2"
|
||||
>
|
||||
<Checkbox
|
||||
hideLabel
|
||||
size="md"
|
||||
checked={nativeTitlebar}
|
||||
title="Native title bar"
|
||||
onChange={setNativeTitlebar}
|
||||
/>
|
||||
{settings.useNativeTitlebar !== nativeTitlebar && (
|
||||
<Button
|
||||
color="primary"
|
||||
size="xs"
|
||||
onClick={async () => {
|
||||
await patchModel(settings, { useNativeTitlebar: nativeTitlebar });
|
||||
await rpc("cmd_restart");
|
||||
}}
|
||||
>
|
||||
Apply and Restart
|
||||
</Button>
|
||||
)}
|
||||
</SettingRow>
|
||||
);
|
||||
}
|
||||
|
||||
function LicenseSettings({ settings }: { settings: Settings }) {
|
||||
const license = useLicense();
|
||||
if (license.check.data?.status !== "personal_use") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection title="License">
|
||||
<SettingRowBoolean
|
||||
checked={settings.hideLicenseBadge}
|
||||
title="Hide personal use badge"
|
||||
description="Hide the personal-use badge from the interface."
|
||||
onChange={async (hideLicenseBadge) => {
|
||||
if (hideLicenseBadge) {
|
||||
const confirmed = await showConfirm({
|
||||
id: "hide-license-badge",
|
||||
title: "Confirm Personal Use",
|
||||
confirmText: "Confirm",
|
||||
description: (
|
||||
<VStack space={3}>
|
||||
<p>Hey there 👋🏼</p>
|
||||
<p>
|
||||
Yaak is free for personal projects and learning.{" "}
|
||||
<strong>If you’re using Yaak at work, a license is required.</strong>
|
||||
</p>
|
||||
<p>
|
||||
Licenses help keep Yaak independent and sustainable.{" "}
|
||||
<Link href={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,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,173 +0,0 @@
|
||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
||||
import { Heading, HStack, Icon, type IconProps, VStack } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { lazy, Suspense } from "react";
|
||||
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
|
||||
import { useResolvedAppearance } from "../../hooks/useResolvedAppearance";
|
||||
import { useResolvedTheme } from "../../hooks/useResolvedTheme";
|
||||
import type { ButtonProps } from "../core/Button";
|
||||
import { IconButton } from "../core/IconButton";
|
||||
import { Link } from "../core/Link";
|
||||
import type { SelectProps } from "../core/Select";
|
||||
import {
|
||||
ModelSettingRowSelect,
|
||||
SettingRowSelect,
|
||||
SettingsList,
|
||||
SettingsSection,
|
||||
} from "../core/SettingRow";
|
||||
|
||||
const Editor = lazy(() => import("../core/Editor/Editor").then((m) => ({ default: m.Editor })));
|
||||
|
||||
const buttonColors: ButtonProps["color"][] = [
|
||||
"primary",
|
||||
"info",
|
||||
"success",
|
||||
"notice",
|
||||
"warning",
|
||||
"danger",
|
||||
"secondary",
|
||||
"default",
|
||||
];
|
||||
|
||||
const icons: IconProps["icon"][] = [
|
||||
"info",
|
||||
"box",
|
||||
"update",
|
||||
"alert_triangle",
|
||||
"arrow_big_right_dash",
|
||||
"download",
|
||||
"copy",
|
||||
"magic_wand",
|
||||
"settings",
|
||||
"trash",
|
||||
"sparkles",
|
||||
"pencil",
|
||||
"paste",
|
||||
"search",
|
||||
"send_horizontal",
|
||||
];
|
||||
|
||||
export function SettingsTheme() {
|
||||
const workspace = useAtomValue(activeWorkspaceAtom);
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const appearance = useResolvedAppearance();
|
||||
const activeTheme = useResolvedTheme();
|
||||
|
||||
if (settings == null || workspace == null || activeTheme.data == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lightThemes: SelectProps<string>["options"] = activeTheme.data.themes
|
||||
.filter((theme) => !theme.dark)
|
||||
.map((theme) => ({
|
||||
label: theme.label,
|
||||
value: theme.id,
|
||||
}));
|
||||
|
||||
const darkThemes: SelectProps<string>["options"] = activeTheme.data.themes
|
||||
.filter((theme) => theme.dark)
|
||||
.map((theme) => ({
|
||||
label: theme.label,
|
||||
value: theme.id,
|
||||
}));
|
||||
|
||||
return (
|
||||
<VStack space={1.5} className="mb-4">
|
||||
<div className="mb-3">
|
||||
<Heading>Theme</Heading>
|
||||
<p className="text-text-subtle">
|
||||
Make Yaak your own by selecting a theme, or{" "}
|
||||
<Link href="https://yaak.app/docs/plugin-development/plugins-quick-start">
|
||||
Create Your Own
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<SettingsList className="space-y-8">
|
||||
<SettingsSection title="Theme">
|
||||
<ModelSettingRowSelect
|
||||
model={settings}
|
||||
modelKey="appearance"
|
||||
title="Appearance"
|
||||
description="Choose whether Yaak follows your system appearance or uses a fixed mode."
|
||||
options={[
|
||||
{ label: "Automatic", value: "system" },
|
||||
{ label: "Light", value: "light" },
|
||||
{ label: "Dark", value: "dark" },
|
||||
]}
|
||||
/>
|
||||
{(settings.appearance === "system" || settings.appearance === "light") && (
|
||||
<SettingRowSelect
|
||||
name="lightTheme"
|
||||
title="Light theme"
|
||||
description="Theme used when Yaak is in light mode."
|
||||
value={activeTheme.data.light.id}
|
||||
options={lightThemes}
|
||||
onChange={(themeLight) => patchModel(settings, { themeLight })}
|
||||
/>
|
||||
)}
|
||||
{(settings.appearance === "system" || settings.appearance === "dark") && (
|
||||
<SettingRowSelect
|
||||
name="darkTheme"
|
||||
title="Dark theme"
|
||||
description="Theme used when Yaak is in dark mode."
|
||||
value={activeTheme.data.dark.id}
|
||||
options={darkThemes}
|
||||
onChange={(themeDark) => patchModel(settings, { themeDark })}
|
||||
/>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Preview">
|
||||
<VStack
|
||||
space={3}
|
||||
className="mt-4 w-full bg-surface p-3 border border-dashed border-border-subtle rounded-sm overflow-x-auto"
|
||||
>
|
||||
<HStack className="text" space={1.5}>
|
||||
<Icon icon={appearance === "dark" ? "moon" : "sun"} />
|
||||
<strong>{activeTheme.data.active.label}</strong>
|
||||
<em>(preview)</em>
|
||||
</HStack>
|
||||
<HStack space={1.5} className="w-full">
|
||||
{buttonColors.map((c, i) => (
|
||||
<IconButton
|
||||
key={c}
|
||||
color={c}
|
||||
size="2xs"
|
||||
iconSize="xs"
|
||||
icon={icons[i % icons.length] ?? "info"}
|
||||
iconClassName="text"
|
||||
title={`${c}`}
|
||||
/>
|
||||
))}
|
||||
{buttonColors.map((c, i) => (
|
||||
<IconButton
|
||||
key={c}
|
||||
color={c}
|
||||
variant="border"
|
||||
size="2xs"
|
||||
iconSize="xs"
|
||||
icon={icons[i % icons.length] ?? "info"}
|
||||
iconClassName="text"
|
||||
title={`${c}`}
|
||||
/>
|
||||
))}
|
||||
</HStack>
|
||||
<Suspense>
|
||||
<Editor
|
||||
defaultValue={[
|
||||
"let foo = { // Demo code editor",
|
||||
' foo: ("bar" || "baz" ?? \'qux\'),',
|
||||
" baz: [1, 10.2, null, false, true],",
|
||||
"};",
|
||||
].join("\n")}
|
||||
heightMode="auto"
|
||||
language="javascript"
|
||||
stateKey={null}
|
||||
/>
|
||||
</Suspense>
|
||||
</VStack>
|
||||
</SettingsSection>
|
||||
</SettingsList>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -1,113 +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,
|
||||
leftSlot: <Icon icon="square_terminal" />,
|
||||
rightSlot: <Icon icon="external_link" color="secondary" />,
|
||||
onSelect: () => platform.openUrl("https://yaak.app/docs/cli"),
|
||||
},
|
||||
{
|
||||
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
@@ -1,39 +0,0 @@
|
||||
import { HStack } from "@yaakapp-internal/ui";
|
||||
import { useMemo } from "react";
|
||||
import { useFloatingSidebarHidden } from "../hooks/useFloatingSidebarHidden";
|
||||
import { useSidebarHidden } from "../hooks/useSidebarHidden";
|
||||
import { CreateDropdown } from "./CreateDropdown";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
|
||||
interface Props {
|
||||
floating?: boolean;
|
||||
}
|
||||
|
||||
export function SidebarActions({ floating = false }: Props) {
|
||||
const [sidebarHidden, setSidebarHidden] = useSidebarHidden();
|
||||
const [floatingHidden, setFloatingHidden] = useFloatingSidebarHidden();
|
||||
|
||||
const hidden = floating ? floatingHidden : sidebarHidden;
|
||||
const setHidden = useMemo(
|
||||
() => (floating ? setFloatingHidden : setSidebarHidden),
|
||||
[floating, setFloatingHidden, setSidebarHidden],
|
||||
);
|
||||
|
||||
return (
|
||||
<HStack className="h-full">
|
||||
<IconButton
|
||||
onClick={async () => {
|
||||
await setHidden(!hidden);
|
||||
}}
|
||||
className="pointer-events-auto"
|
||||
size="sm"
|
||||
title="Toggle sidebar"
|
||||
icon={hidden ? "left_panel_hidden" : "left_panel_visible"}
|
||||
iconColor="secondary"
|
||||
/>
|
||||
<CreateDropdown hotKeyAction="model.create">
|
||||
<IconButton size="sm" icon="plus_circle" iconColor="secondary" title="Add Resource" />
|
||||
</CreateDropdown>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { Banner, VStack } from "@yaakapp-internal/ui";
|
||||
import { useState } from "react";
|
||||
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
|
||||
import { Button } from "./core/Button";
|
||||
import { Checkbox } from "./core/Checkbox";
|
||||
import { SettingRowBoolean, SettingRowDirectory } from "./core/SettingRow";
|
||||
import { SelectFile } from "./SelectFile";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
|
||||
export interface SyncToFilesystemSettingProps {
|
||||
layout?: "form" | "settings";
|
||||
onChange: (args: { filePath: string | null; initGit?: boolean }) => void;
|
||||
onCreateNewWorkspace: () => void;
|
||||
value: { filePath: string | null; initGit?: boolean };
|
||||
}
|
||||
|
||||
export function SyncToFilesystemSetting({
|
||||
layout = "form",
|
||||
onChange,
|
||||
onCreateNewWorkspace,
|
||||
value,
|
||||
}: SyncToFilesystemSettingProps) {
|
||||
const [syncDir, setSyncDir] = useState<string | null>(null);
|
||||
|
||||
const handleFilePathChange = async (filePath: string | null) => {
|
||||
if (filePath != null) {
|
||||
const files = await platform.files.readDir(filePath);
|
||||
if (files.length > 0) {
|
||||
setSyncDir(filePath);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSyncDir(null);
|
||||
onChange({ ...value, filePath });
|
||||
};
|
||||
|
||||
if (layout === "settings") {
|
||||
return (
|
||||
<VStack className="w-full" space={0}>
|
||||
{syncDir && (
|
||||
<Banner color="notice" className="mb-3 flex flex-col gap-1.5">
|
||||
<p>Directory is not empty. Do you want to open it instead?</p>
|
||||
<div>
|
||||
<Button
|
||||
variant="border"
|
||||
color="notice"
|
||||
size="xs"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
openWorkspaceFromSyncDir.mutate(syncDir);
|
||||
onCreateNewWorkspace();
|
||||
}}
|
||||
>
|
||||
Open Workspace
|
||||
</Button>
|
||||
</div>
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
<SettingRowDirectory
|
||||
title="Local directory sync"
|
||||
description="Sync data to a folder for backup and Git integration."
|
||||
filePath={value.filePath}
|
||||
onChange={handleFilePathChange}
|
||||
/>
|
||||
|
||||
{value.filePath && typeof value.initGit === "boolean" && (
|
||||
<SettingRowBoolean
|
||||
checked={value.initGit}
|
||||
title="Initialize Git Repo"
|
||||
description="Create a Git repository in the selected sync directory."
|
||||
onChange={(initGit) => onChange({ ...value, initGit })}
|
||||
/>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack className="w-full my-2" space={3}>
|
||||
{syncDir && (
|
||||
<Banner color="notice" className="flex flex-col gap-1.5">
|
||||
<p>Directory is not empty. Do you want to open it instead?</p>
|
||||
<div>
|
||||
<Button
|
||||
variant="border"
|
||||
color="notice"
|
||||
size="xs"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
openWorkspaceFromSyncDir.mutate(syncDir);
|
||||
onCreateNewWorkspace();
|
||||
}}
|
||||
>
|
||||
Open Workspace
|
||||
</Button>
|
||||
</div>
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
<SelectFile
|
||||
directory
|
||||
label="Local directory sync"
|
||||
size="xs"
|
||||
noun="Directory"
|
||||
help="Sync data to a folder for backup and Git integration."
|
||||
filePath={value.filePath}
|
||||
onChange={async ({ filePath }) => handleFilePathChange(filePath)}
|
||||
/>
|
||||
|
||||
{value.filePath && typeof value.initGit === "boolean" && (
|
||||
<Checkbox
|
||||
checked={value.initGit}
|
||||
onChange={(initGit) => onChange({ ...value, initGit })}
|
||||
title="Initialize Git Repo"
|
||||
/>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import type { WebsocketRequest } from "@yaakapp-internal/models";
|
||||
import classNames from "classnames";
|
||||
import { useAtomValue } from "jotai";
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import { SplitLayout } from "@yaakapp-internal/ui";
|
||||
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
|
||||
import { workspaceLayoutAtom } from "../lib/atoms";
|
||||
import { WebsocketRequestPane } from "./WebsocketRequestPane";
|
||||
import { WebsocketResponsePane } from "./WebsocketResponsePane";
|
||||
|
||||
interface Props {
|
||||
activeRequest: WebsocketRequest;
|
||||
style: CSSProperties;
|
||||
}
|
||||
|
||||
export function WebsocketRequestLayout({ activeRequest, style }: Props) {
|
||||
const workspaceLayout = useAtomValue(workspaceLayoutAtom);
|
||||
const activeWorkspace = useAtomValue(activeWorkspaceAtom);
|
||||
const wsId = activeWorkspace?.id ?? "n/a";
|
||||
return (
|
||||
<SplitLayout
|
||||
storageKey={`websocket_layout::${wsId}`}
|
||||
className="p-3 gap-1.5"
|
||||
layout={workspaceLayout}
|
||||
style={style}
|
||||
firstSlot={({ orientation, style }) => (
|
||||
<WebsocketRequestPane
|
||||
style={style}
|
||||
activeRequest={activeRequest}
|
||||
fullHeight={orientation === "horizontal"}
|
||||
/>
|
||||
)}
|
||||
secondSlot={({ style }) => (
|
||||
<div
|
||||
style={style}
|
||||
className={classNames(
|
||||
"x-theme-responsePane",
|
||||
"max-h-full h-full grid grid-rows-[minmax(0,1fr)] grid-cols-1",
|
||||
"bg-surface rounded-md border border-border-subtle",
|
||||
"shadow relative",
|
||||
)}
|
||||
>
|
||||
<WebsocketResponsePane activeRequest={activeRequest} />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,311 +0,0 @@
|
||||
import type { WebsocketRequest } from "@yaakapp-internal/models";
|
||||
import {
|
||||
flushAllModelWrites,
|
||||
getModel,
|
||||
patchModel,
|
||||
patchModelDebounced,
|
||||
} from "@yaakapp-internal/models";
|
||||
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
||||
import { closeWebsocket, connectWebsocket, sendWebsocket } from "@yaakapp-internal/ws";
|
||||
import classNames from "classnames";
|
||||
import { atom, useAtomValue } from "jotai";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { getActiveCookieJar } from "../hooks/useActiveCookieJar";
|
||||
import { getActiveEnvironment } from "../hooks/useActiveEnvironment";
|
||||
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 { activeWebsocketConnectionAtom } from "../hooks/usePinnedWebsocketConnection";
|
||||
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
|
||||
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
|
||||
import { languageFromContentType } from "../lib/contentType";
|
||||
import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
|
||||
import { prepareImportQuerystring } from "../lib/prepareImportQuerystring";
|
||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
||||
import { CountBadge } from "./core/CountBadge";
|
||||
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
||||
import { getUrlCompletionConfig } from "./core/Editor/url/completion";
|
||||
import { Editor } from "./core/Editor/LazyEditor";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
|
||||
import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs";
|
||||
import { HeadersEditor } from "./HeadersEditor";
|
||||
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
|
||||
import { MarkdownEditor } from "./MarkdownEditor";
|
||||
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
|
||||
import { UrlBar } from "./UrlBar";
|
||||
import { UrlParametersEditor } from "./UrlParameterEditor";
|
||||
|
||||
interface Props {
|
||||
style: CSSProperties;
|
||||
fullHeight: boolean;
|
||||
className?: string;
|
||||
activeRequest: WebsocketRequest;
|
||||
}
|
||||
|
||||
const TAB_MESSAGE = "message";
|
||||
const TAB_PARAMS = "params";
|
||||
const TAB_HEADERS = "headers";
|
||||
const TAB_AUTH = "auth";
|
||||
const TAB_SETTINGS = "settings";
|
||||
const TAB_DESCRIPTION = "description";
|
||||
const TABS_STORAGE_KEY = "websocket_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 WebsocketRequestPane({ style, fullHeight, className, activeRequest }: Props) {
|
||||
const activeRequestId = activeRequest.id;
|
||||
const tabsRef = useRef<TabsRef>(null);
|
||||
const forceUpdateKey = useRequestUpdateKey(activeRequest.id);
|
||||
const [{ urlKey }, { forceUrlRefresh, forceParamsRefresh }] = useRequestEditor();
|
||||
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);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 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("websocket_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],
|
||||
);
|
||||
|
||||
const tabs = useMemo<TabItem[]>(() => {
|
||||
return [
|
||||
{
|
||||
value: TAB_MESSAGE,
|
||||
label: "Message",
|
||||
} as TabItem,
|
||||
{
|
||||
value: TAB_PARAMS,
|
||||
rightSlot: <CountBadge count={urlParameterPairs.length} />,
|
||||
label: "Params",
|
||||
},
|
||||
...headersTab,
|
||||
...authTab,
|
||||
{
|
||||
value: TAB_SETTINGS,
|
||||
label: "Settings",
|
||||
rightSlot: <CountBadge count={numSettingsOverrides} />,
|
||||
},
|
||||
{
|
||||
value: TAB_DESCRIPTION,
|
||||
label: "Info",
|
||||
},
|
||||
];
|
||||
}, [authTab, headersTab, numSettingsOverrides, urlParameterPairs.length]);
|
||||
|
||||
const { activeResponse } = usePinnedHttpResponse(activeRequestId);
|
||||
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
|
||||
const connection = useAtomValue(activeWebsocketConnectionAtom);
|
||||
|
||||
const autocompleteUrls = useAtomValue(requestUrlOptionsAtom);
|
||||
|
||||
const autocomplete: GenericCompletionConfig = useMemo(
|
||||
() => getUrlCompletionConfig(autocompleteUrls),
|
||||
[autocompleteUrls],
|
||||
);
|
||||
|
||||
const handleConnect = useCallback(async () => {
|
||||
await flushAllModelWrites(); // The backend reads the request from the DB
|
||||
await connectWebsocket({
|
||||
requestId: activeRequest.id,
|
||||
environmentId: getActiveEnvironment()?.id ?? null,
|
||||
cookieJarId: getActiveCookieJar()?.id ?? null,
|
||||
});
|
||||
}, [activeRequest.id]);
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (connection == null) return;
|
||||
await flushAllModelWrites(); // The backend reads the message from the DB
|
||||
await sendWebsocket({
|
||||
connectionId: connection?.id,
|
||||
environmentId: getActiveEnvironment()?.id ?? null,
|
||||
});
|
||||
}, [connection]);
|
||||
|
||||
const handleCancel = useCallback(async () => {
|
||||
if (connection == null) return;
|
||||
await closeWebsocket({ connectionId: connection?.id });
|
||||
}, [connection]);
|
||||
|
||||
const handleUrlChange = useCallback(
|
||||
(url: string) => patchModelDebounced(activeRequest, { url }),
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
const handlePaste = useCallback(
|
||||
async (e: ClipboardEvent, text: string) => {
|
||||
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 messageLanguage = languageFromContentType(null, activeRequest.message);
|
||||
|
||||
const isLoading = connection !== null && connection.state !== "closed";
|
||||
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
className={classNames(className, "h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1")}
|
||||
>
|
||||
{activeRequest && (
|
||||
<>
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto]">
|
||||
<UrlBar
|
||||
stateKey={`url.${activeRequest.id}`}
|
||||
key={forceUpdateKey + urlKey}
|
||||
url={activeRequest.url}
|
||||
submitIcon={isLoading ? "send_horizontal" : "arrow_up_down"}
|
||||
rightSlot={
|
||||
isLoading && (
|
||||
<IconButton
|
||||
size="xs"
|
||||
title="Close connection"
|
||||
icon="x"
|
||||
iconColor="secondary"
|
||||
className="w-8 mr-0.5 h-full!"
|
||||
onClick={handleCancel}
|
||||
/>
|
||||
)
|
||||
}
|
||||
placeholder="wss://example.com"
|
||||
onPasteOverwrite={handlePaste}
|
||||
autocomplete={autocomplete}
|
||||
onSend={isLoading ? handleSend : handleConnect}
|
||||
onCancel={cancelResponse}
|
||||
onUrlChange={handleUrlChange}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
isLoading={activeResponse != null && activeResponse.state !== "closed"}
|
||||
/>
|
||||
</div>
|
||||
<Tabs
|
||||
ref={tabsRef}
|
||||
label="Request"
|
||||
tabs={tabs}
|
||||
tabListClassName="mt-1 mb-1.5!"
|
||||
storageKey={TABS_STORAGE_KEY}
|
||||
activeTabKey={activeRequestId}
|
||||
>
|
||||
<TabContent value={TAB_AUTH}>
|
||||
<HttpAuthenticationEditor model={activeRequest} />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_HEADERS}>
|
||||
<HeadersEditor
|
||||
inheritedHeaders={inheritedHeaders}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
headers={activeRequest.headers}
|
||||
stateKey={`headers.${activeRequest.id}`}
|
||||
onChange={(headers) => patchModelDebounced(activeRequest, { headers })}
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_PARAMS}>
|
||||
<UrlParametersEditor
|
||||
stateKey={`params.${activeRequest.id}`}
|
||||
forceUpdateKey={forceUpdateKey + urlParametersKey}
|
||||
pairs={urlParameterPairs}
|
||||
onChange={(urlParameters) => patchModelDebounced(activeRequest, { urlParameters })}
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_MESSAGE}>
|
||||
<Editor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
autocompleteFunctions
|
||||
autocompleteVariables
|
||||
placeholder="..."
|
||||
heightMode={fullHeight ? "full" : "auto"}
|
||||
defaultValue={activeRequest.message}
|
||||
language={messageLanguage}
|
||||
onChange={(message) => patchModelDebounced(activeRequest, { message })}
|
||||
stateKey={`json.${activeRequest.id}`}
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_SETTINGS}>
|
||||
<ModelSettingsEditor model={activeRequest} />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_DESCRIPTION}>
|
||||
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full">
|
||||
<PlainInput
|
||||
label="Request Name"
|
||||
hideLabel
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
defaultValue={activeRequest.name}
|
||||
className="font-sans text-xl! px-0!"
|
||||
containerClassName="border-0"
|
||||
placeholder={resolvedModelName(activeRequest)}
|
||||
onChange={(name) => patchModelDebounced(activeRequest, { name })}
|
||||
/>
|
||||
<MarkdownEditor
|
||||
name="request-description"
|
||||
placeholder="Request description"
|
||||
defaultValue={activeRequest.description}
|
||||
stateKey={`description.${activeRequest.id}`}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
onChange={(description) => patchModelDebounced(activeRequest, { description })}
|
||||
/>
|
||||
</div>
|
||||
</TabContent>
|
||||
</Tabs>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
import type { WebsocketEvent, WebsocketRequest } from "@yaakapp-internal/models";
|
||||
import { HStack, Icon, LoadingIcon, VStack } from "@yaakapp-internal/ui";
|
||||
import { hexy } from "hexy";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useFormatText } from "../hooks/useFormatText";
|
||||
import {
|
||||
activeWebsocketConnectionAtom,
|
||||
activeWebsocketConnectionsAtom,
|
||||
setPinnedWebsocketConnectionId,
|
||||
useWebsocketEvents,
|
||||
} from "../hooks/usePinnedWebsocketConnection";
|
||||
import { useStateWithDeps } from "../hooks/useStateWithDeps";
|
||||
import { languageFromContentType } from "../lib/contentType";
|
||||
import { Button } from "./core/Button";
|
||||
import { Editor } from "./core/Editor/LazyEditor";
|
||||
import { type EventDetailAction, EventDetailHeader, EventViewer } from "./core/EventViewer";
|
||||
import { EventViewerRow } from "./core/EventViewerRow";
|
||||
import { HotkeyList } from "./core/HotkeyList";
|
||||
import { WebsocketStatusTag } from "./core/WebsocketStatusTag";
|
||||
import { EmptyStateText } from "./EmptyStateText";
|
||||
import { ErrorBoundary } from "./ErrorBoundary";
|
||||
import { RecentWebsocketConnectionsDropdown } from "./RecentWebsocketConnectionsDropdown";
|
||||
|
||||
interface Props {
|
||||
activeRequest: WebsocketRequest;
|
||||
}
|
||||
|
||||
export function WebsocketResponsePane({ activeRequest }: Props) {
|
||||
const [showLarge, setShowLarge] = useStateWithDeps<boolean>(false, [activeRequest.id]);
|
||||
const [showingLarge, setShowingLarge] = useState<boolean>(false);
|
||||
const [hexDumps, setHexDumps] = useState<Record<number, boolean>>({});
|
||||
|
||||
const activeConnection = useAtomValue(activeWebsocketConnectionAtom);
|
||||
const connections = useAtomValue(activeWebsocketConnectionsAtom);
|
||||
const events = useWebsocketEvents(activeConnection?.id ?? null);
|
||||
|
||||
if (activeConnection == null) {
|
||||
return (
|
||||
<HotkeyList hotkeys={["request.send", "model.create", "sidebar.focus", "url_bar.focus"]} />
|
||||
);
|
||||
}
|
||||
|
||||
const header = (
|
||||
<HStack className="pl-3 mb-1 font-mono text-sm text-text-subtle">
|
||||
<HStack space={2}>
|
||||
{activeConnection.state !== "closed" && (
|
||||
<LoadingIcon size="sm" className="text-text-subtlest" />
|
||||
)}
|
||||
<WebsocketStatusTag connection={activeConnection} />
|
||||
<span>•</span>
|
||||
<span>{events.length} Messages</span>
|
||||
</HStack>
|
||||
<HStack space={0.5} className="ml-auto">
|
||||
<RecentWebsocketConnectionsDropdown
|
||||
connections={connections}
|
||||
activeConnection={activeConnection}
|
||||
onPinnedConnectionId={setPinnedWebsocketConnectionId}
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
);
|
||||
|
||||
return (
|
||||
<ErrorBoundary name="Websocket Events">
|
||||
<EventViewer
|
||||
events={events}
|
||||
getEventKey={(event) => event.id}
|
||||
error={activeConnection.error}
|
||||
header={header}
|
||||
splitLayoutStorageKey="websocket_events"
|
||||
defaultRatio={0.4}
|
||||
renderRow={({ event, isActive, onClick }) => (
|
||||
<WebsocketEventRow event={event} isActive={isActive} onClick={onClick} />
|
||||
)}
|
||||
renderDetail={({ event, index, onClose }) => (
|
||||
<WebsocketEventDetail
|
||||
event={event}
|
||||
hexDump={hexDumps[index] ?? event.messageType === "binary"}
|
||||
setHexDump={(v) => setHexDumps({ ...hexDumps, [index]: v })}
|
||||
showLarge={showLarge}
|
||||
showingLarge={showingLarge}
|
||||
setShowLarge={setShowLarge}
|
||||
setShowingLarge={setShowingLarge}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function WebsocketEventRow({
|
||||
event,
|
||||
isActive,
|
||||
onClick,
|
||||
}: {
|
||||
event: WebsocketEvent;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const { message: messageBytes, isServer, messageType } = event;
|
||||
const message = messageBytes
|
||||
? new TextDecoder("utf-8").decode(Uint8Array.from(messageBytes))
|
||||
: "";
|
||||
|
||||
const iconColor =
|
||||
messageType === "error"
|
||||
? "warning"
|
||||
: messageType === "close" || messageType === "open"
|
||||
? "secondary"
|
||||
: isServer
|
||||
? "info"
|
||||
: "primary";
|
||||
|
||||
const icon =
|
||||
messageType === "error"
|
||||
? "alert_triangle"
|
||||
: messageType === "close" || messageType === "open"
|
||||
? "info"
|
||||
: isServer
|
||||
? "arrow_big_down_dash"
|
||||
: "arrow_big_up_dash";
|
||||
|
||||
const content =
|
||||
messageType === "close" ? (
|
||||
"Disconnected from server"
|
||||
) : messageType === "open" ? (
|
||||
"Connected to server"
|
||||
) : messageType === "error" ? (
|
||||
<span className="text-warning">{message}</span>
|
||||
) : message === "" ? (
|
||||
<em className="italic text-text-subtlest">No content</em>
|
||||
) : (
|
||||
<span className="text-xs">{message.slice(0, 1000)}</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<EventViewerRow
|
||||
isActive={isActive}
|
||||
onClick={onClick}
|
||||
icon={<Icon color={iconColor} icon={icon} />}
|
||||
content={content}
|
||||
timestamp={event.createdAt}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function WebsocketEventDetail({
|
||||
event,
|
||||
hexDump,
|
||||
setHexDump,
|
||||
showLarge,
|
||||
showingLarge,
|
||||
setShowLarge,
|
||||
setShowingLarge,
|
||||
onClose,
|
||||
}: {
|
||||
event: WebsocketEvent;
|
||||
hexDump: boolean;
|
||||
setHexDump: (v: boolean) => void;
|
||||
showLarge: boolean;
|
||||
showingLarge: boolean;
|
||||
setShowLarge: (v: boolean) => void;
|
||||
setShowingLarge: (v: boolean) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const message = useMemo(() => {
|
||||
if (hexDump) {
|
||||
return event.message ? hexy(event.message) : "";
|
||||
}
|
||||
return event.message ? new TextDecoder("utf-8").decode(Uint8Array.from(event.message)) : "";
|
||||
}, [event.message, hexDump]);
|
||||
|
||||
const language = languageFromContentType(null, message);
|
||||
const formattedMessage = useFormatText({ language, text: message, pretty: true });
|
||||
|
||||
const title =
|
||||
event.messageType === "close"
|
||||
? "Connection Closed"
|
||||
: event.messageType === "open"
|
||||
? "Connection Open"
|
||||
: event.messageType === "error"
|
||||
? "WebSocket Error"
|
||||
: `Message ${event.isServer ? "Received" : "Sent"}`;
|
||||
|
||||
const actions: EventDetailAction[] =
|
||||
message !== ""
|
||||
? [
|
||||
{
|
||||
key: "toggle-hexdump",
|
||||
label: hexDump ? "Show Message" : "Show Hexdump",
|
||||
onClick: () => setHexDump(!hexDump),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
|
||||
<EventDetailHeader
|
||||
title={title}
|
||||
timestamp={event.createdAt}
|
||||
actions={actions}
|
||||
copyText={formattedMessage || undefined}
|
||||
onClose={onClose}
|
||||
/>
|
||||
{!showLarge && event.message.length > 1000 * 1000 ? (
|
||||
<VStack space={2} className="italic text-text-subtlest">
|
||||
Message previews larger than 1MB are hidden
|
||||
<div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowingLarge(true);
|
||||
setTimeout(() => {
|
||||
setShowLarge(true);
|
||||
setShowingLarge(false);
|
||||
}, 500);
|
||||
}}
|
||||
isLoading={showingLarge}
|
||||
color="secondary"
|
||||
variant="border"
|
||||
size="xs"
|
||||
>
|
||||
Try Showing
|
||||
</Button>
|
||||
</div>
|
||||
</VStack>
|
||||
) : event.message.length === 0 ? (
|
||||
<EmptyStateText>No Content</EmptyStateText>
|
||||
) : (
|
||||
<Editor
|
||||
language={language}
|
||||
defaultValue={formattedMessage ?? ""}
|
||||
wrapLines={false}
|
||||
readOnly={true}
|
||||
stateKey={null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { settingsAtom, workspacesAtom } from "@yaakapp-internal/models";
|
||||
import { Banner, HeaderSize, HStack, SidebarLayout } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useAtomValue } from "jotai";
|
||||
import * as m from "motion/react-m";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
getActiveCookieJar,
|
||||
useEnsureActiveCookieJar,
|
||||
useSubscribeActiveCookieJarId,
|
||||
} from "../hooks/useActiveCookieJar";
|
||||
import {
|
||||
activeEnvironmentAtom,
|
||||
useSubscribeActiveEnvironmentId,
|
||||
} from "../hooks/useActiveEnvironment";
|
||||
import { activeFolderAtom } from "../hooks/useActiveFolder";
|
||||
import { useSubscribeActiveFolderId } from "../hooks/useActiveFolderId";
|
||||
import { activeRequestAtom } from "../hooks/useActiveRequest";
|
||||
import { useSubscribeActiveRequestId } from "../hooks/useActiveRequestId";
|
||||
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
|
||||
import { useFloatingSidebarHidden } from "../hooks/useFloatingSidebarHidden";
|
||||
import { useHotKey } from "../hooks/useHotKey";
|
||||
import { useSubscribeRecentCookieJars } from "../hooks/useRecentCookieJars";
|
||||
import { useSubscribeRecentEnvironments } from "../hooks/useRecentEnvironments";
|
||||
import { useSubscribeRecentRequests } from "../hooks/useRecentRequests";
|
||||
import { useSubscribeRecentWorkspaces } from "../hooks/useRecentWorkspaces";
|
||||
import { useSidebarHidden } from "../hooks/useSidebarHidden";
|
||||
import { useSidebarWidth } from "../hooks/useSidebarWidth";
|
||||
import { useSyncWorkspaceRequestTitle } from "../hooks/useSyncWorkspaceRequestTitle";
|
||||
import { duplicateRequestOrFolderAndNavigate } from "../lib/duplicateRequestOrFolderAndNavigate";
|
||||
import { importData } from "../lib/importData";
|
||||
import { jotaiStore } from "../lib/jotai";
|
||||
import { CreateDropdown } from "./CreateDropdown";
|
||||
import { Button } from "./core/Button";
|
||||
import { HotkeyList } from "./core/HotkeyList";
|
||||
import { CookieDialog } from "./CookieDialog";
|
||||
import { FeedbackLink } from "./core/Link";
|
||||
import { ErrorBoundary } from "./ErrorBoundary";
|
||||
import { FolderLayout } from "./FolderLayout";
|
||||
import { GrpcConnectionLayout } from "./GrpcConnectionLayout";
|
||||
import { HttpRequestLayout } from "./HttpRequestLayout";
|
||||
import Sidebar from "./Sidebar";
|
||||
import { SidebarActions } from "./SidebarActions";
|
||||
import { WebsocketRequestLayout } from "./WebsocketRequestLayout";
|
||||
import { WorkspaceHeader } from "./WorkspaceHeader";
|
||||
|
||||
const body = { gridArea: "body" };
|
||||
|
||||
export function Workspace() {
|
||||
// First, subscribe to some things applicable to workspaces
|
||||
useGlobalWorkspaceHooks();
|
||||
|
||||
const workspaces = useAtomValue(workspacesAtom);
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const osType = platform.osType();
|
||||
const [width, setWidth] = useSidebarWidth();
|
||||
const [sidebarHidden, setSidebarHidden] = useSidebarHidden();
|
||||
const [floatingSidebarHidden, setFloatingSidebarHidden] = useFloatingSidebarHidden();
|
||||
const activeEnvironment = useAtomValue(activeEnvironmentAtom);
|
||||
const [floating, setFloating] = useState(false);
|
||||
|
||||
const environmentBgStyle = useMemo(() => {
|
||||
if (activeEnvironment?.color == null) return undefined;
|
||||
const background = `linear-gradient(to right, ${activeEnvironment.color} 15%, transparent 40%)`;
|
||||
return { background };
|
||||
}, [activeEnvironment?.color]);
|
||||
|
||||
// We're loading still
|
||||
if (workspaces.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const header = (
|
||||
<HeaderSize
|
||||
data-tauri-drag-region
|
||||
size="lg"
|
||||
className="relative x-theme-appHeader bg-surface"
|
||||
osType={osType}
|
||||
hideWindowControls={settings.hideWindowControls}
|
||||
useNativeTitlebar={settings.useNativeTitlebar}
|
||||
interfaceScale={settings.interfaceScale}
|
||||
>
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
<div style={environmentBgStyle} className="absolute inset-0 opacity-[0.07]" />
|
||||
<div
|
||||
style={environmentBgStyle}
|
||||
className="absolute left-0 right-0 -bottom-px h-px opacity-20"
|
||||
/>
|
||||
</div>
|
||||
<WorkspaceHeader className="pointer-events-none" floatingSidebar={floating} />
|
||||
</HeaderSize>
|
||||
);
|
||||
|
||||
const workspaceBody = (
|
||||
<ErrorBoundary name="Workspace Body">
|
||||
<WorkspaceBody />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
const sidebarContent = floating ? (
|
||||
<div
|
||||
className={classNames(
|
||||
"x-theme-sidebar",
|
||||
"h-full bg-surface border-r border-border-subtle",
|
||||
"grid grid-rows-[auto_1fr]",
|
||||
)}
|
||||
>
|
||||
<HeaderSize
|
||||
hideControls
|
||||
size="lg"
|
||||
className="border-transparent flex items-center"
|
||||
osType={osType}
|
||||
hideWindowControls={settings.hideWindowControls}
|
||||
useNativeTitlebar={settings.useNativeTitlebar}
|
||||
interfaceScale={settings.interfaceScale}
|
||||
>
|
||||
<SidebarActions floating />
|
||||
</HeaderSize>
|
||||
<ErrorBoundary name="Sidebar (Floating)">
|
||||
<Sidebar />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
) : (
|
||||
<div className="x-theme-sidebar overflow-hidden bg-surface h-full">
|
||||
<ErrorBoundary name="Sidebar">
|
||||
<Sidebar className="border-r border-border-subtle" />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid w-full h-full grid-rows-[auto_minmax(0,1fr)]">
|
||||
{header}
|
||||
<SidebarLayout
|
||||
width={width ?? 250}
|
||||
onWidthChange={setWidth}
|
||||
hidden={sidebarHidden ?? false}
|
||||
onHiddenChange={(hidden) => setSidebarHidden(hidden)}
|
||||
floatingHidden={floatingSidebarHidden ?? true}
|
||||
onFloatingHiddenChange={(hidden) => setFloatingSidebarHidden(hidden)}
|
||||
onFloatingChange={setFloating}
|
||||
sidebar={sidebarContent}
|
||||
>
|
||||
{workspaceBody}
|
||||
</SidebarLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceBody() {
|
||||
const activeRequest = useAtomValue(activeRequestAtom);
|
||||
const activeFolder = useAtomValue(activeFolderAtom);
|
||||
const activeWorkspace = useAtomValue(activeWorkspaceAtom);
|
||||
|
||||
if (activeWorkspace == null) {
|
||||
return (
|
||||
<m.div
|
||||
className="m-auto"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
// Delay the entering because the workspaces might load after a slight delay
|
||||
transition={{ delay: 0.5 }}
|
||||
>
|
||||
<Banner color="warning" className="max-w-120">
|
||||
The active workspace was not found. Select a workspace from the header menu or report this
|
||||
bug to <FeedbackLink />
|
||||
</Banner>
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeRequest?.model === "grpc_request") {
|
||||
return <GrpcConnectionLayout style={body} />;
|
||||
}
|
||||
if (activeRequest?.model === "websocket_request") {
|
||||
return <WebsocketRequestLayout style={body} activeRequest={activeRequest} />;
|
||||
}
|
||||
if (activeRequest?.model === "http_request") {
|
||||
return <HttpRequestLayout activeRequest={activeRequest} style={body} />;
|
||||
}
|
||||
if (activeFolder != null) {
|
||||
return <FolderLayout folder={activeFolder} style={body} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<HotkeyList
|
||||
hotkeys={["model.create", "sidebar.focus", "settings.show"]}
|
||||
bottomSlot={
|
||||
<HStack space={1} justifyContent="center" className="mt-3">
|
||||
<Button variant="border" size="sm" onClick={() => importData.mutate()}>
|
||||
Import
|
||||
</Button>
|
||||
<CreateDropdown hideFolder>
|
||||
<Button variant="border" forDropdown size="sm">
|
||||
New Request
|
||||
</Button>
|
||||
</CreateDropdown>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function useGlobalWorkspaceHooks() {
|
||||
useEnsureActiveCookieJar();
|
||||
|
||||
useSubscribeActiveRequestId();
|
||||
useSubscribeActiveFolderId();
|
||||
useSubscribeActiveEnvironmentId();
|
||||
useSubscribeActiveCookieJarId();
|
||||
|
||||
useSubscribeRecentRequests();
|
||||
useSubscribeRecentWorkspaces();
|
||||
useSubscribeRecentEnvironments();
|
||||
useSubscribeRecentCookieJars();
|
||||
|
||||
useSyncWorkspaceRequestTitle();
|
||||
|
||||
useHotKey("model.duplicate", () =>
|
||||
duplicateRequestOrFolderAndNavigate(jotaiStore.get(activeRequestAtom)),
|
||||
);
|
||||
|
||||
useHotKey("cookies_editor.show", () => CookieDialog.show(getActiveCookieJar()?.id ?? null), {
|
||||
enable: () => getActiveCookieJar() != null,
|
||||
});
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import { getModel, settingsAtom, workspacesAtom } from "@yaakapp-internal/models";
|
||||
import classNames from "classnames";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { memo, useCallback, useMemo } from "react";
|
||||
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
|
||||
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
|
||||
import { switchWorkspace } from "../commands/switchWorkspace";
|
||||
import {
|
||||
activeWorkspaceAtom,
|
||||
activeWorkspaceIdAtom,
|
||||
activeWorkspaceMetaAtom,
|
||||
} from "../hooks/useActiveWorkspace";
|
||||
import { useCreateWorkspace } from "../hooks/useCreateWorkspace";
|
||||
import { useDeleteSendHistory } from "../hooks/useDeleteSendHistory";
|
||||
import { useWorkspaceActions } from "../hooks/useWorkspaceActions";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import { importData } from "../lib/importData";
|
||||
import { jotaiStore } from "../lib/jotai";
|
||||
import { revealInFinderText } from "../lib/reveal";
|
||||
import { CloneGitRepositoryDialog } from "./CloneGitRepositoryDialog";
|
||||
import type { ButtonProps } from "./core/Button";
|
||||
import { Button } from "./core/Button";
|
||||
import type { DropdownItem } from "./core/Dropdown";
|
||||
import { Icon } from "@yaakapp-internal/ui";
|
||||
import type { RadioDropdownItem } from "./core/RadioDropdown";
|
||||
import { RadioDropdown } from "./core/RadioDropdown";
|
||||
import { SwitchWorkspaceDialog } from "./SwitchWorkspaceDialog";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
|
||||
type Props = Pick<ButtonProps, "className" | "justify" | "forDropdown" | "leftSlot">;
|
||||
|
||||
export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
|
||||
className,
|
||||
...buttonProps
|
||||
}: Props) {
|
||||
const workspaces = useAtomValue(workspacesAtom);
|
||||
const workspace = useAtomValue(activeWorkspaceAtom);
|
||||
const createWorkspace = useCreateWorkspace();
|
||||
const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);
|
||||
const { mutate: deleteSendHistory } = useDeleteSendHistory();
|
||||
const workspaceActions = useWorkspaceActions();
|
||||
|
||||
const openCloneGitRepositoryDialog = useCallback(() => {
|
||||
showDialog({
|
||||
id: "clone-git-repository",
|
||||
size: "md",
|
||||
title: "Clone Git Repository",
|
||||
render: ({ hide }) => <CloneGitRepositoryDialog hide={hide} />,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const { workspaceItems, itemsAfter, itemsBefore } = useMemo<{
|
||||
workspaceItems: RadioDropdownItem[];
|
||||
itemsAfter: DropdownItem[];
|
||||
itemsBefore: DropdownItem[];
|
||||
}>(() => {
|
||||
const workspaceItems: RadioDropdownItem[] = workspaces.map((w) => ({
|
||||
key: w.id,
|
||||
label: w.name,
|
||||
value: w.id,
|
||||
leftSlot: w.id === workspace?.id ? <Icon icon="check" /> : <Icon icon="empty" />,
|
||||
}));
|
||||
|
||||
const itemsBefore: DropdownItem[] = [
|
||||
{
|
||||
label: "New Workspace",
|
||||
leftSlot: <Icon icon="plus" />,
|
||||
submenu: [
|
||||
{
|
||||
label: "Create Empty",
|
||||
leftSlot: <Icon icon="plus_circle" />,
|
||||
onSelect: createWorkspace,
|
||||
},
|
||||
{
|
||||
label: "Open Folder",
|
||||
leftSlot: <Icon icon="folder_open" />,
|
||||
onSelect: async () => {
|
||||
const dir = await platform.dialog.open({
|
||||
title: "Select Workspace Directory",
|
||||
directory: true,
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
if (dir == null) return;
|
||||
openWorkspaceFromSyncDir.mutate(dir);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Clone Git Repository",
|
||||
leftSlot: <Icon icon="hard_drive_download" />,
|
||||
onSelect: openCloneGitRepositoryDialog,
|
||||
},
|
||||
{
|
||||
label: "Import Data",
|
||||
leftSlot: <Icon icon="folder_input" />,
|
||||
onSelect: () => importData.mutate(),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const itemsAfter: DropdownItem[] = [
|
||||
...workspaceActions.map((a) => ({
|
||||
label: a.label,
|
||||
leftSlot: <Icon icon={a.icon ?? "empty"} />,
|
||||
onSelect: async () => {
|
||||
if (workspace != null) await a.call(workspace);
|
||||
},
|
||||
})),
|
||||
...(workspaceActions.length > 0 ? [{ type: "separator" as const }] : []),
|
||||
{
|
||||
label: "Workspace Settings",
|
||||
leftSlot: <Icon icon="settings" />,
|
||||
hotKeyAction: "workspace_settings.show",
|
||||
onSelect: openWorkspaceSettings,
|
||||
},
|
||||
{
|
||||
label: revealInFinderText,
|
||||
hidden: workspaceMeta == null || workspaceMeta.settingSyncDir == null,
|
||||
leftSlot: <Icon icon="folder_symlink" />,
|
||||
onSelect: async () => {
|
||||
if (workspaceMeta?.settingSyncDir == null) return;
|
||||
await platform.revealItemInDir(workspaceMeta.settingSyncDir);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Clear Send History",
|
||||
color: "warning",
|
||||
leftSlot: <Icon icon="history" />,
|
||||
onSelect: deleteSendHistory,
|
||||
},
|
||||
];
|
||||
|
||||
return { workspaceItems, itemsAfter, itemsBefore };
|
||||
}, [
|
||||
workspaces,
|
||||
workspaceMeta,
|
||||
deleteSendHistory,
|
||||
createWorkspace,
|
||||
openCloneGitRepositoryDialog,
|
||||
workspace?.id,
|
||||
workspace,
|
||||
workspaceActions.map,
|
||||
workspaceActions.length,
|
||||
]);
|
||||
|
||||
const handleSwitchWorkspace = useCallback(async (workspaceId: string | null) => {
|
||||
if (workspaceId == null) return;
|
||||
|
||||
const settings = jotaiStore.get(settingsAtom);
|
||||
const activeWorkspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||
if (workspaceId === activeWorkspaceId) {
|
||||
// Always open a new window if the selected one is already active
|
||||
switchWorkspace.mutate({ workspaceId, inNewWindow: true });
|
||||
return;
|
||||
}
|
||||
if (typeof settings.openWorkspaceNewWindow === "boolean") {
|
||||
switchWorkspace.mutate({ workspaceId, inNewWindow: settings.openWorkspaceNewWindow });
|
||||
return;
|
||||
}
|
||||
|
||||
const workspace = getModel("workspace", workspaceId);
|
||||
if (workspace == null) return;
|
||||
|
||||
showDialog({
|
||||
id: "switch-workspace",
|
||||
size: "sm",
|
||||
title: "Switch Workspace",
|
||||
render: ({ hide }) => <SwitchWorkspaceDialog workspace={workspace} hide={hide} />,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<RadioDropdown
|
||||
items={workspaceItems}
|
||||
itemsAfter={itemsAfter}
|
||||
itemsBefore={itemsBefore}
|
||||
onChange={handleSwitchWorkspace}
|
||||
value={workspace?.id ?? null}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
className={classNames(
|
||||
className,
|
||||
"text px-2! truncate",
|
||||
workspace === null && "italic opacity-disabled",
|
||||
)}
|
||||
{...buttonProps}
|
||||
>
|
||||
{workspace?.name ?? "Workspace"}
|
||||
</Button>
|
||||
</RadioDropdown>
|
||||
);
|
||||
});
|
||||
@@ -1,94 +0,0 @@
|
||||
import { HStack, Icon } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { memo } from "react";
|
||||
import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from "../hooks/useActiveWorkspace";
|
||||
import { useToggleCommandPalette } from "../hooks/useToggleCommandPalette";
|
||||
import { workspaceLayoutAtom } from "../lib/atoms";
|
||||
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
|
||||
import { CookieDropdown } from "./CookieDropdown";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { PillButton } from "./core/PillButton";
|
||||
import { EnvironmentActionsDropdown } from "./EnvironmentActionsDropdown";
|
||||
import { ImportCurl } from "./ImportCurl";
|
||||
import { LicenseBadge } from "./LicenseBadge";
|
||||
import { RecentRequestsDropdown } from "./RecentRequestsDropdown";
|
||||
import { SettingsDropdown } from "./SettingsDropdown";
|
||||
import { SidebarActions } from "./SidebarActions";
|
||||
import { WorkspaceActionsDropdown } from "./WorkspaceActionsDropdown";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
floatingSidebar?: boolean;
|
||||
}
|
||||
|
||||
export const WorkspaceHeader = memo(function WorkspaceHeader({
|
||||
className,
|
||||
floatingSidebar,
|
||||
}: Props) {
|
||||
const togglePalette = useToggleCommandPalette();
|
||||
const [workspaceLayout, setWorkspaceLayout] = useAtom(workspaceLayoutAtom);
|
||||
const workspace = useAtomValue(activeWorkspaceAtom);
|
||||
const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);
|
||||
const showEncryptionSetup =
|
||||
workspace != null &&
|
||||
workspaceMeta != null &&
|
||||
workspace.encryptionKeyChallenge != null &&
|
||||
workspaceMeta.encryptionKey == null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
"grid grid-cols-[auto_minmax(0,1fr)_auto] items-center w-full h-full",
|
||||
)}
|
||||
>
|
||||
<HStack space={0.5} className={classNames("flex-1 pointer-events-none")}>
|
||||
<SidebarActions floating={floatingSidebar} />
|
||||
<CookieDropdown />
|
||||
<HStack className="min-w-0">
|
||||
<WorkspaceActionsDropdown />
|
||||
<Icon icon="chevron_right" color="secondary" />
|
||||
<EnvironmentActionsDropdown className="w-auto pointer-events-auto" />
|
||||
</HStack>
|
||||
</HStack>
|
||||
<div className="pointer-events-none w-full max-w-[30vw] mx-auto flex justify-center">
|
||||
<RecentRequestsDropdown />
|
||||
</div>
|
||||
<div className="flex-1 flex gap-1 items-center h-full justify-end pointer-events-none pr-1">
|
||||
<ImportCurl />
|
||||
{showEncryptionSetup ? (
|
||||
<PillButton color="danger" onClick={setupOrConfigureEncryption}>
|
||||
Enter Encryption Key
|
||||
</PillButton>
|
||||
) : (
|
||||
<LicenseBadge />
|
||||
)}
|
||||
<IconButton
|
||||
icon={
|
||||
workspaceLayout === "responsive"
|
||||
? "magic_wand"
|
||||
: workspaceLayout === "horizontal"
|
||||
? "columns_2"
|
||||
: "rows_2"
|
||||
}
|
||||
title={`Change to ${workspaceLayout === "horizontal" ? "vertical" : "horizontal"} layout`}
|
||||
size="sm"
|
||||
iconColor="secondary"
|
||||
onClick={() =>
|
||||
setWorkspaceLayout((prev) => (prev === "horizontal" ? "vertical" : "horizontal"))
|
||||
}
|
||||
/>
|
||||
<IconButton
|
||||
icon="search"
|
||||
title="Search or execute a command"
|
||||
size="sm"
|
||||
hotkeyAction="command_palette.toggle"
|
||||
iconColor="secondary"
|
||||
onClick={togglePalette}
|
||||
/>
|
||||
<SettingsDropdown />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { parseBulkPairLine } from "./BulkPairEditor";
|
||||
|
||||
describe("parseBulkPairLine", () => {
|
||||
test("parses colon-space pairs as name and value", () => {
|
||||
expect(parseBulkPairLine("foo: bar")).toMatchObject({
|
||||
enabled: true,
|
||||
name: "foo",
|
||||
value: "bar",
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves colon-without-space lines as a name with an empty value", () => {
|
||||
expect(parseBulkPairLine("foo:bar")).toMatchObject({
|
||||
enabled: true,
|
||||
name: "foo:bar",
|
||||
value: "",
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves malformed lines instead of dropping their contents", () => {
|
||||
expect(parseBulkPairLine("not a pair")).toMatchObject({
|
||||
enabled: true,
|
||||
name: "not a pair",
|
||||
value: "",
|
||||
});
|
||||
});
|
||||
|
||||
test("unescapes newlines in parsed values", () => {
|
||||
expect(parseBulkPairLine("foo: bar\\nbaz")).toMatchObject({
|
||||
enabled: true,
|
||||
name: "foo",
|
||||
value: "bar\nbaz",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
import { Button as BaseButton, type ButtonProps as BaseButtonProps } from "@yaakapp-internal/ui";
|
||||
import { forwardRef, useImperativeHandle, useRef } from "react";
|
||||
import type { HotkeyAction } from "../../hooks/useHotKey";
|
||||
import { useFormattedHotkey, useHotKey } from "../../hooks/useHotKey";
|
||||
|
||||
export type ButtonProps = BaseButtonProps & {
|
||||
hotkeyAction?: HotkeyAction;
|
||||
hotkeyLabelOnly?: boolean;
|
||||
hotkeyPriority?: number;
|
||||
};
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
{ hotkeyAction, hotkeyPriority, hotkeyLabelOnly, title, ...props }: ButtonProps,
|
||||
ref,
|
||||
) {
|
||||
const hotkeyTrigger = useFormattedHotkey(hotkeyAction ?? null)?.join("");
|
||||
const fullTitle = hotkeyTrigger ? `${title ?? ""} ${hotkeyTrigger}`.trim() : title;
|
||||
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
useImperativeHandle<HTMLButtonElement | null, HTMLButtonElement | null>(
|
||||
ref,
|
||||
() => buttonRef.current,
|
||||
);
|
||||
|
||||
useHotKey(
|
||||
hotkeyAction ?? null,
|
||||
() => {
|
||||
buttonRef.current?.click();
|
||||
},
|
||||
{ priority: hotkeyPriority, enable: !hotkeyLabelOnly },
|
||||
);
|
||||
|
||||
return <BaseButton ref={buttonRef} title={fullTitle} {...props} />;
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
import { HStack, Icon } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import type { ReactNode } from "react";
|
||||
import { IconTooltip } from "./IconTooltip";
|
||||
|
||||
export interface CheckboxProps {
|
||||
checked: boolean | "indeterminate";
|
||||
title: ReactNode;
|
||||
onChange: (checked: boolean) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
inputWrapperClassName?: string;
|
||||
hideLabel?: boolean;
|
||||
fullWidth?: boolean;
|
||||
help?: ReactNode;
|
||||
size?: "sm" | "md";
|
||||
}
|
||||
|
||||
export function Checkbox({
|
||||
checked,
|
||||
onChange,
|
||||
className,
|
||||
inputWrapperClassName,
|
||||
disabled,
|
||||
title,
|
||||
hideLabel,
|
||||
fullWidth,
|
||||
help,
|
||||
size = "sm",
|
||||
}: CheckboxProps) {
|
||||
return (
|
||||
<HStack
|
||||
as="label"
|
||||
alignItems="center"
|
||||
space={2}
|
||||
className={classNames(className, "text-text mr-auto")}
|
||||
>
|
||||
<div className={classNames(inputWrapperClassName, "x-theme-input", "relative flex mr-0.5")}>
|
||||
<input
|
||||
aria-hidden
|
||||
className={classNames(
|
||||
"appearance-none shrink-0 border border-border",
|
||||
size === "sm" && "w-4 h-4",
|
||||
size === "md" && "w-5 h-5",
|
||||
"rounded-sm outline-hidden ring-0",
|
||||
!disabled && "hocus:border-border-focus hocus:bg-focus/[5%]",
|
||||
disabled && "border-dotted",
|
||||
)}
|
||||
type="checkbox"
|
||||
disabled={disabled}
|
||||
onChange={() => {
|
||||
onChange(checked === "indeterminate" ? true : !checked);
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Icon
|
||||
size={size}
|
||||
className={classNames(disabled && "opacity-disabled")}
|
||||
icon={checked === "indeterminate" ? "minus" : checked ? "check" : "empty"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!hideLabel && (
|
||||
<div
|
||||
className={classNames("text-sm", fullWidth && "w-full", disabled && "opacity-disabled")}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{help && <IconTooltip content={help} />}
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { Color } from "@yaakapp-internal/plugins";
|
||||
import classNames from "classnames";
|
||||
|
||||
interface Props {
|
||||
count: number | true;
|
||||
count2?: number | true;
|
||||
className?: string;
|
||||
color?: Color;
|
||||
showZero?: boolean;
|
||||
}
|
||||
|
||||
export function CountBadge({ count, count2, className, color, showZero }: Props) {
|
||||
if (count === 0 && !showZero) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className={classNames(
|
||||
className,
|
||||
"flex items-center",
|
||||
"opacity-70 border text-4xs rounded-sm mb-0.5 px-1 ml-1 h-4 font-mono",
|
||||
color == null && "border-border-subtle",
|
||||
color === "primary" && "text-primary",
|
||||
color === "secondary" && "text-secondary",
|
||||
color === "success" && "text-success",
|
||||
color === "notice" && "text-notice",
|
||||
color === "warning" && "text-warning",
|
||||
color === "danger" && "text-danger",
|
||||
)}
|
||||
>
|
||||
{count === true ? (
|
||||
<div aria-hidden className="rounded-full h-1 w-1 bg-[currentColor]" />
|
||||
) : (
|
||||
count
|
||||
)}
|
||||
{count2 != null && (
|
||||
<>
|
||||
/
|
||||
{count2 === true ? (
|
||||
<div aria-hidden className="rounded-full h-1 w-1 bg-[currentColor]" />
|
||||
) : (
|
||||
count2
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import classNames from "classnames";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { atomWithKVStorage } from "../../lib/atoms/atomWithKVStorage";
|
||||
import type { BannerProps } from "@yaakapp-internal/ui";
|
||||
import { Banner } from "@yaakapp-internal/ui";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDetailsElement> {
|
||||
summary: ReactNode;
|
||||
color?: BannerProps["color"];
|
||||
defaultOpen?: boolean;
|
||||
storageKey?: string;
|
||||
}
|
||||
|
||||
export function DetailsBanner({
|
||||
className,
|
||||
color,
|
||||
summary,
|
||||
children,
|
||||
defaultOpen,
|
||||
storageKey,
|
||||
...extraProps
|
||||
}: Props) {
|
||||
// oxlint-disable-next-line react-hooks/exhaustive-deps -- We only want to recompute the atom when storageKey changes
|
||||
const openAtom = useMemo(
|
||||
() =>
|
||||
storageKey
|
||||
? atomWithKVStorage<boolean>(["details_banner", storageKey], defaultOpen ?? false)
|
||||
: atom(defaultOpen ?? false),
|
||||
[storageKey],
|
||||
);
|
||||
|
||||
const [isOpen, setIsOpen] = useAtom(openAtom);
|
||||
|
||||
const handleToggle = (e: React.SyntheticEvent<HTMLDetailsElement>) => {
|
||||
if (storageKey) {
|
||||
setIsOpen(e.currentTarget.open);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Banner color={color} className={className}>
|
||||
<details className="group list-none" open={isOpen} onToggle={handleToggle} {...extraProps}>
|
||||
<summary className="cursor-default! select-none! list-none flex items-center gap-3 focus:outline-hidden opacity-70">
|
||||
<div
|
||||
className={classNames(
|
||||
"transition-transform",
|
||||
"group-open:rotate-90",
|
||||
"w-0 h-0 border-t-[0.3em] border-b-[0.3em] border-l-[0.5em] border-r-0",
|
||||
"border-t-transparent border-b-transparent border-l-text-subtle",
|
||||
)}
|
||||
/>
|
||||
{summary}
|
||||
</summary>
|
||||
<div className="mt-1.5 pb-2">{children}</div>
|
||||
</details>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
import type { Color } from "@yaakapp-internal/plugins";
|
||||
import type { BannerProps } from "@yaakapp-internal/ui";
|
||||
import { Banner } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import type { MouseEvent } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useKeyValue } from "../../hooks/useKeyValue";
|
||||
import type { ButtonProps } from "./Button";
|
||||
import { Button } from "./Button";
|
||||
|
||||
type DismissibleBannerSize = "sm" | "xs";
|
||||
|
||||
export function DismissibleBanner({
|
||||
children,
|
||||
className,
|
||||
id,
|
||||
size = "sm",
|
||||
onDismiss,
|
||||
onShow,
|
||||
actions,
|
||||
...props
|
||||
}: BannerProps & {
|
||||
id: string;
|
||||
size?: DismissibleBannerSize;
|
||||
onDismiss?: () => void | Promise<void>;
|
||||
onShow?: () => void | Promise<void>;
|
||||
actions?: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
color?: Color;
|
||||
variant?: ButtonProps["variant"];
|
||||
}[];
|
||||
}) {
|
||||
const {
|
||||
isLoading,
|
||||
set: setDismissed,
|
||||
value: dismissed,
|
||||
} = useKeyValue<boolean>({
|
||||
namespace: "global",
|
||||
key: ["dismiss-banner", id],
|
||||
fallback: false,
|
||||
});
|
||||
|
||||
const shouldShow = !isLoading && !dismissed;
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldShow) {
|
||||
Promise.resolve(onShow?.()).catch(console.error);
|
||||
}
|
||||
}, [onShow, shouldShow]);
|
||||
|
||||
if (!shouldShow) return null;
|
||||
|
||||
const actionSize: ButtonProps["size"] = size === "xs" ? "2xs" : "xs";
|
||||
const stopParentClick = (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
<Banner
|
||||
className={classNames(
|
||||
className,
|
||||
"relative",
|
||||
size === "xs" && "!px-2 !py-2 text-xs",
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="@container">
|
||||
<div
|
||||
className={classNames(
|
||||
"grid @[34rem]:grid-cols-[minmax(0,1fr)_auto] @[34rem]:items-center",
|
||||
size === "xs" ? "gap-1.5 @[34rem]:gap-2" : "gap-2 @[34rem]:gap-3",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<div className="flex flex-wrap gap-1.5 @[34rem]:justify-end">
|
||||
<Button
|
||||
variant="border"
|
||||
color={props.color}
|
||||
size={actionSize}
|
||||
onClick={(event) => {
|
||||
stopParentClick(event);
|
||||
setDismissed(true).catch(console.error);
|
||||
Promise.resolve(onDismiss?.()).catch(console.error);
|
||||
}}
|
||||
title="Dismiss message"
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
{actions?.map((a) => (
|
||||
<Button
|
||||
key={a.label}
|
||||
variant={a.variant ?? "border"}
|
||||
color={a.color ?? props.color}
|
||||
size={actionSize}
|
||||
onClick={(event) => {
|
||||
stopParentClick(event);
|
||||
a.onClick();
|
||||
}}
|
||||
title={a.label}
|
||||
>
|
||||
{a.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user