Compare commits

..

14 Commits

Author SHA1 Message Date
Gregory Schier 5919fae739 Run oxfmt across repo, add format script and ignore config
Format all non-generated files with oxfmt via `vp fmt`. Add
.oxfmtignore to skip bindings/ and wasm-pack output. Add npm
format script and update DEVELOPMENT.md docs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 09:52:11 -07:00
Gregory Schier a9cccb21b8 Use npm commands in DEVELOPMENT.md for contributor familiarity
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 09:49:03 -07:00
Gregory Schier 1c0435e3ff Update DEVELOPMENT.md for Vite+ toolchain
Replace Biome references with Vite+ (oxlint/oxfmt), add vp as a
prerequisite, and document pre-commit hook and VS Code extensions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 09:48:21 -07:00
Gregory Schier a629a1fa79 Fix wasm-pack output for Vite 8/rolldown production builds
Rewrite generated yaak_templates.js to use Vite's ?init import style
instead of the ES Module Integration style that rolldown doesn't support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 09:02:12 -07:00
Gregory Schier dde8d61b4b Run bootstrap 2026-03-13 08:50:43 -07:00
Gregory Schier 08a64b6938 VSCode suggestion and CI 2026-03-13 08:44:52 -07:00
Gregory Schier 560c4667e4 More fixes 2026-03-13 08:32:40 -07:00
Gregory Schier 21f775741a More fixes 2026-03-13 08:29:36 -07:00
Gregory Schier 44a331929f Enable type-aware linting and replace biome-ignore with oxlint-disable
- Enable typeAware option and no-explicit-any (error) in vite.config.ts
- Ignore generated binding files from linting
- Convert all 96 biome-ignore comments to oxlint-disable equivalents
- Add suppression comments for 3 previously uncovered any usages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 08:24:45 -07:00
Gregory Schier 7670ab007f Fix lint errors 2026-03-13 08:12:21 -07:00
Gregory Schier be34dfe74a Fix no-base-to-string 2026-03-13 07:48:50 -07:00
Gregory Schier e4103f1a4a Fix some lint 2026-03-13 07:36:26 -07:00
Gregory Schier 7f4eedd630 Get lint running 2026-03-13 07:32:55 -07:00
Gregory Schier 49659a3da9 Migrate to Vite+ (vite-plus) unified toolchain
Replace Vite/Vitest with vite-plus, update WASM loading to native Vite 8
?init pattern, switch React compiler to @rolldown/plugin-babel, and
migrate git hooks from husky to vite-hooks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 07:23:11 -07:00
885 changed files with 9225 additions and 26591 deletions
+7 -7
View File
@@ -8,7 +8,7 @@ Make Yaak runnable as a standalone CLI without Tauri as a dependency. The core R
``` ```
crates/ # Core crates - should NOT depend on Tauri crates/ # Core crates - should NOT depend on Tauri
crates-tauri/ # Tauri-specific crates (yaak-app-client, yaak-tauri-utils, etc.) crates-tauri/ # Tauri-specific crates (yaak-app, yaak-tauri-utils, etc.)
crates-cli/ # CLI crate (yaak-cli) crates-cli/ # CLI crate (yaak-cli)
``` ```
@@ -16,7 +16,7 @@ crates-cli/ # CLI crate (yaak-cli)
### 1. Folder Restructure ### 1. Folder Restructure
- Moved Tauri-dependent app code to `crates-tauri/yaak-app-client/` - Moved Tauri-dependent app code to `crates-tauri/yaak-app/`
- Created `crates-tauri/yaak-tauri-utils/` for shared Tauri utilities (window traits, api_client, error handling) - Created `crates-tauri/yaak-tauri-utils/` for shared Tauri utilities (window traits, api_client, error handling)
- Created `crates-cli/yaak-cli/` for the standalone CLI - Created `crates-cli/yaak-cli/` for the standalone CLI
@@ -50,14 +50,14 @@ crates-cli/ # CLI crate (yaak-cli)
3. Move extension traits (e.g., `SomethingManagerExt`) to yaak-app or yaak-tauri-utils 3. Move extension traits (e.g., `SomethingManagerExt`) to yaak-app or yaak-tauri-utils
4. Initialize managers in yaak-app's `.setup()` block 4. Initialize managers in yaak-app's `.setup()` block
5. Remove `tauri` from Cargo.toml dependencies 5. Remove `tauri` from Cargo.toml dependencies
6. Update `crates-tauri/yaak-app-client/capabilities/default.json` to remove the plugin permission 6. Update `crates-tauri/yaak-app/capabilities/default.json` to remove the plugin permission
7. Replace `tauri::async_runtime::block_on` with `tokio::runtime::Handle::current().block_on()` 7. Replace `tauri::async_runtime::block_on` with `tokio::runtime::Handle::current().block_on()`
## Key Files ## Key Files
- `crates-tauri/yaak-app-client/src/lib.rs` - Main Tauri app, setup block initializes managers - `crates-tauri/yaak-app/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/src/commands.rs` - Migrated Tauri commands
- `crates-tauri/yaak-app-client/src/models_ext.rs` - Database plugin and extension traits - `crates-tauri/yaak-app/src/models_ext.rs` - Database plugin and extension traits
- `crates-tauri/yaak-tauri-utils/src/window.rs` - WorkspaceWindowTrait for window state - `crates-tauri/yaak-tauri-utils/src/window.rs` - WorkspaceWindowTrait for window state
- `crates/yaak-models/src/lib.rs` - Contains `init_standalone()` for CLI usage - `crates/yaak-models/src/lib.rs` - Contains `init_standalone()` for CLI usage
@@ -79,5 +79,5 @@ e718a5f1 Refactor models_ext to use init_standalone from yaak-models
## Testing ## Testing
- Run `cargo check -p <crate>` to verify a crate builds without Tauri - Run `cargo check -p <crate>` to verify a crate builds without Tauri
- Run `npm run client:dev` to test the Tauri app still works - Run `npm run app-dev` to test the Tauri app still works
- Run `cargo run -p yaak-cli -- --help` to test the CLI - Run `cargo run -p yaak-cli -- --help` to test the CLI
@@ -0,0 +1,50 @@
---
description: Generate formatted release notes for Yaak releases
allowed-tools: Bash(git tag:*)
---
Generate formatted release notes for Yaak releases by analyzing git history and pull request descriptions.
## What to do
1. Identifies the version tag and previous version
2. Retrieves all commits between versions
- If the version is a beta version, it retrieves commits between the beta version and previous beta version
- If the version is a stable version, it retrieves commits between the stable version and the previous stable version
3. Fetches PR descriptions for linked issues to find:
- Feedback URLs (feedback.yaak.app)
- Additional context and descriptions
- Installation links for plugins
4. Formats the release notes using the standard Yaak format:
- Changelog badge at the top
- Bulleted list of changes with PR links
- Feedback links where available
- Full changelog comparison link at the bottom
## Output Format
The skill generates markdown-formatted release notes following this structure:
```markdown
[![Changelog](https://img.shields.io/badge/Changelog-VERSION-blue)](https://yaak.app/changelog/VERSION)
- Feature/fix description in by @username [#123](https://github.com/mountain-loop/yaak/pull/123)
- [Linked feedback item](https://feedback.yaak.app/p/item) by @username in [#456](https://github.com/mountain-loop/yaak/pull/456)
- A simple item that doesn't have a feedback or PR link
**Full Changelog**: https://github.com/mountain-loop/yaak/compare/vPREV...vCURRENT
```
**IMPORTANT**: Always add a blank lines around the markdown code fence and output the markdown code block last
**IMPORTANT**: PRs by `@gschier` should not mention the @username
**IMPORTANT**: These are app release notes. Exclude CLI-only changes (commits prefixed with `cli:` or only touching `crates-cli/`) since the CLI has its own release process.
## After Generating Release Notes
After outputting the release notes, ask the user if they would like to create a draft GitHub release with these notes. If they confirm, create the release using:
```bash
gh release create <tag> --draft --prerelease --title "Release <version>" --notes '<release notes>'
```
**IMPORTANT**: The release title format is "Release XXXX" where XXXX is the version WITHOUT the `v` prefix. For example, tag `v2026.2.1-beta.1` gets title "Release 2026.2.1-beta.1".
@@ -19,12 +19,10 @@ Generate formatted markdown release notes for a Yaak tag.
- `gh pr view <PR_NUMBER> --json number,title,body,author,url` - `gh pr view <PR_NUMBER> --json number,title,body,author,url`
5. Extract useful details: 5. Extract useful details:
- Feedback URLs (`feedback.yaak.app`) - Feedback URLs (`feedback.yaak.app`)
- Contributor GitHub handles from `author.login`
- Plugin install links or other notable context - Plugin install links or other notable context
6. Format notes using Yaak style: 6. Format notes using Yaak style:
- Changelog badge at top - Changelog badge at top
- Bulleted items with PR links where available - Bulleted items with PR links where available
- Contributor handles for external PRs
- Feedback links where available - Feedback links where available
- Full changelog compare link at bottom - Full changelog compare link at bottom
@@ -33,7 +31,6 @@ Generate formatted markdown release notes for a Yaak tag.
- Wrap final notes in a markdown code fence. - Wrap final notes in a markdown code fence.
- Keep a blank line before and after the code fence. - Keep a blank line before and after the code fence.
- Output the markdown code block last. - 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`. - 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. - 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.
-104
View File
@@ -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 -2
View File
@@ -1,5 +1,5 @@
crates-tauri/yaak-app-client/vendored/**/* linguist-generated=true crates-tauri/yaak-app/vendored/**/* linguist-generated=true
crates-tauri/yaak-app-client/gen/schemas/**/* linguist-generated=true crates-tauri/yaak-app/gen/schemas/**/* linguist-generated=true
**/bindings/* linguist-generated=true **/bindings/* linguist-generated=true
crates/yaak-templates/pkg/* linguist-generated=true crates/yaak-templates/pkg/* linguist-generated=true
+4 -7
View File
@@ -4,16 +4,13 @@
## Submission ## 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 or small-scope improvement.
- [ ] If this PR is not a bug fix or small-scope improvement, I linked an approved feedback item below.
- [ ] 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 have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).
- [ ] I tested this change locally. - [ ] I tested this change locally.
- [ ] I added or updated tests, or tests are not reasonable for this change. - [ ] I added or updated tests when reasonable.
- [ ] I added screenshots or recordings, or this change does not affect the UI.
Explicit permission feedback item (required if not a bug fix): Approved feedback item (required if not a bug fix or small-scope improvement):
<!-- https://yaak.app/feedback/... --> <!-- https://yaak.app/feedback/... -->
@@ -1,865 +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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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 (
minimumAutomaticPullNumber != null &&
pr.number < minimumAutomaticPullNumber
) {
core.notice(
`Skipping contribution policy for PR #${pr.number} because automatic checks start at PR #${minimumAutomaticPullNumber}.`,
);
return {
blocked: false,
number: pr.number,
summary: summarizeResult({
pr,
skipped: true,
skipReason: `before automatic rollout PR #${minimumAutomaticPullNumber}`,
}),
skipped: true,
};
}
if (pr.draft) {
core.notice(`Skipping contribution policy for draft PR #${pr.number}.`);
return {
blocked: false,
number: pr.number,
summary: summarizeResult({
pr,
skipped: true,
skipReason: "draft",
}),
skipped: true,
};
}
if (await isOfficialMaintainer({ github, owner, repo, pr })) {
core.notice(
`Skipping contribution policy for maintainer PR #${pr.number} from @${pr.user.login}.`,
);
if (!dryRun) {
await syncLabels({ github, owner, repo, issueNumber, desiredLabels: [] });
await deletePolicyComment({ github, owner, repo, issueNumber });
}
return {
blocked: false,
number: pr.number,
summary: summarizeResult({
pr,
skipped: true,
skipReason: `maintainer @${pr.user.login}`,
}),
skipped: true,
};
}
const analysis = analyzePullRequest(pr);
if (dryRun) {
const summary = summarizeResult({ pr, analysis });
core.notice(
`[dry-run] PR #${summary.number}: ${summary.status}; labels: ${summary.labels}; details: ${summary.details}`,
);
return {
blocked: analysis.blockers.length > 0,
number: pr.number,
summary,
skipped: false,
};
}
await syncLabels({
github,
owner,
repo,
issueNumber,
desiredLabels: analysis.desiredLabels,
});
if (analysis.blockers.length > 0) {
await upsertPolicyComment({
github,
owner,
repo,
issueNumber,
body: buildPolicyComment(analysis),
});
return {
blocked: true,
number: pr.number,
summary: summarizeResult({ pr, analysis }),
skipped: false,
};
}
await upsertPolicyComment({
github,
owner,
repo,
issueNumber,
body: buildPolicyComment(analysis),
});
await requestMaintainerReview({ github, owner, repo, pr });
core.notice(`Contribution policy check passed for PR #${pr.number}.`);
return {
blocked: false,
number: pr.number,
summary: summarizeResult({ pr, analysis }),
skipped: false,
};
}
async function listOpenPullRequests({ github, owner, repo }) {
return github.paginate(github.rest.pulls.list, {
owner,
repo,
state: "open",
per_page: 100,
});
}
function getManualPullRequestNumbers({ context, core }) {
const value = String(context.payload.inputs?.pr || "all").trim();
if (value.toLowerCase() === "all") {
return null;
}
const pullNumber = Number(value);
if (!Number.isInteger(pullNumber) || pullNumber <= 0) {
core.setFailed('The "pr" input must be "all" or a positive PR number.');
return [];
}
return [pullNumber];
}
async function run({ github, context, core }) {
const { owner, repo } = context.repo;
const payloadPr = context.payload.pull_request;
const dryRunInput = context.payload.inputs?.dry_run;
const dryRun =
context.eventName === "workflow_dispatch" &&
dryRunInput !== false &&
dryRunInput !== "false";
const minimumAutomaticPullNumber =
payloadPr == null ? null : MIN_AUTOMATIC_PR_NUMBER;
let pullNumbers;
if (payloadPr != null) {
pullNumbers = [payloadPr.number];
} else {
pullNumbers = getManualPullRequestNumbers({ context, core });
}
if (pullNumbers?.length === 0) {
return;
}
const pullRequests =
pullNumbers == null
? await listOpenPullRequests({ github, owner, repo })
: pullNumbers.map((number) => ({ number }));
const results = [];
if (dryRun) {
core.notice(
`Running contribution policy in dry-run mode for ${
pullNumbers == null
? "all open PRs"
: pullNumbers.map((number) => `#${number}`).join(", ")
}.`,
);
}
for (const pr of pullRequests) {
results.push(
await checkPullRequest({
github,
core,
owner,
repo,
pullNumber: pr.number,
dryRun,
minimumAutomaticPullNumber,
}),
);
}
await core.summary
.addHeading(`Contribution Policy ${dryRun ? "Dry Run" : "Results"}`)
.addTable([
[
{ data: "PR", header: true },
{ data: "Title", header: true },
{ data: "Status", header: true },
{ data: "Labels", header: true },
{ data: "Details", header: true },
{ data: "Comment", header: true },
],
...results.map((result) => [
result.summary.prLink,
result.summary.title,
result.summary.status,
result.summary.labels,
result.summary.details,
result.summary.comment,
]),
])
.write();
const blockedPullRequests = results.filter((result) => result.blocked);
if (blockedPullRequests.length > 0) {
if (dryRun) {
core.warning(
`Dry run found contribution policy failures for PR(s): ${blockedPullRequests
.map((result) => `#${result.number}`)
.join(", ")}`,
);
return;
}
core.setFailed(
`Contribution policy failed for PR(s): ${blockedPullRequests
.map((result) => `#${result.number}`)
.join(", ")}`,
);
}
}
module.exports = {
analyzePullRequest,
run,
};
+1 -1
View File
@@ -30,4 +30,4 @@ jobs:
- name: Run JS Tests - name: Run JS Tests
run: vp test run: vp test
- name: Run Rust Tests - name: Run Rust Tests
run: cargo test --all --features yaak-app-client/wry run: cargo test --all
+49
View File
@@ -0,0 +1,49 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'
-48
View File
@@ -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 });
+6 -10
View File
@@ -1,21 +1,17 @@
name: Update Flathub name: Update Flathub
on: on:
workflow_dispatch: release:
inputs: types: [published]
tag:
description: Release tag to publish to Flathub
required: true
type: string
permissions: permissions:
contents: read contents: read
jobs: jobs:
update-flathub: update-flathub:
if: github.repository == 'mountain-loop/yaak'
name: Update Flathub manifest name: Update Flathub manifest
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Only run for stable releases (skip betas/pre-releases)
if: ${{ !github.event.release.prerelease }}
steps: steps:
- name: Checkout app repo - name: Checkout app repo
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -43,7 +39,7 @@ jobs:
git clone --depth 1 https://github.com/flatpak/flatpak-builder-tools flatpak/flatpak-builder-tools git clone --depth 1 https://github.com/flatpak/flatpak-builder-tools flatpak/flatpak-builder-tools
- name: Run update-manifest.sh - name: Run update-manifest.sh
run: bash flatpak/update-manifest.sh "${{ inputs.tag }}" flathub-repo run: bash flatpak/update-manifest.sh "${{ github.event.release.tag_name }}" flathub-repo
- name: Commit and push to Flathub - name: Commit and push to Flathub
working-directory: flathub-repo working-directory: flathub-repo
@@ -52,5 +48,5 @@ jobs:
git config user.email "github-actions[bot]@users.noreply.github.com" git config user.email "github-actions[bot]@users.noreply.github.com"
git add -A git add -A
git diff --cached --quiet && echo "No changes to commit" && exit 0 git diff --cached --quiet && echo "No changes to commit" && exit 0
git commit -m "Update to ${{ inputs.tag }}" git commit -m "Update to ${{ github.event.release.tag_name }}"
git push git push
-1
View File
@@ -15,7 +15,6 @@ permissions:
jobs: jobs:
publish-npm: publish-npm:
if: github.repository == 'mountain-loop/yaak'
name: Publish @yaakapp/api name: Publish @yaakapp/api
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
+12 -64
View File
@@ -5,7 +5,6 @@ on:
jobs: jobs:
build-artifacts: build-artifacts:
if: github.repository == 'mountain-loop/yaak'
permissions: permissions:
contents: write contents: write
@@ -15,59 +14,35 @@ jobs:
matrix: matrix:
include: include:
- platform: "macos-latest" # for Arm-based Macs (M1 and above). - 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"]}}''' args: "--target aarch64-apple-darwin"
yaak_arch: "arm64" yaak_arch: "arm64"
os: "macos" os: "macos"
runtime: "wry"
targets: "aarch64-apple-darwin" targets: "aarch64-apple-darwin"
- platform: "macos-latest" # for Intel-based Macs. - platform: "macos-latest" # for Intel-based Macs.
args: '--target x86_64-apple-darwin --config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}''' args: "--target x86_64-apple-darwin"
yaak_arch: "x64" yaak_arch: "x64"
os: "macos" os: "macos"
runtime: "wry"
targets: "x86_64-apple-darwin" targets: "x86_64-apple-darwin"
- platform: "ubuntu-22.04" - platform: "ubuntu-22.04"
args: '--config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}''' args: ""
yaak_arch: "x64" yaak_arch: "x64"
os: "ubuntu" os: "ubuntu"
runtime: "wry"
targets: "" targets: ""
- platform: "ubuntu-22.04-arm" - platform: "ubuntu-22.04-arm"
args: '--config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}''' args: ""
yaak_arch: "arm64" yaak_arch: "arm64"
os: "ubuntu" 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: "" targets: ""
- platform: "windows-latest" - platform: "windows-latest"
args: '--config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}''' args: ""
yaak_arch: "x64" yaak_arch: "x64"
os: "windows" os: "windows"
runtime: "wry"
targets: "" targets: ""
# Windows ARM64 # Windows ARM64
- platform: "windows-latest" - platform: "windows-latest"
args: '--target aarch64-pc-windows-msvc --config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}''' args: "--target aarch64-pc-windows-msvc"
yaak_arch: "arm64" yaak_arch: "arm64"
os: "windows" os: "windows"
runtime: "wry"
targets: "aarch64-pc-windows-msvc" targets: "aarch64-pc-windows-msvc"
runs-on: ${{ matrix.platform }} runs-on: ${{ matrix.platform }}
timeout-minutes: 40 timeout-minutes: 40
@@ -91,18 +66,11 @@ jobs:
shared-key: ci shared-key: ci
cache-on-failure: true 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) - name: install dependencies (Linux only)
if: matrix.os == 'ubuntu' if: matrix.os == 'ubuntu'
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y cmake ninja-build libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libnss3 patchelf xdg-utils sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
- name: Install Protoc for plugin-runtime - name: Install Protoc for plugin-runtime
uses: arduino/setup-protoc@v3 uses: arduino/setup-protoc@v3
@@ -130,7 +98,7 @@ jobs:
- name: Run JS Tests - name: Run JS Tests
run: vp test run: vp test
- name: Run Rust Tests - name: Run Rust Tests
run: cargo test --all --exclude yaak-cli --features yaak-app-client/wry run: cargo test --all --exclude yaak-cli
- name: Set version - name: Set version
run: npm run replace-version run: npm run replace-version
@@ -157,8 +125,8 @@ jobs:
security list-keychain -d user -s $KEYCHAIN_PATH security list-keychain -d user -s $KEYCHAIN_PATH
# Sign vendored binaries with hardened runtime and their specific entitlements # 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/macos/entitlements.yaakprotoc.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app/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 codesign --force --options runtime --entitlements crates-tauri/yaak-app/macos/entitlements.yaaknode.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app/vendored/node/yaaknode || true
- uses: tauri-apps/tauri-action@v0 - uses: tauri-apps/tauri-action@v0
env: env:
@@ -182,30 +150,12 @@ jobs:
AZURE_CLIENT_SECRET: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_SECRET }} AZURE_CLIENT_SECRET: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_SECRET }}
AZURE_TENANT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_TENANT_ID }} AZURE_TENANT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_TENANT_ID }}
with: with:
tauriScript: "node ../../node_modules/@tauri-apps/cli/tauri.js"
tagName: "v__VERSION__" tagName: "v__VERSION__"
releaseName: "Release __VERSION__" releaseName: "Release __VERSION__"
releaseBody: "[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)" releaseBody: "[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)"
releaseDraft: true releaseDraft: true
prerelease: true prerelease: true
projectPath: ./crates-tauri/yaak-app-client args: "${{ matrix.args }} --config ./crates-tauri/yaak-app/tauri.release.conf.json"
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) # Build a per-machine NSIS installer for enterprise deployment (PDQ, SCCM, Intune)
- name: Build and upload machine-wide installer (Windows only) - name: Build and upload machine-wide installer (Windows only)
@@ -221,9 +171,7 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
run: | run: |
Get-ChildItem -Recurse -Path target -File -Filter "*.exe.sig" | Remove-Item -Force 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 ./crates-tauri/yaak-app/tauri.release.conf.json --config '{"bundle":{"createUpdaterArtifacts":true,"windows":{"nsis":{"installMode":"perMachine"}}}}'
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 $setup = Get-ChildItem -Recurse -Path target -Filter "*setup*.exe" | Select-Object -First 1
$setupSig = "$($setup.FullName).sig" $setupSig = "$($setup.FullName).sig"
$dest = $setup.FullName -replace '-setup\.exe$', '-setup-machine.exe' $dest = $setup.FullName -replace '-setup\.exe$', '-setup-machine.exe'
+6 -7
View File
@@ -2,7 +2,7 @@ name: Release CLI to NPM
on: on:
push: push:
tags: [v*] tags: [yaak-cli-*]
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version: version:
@@ -15,7 +15,6 @@ permissions:
jobs: jobs:
prepare-vendored-assets: prepare-vendored-assets:
if: github.repository == 'mountain-loop/yaak'
name: Prepare vendored plugin assets name: Prepare vendored plugin assets
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -46,8 +45,8 @@ jobs:
with: with:
name: vendored-assets name: vendored-assets
path: | path: |
crates-tauri/yaak-app-client/vendored/plugin-runtime/index.cjs crates-tauri/yaak-app/vendored/plugin-runtime/index.cjs
crates-tauri/yaak-app-client/vendored/plugins crates-tauri/yaak-app/vendored/plugins
if-no-files-found: error if-no-files-found: error
build-binaries: build-binaries:
@@ -108,7 +107,7 @@ jobs:
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
name: vendored-assets name: vendored-assets
path: crates-tauri/yaak-app-client/vendored path: crates-tauri/yaak-app/vendored
- name: Set CLI build version - name: Set CLI build version
shell: bash shell: bash
@@ -119,7 +118,7 @@ jobs:
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="$WORKFLOW_VERSION" VERSION="$WORKFLOW_VERSION"
else else
VERSION="${GITHUB_REF_NAME}" VERSION="${GITHUB_REF_NAME#yaak-cli-}"
fi fi
VERSION="${VERSION#v}" VERSION="${VERSION#v}"
echo "Building yaak version: $VERSION" echo "Building yaak version: $VERSION"
@@ -176,7 +175,7 @@ jobs:
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="$WORKFLOW_VERSION" VERSION="$WORKFLOW_VERSION"
else else
VERSION="${GITHUB_REF_NAME}" VERSION="${GITHUB_REF_NAME#yaak-cli-}"
fi fi
VERSION="${VERSION#v}" VERSION="${VERSION#v}"
if [[ "$VERSION" == *-* ]]; then if [[ "$VERSION" == *-* ]]; then
-1
View File
@@ -7,7 +7,6 @@ permissions:
contents: write contents: write
jobs: jobs:
deploy: deploy:
if: github.repository == 'mountain-loop/yaak'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout 🛎️ - name: Checkout 🛎️
+1 -2
View File
@@ -39,8 +39,7 @@ codebook.toml
target target
# Per-worktree Tauri config (generated by post-checkout hook) # Per-worktree Tauri config (generated by post-checkout hook)
crates-tauri/yaak-app-client/tauri.worktree.conf.json crates-tauri/yaak-app/tauri.worktree.conf.json
crates-tauri/yaak-app-proxy/tauri.worktree.conf.json
# Tauri auto-generated permission files # Tauri auto-generated permission files
**/permissions/autogenerated **/permissions/autogenerated
+1
View File
@@ -0,0 +1 @@
20
-1
View File
@@ -1,3 +1,2 @@
**/bindings/** **/bindings/**
**/routeTree.gen.ts
crates/yaak-templates/pkg/** crates/yaak-templates/pkg/**
-8
View File
@@ -1,8 +0,0 @@
{
"printWidth": 100,
"ignorePatterns": [
"**/bindings/**",
"crates/yaak-templates/pkg/**",
"apps/yaak-client/routeTree.gen.ts"
]
}
-1
View File
@@ -1,2 +1 @@
vp lint vp lint
vp staged
+1 -1
View File
@@ -1,2 +1,2 @@
- 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. - Tag safety: app releases use `v*` tags and CLI releases use `yaak-cli-*` tags; always confirm which one is requested before retagging.
- Do not commit, push, or tag without explicit approval - Do not commit, push, or tag without explicit approval
+2 -1
View File
@@ -3,12 +3,13 @@
Yaak accepts community pull requests for: Yaak accepts community pull requests for:
- Bug fixes - Bug fixes
- Small-scope improvements directly tied to existing behavior
Pull requests that introduce broad new features, major redesigns, or large refactors are out of scope unless explicitly approved first. 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 ## 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. If your PR is not a bug fix or small-scope improvement, include a link to the approved [feedback item](https://yaak.app/feedback) where contribution approval was explicitly stated.
## Development Setup ## Development Setup
Generated
+376 -1165
View File
File diff suppressed because it is too large Load Diff
+5 -32
View File
@@ -2,9 +2,6 @@
resolver = "2" resolver = "2"
members = [ members = [
"crates/yaak", "crates/yaak",
# Common/foundation crates
"crates/common/yaak-database",
"crates/common/yaak-rpc",
# Shared crates (no Tauri dependency) # Shared crates (no Tauri dependency)
"crates/yaak-core", "crates/yaak-core",
"crates/yaak-common", "crates/yaak-common",
@@ -20,20 +17,14 @@ members = [
"crates/yaak-tls", "crates/yaak-tls",
"crates/yaak-ws", "crates/yaak-ws",
"crates/yaak-api", "crates/yaak-api",
"crates/yaak-proxy",
# Proxy-specific crates
"crates-proxy/yaak-proxy-lib",
# CLI crates # CLI crates
"crates-cli/yaak-cli", "crates-cli/yaak-cli",
# Tauri-specific crates # Tauri-specific crates
"crates-tauri/yaak-app-client", "crates-tauri/yaak-app",
"crates-tauri/yaak-app-proxy",
"crates-tauri/yaak-fonts", "crates-tauri/yaak-fonts",
"crates-tauri/yaak-license", "crates-tauri/yaak-license",
"crates-tauri/yaak-mac-window", "crates-tauri/yaak-mac-window",
"crates-tauri/yaak-system-appearance",
"crates-tauri/yaak-tauri-utils", "crates-tauri/yaak-tauri-utils",
"crates-tauri/yaak-window",
] ]
[workspace.dependencies] [workspace.dependencies]
@@ -48,22 +39,14 @@ schemars = { version = "0.8.22", features = ["chrono"] }
serde = "1.0.228" serde = "1.0.228"
serde_json = "1.0.145" serde_json = "1.0.145"
sha2 = "0.10.9" sha2 = "0.10.9"
tauri = { version = "2.11.1", default-features = false, features = [ tauri = "2.9.5"
"common-controls-v6", tauri-plugin = "2.5.2"
"compression", tauri-plugin-dialog = "2.4.2"
"dynamic-acl", tauri-plugin-shell = "2.3.3"
] }
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" thiserror = "2.0.17"
tokio = "1.48.0" tokio = "1.48.0"
ts-rs = "11.1.0" ts-rs = "11.1.0"
# Internal crates - common/foundation
yaak-database = { path = "crates/common/yaak-database" }
yaak-rpc = { path = "crates/common/yaak-rpc" }
# Internal crates - shared # Internal crates - shared
yaak-core = { path = "crates/yaak-core" } yaak-core = { path = "crates/yaak-core" }
yaak = { path = "crates/yaak" } yaak = { path = "crates/yaak" }
@@ -80,22 +63,12 @@ yaak-templates = { path = "crates/yaak-templates" }
yaak-tls = { path = "crates/yaak-tls" } yaak-tls = { path = "crates/yaak-tls" }
yaak-ws = { path = "crates/yaak-ws" } yaak-ws = { path = "crates/yaak-ws" }
yaak-api = { path = "crates/yaak-api" } yaak-api = { path = "crates/yaak-api" }
yaak-proxy = { path = "crates/yaak-proxy" }
# Internal crates - proxy
yaak-proxy-lib = { path = "crates-proxy/yaak-proxy-lib" }
# Internal crates - Tauri-specific # Internal crates - Tauri-specific
yaak-fonts = { path = "crates-tauri/yaak-fonts" } yaak-fonts = { path = "crates-tauri/yaak-fonts" }
yaak-license = { path = "crates-tauri/yaak-license" } yaak-license = { path = "crates-tauri/yaak-license" }
yaak-mac-window = { path = "crates-tauri/yaak-mac-window" } yaak-mac-window = { path = "crates-tauri/yaak-mac-window" }
yaak-system-appearance = { path = "crates-tauri/yaak-system-appearance" }
yaak-tauri-utils = { path = "crates-tauri/yaak-tauri-utils" } yaak-tauri-utils = { path = "crates-tauri/yaak-tauri-utils" }
yaak-window = { path = "crates-tauri/yaak-window" }
[profile.release] [profile.release]
strip = false strip = false
[patch.crates-io]
tauri = { git = "https://github.com/tauri-apps/tauri", rev = "d9bc695c18d9a25baec21d8a5f36d72e3a14ee53" }
tauri-build = { git = "https://github.com/tauri-apps/tauri", rev = "d9bc695c18d9a25baec21d8a5f36d72e3a14ee53" }
+3 -3
View File
@@ -1,6 +1,6 @@
<p align="center"> <p align="center">
<a href="https://github.com/JamesIves/github-sponsors-readme-action"> <a href="https://github.com/JamesIves/github-sponsors-readme-action">
<img width="200px" src="https://github.com/mountain-loop/yaak/raw/main/crates-tauri/yaak-app-client/icons/icon.png"> <img width="200px" src="https://github.com/mountain-loop/yaak/raw/main/crates-tauri/yaak-app/icons/icon.png">
</a> </a>
</p> </p>
@@ -57,8 +57,8 @@ Built with [Tauri](https://tauri.app), Rust, and React, its fast, lightweight
## Contribution Policy ## Contribution Policy
> [!IMPORTANT] > [!IMPORTANT]
> Community PRs are currently limited to bug fixes. > Community PRs are currently limited to bug fixes and small-scope improvements.
> 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. > If your PR is out of scope, link an approved feedback item from [yaak.app/feedback](https://yaak.app/feedback).
> See [`CONTRIBUTING.md`](CONTRIBUTING.md) for policy details and [`DEVELOPMENT.md`](DEVELOPMENT.md) for local setup. > See [`CONTRIBUTING.md`](CONTRIBUTING.md) for policy details and [`DEVELOPMENT.md`](DEVELOPMENT.md) for local setup.
## Useful Resources ## Useful Resources
@@ -1,130 +0,0 @@
import { invoke } from "@tauri-apps/api/core";
import { openUrl } from "@tauri-apps/plugin-opener";
import type { LicenseCheckStatus } 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";
const COMMERCIAL_USE_SNOOZE_MS = 7 * 24 * 60 * 60 * 1000;
const COMMERCIAL_USE_BANNER_MESSAGE =
"Personal use of Yaak is free. If youre 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 invoke<LicenseCheckStatus>("plugin:yaak-license|check");
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 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,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,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 { invokeCmd } from "../lib/tauri";
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
invokeCmd("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,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,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">&bull;</span>
<span className="font-mono">{r.elapsed >= 0 ? formatMillis(r.elapsed) : "n/a"}</span>
<span className="text-text-subtlest">&bull;</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,180 +0,0 @@
import { revealItemInDir } from "@tauri-apps/plugin-opener";
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { Heading, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { 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 {
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: () => revealItemInDir(appInfo.appDataDir),
},
]}
/>
</SettingRow>
<SettingRow
title="Logs Directory"
description="Where Yaak writes application logs."
controlClassName="min-w-0 max-w-[min(42rem,55vw)] gap-2"
>
<SettingValue
value={appInfo.appLogDir}
actions={[
{
title: revealInFinderText,
icon: "folder_open",
onClick: () => revealItemInDir(appInfo.appLogDir),
},
]}
/>
</SettingRow>
</SettingsSection>
</SettingsList>
</VStack>
);
}
@@ -1,274 +0,0 @@
import { type } from "@tauri-apps/plugin-os";
import { useFonts } from "@yaakapp-internal/fonts";
import { useLicense } from "@yaakapp-internal/license";
import type { EditorKeymap, Settings } from "@yaakapp-internal/models";
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { clamp, Heading, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useState } from "react";
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
import { showConfirm } from "../../lib/confirm";
import { pricingUrl } from "../../lib/pricingUrl";
import { invokeCmd } from "../../lib/tauri";
import { CargoFeature } from "../CargoFeature";
import { Button } from "../core/Button";
import { Checkbox } from "../core/Checkbox";
import { Link } from "../core/Link";
import {
ModelSettingRowBoolean,
ModelSettingRowSelect,
SettingRow,
SettingRowBoolean,
SettingRowSelect,
SettingSelectControl,
SettingsList,
SettingsSection,
} from "../core/SettingRow";
const NULL_FONT_VALUE = "__NULL_FONT__";
const fontSizeOptions = [
8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30,
].map((n) => ({ label: `${n}`, value: `${n}` }));
const keymaps: { value: EditorKeymap; label: string }[] = [
{ value: "default", label: "Default" },
{ value: "vim", label: "Vim" },
{ value: "vscode", label: "VSCode" },
{ value: "emacs", label: "Emacs" },
];
export function SettingsInterface() {
const workspace = useAtomValue(activeWorkspaceAtom);
const settings = useAtomValue(settingsAtom);
const fonts = useFonts();
if (settings == null || workspace == null) {
return null;
}
return (
<VStack space={1.5} className="mb-4">
<div className="mb-3">
<Heading>Interface</Heading>
<p className="text-text-subtle">Tweak settings related to the user interface.</p>
</div>
<SettingsList className="space-y-8">
<SettingsSection title="Workspaces">
<SettingRowSelect
title="Open workspace behavior"
description="Choose what happens when opening another workspace."
name="switchWorkspaceBehavior"
value={
settings.openWorkspaceNewWindow === true
? "new"
: settings.openWorkspaceNewWindow === false
? "current"
: "ask"
}
onChange={async (v) => {
if (v === "current") await patchModel(settings, { openWorkspaceNewWindow: false });
else if (v === "new") await patchModel(settings, { openWorkspaceNewWindow: true });
else await patchModel(settings, { openWorkspaceNewWindow: null });
}}
options={[
{ label: "Always ask", value: "ask" },
{ label: "Open in current window", value: "current" },
{ label: "Open in new window", value: "new" },
]}
/>
</SettingsSection>
<SettingsSection title="Fonts">
<SettingRow
title="Interface font"
description="Font used for Yaak interface controls."
controlClassName="gap-1"
>
{fonts.data && (
<SettingSelectControl
name="uiFont"
label="Interface font"
selectClassName="w-72!"
value={settings.interfaceFont ?? NULL_FONT_VALUE}
defaultValue={NULL_FONT_VALUE}
options={[
{ label: "System default", value: NULL_FONT_VALUE },
...fonts.data.uiFonts.map((f) => ({ label: f, value: f })),
...fonts.data.editorFonts.map((f) => ({ label: f, value: f })),
]}
onChange={async (v) => {
const interfaceFont = v === NULL_FONT_VALUE ? null : v;
await patchModel(settings, { interfaceFont });
}}
/>
)}
<SettingSelectControl
name="interfaceFontSize"
label="Interface Font Size"
selectClassName="w-20!"
value={`${settings.interfaceFontSize}`}
defaultValue="14"
options={fontSizeOptions}
onChange={(v) => patchModel(settings, { interfaceFontSize: Number.parseInt(v, 10) })}
/>
</SettingRow>
<SettingRow
title="Editor font"
description="Font used in request and response editors."
controlClassName="gap-1"
>
{fonts.data && (
<SettingSelectControl
name="editorFont"
label="Editor font"
selectClassName="w-72!"
value={settings.editorFont ?? NULL_FONT_VALUE}
defaultValue={NULL_FONT_VALUE}
options={[
{ label: "System default", value: NULL_FONT_VALUE },
...fonts.data.editorFonts.map((f) => ({ label: f, value: f })),
]}
onChange={async (v) => {
const editorFont = v === NULL_FONT_VALUE ? null : v;
await patchModel(settings, { editorFont });
}}
/>
)}
<SettingSelectControl
name="editorFontSize"
label="Editor Font Size"
selectClassName="w-20!"
value={`${settings.editorFontSize}`}
defaultValue="12"
options={fontSizeOptions}
onChange={(v) =>
patchModel(settings, {
editorFontSize: clamp(Number.parseInt(v, 10) || 14, 8, 30),
})
}
/>
</SettingRow>
</SettingsSection>
<SettingsSection title="Editor">
<ModelSettingRowSelect
model={settings}
modelKey="editorKeymap"
title="Editor keymap"
description="Keyboard shortcut preset used by text editors."
options={keymaps}
/>
<ModelSettingRowBoolean
model={settings}
modelKey="editorSoftWrap"
title="Wrap editor lines"
description="Wrap long lines in request and response editors."
/>
<ModelSettingRowBoolean
model={settings}
modelKey="coloredMethods"
title="Colorize request methods"
description="Use method-specific colors for HTTP request methods."
/>
</SettingsSection>
<SettingsSection title="Window">
<NativeTitlebarSetting settings={settings} />
{type() !== "macos" && (
<ModelSettingRowBoolean
model={settings}
modelKey="hideWindowControls"
title="Hide window controls"
description="Hide the close, maximize, and minimize controls on Windows or Linux."
/>
)}
</SettingsSection>
<CargoFeature feature="license">
<LicenseSettings settings={settings} />
</CargoFeature>
</SettingsList>
</VStack>
);
}
function NativeTitlebarSetting({ settings }: { settings: Settings }) {
const [nativeTitlebar, setNativeTitlebar] = useState(settings.useNativeTitlebar);
return (
<SettingRow
title="Native title bar"
description="Use the operating system's standard title bar and window controls."
controlClassName="gap-2"
>
<Checkbox
hideLabel
size="md"
checked={nativeTitlebar}
title="Native title bar"
onChange={setNativeTitlebar}
/>
{settings.useNativeTitlebar !== nativeTitlebar && (
<Button
color="primary"
size="xs"
onClick={async () => {
await patchModel(settings, { useNativeTitlebar: nativeTitlebar });
await invokeCmd("cmd_restart");
}}
>
Apply and Restart
</Button>
)}
</SettingRow>
);
}
function LicenseSettings({ settings }: { settings: Settings }) {
const license = useLicense();
if (license.check.data?.status !== "personal_use") {
return null;
}
return (
<SettingsSection title="License">
<SettingRowBoolean
checked={settings.hideLicenseBadge}
title="Hide personal use badge"
description="Hide the personal-use badge from the interface."
onChange={async (hideLicenseBadge) => {
if (hideLicenseBadge) {
const confirmed = await showConfirm({
id: "hide-license-badge",
title: "Confirm Personal Use",
confirmText: "Confirm",
description: (
<VStack space={3}>
<p>Hey there 👋🏼</p>
<p>
Yaak is free for personal projects and learning.{" "}
<strong>If youre 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,121 +0,0 @@
import { readDir } from "@tauri-apps/plugin-fs";
import { Banner, VStack } from "@yaakapp-internal/ui";
import { useState } from "react";
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { SettingRowBoolean, SettingRowDirectory } from "./core/SettingRow";
import { SelectFile } from "./SelectFile";
export interface SyncToFilesystemSettingProps {
layout?: "form" | "settings";
onChange: (args: { filePath: string | null; initGit?: boolean }) => void;
onCreateNewWorkspace: () => void;
value: { filePath: string | null; initGit?: boolean };
}
export function SyncToFilesystemSetting({
layout = "form",
onChange,
onCreateNewWorkspace,
value,
}: SyncToFilesystemSettingProps) {
const [syncDir, setSyncDir] = useState<string | null>(null);
const handleFilePathChange = async (filePath: string | null) => {
if (filePath != null) {
const files = await readDir(filePath);
if (files.length > 0) {
setSyncDir(filePath);
return;
}
}
setSyncDir(null);
onChange({ ...value, filePath });
};
if (layout === "settings") {
return (
<VStack className="w-full" space={0}>
{syncDir && (
<Banner color="notice" className="mb-3 flex flex-col gap-1.5">
<p>Directory is not empty. Do you want to open it instead?</p>
<div>
<Button
variant="border"
color="notice"
size="xs"
type="button"
onClick={() => {
openWorkspaceFromSyncDir.mutate(syncDir);
onCreateNewWorkspace();
}}
>
Open Workspace
</Button>
</div>
</Banner>
)}
<SettingRowDirectory
title="Local directory sync"
description="Sync data to a folder for backup and Git integration."
filePath={value.filePath}
onChange={handleFilePathChange}
/>
{value.filePath && typeof value.initGit === "boolean" && (
<SettingRowBoolean
checked={value.initGit}
title="Initialize Git Repo"
description="Create a Git repository in the selected sync directory."
onChange={(initGit) => onChange({ ...value, initGit })}
/>
)}
</VStack>
);
}
return (
<VStack className="w-full my-2" space={3}>
{syncDir && (
<Banner color="notice" className="flex flex-col gap-1.5">
<p>Directory is not empty. Do you want to open it instead?</p>
<div>
<Button
variant="border"
color="notice"
size="xs"
type="button"
onClick={() => {
openWorkspaceFromSyncDir.mutate(syncDir);
onCreateNewWorkspace();
}}
>
Open Workspace
</Button>
</div>
</Banner>
)}
<SelectFile
directory
label="Local directory sync"
size="xs"
noun="Directory"
help="Sync data to a folder for backup and Git integration."
filePath={value.filePath}
onChange={async ({ filePath }) => handleFilePathChange(filePath)}
/>
{value.filePath && typeof value.initGit === "boolean" && (
<Checkbox
checked={value.initGit}
onChange={(initGit) => onChange({ ...value, initGit })}
title="Initialize Git Repo"
/>
)}
</VStack>
);
}
@@ -1,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,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>
);
}
@@ -1,42 +0,0 @@
import { describe, expect, test } from "vite-plus/test";
import { parser } from "./filter";
function getNodeNames(input: string): string[] {
const tree = parser.parse(input);
const nodes: string[] = [];
const cursor = tree.cursor();
do {
if (cursor.name !== "Query") {
nodes.push(cursor.name);
}
} while (cursor.next());
return nodes;
}
describe("filter grammar", () => {
test("parses URL-like field values as one value", () => {
const nodes = getNodeNames("@url:yaak.app/foo-bar");
expect(nodes).not.toContain("⚠");
expect(nodes).toContain("FieldValue");
expect(nodes).toContain("FieldValueWord");
});
test("parses punctuation-heavy field values as one value", () => {
const nodes = getNodeNames("@url:yaa$&#*@tsrna(*)");
expect(nodes).not.toContain("⚠");
expect(nodes).toContain("FieldValue");
expect(nodes).toContain("FieldValueWord");
});
test("parses operator-looking field values as one value", () => {
const negativeValueNodes = getNodeNames("@url:-foo");
const operatorWordNodes = getNodeNames("@url:AND");
expect(negativeValueNodes).not.toContain("⚠");
expect(negativeValueNodes).toContain("FieldValueWord");
expect(operatorWordNodes).not.toContain("⚠");
expect(operatorWordNodes).toContain("FieldValueWord");
});
});
@@ -1,22 +0,0 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
import {LRParser} from "@lezer/lr"
import {highlight} from "./highlight"
export const parser = LRParser.deserialize({
version: 14,
states: "%WOVQPOOPhOPOOOVQPO'#CfOmQPO'#ChO!_QPO'#ChO!dQPO'#CgOOQO'#Cc'#CcOVQPO'#CaOOQO'#Ca'#CaO!iQPO'#C`O!yQPO'#C_OOQO'#C^'#C^QOQPOOPOOO'#Cr'#CrP#UOPO)C>lO#]QPO,59QOOQO,59S,59SO#bQQO,59ROOQO,58{,58{OVQPO'#CsOOQO'#Cs'#CsO#jQPO,58zOVQPO'#CtO#zQPO,58yPOOO-E6p-E6pOOQO1G.l1G.lOOQO'#Cl'#ClOOQO1G.m1G.mOOQO,59_,59_OOQO-E6q-E6qOOQO,59`,59`OOQO-E6r-E6r",
stateData: "$]~OkPQ~OUVOXQO]SO^ROaUO~Ok]O~OUcXXcX]cX^cX_[XacXdcXecXicXWcX~O^`O~O_aO~OdcOeSXiSXWSX~PVOefOiRXWRX~Ok]O~Qj]WiO~OajObjO~OdcOeSaiSaWSa~PVOefOiRaWRa~OUde^e~",
goto: "#^iPPjpt{P![PP!e!e!nPPP!wPP!ePP!z#Q#WQ[OR_QTZOQSYOQRnfUXOQfQbVSdXeRlc_WOQVXcef_UOQVXcef_TOQVXcefRkaQ^PRh^QeXRmeQgYRog",
nodeNames: "⚠ Query Expr OrExpr AndExpr Unary Not Primary RParen LParen Group Field FieldName At Word Colon FieldValue Phrase FieldValueWord Term And Or",
maxTerm: 27,
nodeProps: [
["openedBy", 8,"LParen"],
["closedBy", 9,"RParen"]
],
propSources: [highlight],
skippedNodes: [0,22],
repeatNodeCount: 3,
tokenData: "2h~RiOX!pXY$hYZ$hZ]!p]^$h^p!ppq$hqr!prs$ysx!pxy&gyz'Qz}!p}!O'k!O![!p![!](U!]!b!p!b!c(o!c!d)Y!d!p!p!p!q,q!q!r0Y!r;'S!p;'S;=`$b<%lO!pR!w^bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!pQ#xUbQOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sQ$_P;=`<%l#sR$eP;=`<%l!p~$mSk~XY$hYZ$h]^$hpq$h~$|VOr$yrs%cs#O$y#O#P%h#P;'S$y;'S;=`&a<%lO$y~%hOa~~%kRO;'S$y;'S;=`%t;=`O$y~%wWOr$yrs%cs#O$y#O#P%h#P;'S$y;'S;=`&a;=`<%l$y<%lO$y~&dP;=`<%l$yR&nUbQXPOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR'XUbQWPOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR'rUbQUPOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR(]U_PbQOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR(vU]PbQOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR)a`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!p!p!p!q*c!q;'S!p;'S;=`$b<%lO!pR*j`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!f!p!f!g+l!g;'S!p;'S;=`$b<%lO!pR+u^bQdP^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!pR,x`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!q!p!q!r-z!r;'S!p;'S;=`$b<%lO!pR.R`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!v!p!v!w/T!w;'S!p;'S;=`$b<%lO!pR/^^bQUP^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!pR0a`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!t!p!t!u1c!u;'S!p;'S;=`$b<%lO!pR1l^bQeP^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!p",
tokenizers: [0, 1],
topRules: {"Query":[0,1]},
tokenPrec: 145
})
@@ -1,43 +0,0 @@
import { describe, expect, test } from "vite-plus/test";
import { formatFieldFilter } from "./format";
import { evaluate, parseQuery } from "./query";
function matchesFormattedUrl(value: string) {
return evaluate(parseQuery(formatFieldFilter("url", value)), {
fields: { url: value },
});
}
describe("formatFieldFilter", () => {
test("keeps URL-like values bare", () => {
expect(formatFieldFilter("url", "yaak.app/foo-bar")).toBe("@url:yaak.app/foo-bar");
expect(matchesFormattedUrl("yaak.app/foo-bar")).toBe(true);
});
test("keeps non-syntax punctuation bare", () => {
expect(formatFieldFilter("url", "yaa$&#*@tsrna(*)")).toBe("@url:yaa$&#*@tsrna(*)");
expect(matchesFormattedUrl("yaa$&#*@tsrna(*)")).toBe(true);
});
test("keeps values that start with an operator token bare", () => {
expect(formatFieldFilter("url", "-foo")).toBe("@url:-foo");
expect(matchesFormattedUrl("-foo")).toBe(true);
});
test("keeps boolean operator words bare", () => {
expect(formatFieldFilter("url", "AND")).toBe("@url:AND");
expect(formatFieldFilter("url", "or")).toBe("@url:or");
expect(formatFieldFilter("url", "Not")).toBe("@url:Not");
expect(matchesFormattedUrl("AND")).toBe(true);
});
test("escapes quoted values", () => {
expect(formatFieldFilter("url", 'say "hi"')).toBe('@url:"say \\"hi\\""');
expect(matchesFormattedUrl('say "hi"')).toBe(true);
});
test("quotes values that start with a quote", () => {
expect(formatFieldFilter("url", '"hi"')).toBe('@url:"\\"hi\\""');
expect(matchesFormattedUrl('"hi"')).toBe(true);
});
});
@@ -1,7 +0,0 @@
const bareFieldValue = /^[^\s"]\S*$/;
export function formatFieldFilter(field: string, value: string) {
const escapedValue = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
const filterValue = bareFieldValue.test(value) ? value : `"${escapedValue}"`;
return `@${field}:${filterValue}`;
}
@@ -1,26 +0,0 @@
import { describe, expect, test } from "vite-plus/test";
import { parser } from "./pairs";
function getNodeNames(input: string): string[] {
const tree = parser.parse(input);
const nodes: string[] = [];
const cursor = tree.cursor();
do {
if (cursor.name !== "pairs") {
nodes.push(cursor.name);
}
} while (cursor.next());
return nodes;
}
describe("pairs grammar", () => {
test("parses colon-space pairs with a value", () => {
expect(getNodeNames("foo: bar\n")).toEqual(["Key", "Sep", "Value"]);
});
test("does not parse colon-without-space as a value", () => {
const nodes = getNodeNames("foo:bar\n");
expect(nodes).not.toContain("Value");
});
});
@@ -1,24 +0,0 @@
// Host is optional so URLs starting with `/` go straight to Path. Without this,
// the parser error-recovers past the leading `/` and consumes the first segment as
// Host (since Host's char class includes `:` for `host:port`), eating an initial
// `:name` placeholder like `/:foo/:bar`.
@top url { Protocol? Host? Path? Query? }
Path { ("/" PathSegment)+ }
Placeholder { ":" pathChars }
PathSegment { Placeholder (":" pathChars)* | pathChars (":" pathChars)* }
Query { "?" queryPair ("&" queryPair)* }
@tokens {
Protocol { $[a-zA-Z]+ "://" }
Host { $[a-zA-Z0-9-_.:\[\]]+ }
@precedence { Protocol, Host }
pathChars { ![/?#:]+ }
queryPair { ($[a-zA-Z0-9]+ ("=" $[a-zA-Z0-9]*)?) }
}
@external propSource highlight from "./highlight"
@@ -1,52 +0,0 @@
import { describe, expect, test } from "vite-plus/test";
import { parser } from "./url";
function expectValidParse(input: string) {
expect(parser.parse(input).toString()).not.toContain("⚠");
}
function placeholderValues(input: string): string[] {
const values: string[] = [];
parser
.parse(input)
.cursor()
.iterate((node) => {
if (node.name === "Placeholder") values.push(input.slice(node.from, node.to));
});
return values;
}
describe("URL grammar Placeholder", () => {
test("recognizes path placeholders", () => {
expectValidParse("https://x.com/users/:id");
expect(placeholderValues("https://x.com/users/:id")).toEqual([":id"]);
});
test("treats a colon suffix as literal path text", () => {
expectValidParse("https://yaak.app/x/echo/:foo:bar/baz");
expect(placeholderValues("https://yaak.app/x/echo/:foo:bar/baz")).toEqual([":foo"]);
});
test("treats repeated colon suffixes as literal path text", () => {
expectValidParse("https://yaak.app/x/echo/:foo:bar:baz");
expect(placeholderValues("https://yaak.app/x/echo/:foo:bar:baz")).toEqual([":foo"]);
});
test("does not recognize a colon in the middle of a plain path segment", () => {
expectValidParse("https://yaak.app/x/echo/foo:bar/baz");
expect(placeholderValues("https://yaak.app/x/echo/foo:bar/baz")).toEqual([]);
});
test("does not recognize query parameters as path placeholders", () => {
expect(placeholderValues("https://yaak.app/x/echo/:foo?bar=ss&:bar=baz")).toEqual([":foo"]);
});
test("recognizes placeholders in a path fragment after a templated base URL", () => {
// Mixed Twig parsing can feed the URL parser only the text after a template tag,
// as in `${[ URL ]}/x/:foo/:hello`.
expect(placeholderValues("/x/hi:echo/:foo/:hello?bar=ss&:bar=baz")).toEqual([
":foo",
":hello",
]);
});
});
@@ -1,18 +0,0 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
import {LRParser} from "@lezer/lr"
import {highlight} from "./highlight"
export const parser = LRParser.deserialize({
version: 14,
states: "#xQQOPOOO`OQO'#CdOhOPO'#C`OsOSO'#CcQOOOOOQZOPOOQWOPOOQTOPOOOxOQO'#CbO}OQO'#CaOOOO,59O,59OOOOO-E6b-E6bO!]OPO,58}OOOO,58|,58|O!eOQO'#CeO!jOQO,58{O!xOSO'#CfO!}OPO1G.iOOOO,59P,59POOOO-E6c-E6cOOOO,59Q,59QOOOO-E6d-E6d",
stateData: "#Y~OQVORUO[PO_RO~O]WO^XO~O[POZSX_SX~O`[O~O^]O~O]^OZTX[TX_TX~Oa`OZVa~O^bO~O]^OZTa[Ta_Ta~O`dO~Oa`OZVi~OQR~",
goto: "!RZPPPP[adgmu{VTOUVRYPRXPXSOTUVUQOUVRZQQ_XRc_Qa[Rea",
nodeNames: "⚠ url Protocol Host Path PathSegment Placeholder Query",
maxTerm: 17,
propSources: [highlight],
skippedNodes: [0],
repeatNodeCount: 3,
tokenData: "+z~RgOs!jtv!jvw#[w}!j}!O#x!O!P#x!P!Q%|!Q![&R![!](g!]!a!j!a!b)Z!b!c!j!c!})`!}#O#x#O#P!j#P#Q#x#Q#R!j#R#S#x#S#T!j#T#o)`#o;'S!j;'S;=`#U<%lO!jQ!oV^QOs!jt!P!j!Q![!j!]!a!j!b;'S!j;'S;=`#U<%lO!jQ#XP;=`<%l!jR#cVaP^QOs!jt!P!j!Q![!j!]!a!j!b;'S!j;'S;=`#U<%lO!jR$Pc^QRPOs!jt}!j}!O#x!O!P#x!Q![#x![!]%[!]!a!j!b!c!j!c!}#x!}#O#x#O#P!j#P#Q#x#Q#R!j#R#S#x#S#T!j#T#o#x#o;'S!j;'S;=`#U<%lO!jP%aXRP}!O%[!O!P%[!Q![%[![!]%[!c!}%[!}#O%[#P#Q%[#R#S%[#T#o%[~&RO[~V&[e^Q`SRPOs!jt}!j}!O#x!O!P#x!Q![&R![!]%[!]!_!j!_!`'m!`!a!j!b!c!j!c!}&R!}#O#x#O#P!j#P#Q#x#Q#R!j#R#S#x#S#T!j#T#o&R#o;'S!j;'S;=`#U<%lO!jU'tZ^Q`SOs!jt!P!j!Q!['m!]!a!j!b!c!j!c!}'m!}#T!j#T#o'm#o;'S!j;'S;=`#U<%lO!jR(nX]QRP}!O%[!O!P%[!Q![%[![!]%[!c!}%[!}#O%[#P#Q%[#R#S%[#T#o%[~)`O_~V)ie^Q`SRPOs!jt}!j}!O#x!O!P#x!Q![&R![!]*z!]!_!j!_!`'m!`!a!j!b!c!j!c!})`!}#O#x#O#P!j#P#Q#x#Q#R!j#R#S#x#S#T!j#T#o)`#o;'S!j;'S;=`#U<%lO!jP+PYRP}!O%[!O!P%[!P!Q+o!Q![%[![!]%[!c!}%[!}#O%[#P#Q%[#R#S%[#T#o%[P+rP!P!Q+uP+zOQP",
tokenizers: [0, 1, 2],
topRules: {"url":[0,1]},
tokenPrec: 99
})
@@ -1,37 +0,0 @@
import {
IconButton as BaseIconButton,
type IconButtonProps as BaseIconButtonProps,
} from "@yaakapp-internal/ui";
import { forwardRef, useImperativeHandle, useRef } from "react";
import type { HotkeyAction } from "../../hooks/useHotKey";
import { useFormattedHotkey, useHotKey } from "../../hooks/useHotKey";
export type IconButtonProps = BaseIconButtonProps & {
hotkeyAction?: HotkeyAction;
hotkeyLabelOnly?: boolean;
hotkeyPriority?: number;
};
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(
{ hotkeyAction, hotkeyPriority, hotkeyLabelOnly, title, ...props }: IconButtonProps,
ref,
) {
const hotkeyTrigger = useFormattedHotkey(hotkeyAction ?? null)?.join("");
const fullTitle = hotkeyTrigger ? `${title ?? ""} ${hotkeyTrigger}`.trim() : title;
const buttonRef = useRef<HTMLButtonElement>(null);
useImperativeHandle<HTMLButtonElement | null, HTMLButtonElement | null>(
ref,
() => buttonRef.current,
);
useHotKey(
hotkeyAction ?? null,
() => {
buttonRef.current?.click();
},
{ priority: hotkeyPriority, enable: !hotkeyLabelOnly },
);
return <BaseIconButton ref={buttonRef} title={fullTitle} {...props} />;
});
@@ -1,106 +0,0 @@
import classNames from "classnames";
import type { HTMLAttributes, ReactElement, ReactNode } from "react";
import { CopyIconButton } from "../CopyIconButton";
interface Props {
children:
| ReactElement<HTMLAttributes<HTMLTableColElement>>
| (ReactElement<HTMLAttributes<HTMLTableColElement>> | null)[];
selectable?: boolean;
}
export function KeyValueRows({ children, selectable }: Props) {
const childArray = Array.isArray(children) ? children.filter(Boolean) : [children];
return (
<table
className={classNames(
"text-editor font-mono min-w-0 w-full mb-auto",
selectable &&
"[&_td]:select-auto [&_td]:cursor-auto [&_td_*]:select-auto [&_td_*]:cursor-auto",
)}
>
<tbody className="divide-y divide-surface-highlight">
{childArray.map((child, i) => (
// oxlint-disable-next-line react/no-array-index-key
<tr key={i}>{child}</tr>
))}
</tbody>
</table>
);
}
interface KeyValueRowProps {
label: ReactNode;
children: ReactNode;
rightSlot?: ReactNode;
leftSlot?: ReactNode;
align?: "top" | "middle";
labelClassName?: string;
labelColor?: "secondary" | "primary" | "info";
enableCopy?: boolean;
copyText?: string;
}
export function KeyValueRow({
label,
children,
rightSlot,
leftSlot,
align = "top",
labelColor = "secondary",
labelClassName,
enableCopy,
copyText,
}: KeyValueRowProps) {
const textToCopy =
copyText ??
(typeof children === "string" || typeof children === "number" ? `${children}` : null);
const copyTitle =
typeof label === "string" || typeof label === "number" ? `Copy ${label}` : "Copy value";
const resolvedRightSlot =
rightSlot ??
(enableCopy && textToCopy != null ? (
<CopyIconButton
text={textToCopy}
className="text-text-subtle"
size="2xs"
title={copyTitle}
iconSize="sm"
/>
) : null);
return (
<>
<td
className={classNames(
"select-none py-0.5 pr-2 h-full max-w-40",
align === "top" && "align-top",
align === "middle" && "align-middle",
labelClassName,
labelColor === "primary" && "text-primary",
labelColor === "secondary" && "text-text-subtle",
labelColor === "info" && "text-info",
)}
>
<span className="select-text cursor-text">{label}</span>
</td>
<td
className={classNames(
"select-none py-0.5 break-all max-w-60",
align === "top" && "align-top",
align === "middle" && "align-middle",
)}
>
<div className="select-text cursor-text max-h-48 overflow-y-auto grid grid-cols-[auto_minmax(0,1fr)_auto]">
{leftSlot ?? <span aria-hidden />}
{children}
{resolvedRightSlot ? (
<div className="ml-1.5">{resolvedRightSlot}</div>
) : (
<span aria-hidden />
)}
</div>
</td>
</>
);
}
@@ -1,514 +0,0 @@
import type { AnyModel } from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models";
import classNames from "classnames";
import type { ReactNode } from "react";
import { CopyIconButton } from "../CopyIconButton";
import { Checkbox } from "./Checkbox";
import { IconButton, type IconButtonProps } from "./IconButton";
import { PlainInput } from "./PlainInput";
import type { RadioDropdownItem } from "./RadioDropdown";
import { Select } from "./Select";
import { SelectFile } from "../SelectFile";
type ModelKeyOfValue<T, V> = {
[K in keyof T]-?: T[K] extends V ? K : never;
}[keyof T];
type SettingRowBaseProps = {
className?: string;
controlClassName?: string;
description?: ReactNode;
disabled?: boolean;
title: ReactNode;
};
export function SettingsList({ children, className }: { children: ReactNode; className?: string }) {
return <div className={classNames("w-full", className)}>{children}</div>;
}
export function SettingsSection({
children,
className,
description,
title,
}: {
children: ReactNode;
className?: string;
description?: ReactNode;
title: ReactNode | null;
}) {
const showHeader = title != null || description != null;
return (
<section className={classNames(className, "w-full")}>
{showHeader && (
<div className="border-b border-border-subtle pb-2">
{title != null && <div className="text-text-subtle">{title}</div>}
{description != null && <p className="mt-1 text-sm text-text-subtlest">{description}</p>}
</div>
)}
<div className="[&>*:last-child]:border-b-0">{children}</div>
</section>
);
}
export function SettingRow({
children,
className,
controlClassName,
description,
disabled,
title,
}: {
children: ReactNode;
} & SettingRowBaseProps) {
return (
<div
aria-disabled={disabled || undefined}
className={classNames(
className,
"@container border-b border-border-subtle py-4",
disabled && "opacity-disabled",
)}
>
<div
className={classNames(
"grid grid-cols-1 gap-2",
"@[30rem]:grid-cols-[minmax(0,1fr)_auto] items-center",
)}
>
<div className="min-w-0">
<div className="text text-text">{title}</div>
{description != null && (
<div className="mt-1 max-w-2xl text-sm text-text-subtle">{description}</div>
)}
</div>
<div
className={classNames(
"flex min-w-0 items-center justify-start @[40rem]:justify-end",
controlClassName,
)}
>
{children}
</div>
</div>
</div>
);
}
export function SettingValue({
actions,
className,
copyText,
enableCopy = true,
value,
}: {
actions?: SettingValueAction[];
className?: string;
copyText?: string;
enableCopy?: boolean;
value: ReactNode;
}) {
const textValue = typeof value === "string" || typeof value === "number" ? `${value}` : null;
const textToCopy = copyText ?? textValue;
return (
<>
<span
className={classNames(
className,
"cursor-text select-text truncate font-mono text-editor text-text-subtle pr-1.5",
)}
>
{value}
</span>
{actions?.map((action) => (
<IconButton
key={action.title}
icon={action.icon}
title={action.title}
size="2xs"
iconSize="sm"
onClick={action.onClick}
/>
))}
{enableCopy && textToCopy != null && (
<CopyIconButton size="2xs" text={textToCopy} title="Copy value" />
)}
</>
);
}
type SettingValueAction = {
icon: IconButtonProps["icon"];
onClick: () => void;
title: string;
};
export function SettingRowBoolean({
checked,
checkboxSize = "md",
onChange,
title,
...props
}: {
checked: boolean;
checkboxSize?: "sm" | "md";
onChange: (checked: boolean) => void;
} & SettingRowBaseProps) {
return (
<SettingRow title={title} {...props}>
<Checkbox
hideLabel
size={checkboxSize}
checked={checked}
disabled={props.disabled}
title={title}
onChange={onChange}
/>
</SettingRow>
);
}
export function ModelSettingRowBoolean<M extends AnyModel, K extends ModelKeyOfValue<M, boolean>>({
model,
modelKey,
...props
}: {
model: M;
modelKey: K;
} & Omit<Parameters<typeof SettingRowBoolean>[0], "checked" | "onChange">) {
return (
<SettingRowBoolean
checked={model[modelKey] as boolean}
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
{...props}
/>
);
}
export function SettingRowNumber({
inputClassName,
inputWidthClassName = "w-48!",
name,
onChange,
placeholder,
required,
title,
type = "number",
validate,
value,
...props
}: {
inputClassName?: string;
inputWidthClassName?: string;
name: string;
onChange: (value: number) => void;
placeholder?: string;
required?: boolean;
type?: "number";
validate?: (value: string) => boolean;
value: number;
} & SettingRowBaseProps) {
return (
<SettingRow title={title} {...props}>
<PlainInput
required={required}
hideLabel
size="sm"
name={name}
label={typeof title === "string" ? title : name}
placeholder={placeholder}
defaultValue={`${value}`}
validate={validate}
onChange={(value) => onChange(Number.parseInt(value, 10) || 0)}
type={type}
className={inputClassName}
containerClassName={inputWidthClassName}
disabled={props.disabled}
/>
</SettingRow>
);
}
export function ModelSettingRowNumber<M extends AnyModel, K extends ModelKeyOfValue<M, number>>({
model,
modelKey,
...props
}: {
model: M;
modelKey: K;
} & Omit<Parameters<typeof SettingRowNumber>[0], "name" | "onChange" | "value">) {
return (
<SettingRowNumber
name={String(modelKey)}
value={model[modelKey] as number}
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
{...props}
/>
);
}
export function SettingRowText({
inputClassName,
inputWidthClassName = "w-80!",
name,
onChange,
placeholder,
required,
title,
type = "text",
value,
...props
}: {
inputClassName?: string;
inputWidthClassName?: string;
name: string;
onChange: (value: string) => void;
placeholder?: string;
required?: boolean;
type?: "text" | "password";
value: string;
} & SettingRowBaseProps) {
return (
<SettingRow title={title} {...props}>
<PlainInput
required={required}
hideLabel
size="sm"
name={name}
label={typeof title === "string" ? title : name}
placeholder={placeholder}
defaultValue={value}
onChange={onChange}
type={type}
className={inputClassName}
containerClassName={inputWidthClassName}
disabled={props.disabled}
/>
</SettingRow>
);
}
export function ModelSettingRowText<M extends AnyModel, K extends ModelKeyOfValue<M, string>>({
model,
modelKey,
...props
}: {
model: M;
modelKey: K;
} & Omit<Parameters<typeof SettingRowText>[0], "name" | "onChange" | "value">) {
return (
<SettingRowText
name={String(modelKey)}
value={model[modelKey] as string}
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
{...props}
/>
);
}
export function SettingRowFile({
buttonClassName,
controlClassName = "min-w-0 max-w-[min(32rem,45vw)]",
directory,
filePath,
nameOverride,
noun,
onChange,
size = "xs",
title,
...props
}: {
buttonClassName?: string;
directory?: boolean;
filePath: string | null;
nameOverride?: string | null;
noun?: string;
onChange: (filePath: string | null) => void | Promise<void>;
size?: Parameters<typeof SelectFile>[0]["size"];
} & SettingRowBaseProps) {
return (
<SettingRow title={title} controlClassName={controlClassName} {...props}>
<SelectFile
directory={directory}
inline
hideLabel
label={typeof title === "string" ? title : noun}
size={size}
noun={noun}
nameOverride={nameOverride}
filePath={filePath}
className={buttonClassName}
onChange={({ filePath }) => onChange(filePath)}
/>
</SettingRow>
);
}
export function SettingRowDirectory({
noun = "Directory",
...props
}: Omit<Parameters<typeof SettingRowFile>[0], "directory">) {
return <SettingRowFile directory noun={noun} {...props} />;
}
export function SettingRowSelect<T extends string>({
defaultValue,
name,
onChange,
options,
selectClassName = "w-48!",
title,
value,
...props
}: {
defaultValue?: T;
name: string;
onChange: (value: T) => void;
options: RadioDropdownItem<T>[];
selectClassName?: string;
value: T;
} & SettingRowBaseProps) {
return (
<SettingRow title={title} {...props}>
<SettingSelectControl
name={name}
label={typeof title === "string" ? title : name}
value={value}
defaultValue={defaultValue}
selectClassName={selectClassName}
disabled={props.disabled}
onChange={onChange}
options={options}
/>
</SettingRow>
);
}
export function SettingSelectControl<T extends string>({
defaultValue,
disabled,
label,
name,
onChange,
options,
selectClassName = "w-48!",
value,
}: {
defaultValue?: T;
disabled?: boolean;
label: string;
name: string;
onChange: (value: T) => void;
options: RadioDropdownItem<T>[];
selectClassName?: string;
value: T;
}) {
return (
<Select
hideLabel
name={name}
value={value}
defaultValue={defaultValue}
label={label}
size="sm"
className={selectClassName}
disabled={disabled}
onChange={onChange}
options={options}
/>
);
}
export function ModelSettingSelectControl<
M extends AnyModel,
K extends ModelKeyOfValue<M, string>,
V extends M[K] & string,
>({
model,
modelKey,
...props
}: {
model: M;
modelKey: K;
} & Omit<Parameters<typeof SettingSelectControl<V>>[0], "name" | "onChange" | "value">) {
return (
<SettingSelectControl
name={String(modelKey)}
value={model[modelKey] as V}
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
{...props}
/>
);
}
export function ModelSettingRowSelect<
M extends AnyModel,
K extends ModelKeyOfValue<M, string>,
V extends M[K] & string,
>({
model,
modelKey,
...props
}: {
model: M;
modelKey: K;
} & Omit<Parameters<typeof SettingRowSelect<V>>[0], "name" | "onChange" | "value">) {
return (
<SettingRowSelect
name={String(modelKey)}
value={model[modelKey] as V}
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
{...props}
/>
);
}
export function SettingOverrideRow({
children,
className,
controlClassName,
description,
disabled,
onResetOverride,
overridden,
resetTitle = "Reset override",
title,
}: {
children: ReactNode;
className?: string;
controlClassName?: string;
description?: ReactNode;
disabled?: boolean;
onResetOverride: () => void;
overridden: boolean;
resetTitle?: string;
title: ReactNode;
}) {
return (
<SettingRow
className={className}
controlClassName={controlClassName}
description={description}
disabled={disabled}
title={
<span className="inline-flex items-center gap-1.5">
{title}
{overridden && (
<IconButton
icon="undo_2"
size="2xs"
iconSize="sm"
title={resetTitle}
className="text-text-subtle"
onClick={onResetOverride}
/>
)}
</span>
}
>
{children}
</SettingRow>
);
}
-148
View File
@@ -1,148 +0,0 @@
import type { ShowToastRequest } from "@yaakapp-internal/plugins";
import { Icon, type IconProps, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import * as m from "motion/react-m";
import type { ReactNode } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useKey } from "react-use";
import { IconButton } from "./IconButton";
export interface ToastProps {
children: ReactNode;
open: boolean;
onClose: () => void;
className?: string;
timeout: number | null;
action?: (args: { hide: () => void }) => ReactNode;
icon?: ShowToastRequest["icon"] | null;
color?: ShowToastRequest["color"];
// Grow with the content (up to the viewport) instead of scrolling internally
// past the default max height
dynamicHeight?: boolean;
// Hide the close button, for toasts that render their own dismiss action.
// Escape still closes the toast
hideDismiss?: boolean;
}
const ICONS: Record<NonNullable<ToastProps["color"] | "custom">, IconProps["icon"] | null> = {
custom: null,
danger: "alert_triangle",
info: "info",
notice: "alert_triangle",
primary: "info",
secondary: "info",
success: "check_circle",
warning: "alert_triangle",
};
export function Toast({
children,
open,
onClose,
timeout,
action,
icon,
color,
dynamicHeight,
hideDismiss,
}: ToastProps) {
const onCloseRef = useRef(onClose);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [autoHideCanceled, setAutoHideCanceled] = useState(false);
useEffect(() => {
onCloseRef.current = onClose;
}, [onClose]);
const cancelAutoHide = useCallback(() => {
if (timeoutRef.current == null) return;
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
setAutoHideCanceled(true);
}, []);
useEffect(() => {
if (!open || timeout == null || autoHideCanceled) return;
timeoutRef.current = setTimeout(() => {
timeoutRef.current = null;
onCloseRef.current();
}, timeout);
return () => {
if (timeoutRef.current == null) return;
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
};
}, [autoHideCanceled, open, timeout]);
useKey(
"Escape",
() => {
if (!open) return;
onClose();
},
{},
[open],
);
const toastIcon = icon === null ? null : (icon ?? (color && color in ICONS && ICONS[color]));
return (
<m.div
initial={{ opacity: 0, right: "-10%" }}
animate={{ opacity: 100, right: 0 }}
exit={{ opacity: 0, right: "-100%" }}
transition={{ duration: 0.2 }}
className={classNames("bg-surface m-2 rounded-lg")}
>
<div
className={classNames(
`x-theme-toast x-theme-toast--${color}`,
"pointer-events-auto overflow-hidden",
"relative pointer-events-auto bg-surface text-text rounded-lg",
"border border-border shadow-lg w-100",
)}
onFocusCapture={cancelAutoHide}
onKeyDownCapture={cancelAutoHide}
onPointerDownCapture={cancelAutoHide}
>
<div
className={classNames(
"pl-3 py-3 flex items-start gap-2 w-full overflow-auto",
hideDismiss ? "pr-3" : "pr-10",
dynamicHeight ? "max-h-[80vh]" : "max-h-44",
)}
>
{toastIcon && <Icon icon={toastIcon} color={color} className="mt-1 shrink-0" />}
<VStack space={2} className="w-full min-w-0">
<div className="select-auto">{children}</div>
{action?.({ hide: onClose })}
</VStack>
</div>
{!hideDismiss && (
<IconButton
color={color}
variant="border"
className="opacity-60 border-0 absolute! top-2 right-2"
title="Dismiss"
icon="x"
onClick={onClose}
/>
)}
{timeout != null && !autoHideCanceled && (
<div className="w-full absolute bottom-0 left-0 right-0">
<m.div
className="bg-surface-highlight h-[3px]"
initial={{ width: "100%" }}
animate={{ width: "0%", opacity: 0.2 }}
transition={{ duration: timeout / 1000, ease: "linear" }}
/>
</div>
)}
</div>
</m.div>
);
}
@@ -1,131 +0,0 @@
import { useGitFileDiffForCommit, useGitLog, useGitMutations } from "@yaakapp-internal/git";
import type { GitCommit } from "@yaakapp-internal/git";
import { SplitLayout } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { formatDistanceToNowStrict } from "date-fns";
import { useCallback, useEffect, useMemo, useState } from "react";
import { sync } from "../../init/sync";
import { showConfirm } from "../../lib/confirm";
import { EmptyStateText } from "../EmptyStateText";
import { Button } from "../core/Button";
import { DiffViewer } from "../core/Editor/DiffViewer";
import { useGitCallbacks } from "./callbacks";
export function FileHistoryDialog({ dir, relaPath }: { dir: string; relaPath: string }) {
const callbacks = useGitCallbacks(dir);
const { restoreFileFromCommit } = useGitMutations(dir, callbacks);
const log = useGitLog(dir, undefined, relaPath);
const commits = log.data ?? [];
const [selectedOid, setSelectedOid] = useState<string | null>(null);
const selectedCommit = useMemo(
() => commits.find((commit) => commit.oid === selectedOid) ?? null,
[commits, selectedOid],
);
const diff = useGitFileDiffForCommit(dir, relaPath, selectedCommit?.oid);
useEffect(() => {
if (commits.length === 0) {
setSelectedOid(null);
} else if (selectedOid == null || !commits.some((commit) => commit.oid === selectedOid)) {
setSelectedOid(commits[0]?.oid ?? null);
}
}, [commits, selectedOid]);
const handleRestoreCommit = useCallback(
async (commit: GitCommit) => {
const confirmed = await showConfirm({
id: "git-restore-file-history-entry",
title: "Restore File",
description: "This will restore the file to the selected commit.",
confirmText: "Restore",
color: "warning",
});
if (!confirmed) return;
await restoreFileFromCommit.mutateAsync({ commitOid: commit.oid, relaPath });
await sync({ force: true });
},
[relaPath, restoreFileFromCommit],
);
if (commits.length === 0 && !log.isLoading) {
return <EmptyStateText>No history for this file</EmptyStateText>;
}
return (
<div className="h-full px-2 pb-4">
<SplitLayout
storageKey="git-file-history-horizontal"
layout="horizontal"
defaultRatio={0.6}
firstSlot={({ style }) => (
<div style={style} className="h-full overflow-y-auto px-4 pb-2 transform-cpu">
<div className="flex flex-col pt-1.5">
{commits.map((commit) => (
<CommitListItem
key={commit.oid}
commit={commit}
selected={commit.oid === selectedCommit?.oid}
onSelect={() => setSelectedOid(commit.oid)}
/>
))}
</div>
</div>
)}
secondSlot={({ style }) => (
<div style={style} className="h-full min-w-0 border-l border-l-border-subtle px-4">
{selectedCommit == null ? (
<EmptyStateText>Select a commit to view diff</EmptyStateText>
) : (
<div className="h-full flex flex-col">
<div className="mb-2 min-w-0 text-text-subtle grid items-center gap-2 grid-cols-[minmax(0,1fr)_auto]">
<div className="min-w-0 truncate">{selectedCommit.message || "No message"}</div>
<Button
className="ml-auto"
color="warning"
size="2xs"
variant="border"
onClick={() => handleRestoreCommit(selectedCommit)}
>
Restore File
</Button>
</div>
<DiffViewer
original={diff.data?.original ?? ""}
modified={diff.data?.modified ?? ""}
className="flex-1 min-h-0"
/>
</div>
)}
</div>
)}
/>
</div>
);
}
function CommitListItem({
commit,
selected,
onSelect,
}: {
commit: GitCommit;
selected: boolean;
onSelect: () => void;
}) {
return (
<button
type="button"
className={classNames(
"w-full min-w-0 text-left rounded-sm px-2 py-1.5",
selected && "bg-surface-active",
)}
onClick={onSelect}
>
<div className="truncate flex-1">{commit.message || "No message"}</div>
<div className="text-text-subtle text-sm truncate">
{commit.author.name || "Unknown"} - {formatDistanceToNowStrict(commit.when)} ago - <span className="shrink-0 text-2xs text-text-subtle font-mono">{commit.oid.slice(0, 7)}</span>
</div>
</button>
);
}
@@ -1,681 +0,0 @@
import { useGitBranchInfo, useGitMutations } from "@yaakapp-internal/git";
import type { WorkspaceMeta } from "@yaakapp-internal/models";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import type { HTMLAttributes } from "react";
import { forwardRef, useCallback, useMemo } from "react";
import { openWorkspaceSettings } from "../../commands/openWorkspaceSettings";
import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from "../../hooks/useActiveWorkspace";
import { useKeyValue } from "../../hooks/useKeyValue";
import { useRandomKey } from "../../hooks/useRandomKey";
import { sync } from "../../init/sync";
import { showConfirm, showConfirmDelete } from "../../lib/confirm";
import { fireAndForget } from "../../lib/fireAndForget";
import { showDialog } from "../../lib/dialog";
import { gitWorktreeStatusAtom } from "../../lib/gitWorktreeStatus";
import { showPrompt } from "../../lib/prompt";
import { showErrorToast, showToast } from "../../lib/toast";
import type { DropdownItem } from "../core/Dropdown";
import { Dropdown } from "../core/Dropdown";
import { Banner, Icon, InlineCode } from "@yaakapp-internal/ui";
import { useGitCallbacks } from "./callbacks";
import { GitCommitDialog } from "./GitCommitDialog";
import { GitRemotesDialog } from "./GitRemotesDialog";
import { handlePullResult, handlePushResult } from "./git-util";
import { HistoryDialog } from "./HistoryDialog";
const EMPTY_BRANCHES: string[] = [];
export function GitDropdown() {
const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);
if (workspaceMeta == null) return null;
if (workspaceMeta.settingSyncDir == null) {
return <SetupSyncDropdown workspaceMeta={workspaceMeta} />;
}
return <SyncDropdownWithSyncDir syncDir={workspaceMeta.settingSyncDir} />;
}
function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
const workspace = useAtomValue(activeWorkspaceAtom);
const worktreeStatus = useAtomValue(gitWorktreeStatusAtom);
const [refreshKey, regenerateKey] = useRandomKey();
const branchInfo = useGitBranchInfo(syncDir, refreshKey);
const callbacks = useGitCallbacks(syncDir);
const {
createBranch,
deleteBranch,
deleteRemoteBranch,
renameBranch,
mergeBranch,
push,
pull,
checkout,
resetChanges,
init,
} = useGitMutations(syncDir, callbacks);
const localBranches = branchInfo.data?.localBranches ?? EMPTY_BRANCHES;
const remoteBranches = branchInfo.data?.remoteBranches ?? EMPTY_BRANCHES;
const remoteOnlyBranches = useMemo(
() => remoteBranches.filter((b) => !localBranches.includes(b.replace(/^origin\//, ""))),
[localBranches, remoteBranches],
);
const currentBranch = branchInfo.data?.headRefShorthand;
const hasChanges = worktreeStatus?.entries.some((e) => e.status !== "current") ?? false;
const ahead = branchInfo.data?.ahead ?? 0;
const behind = branchInfo.data?.behind ?? 0;
const initRepo = useCallback(() => {
init.mutate();
}, [init]);
const items: DropdownItem[] = useMemo(() => {
if (workspace == null || branchInfo.data == null) return [];
const tryCheckout = (branch: string, force: boolean) => {
checkout.mutate(
{ branch, force },
{
disableToastError: true,
async onError(err) {
if (!force) {
// Checkout failed so ask user if they want to force it
const forceCheckout = await showConfirm({
id: "git-force-checkout",
title: "Conflicts Detected",
description:
"Your branch has conflicts. Either make a commit or force checkout to discard changes.",
confirmText: "Force Checkout",
color: "warning",
});
if (forceCheckout) {
tryCheckout(branch, true);
}
} else {
// Checkout failed
showErrorToast({
id: "git-checkout-error",
title: "Error checking out branch",
message: String(err),
});
}
},
async onSuccess(branchName) {
showToast({
id: "git-checkout-success",
message: (
<>
Switched branch <InlineCode>{branchName}</InlineCode>
</>
),
color: "success",
});
await sync({ force: true });
},
},
);
};
return [
{
label: "View History...",
leftSlot: <Icon icon="history" />,
onSelect: async () => {
showDialog({
id: "git-history",
size: "md",
title: "Commit History",
noPadding: true,
render: () => <HistoryDialog dir={syncDir} />,
});
},
},
{
label: "Manage Remotes...",
leftSlot: <Icon icon="hard_drive_download" />,
onSelect: () => GitRemotesDialog.show(syncDir),
},
{ type: "separator" },
{
label: "New Branch...",
leftSlot: <Icon icon="git_branch_plus" />,
async onSelect() {
const name = await showPrompt({
id: "git-branch-name",
title: "Create Branch",
label: "Branch Name",
});
if (!name) return;
await createBranch.mutateAsync(
{ branch: name },
{
disableToastError: true,
onError: (err) => {
showErrorToast({
id: "git-branch-error",
title: "Error creating branch",
message: String(err),
});
},
},
);
tryCheckout(name, false);
},
},
{ type: "separator" },
{
label: "Push",
leftSlot: <Icon icon="arrow_up_from_line" />,
waitForOnSelect: true,
async onSelect() {
await push.mutateAsync(undefined, {
disableToastError: true,
onSuccess: handlePushResult,
onError(err) {
showErrorToast({
id: "git-push-error",
title: "Error pushing changes",
message: String(err),
});
},
});
},
},
{
label: "Pull",
leftSlot: <Icon icon="arrow_down_to_line" />,
waitForOnSelect: true,
async onSelect() {
await pull.mutateAsync(undefined, {
disableToastError: true,
onSuccess: handlePullResult,
onError(err) {
showErrorToast({
id: "git-pull-error",
title: "Error pulling changes",
message: String(err),
});
},
});
},
},
{
label: "Commit...",
leftSlot: <Icon icon="git_commit_vertical" />,
onSelect() {
showDialog({
id: "commit",
title: "Commit Changes",
size: "full",
noPadding: true,
render: ({ hide }) => (
<GitCommitDialog syncDir={syncDir} onDone={hide} workspace={workspace} />
),
});
},
},
{
label: "Reset Changes",
hidden: !hasChanges,
leftSlot: <Icon icon="rotate_ccw" />,
color: "danger",
async onSelect() {
const confirmed = await showConfirm({
id: "git-reset-changes",
title: "Reset Changes",
description: "This will discard all uncommitted changes. This cannot be undone.",
confirmText: "Reset",
color: "danger",
});
if (!confirmed) return;
await resetChanges.mutateAsync(undefined, {
disableToastError: true,
onSuccess() {
showToast({
id: "git-reset-success",
message: "Changes have been reset",
color: "success",
});
fireAndForget(sync({ force: true }));
},
onError(err) {
showErrorToast({
id: "git-reset-error",
title: "Error resetting changes",
message: String(err),
});
},
});
},
},
{ type: "separator", label: "Branches", hidden: localBranches.length < 1 },
...localBranches.map((branch) => {
const isCurrent = currentBranch === branch;
return {
label: branch,
leftSlot: <Icon icon={isCurrent ? "check" : "empty"} />,
submenuOpenOnClick: true,
submenu: [
{
label: "Checkout",
hidden: isCurrent,
onSelect: () => tryCheckout(branch, false),
},
{
label: (
<>
Merge into <InlineCode>{currentBranch}</InlineCode>
</>
),
hidden: isCurrent,
async onSelect() {
await mergeBranch.mutateAsync(
{ branch },
{
disableToastError: true,
onSuccess() {
showToast({
id: "git-merged-branch",
message: (
<>
Merged <InlineCode>{branch}</InlineCode> into{" "}
<InlineCode>{currentBranch}</InlineCode>
</>
),
});
fireAndForget(sync({ force: true }));
},
onError(err) {
showErrorToast({
id: "git-merged-branch-error",
title: "Error merging branch",
message: String(err),
});
},
},
);
},
},
{
label: "New Branch...",
async onSelect() {
const name = await showPrompt({
id: "git-new-branch-from",
title: "New Branch",
description: (
<>
Create a new branch from <InlineCode>{branch}</InlineCode>
</>
),
label: "Branch Name",
});
if (!name) return;
await createBranch.mutateAsync(
{ branch: name, base: branch },
{
disableToastError: true,
onError: (err) => {
showErrorToast({
id: "git-branch-error",
title: "Error creating branch",
message: String(err),
});
},
},
);
tryCheckout(name, false);
},
},
{
label: "Rename...",
async onSelect() {
const newName = await showPrompt({
id: "git-rename-branch",
title: "Rename Branch",
label: "New Branch Name",
defaultValue: branch,
});
if (!newName || newName === branch) return;
await renameBranch.mutateAsync(
{ oldName: branch, newName },
{
disableToastError: true,
onSuccess() {
showToast({
id: "git-rename-branch-success",
message: (
<>
Renamed <InlineCode>{branch}</InlineCode> to{" "}
<InlineCode>{newName}</InlineCode>
</>
),
color: "success",
});
},
onError(err) {
showErrorToast({
id: "git-rename-branch-error",
title: "Error renaming branch",
message: String(err),
});
},
},
);
},
},
{ type: "separator", hidden: isCurrent },
{
label: "Delete",
color: "danger",
hidden: isCurrent,
onSelect: async () => {
const confirmed = await showConfirmDelete({
id: "git-delete-branch",
title: "Delete Branch",
description: (
<>
Permanently delete <InlineCode>{branch}</InlineCode>?
</>
),
});
if (!confirmed) {
return;
}
const result = await deleteBranch.mutateAsync(
{ branch },
{
disableToastError: true,
onError(err) {
showErrorToast({
id: "git-delete-branch-error",
title: "Error deleting branch",
message: String(err),
});
},
},
);
if (result.type === "not_fully_merged") {
const confirmed = await showConfirm({
id: "force-branch-delete",
title: "Branch not fully merged",
description: (
<>
<p>
Branch <InlineCode>{branch}</InlineCode> is not fully merged.
</p>
<p>Do you want to delete it anyway?</p>
</>
),
});
if (confirmed) {
await deleteBranch.mutateAsync(
{ branch, force: true },
{
disableToastError: true,
onError(err) {
showErrorToast({
id: "git-force-delete-branch-error",
title: "Error force deleting branch",
message: String(err),
});
},
},
);
}
}
},
},
],
} satisfies DropdownItem;
}),
...remoteOnlyBranches.map((branch) => {
const isCurrent = currentBranch === branch;
return {
label: branch,
leftSlot: <Icon icon={isCurrent ? "check" : "empty"} />,
submenuOpenOnClick: true,
submenu: [
{
label: "Checkout",
hidden: isCurrent,
onSelect: () => tryCheckout(branch, false),
},
{
label: "Delete",
color: "danger",
async onSelect() {
const confirmed = await showConfirmDelete({
id: "git-delete-remote-branch",
title: "Delete Remote Branch",
description: (
<>
Permanently delete <InlineCode>{branch}</InlineCode> from the remote?
</>
),
});
if (!confirmed) return;
await deleteRemoteBranch.mutateAsync(
{ branch },
{
disableToastError: true,
onSuccess() {
showToast({
id: "git-delete-remote-branch-success",
message: (
<>
Deleted remote branch <InlineCode>{branch}</InlineCode>
</>
),
color: "success",
});
},
onError(err) {
showErrorToast({
id: "git-delete-remote-branch-error",
title: "Error deleting remote branch",
message: String(err),
});
},
},
);
},
},
],
} satisfies DropdownItem;
}),
];
}, [
branchInfo.data,
checkout,
createBranch,
currentBranch,
deleteBranch,
deleteRemoteBranch,
hasChanges,
localBranches,
mergeBranch,
pull,
push,
remoteOnlyBranches,
renameBranch,
resetChanges,
syncDir,
workspace,
]);
if (workspace == null) {
return null;
}
const noRepo = branchInfo.error?.includes("not found");
if (noRepo) {
return <SetupGitDropdown workspaceId={workspace.id} initRepo={initRepo} />;
}
// Still loading
if (branchInfo.data == null) {
return null;
}
return (
<Dropdown fullWidth items={items} onOpen={regenerateKey}>
<GitMenuButton>
<InlineCode className="flex items-center gap-1">
<Icon icon="git_branch" size="xs" className="opacity-50" />
{currentBranch}
</InlineCode>
<div className="flex items-center gap-1.5">
{ahead > 0 && (
<span className="text-xs flex items-center gap-0.5">
<span className="text-primary"></span>
{ahead}
</span>
)}
{behind > 0 && (
<span className="text-xs flex items-center gap-0.5">
<span className="text-info"></span>
{behind}
</span>
)}
</div>
</GitMenuButton>
</Dropdown>
);
}
const GitMenuButton = forwardRef<HTMLButtonElement, HTMLAttributes<HTMLButtonElement>>(
function GitMenuButton({ className, ...props }: HTMLAttributes<HTMLButtonElement>, ref) {
return (
<button
ref={ref}
className={classNames(
className,
"px-3 h-md border-t border-border flex items-center justify-between text-text-subtle outline-hidden focus-visible:bg-surface-highlight",
)}
{...props}
/>
);
},
);
function SetupSyncDropdown({ workspaceMeta }: { workspaceMeta: WorkspaceMeta }) {
const { value: hidden, set: setHidden } = useKeyValue<Record<string, boolean>>({
key: "setup_sync",
fallback: {},
});
if (hidden == null || hidden[workspaceMeta.workspaceId]) {
return null;
}
const banner = (
<Banner color="info">
When enabled, workspace data syncs to the chosen folder as text files, ideal for backup and
Git collaboration.
</Banner>
);
return (
<Dropdown
fullWidth
items={[
{
type: "content",
label: banner,
},
{
color: "success",
label: "Open Workspace Settings",
leftSlot: <Icon icon="settings" />,
onSelect: () => openWorkspaceSettings("settings"),
},
{ type: "separator" },
{
label: "Hide This Message",
leftSlot: <Icon icon="eye_closed" />,
async onSelect() {
const confirmed = await showConfirm({
id: "hide-sync-menu-prompt",
title: "Hide Setup Message",
description: "You can configure filesystem sync or Git it in the workspace settings",
});
if (confirmed) {
await setHidden((prev) => ({ ...prev, [workspaceMeta.workspaceId]: true }));
}
},
},
]}
>
<GitMenuButton>
<div className="text-sm text-text-subtle grid grid-cols-[auto_minmax(0,1fr)] items-center gap-2">
<Icon icon="wrench" />
<div className="truncate">Setup FS Sync or Git</div>
</div>
</GitMenuButton>
</Dropdown>
);
}
function SetupGitDropdown({
workspaceId,
initRepo,
}: {
workspaceId: string;
initRepo: () => void;
}) {
const { value: hidden, set: setHidden } = useKeyValue<Record<string, boolean>>({
key: "setup_git_repo",
fallback: {},
});
if (hidden == null || hidden[workspaceId]) {
return null;
}
const banner = <Banner color="info">Initialize local repo to start versioning with Git</Banner>;
return (
<Dropdown
fullWidth
items={[
{ type: "content", label: banner },
{
label: "Initialize Git Repo",
leftSlot: <Icon icon="magic_wand" />,
onSelect: initRepo,
},
{ type: "separator" },
{
label: "Hide This Message",
leftSlot: <Icon icon="eye_closed" />,
async onSelect() {
const confirmed = await showConfirm({
id: "hide-git-init-prompt",
title: "Hide Git Setup",
description: "You can initialize a git repo outside of Yaak to bring this back",
});
if (confirmed) {
await setHidden((prev) => ({ ...prev, [workspaceId]: true }));
}
},
},
]}
>
<GitMenuButton>
<div className="text-sm text-text-subtle grid grid-cols-[auto_minmax(0,1fr)] items-center gap-2">
<Icon icon="folder_git" />
<div className="truncate">Setup Git</div>
</div>
</GitMenuButton>
</Dropdown>
);
}
@@ -1,336 +0,0 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo } from "react";
import { useLocalStorage } from "react-use";
import { useIntrospectGraphQL } from "../../hooks/useIntrospectGraphQL";
import { useStateWithDeps } from "../../hooks/useStateWithDeps";
import { showDialog } from "../../lib/dialog";
import { Button } from "../core/Button";
import type { DropdownItem } from "../core/Dropdown";
import { Dropdown } from "../core/Dropdown";
import type { EditorProps } from "../core/Editor/Editor";
import { Editor } from "../core/Editor/LazyEditor";
import type { RadioDropdownItem } from "../core/RadioDropdown";
import { RadioDropdown } from "../core/RadioDropdown";
import { Banner, FormattedError, Icon } from "@yaakapp-internal/ui";
import { Separator } from "../core/Separator";
import { tryFormatGraphql } from "../../lib/formatters";
import { parseGraphQLOperationNames } from "../../lib/graphqlOperationNames";
import { normalizeGraphQLBody } from "../../lib/requestBodyConversion";
import { showGraphQLDocExplorerAtom } from "./graphqlAtoms";
type Props = Pick<EditorProps, "heightMode" | "className" | "forceUpdateKey"> & {
baseRequest: HttpRequest;
onChange: (body: HttpRequest["body"]) => void;
request: HttpRequest;
};
const OPERATION_NAME_NOT_SPECIFIED = "";
export function GraphQLEditor(props: Props) {
// There's some weirdness with stale onChange being called when switching requests, so we'll
// key on the request ID as a workaround for now.
return <GraphQLEditorInner key={props.request.id} {...props} />;
}
function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProps }: Props) {
const [autoIntrospectDisabled, setAutoIntrospectDisabled] = useLocalStorage<
Record<string, boolean>
>("graphQLAutoIntrospectDisabled", {});
const { schema, isLoading, error, refetch, clear } = useIntrospectGraphQL(baseRequest, {
disabled: autoIntrospectDisabled?.[baseRequest.id],
});
const [currentBody, setCurrentBody] = useStateWithDeps<{
query: string;
variables: string | undefined;
operationName?: string;
}>(() => {
// Migrate text bodies to GraphQL format
// NOTE: This is how GraphQL used to be stored
return normalizeGraphQLBody(request.body);
}, [extraEditorProps.forceUpdateKey]);
const [isDocOpenRecord, setGraphqlDocStateAtomValue] = useAtom(showGraphQLDocExplorerAtom);
const isDocOpen = isDocOpenRecord[request.id] !== undefined;
const parsedOperationNames = useMemo(
() => parseGraphQLOperationNames(currentBody.query),
[currentBody.query],
);
const operationNames = useMemo(() => parsedOperationNames ?? [], [parsedOperationNames]);
const handleChangeQuery = useCallback(
(query: string) => {
setCurrentBody(({ variables, operationName }) => {
const newBody = buildGraphQLBody({ query, variables, operationName });
onChange(newBody);
return newBody;
});
},
[onChange, setCurrentBody],
);
const handleChangeVariables = useCallback(
(variables: string) => {
setCurrentBody(({ query, operationName }) => {
const newBody = buildGraphQLBody({ query, variables, operationName });
onChange(newBody);
return newBody;
});
},
[onChange, setCurrentBody],
);
const handleChangeOperationName = useCallback(
(operationName: string) => {
setCurrentBody(({ query, variables }) => {
const newBody = buildGraphQLBody({ query, variables, operationName });
onChange(newBody);
return newBody;
});
},
[onChange, setCurrentBody],
);
useEffect(() => {
if (parsedOperationNames == null) {
return;
}
if (currentBody.operationName === OPERATION_NAME_NOT_SPECIFIED) {
return;
}
if (currentBody.operationName && operationNames.includes(currentBody.operationName)) {
return;
}
// Keep the saved body aligned with the visible default, so send/copy use the selected operation.
const operationName = operationNames[0];
if (currentBody.operationName === operationName) {
return;
}
setCurrentBody(({ query, variables }) => {
const newBody = buildGraphQLBody({ query, variables, operationName });
onChange(newBody);
return newBody;
});
}, [
currentBody.operationName,
onChange,
operationNames,
parsedOperationNames,
setCurrentBody,
]);
const actions = useMemo<EditorProps["actions"]>(
() => [
operationNames.length > 0 ? (
<div key="operation" className="opacity-100!">
<RadioDropdown
value={currentBody.operationName ?? operationNames[0] ?? OPERATION_NAME_NOT_SPECIFIED}
onChange={handleChangeOperationName}
items={[
{ type: "separator", label: "Operation Name" },
{
label: <span className="text-text-subtle italic">Not specified</span>,
value: OPERATION_NAME_NOT_SPECIFIED,
},
...operationNames.map((operationName) => ({
label: operationName,
value: operationName,
})),
] satisfies RadioDropdownItem<string>[]}
>
<Button size="sm" variant="border" title="Select Operation" forDropdown>
{currentBody.operationName === OPERATION_NAME_NOT_SPECIFIED ? (
<span className="text-text-subtle italic">Not specified</span>
) : (
currentBody.operationName ?? operationNames[0]
)}
</Button>
</RadioDropdown>
</div>
) : null,
<div key="introspection" className="opacity-100!">
{schema === undefined ? null /* Initializing */ : (
<Dropdown
items={[
...((schema != null
? [
{
label: "Clear",
onSelect: clear,
color: "danger",
leftSlot: <Icon icon="trash" />,
},
{ type: "separator" },
]
: []) satisfies DropdownItem[]),
{
hidden: !error,
label: (
<Banner color="danger">
<p className="mb-1">Schema introspection failed</p>
<Button
size="xs"
color="danger"
variant="border"
onClick={() => {
showDialog({
title: "Introspection Failed",
size: "sm",
id: "introspection-failed",
render: ({ hide }) => (
<>
<FormattedError>{error ?? "unknown"}</FormattedError>
<div className="w-full my-4">
<Button
onClick={async () => {
hide();
await refetch();
}}
className="ml-auto"
color="primary"
size="sm"
>
Retry Request
</Button>
</div>
</>
),
});
}}
>
View Error
</Button>
</Banner>
),
type: "content",
},
{
hidden: schema == null,
label: `${isDocOpen ? "Hide" : "Show"} Documentation`,
leftSlot: <Icon icon="book_open_text" />,
onSelect: () => {
setGraphqlDocStateAtomValue((v) => ({
...v,
[request.id]: isDocOpen ? undefined : null,
}));
},
},
{
label: "Introspect Schema",
leftSlot: <Icon icon="refresh" spin={isLoading} />,
keepOpenOnSelect: true,
onSelect: refetch,
},
{ type: "separator", label: "Setting" },
{
label: "Automatic Introspection",
keepOpenOnSelect: true,
onSelect: () => {
setAutoIntrospectDisabled({
...autoIntrospectDisabled,
[baseRequest.id]: !autoIntrospectDisabled?.[baseRequest.id],
});
},
leftSlot: (
<Icon
icon={
autoIntrospectDisabled?.[baseRequest.id]
? "check_square_unchecked"
: "check_square_checked"
}
/>
),
},
]}
>
<Button
size="sm"
variant="border"
title="Refetch Schema"
isLoading={isLoading}
color={error ? "danger" : "default"}
forDropdown
>
{error ? "Introspection Failed" : schema ? "Schema" : "No Schema"}
</Button>
</Dropdown>
)}
</div>,
],
[
schema,
clear,
error,
currentBody.operationName,
handleChangeOperationName,
isDocOpen,
isLoading,
operationNames,
refetch,
autoIntrospectDisabled,
baseRequest.id,
setGraphqlDocStateAtomValue,
request.id,
setAutoIntrospectDisabled,
],
);
return (
<div className="h-full w-full grid grid-cols-1 grid-rows-[minmax(0,100%)_auto]">
<Editor
language="graphql"
heightMode="auto"
graphQLSchema={schema}
format={tryFormatGraphql}
defaultValue={currentBody.query}
onChange={handleChangeQuery}
placeholder="..."
actions={actions}
stateKey={`graphql_body.${request.id}`}
{...extraEditorProps}
/>
<div className="grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1 min-h-20">
<Separator dashed className="pb-1">
Variables
</Separator>
<Editor
language="json"
heightMode="auto"
defaultValue={currentBody.variables}
onChange={handleChangeVariables}
placeholder="{}"
stateKey={`graphql_vars.${request.id}`}
autocompleteFunctions
autocompleteVariables
{...extraEditorProps}
/>
</div>
</div>
);
}
function buildGraphQLBody(body: {
query: string;
variables: string | undefined;
operationName?: string;
}) {
const result: {
query: string;
variables: string | undefined;
operationName?: string;
} = {
query: body.query,
variables: body.variables || undefined,
};
if (typeof body.operationName === "string") {
result.operationName = body.operationName;
}
return result;
}
@@ -1,428 +0,0 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { extractSseValueAtPath, type ServerSentEvent } from "@yaakapp-internal/sse";
import { HStack, Icon, InlineCode, SplitLayout, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { CSSProperties, ReactNode } from "react";
import { Fragment, useMemo, useState } from "react";
import { useKeyValue } from "../../hooks/useKeyValue";
import { useFormatText } from "../../hooks/useFormatText";
import { useResponseBodyEventSource } from "../../hooks/useResponseBodyEventSource";
import { useResponseBodySseSummary } from "../../hooks/useResponseBodySseSummary";
import {
sseSummaryResultKeyPathAutocomplete,
useSseSummaryResultKeyPath,
} from "../../hooks/useSseSummaryResultKeyPath";
import { isJSON } from "../../lib/contentType";
import { EmptyStateText } from "../EmptyStateText";
import { Markdown } from "../Markdown";
import { Button } from "../core/Button";
import type { DropdownItem } from "../core/Dropdown";
import { Dropdown } from "../core/Dropdown";
import type { EditorProps } from "../core/Editor/Editor";
import { Editor } from "../core/Editor/LazyEditor";
import { EventDetailHeader, EventViewer } from "../core/EventViewer";
import { EventViewerRow } from "../core/EventViewerRow";
import { IconButton } from "../core/IconButton";
import { IconTooltip } from "../core/IconTooltip";
import { Input } from "../core/Input";
import { Select } from "../core/Select";
interface Props {
response: HttpResponse;
}
const DEFAULT_EXTRACTED_TEXT_RATIO = 0.28;
export function EventStreamViewer({ response }: Props) {
return (
<Fragment
key={response.id} // force a refresh when the response changes
>
<ActualEventStreamViewer response={response} />
</Fragment>
);
}
function ActualEventStreamViewer({ response }: Props) {
const [showLarge, setShowLarge] = useState<boolean>(false);
const [showingLarge, setShowingLarge] = useState<boolean>(false);
const filterEventPreviewsSetting = useKeyValue<boolean>({
namespace: "no_sync",
key: ["sse_filter_event_previews", response.requestId],
fallback: false,
});
const applyToDetailsSetting = useKeyValue<boolean>({
namespace: "no_sync",
key: ["sse_apply_to_details", response.requestId],
fallback: false,
});
const renderMarkdownSetting = useKeyValue<boolean>({
namespace: "no_sync",
key: ["sse_render_markdown", response.requestId],
fallback: false,
});
const summarySettings = useSseSummaryResultKeyPath({ response });
const events = useResponseBodyEventSource(response);
const summary = useResponseBodySseSummary(response, summarySettings.resultKeyPath);
const showExtractedText = summarySettings.resultKeyPath != null;
const showResultKeyPathWarning =
showExtractedText &&
summary.data != null &&
summary.data.fragmentCount === 0 &&
!summary.isFetching &&
summary.error == null;
const filterEventPreviews = showExtractedText && filterEventPreviewsSetting.value === true;
const applyToDetails = showExtractedText && applyToDetailsSetting.value === true;
const renderMarkdown = showExtractedText && renderMarkdownSetting.value === true;
const settingsItems = useMemo<DropdownItem[]>(
() => [
{
label: "Apply to Previews",
keepOpenOnSelect: true,
onSelect: () => filterEventPreviewsSetting.set(filterEventPreviewsSetting.value !== true),
leftSlot: (
<Icon
icon={
filterEventPreviewsSetting.value === true
? "check_square_checked"
: "check_square_unchecked"
}
/>
),
},
{
label: "Apply to Details",
keepOpenOnSelect: true,
onSelect: () => applyToDetailsSetting.set(applyToDetailsSetting.value !== true),
leftSlot: (
<Icon
icon={
applyToDetailsSetting.value === true
? "check_square_checked"
: "check_square_unchecked"
}
/>
),
},
],
[
applyToDetailsSetting,
filterEventPreviewsSetting,
],
);
return (
<div className="h-full min-h-0 grid grid-rows-[auto_minmax(0,1fr)]">
<HStack space={2} alignItems="center" className="pt-1 pb-1 border-b border-border-subtle">
<div className={classNames(summarySettings.enabled ? "w-44 shrink-0" : "min-w-40 flex-1")}>
<Select
name={`sse-summary-result-key-path-enabled::${response.requestId}`}
label="Extracted text"
hideLabel
size="xs"
value={summarySettings.enabled ? "jsonpath" : "off"}
options={[
{ label: "Full events", value: "off" },
{ label: "JSONPath", value: "jsonpath" },
]}
onChange={(value) => summarySettings.setEnabled(value === "jsonpath")}
/>
</div>
{summarySettings.enabled && (
<>
<div className="min-w-40 flex-1">
<Input
label="Result JSON path"
hideLabel
size="xs"
autocomplete={sseSummaryResultKeyPathAutocomplete}
defaultValue={summarySettings.resultKeyPathInputValue}
forceUpdateKey={`${response.requestId}:${summarySettings.inferredResultKeyPath ?? ""}`}
placeholder="$.choices[0].delta.content"
rightSlot={
showResultKeyPathWarning ? (
<div className="flex items-center px-2">
<IconTooltip
tabIndex={-1}
icon="alert_triangle"
iconColor="notice"
content="No text fragments matched this JSONPath."
/>
</div>
) : null
}
stateKey={`sse-summary-result-key-path::${response.requestId}`}
tint={showResultKeyPathWarning ? "notice" : undefined}
onChange={summarySettings.setResultKeyPath}
/>
</div>
<Dropdown items={settingsItems}>
<IconButton
size="xs"
variant="border"
icon="settings"
title="Extracted text settings"
/>
</Dropdown>
</>
)}
</HStack>
<SplitLayout
layout="vertical"
storageKey={`sse_extracted_text::${response.requestId}`}
defaultRatio={DEFAULT_EXTRACTED_TEXT_RATIO}
minHeightPx={72}
resizeHandleClassName="hover:bg-surface-highlight active:bg-surface-highlight"
firstSlot={({ style }) => (
<div style={style} className="min-h-0">
<EventViewer
events={events.data ?? []}
getEventKey={(_, index) => String(index)}
error={events.error ? String(events.error) : null}
splitLayoutStorageKey="sse_events"
defaultRatio={0.4}
renderRow={({ event, index, isActive, onClick }) => (
<EventViewerRow
isActive={isActive}
onClick={onClick}
icon={<Icon color="info" title="Server Message" icon="arrow_big_down_dash" />}
content={
<HStack space={2} className="items-center">
<EventLabels event={event} index={index} isActive={isActive} />
<span className="truncate text-xs">
{getEventPreview(event, summarySettings.resultKeyPath, filterEventPreviews)}
</span>
</HStack>
}
/>
)}
renderDetail={({ event, index, onClose }) => (
<EventDetail
event={event}
index={index}
applyJsonPath={applyToDetails}
resultKeyPath={summarySettings.resultKeyPath}
showLarge={showLarge}
showingLarge={showingLarge}
setShowLarge={setShowLarge}
setShowingLarge={setShowingLarge}
onClose={onClose}
/>
)}
/>
</div>
)}
secondSlot={
showExtractedText
? ({ style }) => (
<SseSummaryFooter
style={style}
error={summary.error ? String(summary.error) : null}
isLoading={summary.isLoading}
onRenderMarkdownChange={renderMarkdownSetting.set}
renderMarkdown={renderMarkdown}
resultKeyPath={summarySettings.resultKeyPath ?? ""}
summary={summary.data?.summary ?? ""}
fragmentCount={summary.data?.fragmentCount ?? 0}
/>
)
: null
}
/>
</div>
);
}
function SseSummaryFooter({
error,
fragmentCount,
isLoading,
onRenderMarkdownChange,
renderMarkdown,
resultKeyPath,
style,
summary,
}: {
error: string | null;
fragmentCount: number;
isLoading: boolean;
onRenderMarkdownChange: (renderMarkdown: boolean) => void;
renderMarkdown: boolean;
resultKeyPath: string;
style: CSSProperties;
summary: string;
}) {
const hasSummary = fragmentCount > 0;
const actions = useMemo(
() => [
{
key: "sse-summary-format",
label: "Extracted text format",
type: "select" as const,
value: renderMarkdown ? "markdown" : "text",
options: [
{ label: "Text", value: "text" },
{ label: "Markdown", value: "markdown" },
],
onChange: (value: string) => onRenderMarkdownChange(value === "markdown"),
},
],
[onRenderMarkdownChange, renderMarkdown],
);
return (
<div
style={style}
className="min-h-0 overflow-hidden border-t border-border-subtle bg-surface grid grid-rows-[auto_minmax(0,1fr)]"
>
<div className="pt-2">
<EventDetailHeader
actions={actions}
title="Extracted Text"
copyText={hasSummary ? summary : undefined}
/>
</div>
<div
className={classNames(
"min-h-0 py-2 overflow-auto",
(error != null || isLoading || (hasSummary && !renderMarkdown)) && "text-xs",
)}
>
{error != null ? (
<span className="text-danger">{error}</span>
) : isLoading ? (
<span className="italic text-text-subtlest">Loading extracted text...</span>
) : hasSummary ? (
renderMarkdown ? (
<div className="min-h-0">
<Markdown className="select-auto cursor-auto">{summary}</Markdown>
</div>
) : (
<pre className="font-mono whitespace-pre-wrap wrap-break-word select-auto cursor-auto">
{summary}
</pre>
)
) : (
<EmptyStateText className="gap-1.5">
No fragments for <InlineCode className="py-0">{resultKeyPath}</InlineCode>
</EmptyStateText>
)}
</div>
</div>
);
}
function getEventPreview(
event: ServerSentEvent,
resultKeyPath: string | null,
filterEventPreview: boolean,
): string {
if (filterEventPreview && resultKeyPath != null) {
return (extractSseValueAtPath(event.data, resultKeyPath) ?? event.data).slice(0, 1000);
}
return event.data.slice(0, 1000);
}
function EventDetail({
applyJsonPath,
event,
index,
resultKeyPath,
showLarge,
showingLarge,
setShowLarge,
setShowingLarge,
onClose,
}: {
applyJsonPath: boolean;
event: ServerSentEvent;
index: number;
resultKeyPath: string | null;
showLarge: boolean;
showingLarge: boolean;
setShowLarge: (v: boolean) => void;
setShowingLarge: (v: boolean) => void;
onClose: () => void;
}) {
const detailText = useMemo(
() =>
applyJsonPath && resultKeyPath != null
? (extractSseValueAtPath(event.data, resultKeyPath) ?? event.data)
: event.data,
[applyJsonPath, event.data, resultKeyPath],
);
const language = useMemo<"text" | "json">(() => {
if (!detailText) return "text";
return isJSON(detailText) ? "json" : "text";
}, [detailText]);
return (
<div className="flex flex-col h-full">
<EventDetailHeader
title="Message Received"
prefix={<EventLabels event={event} index={index} />}
onClose={onClose}
/>
{!showLarge && detailText.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>
) : (
<FormattedEditor language={language} text={detailText} />
)}
</div>
);
}
function FormattedEditor({ text, language }: { text: string; language: EditorProps["language"] }) {
const formatted = useFormatText({ text, language, pretty: true });
if (formatted == null) return null;
return <Editor readOnly defaultValue={formatted} language={language} stateKey={null} />;
}
function EventLabels({
className,
event,
index,
isActive,
}: {
event: ServerSentEvent;
index: number;
className?: string;
isActive?: boolean;
}) {
return (
<HStack space={1.5} alignItems="center" className={className}>
<EventLabel isActive={isActive}>{event.id ?? index}</EventLabel>
{event.eventType && <EventLabel isActive={isActive}>{event.eventType}</EventLabel>}
</HStack>
);
}
function EventLabel({ children, isActive }: { children: ReactNode; isActive?: boolean }) {
return (
<InlineCode className={classNames("py-0", isActive && "relative overflow-hidden")}>
{isActive && <span className="absolute inset-0 bg-text opacity-5 pointer-events-none" />}
<span className="relative">{children}</span>
</InlineCode>
);
}
-206
View File
@@ -1,206 +0,0 @@
import type { Folder } from "@yaakapp-internal/models";
import { modelTypeLabel, patchModel } from "@yaakapp-internal/models";
import { HStack, Icon, InlineCode } from "@yaakapp-internal/ui";
import { useMemo } from "react";
import { openFolderSettings } from "../commands/openFolderSettings";
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
import { IconTooltip } from "../components/core/IconTooltip";
import type { RadioDropdownProps } from "../components/core/RadioDropdown";
import type { TabItem } from "../components/core/Tabs/Tabs";
import { capitalize } from "../lib/capitalize";
import { showConfirm } from "../lib/confirm";
import { resolvedModelName } from "../lib/resolvedModelName";
import { useHttpAuthenticationSummaries } from "./useHttpAuthentication";
import type { AuthenticatedModel } from "./useInheritedAuthentication";
import { useInheritedAuthentication } from "./useInheritedAuthentication";
import { useModelAncestors } from "./useModelAncestors";
export function useAuthTab<T extends string>(
tabValue: T,
model: AuthenticatedModel | null,
) {
const options = useAuthDropdownOptions(model);
return useMemo<TabItem[]>(() => {
if (model == null || options == null) return [];
const tab: TabItem = {
value: tabValue,
label: "Auth",
options,
};
return [tab];
}, [model, options, tabValue]);
}
export function useAuthDropdownOptions(
model: AuthenticatedModel | null,
): Omit<RadioDropdownProps, "children"> | null {
const authentication = useHttpAuthenticationSummaries();
const inheritedAuth = useInheritedAuthentication(model);
const ancestors = useModelAncestors(model);
const parentModel = ancestors[0] ?? null;
return useMemo(() => {
if (model == null) return null;
return {
value: model.authenticationType,
items: [
...authentication.map((a) => ({
label: a.label || "UNKNOWN",
shortLabel: a.shortLabel,
value: a.name,
})),
{ type: "separator" },
{
label: "Inherit from Parent",
shortLabel:
inheritedAuth != null &&
inheritedAuth.authenticationType !== "none" ? (
<HStack space={1.5}>
{authentication.find(
(a) => a.name === inheritedAuth.authenticationType,
)?.shortLabel ?? "UNKNOWN"}
<IconTooltip
icon="zap_off"
iconSize="xs"
content="Authentication was inherited from an ancestor"
/>
</HStack>
) : (
"Auth"
),
value: null,
},
{ label: "No Auth", shortLabel: "No Auth", value: "none" },
],
itemsAfter: (() => {
const actions: (
| { type: "separator"; label: string }
| {
label: string;
leftSlot: React.ReactNode;
onSelect: () => Promise<void>;
}
)[] = [];
// Promote: move auth from current model up to parent
if (
parentModel &&
model.authenticationType &&
model.authenticationType !== "none" &&
(parentModel.authenticationType == null ||
parentModel.authenticationType === "none")
) {
actions.push(
{ type: "separator", label: "Actions" },
{
label: `Promote to ${capitalize(parentModel.model)}`,
leftSlot: (
<Icon
icon={
parentModel.model === "workspace"
? "corner_right_up"
: "folder_up"
}
/>
),
onSelect: async () => {
const confirmed = await showConfirm({
id: "promote-auth-confirm",
title: "Promote Authentication",
confirmText: "Promote",
description: (
<>
Move authentication config to{" "}
<InlineCode>{resolvedModelName(parentModel)}</InlineCode>?
</>
),
});
if (confirmed) {
await patchModel(model, {
authentication: {},
authenticationType: null,
});
await patchModel(parentModel, {
authentication: model.authentication,
authenticationType: model.authenticationType,
});
if (parentModel.model === "folder") {
openFolderSettings(parentModel.id, "auth");
} else {
openWorkspaceSettings("auth");
}
}
},
},
);
}
// Copy from ancestor: copy auth config down to current model
const ancestorWithAuth = ancestors.find(
(a) =>
a.authenticationType != null && a.authenticationType !== "none",
);
if (ancestorWithAuth) {
if (actions.length === 0) {
actions.push({ type: "separator", label: "Actions" });
}
actions.push({
label: `Copy from ${modelTypeLabel(ancestorWithAuth)}`,
leftSlot: (
<Icon
icon={
ancestorWithAuth.model === "workspace"
? "corner_right_down"
: "folder_down"
}
/>
),
onSelect: async () => {
const confirmed = await showConfirm({
id: "copy-auth-confirm",
title: "Copy Authentication",
confirmText: "Copy",
description: (
<>
Copy{" "}
{authentication.find(
(a) => a.name === ancestorWithAuth.authenticationType,
)?.label ?? "authentication"}{" "}
config from{" "}
<InlineCode>
{resolvedModelName(ancestorWithAuth)}
</InlineCode>
? This will override the current authentication but will not
affect the {modelTypeLabel(ancestorWithAuth).toLowerCase()}.
</>
),
});
if (confirmed) {
await patchModel(model, {
authentication: { ...ancestorWithAuth.authentication },
authenticationType: ancestorWithAuth.authenticationType,
});
}
},
});
}
return actions.length > 0 ? actions : undefined;
})(),
onChange: async (authenticationType) => {
let authentication: Folder["authentication"] = model.authentication;
if (model.authenticationType !== authenticationType) {
authentication = {
// Reset auth if changing types
};
}
await patchModel(model, { authentication, authenticationType });
},
};
}, [authentication, inheritedAuth, model, parentModel, ancestors]);
}
@@ -1,18 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import type { HttpResponse } from "@yaakapp-internal/models";
import type { SseSummary } from "@yaakapp-internal/sse";
import { getResponseBodySseSummary } from "../lib/responseBody";
export function useResponseBodySseSummary(response: HttpResponse, resultKeyPath: string | null) {
return useQuery<SseSummary>({
enabled: resultKeyPath != null,
queryKey: [
"response-body-sse-summary",
response.id,
response.updatedAt,
response.contentLength,
resultKeyPath,
],
queryFn: () => getResponseBodySseSummary(response, resultKeyPath ?? ""),
});
}
@@ -1,32 +0,0 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { flushAllModelWrites } from "@yaakapp-internal/models";
import { invokeCmd } from "../lib/tauri";
import { getActiveCookieJar } from "./useActiveCookieJar";
import { getActiveEnvironment } from "./useActiveEnvironment";
import { createFastMutation, useFastMutation } from "./useFastMutation";
async function sendAnyHttpRequestById(id: string | null): Promise<HttpResponse | null> {
if (id == null) {
return null;
}
await flushAllModelWrites();
return invokeCmd("cmd_send_http_request", {
requestId: id,
environmentId: getActiveEnvironment()?.id,
cookieJarId: getActiveCookieJar()?.id,
});
}
export function useSendAnyHttpRequest() {
return useFastMutation<HttpResponse | null, string, string | null>({
mutationKey: ["send_any_request"],
mutationFn: sendAnyHttpRequestById,
});
}
export const sendAnyHttpRequest = createFastMutation<HttpResponse | null, string, string | null>({
mutationKey: ["send_any_request"],
mutationFn: sendAnyHttpRequestById,
});
@@ -1,98 +0,0 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
import { useMemo } from "react";
import type { GenericCompletionConfig } from "../components/core/Editor/genericCompletion";
import { useKeyValue } from "./useKeyValue";
const OPENAI_CHAT_COMPLETIONS_RESULT_KEY_PATH = "$.choices[0].delta.content";
const OPENAI_RESPONSES_RESULT_KEY_PATH = "$.delta";
const ANTHROPIC_RESULT_KEY_PATH = "$.delta.text";
const GOOGLE_RESULT_KEY_PATH = "$.candidates[0].content.parts[0].text";
const sseSummaryResultKeyPathOptions: GenericCompletionOption[] = [
{
label: OPENAI_CHAT_COMPLETIONS_RESULT_KEY_PATH,
detail: "ChatGPT (OpenAI)",
type: "constant",
boost: 1,
},
{
label: OPENAI_RESPONSES_RESULT_KEY_PATH,
detail: "Responses (OpenAI)",
type: "constant",
boost: 1,
},
{
label: ANTHROPIC_RESULT_KEY_PATH,
detail: "Claude (Anthropic)",
type: "constant",
boost: 1,
},
{
label: GOOGLE_RESULT_KEY_PATH,
detail: "Gemini (Google)",
type: "constant",
boost: 1,
},
];
export const sseSummaryResultKeyPathAutocomplete: GenericCompletionConfig = {
minMatch: 0,
options: sseSummaryResultKeyPathOptions,
};
export function useSseSummaryResultKeyPath({ response }: { response: HttpResponse }) {
const storedResultKeyPath = useKeyValue<string | null>({
namespace: "no_sync",
key: ["sse_summary_result_key_path", response.requestId],
fallback: null,
});
const enabled = useKeyValue<boolean | null>({
namespace: "no_sync",
key: ["sse_summary_result_key_path_enabled", response.requestId],
fallback: null,
});
const inferredResultKeyPath = useMemo(() => inferSseSummaryResultKeyPath(response), [response.url]);
const resultKeyPath = storedResultKeyPath.value ?? inferredResultKeyPath;
const trimmedResultKeyPath = resultKeyPath?.trim() ?? "";
const isEnabled = enabled.value ?? inferredResultKeyPath != null;
return {
enabled: isEnabled,
inferredResultKeyPath,
resultKeyPath: isEnabled && trimmedResultKeyPath.length > 0 ? trimmedResultKeyPath : null,
resultKeyPathInputValue: resultKeyPath ?? "",
setEnabled: enabled.set,
setResultKeyPath: storedResultKeyPath.set,
};
}
function inferSseSummaryResultKeyPath(response: HttpResponse): string | null {
let url: URL;
try {
url = new URL(response.url);
} catch {
return null;
}
const hostname = url.hostname.toLowerCase();
const pathname = url.pathname.toLowerCase();
if (hostname === "api.openai.com" && pathname === "/v1/chat/completions") {
return OPENAI_CHAT_COMPLETIONS_RESULT_KEY_PATH;
}
if (hostname === "api.openai.com" && pathname === "/v1/responses") {
return OPENAI_RESPONSES_RESULT_KEY_PATH;
}
if (hostname === "api.anthropic.com" && pathname === "/v1/messages") {
return ANTHROPIC_RESULT_KEY_PATH;
}
if (
hostname === "generativelanguage.googleapis.com" &&
pathname.includes(":streamgeneratecontent")
) {
return GOOGLE_RESULT_KEY_PATH;
}
return null;
}
-38
View File
@@ -1,38 +0,0 @@
import { watchGitWorktreeStatus, type GitWorktreeStatusEntry } from "@yaakapp-internal/git";
import { activeWorkspaceMetaAtom } from "../hooks/useActiveWorkspace";
import { gitWorktreeStatusAtom, gitWorktreeStatusByModelIdAtom } from "../lib/gitWorktreeStatus";
import { jotaiStore } from "../lib/jotai";
export function initGit() {
let watchedDir: string | null = null;
let unwatch: null | ReturnType<typeof watchGitWorktreeStatus> = null;
const watchActiveWorkspace = () => {
const syncDir = jotaiStore.get(activeWorkspaceMetaAtom)?.settingSyncDir ?? null;
if (syncDir === watchedDir) return;
void unwatch?.();
unwatch = null;
watchedDir = syncDir;
jotaiStore.set(gitWorktreeStatusAtom, null);
jotaiStore.set(gitWorktreeStatusByModelIdAtom, {});
if (syncDir == null) return;
unwatch = watchGitWorktreeStatus(syncDir, (status) => {
if (syncDir !== watchedDir) return;
jotaiStore.set(gitWorktreeStatusAtom, status);
const statusByModelId: Record<string, GitWorktreeStatusEntry> = {};
for (const entry of status.entries) {
if (entry.modelId == null) continue;
statusByModelId[entry.modelId] = entry;
}
jotaiStore.set(gitWorktreeStatusByModelIdAtom, statusByModelId);
});
};
watchActiveWorkspace();
jotaiStore.sub(activeWorkspaceMetaAtom, watchActiveWorkspace);
}
-103
View File
@@ -1,103 +0,0 @@
import { settingsAtom } from "@yaakapp-internal/models";
import { FeedbackToast } from "../components/FeedbackToast";
import { appInfo } from "./appInfo";
import type { FeedbackFeature } from "./featureFeedbackConstants";
import { dialogsAtom } from "./dialog";
import { jotaiStore } from "./jotai";
import { getKeyValue, setKeyValue } from "./keyValueStore";
import { showToast } from "./toast";
interface FeatureFeedbackState {
uses: number;
done: boolean;
}
const FEEDBACK_PROMPT_DELAY_MS = 1500;
const FEEDBACK_PROMPT_TIMEOUT_MS = 8000;
// Ask once the user has used a feature enough times to have formed an opinion
const PROMPT_AFTER_USES = 3;
// Show at most one feedback prompt per app session to stay unobtrusive
let promptedThisSession = false;
const lastTrackedAt: Partial<Record<FeedbackFeature, number>> = {};
const FEATURE_USE_DEBOUNCE_MS = 10_000;
const kvArgs = (feature: FeedbackFeature) => ({
namespace: "global",
key: ["feature-feedback", feature],
});
function getFeatureFeedbackState(feature: FeedbackFeature): FeatureFeedbackState {
return getKeyValue<FeatureFeedbackState>({
...kvArgs(feature),
fallback: { uses: 0, done: false },
});
}
function patchFeatureFeedbackState(feature: FeedbackFeature, patch: Partial<FeatureFeedbackState>) {
const value = { ...getFeatureFeedbackState(feature), ...patch };
setKeyValue({ ...kvArgs(feature), value }).catch(console.error);
}
function markFeatureFeedbackDone(feature: FeedbackFeature) {
patchFeatureFeedbackState(feature, { done: true });
}
function showFeedbackToast(feature: FeedbackFeature) {
if (!jotaiStore.get(settingsAtom).promptFeedback) return;
showToast({
id: `feature-feedback-${feature}`,
timeout: FEEDBACK_PROMPT_TIMEOUT_MS,
dynamicHeight: true,
hideDismiss: true,
message: (
<FeedbackToast feature={feature} onDone={() => markFeatureFeedbackDone(feature)} />
),
});
}
function showFeedbackToastWhenReady(feature: FeedbackFeature) {
setTimeout(() => {
if (!jotaiStore.get(settingsAtom).promptFeedback) return;
if (jotaiStore.get(dialogsAtom).length === 0) {
showFeedbackToast(feature);
return;
}
const unsubscribe = jotaiStore.sub(dialogsAtom, () => {
if (jotaiStore.get(dialogsAtom).length > 0) return;
unsubscribe();
showFeedbackToast(feature);
});
}, FEEDBACK_PROMPT_DELAY_MS);
}
// Record a successful use of a feature, and prompt for feedback on the Nth use.
// Nothing is ever sent to the server from here; showing the toast is local-only
// and a submission only happens when the user clicks Send in it.
export function trackFeatureUsage(feature: FeedbackFeature) {
if (appInfo.featureLicense !== true || !jotaiStore.get(settingsAtom).promptFeedback) return;
const now = Date.now();
if (lastTrackedAt[feature] != null && now - lastTrackedAt[feature] < FEATURE_USE_DEBOUNCE_MS) {
return;
}
lastTrackedAt[feature] = now;
const state = getFeatureFeedbackState(feature);
if (state.done) return;
const uses = state.uses + 1;
const shouldPrompt = uses >= PROMPT_AFTER_USES && !promptedThisSession;
patchFeatureFeedbackState(feature, { uses });
if (!shouldPrompt) return;
promptedThisSession = true;
showFeedbackToastWhenReady(feature);
}
@@ -1,8 +0,0 @@
// Feature keys are sent to the server and used to group feedback for analysis.
// NEVER rename a key once it has shipped, or historical feedback will be split
// across the old and new names.
export const FEEDBACK_FEATURES = {
"git-sync": "How is Git sync working for you?",
} as const;
export type FeedbackFeature = keyof typeof FEEDBACK_FEATURES;
-22
View File
@@ -1,22 +0,0 @@
import type { GitWorktreeStatus, GitWorktreeStatusEntry } from "@yaakapp-internal/git";
import { atom } from "jotai";
import { atomFamily } from "jotai-family";
import { selectAtom } from "jotai/utils";
export const gitWorktreeStatusAtom = atom<GitWorktreeStatus | null>(null);
export const gitWorktreeStatusByModelIdAtom = atom<Record<string, GitWorktreeStatusEntry>>({});
export const gitWorktreeStatusFamily = atomFamily(
(modelId: string) =>
selectAtom(
gitWorktreeStatusByModelIdAtom,
(statusByModelId) => statusByModelId[modelId] ?? null,
(a, b) =>
a?.relaPath === b?.relaPath &&
a?.status === b?.status &&
a?.staged === b?.staged &&
a?.modelId === b?.modelId,
),
Object.is,
);
@@ -1,37 +0,0 @@
import { describe, expect, test } from "vite-plus/test";
import { getGraphQLOperationNames, parseGraphQLOperationNames } from "./graphqlOperationNames";
describe("getGraphQLOperationNames", () => {
test("returns named operations from a GraphQL document", () => {
expect(
getGraphQLOperationNames(`
query GetUser { user { id } }
mutation UpdateUser { updateUser { id } }
subscription UserChanged { userChanged { id } }
fragment UserFields on User { id }
`),
).toEqual(["GetUser", "UpdateUser", "UserChanged"]);
});
test("ignores anonymous operations", () => {
expect(getGraphQLOperationNames(`{ user { id } }`)).toEqual([]);
});
test("returns unique operation names in document order", () => {
expect(
getGraphQLOperationNames(`
query GetUser { user { id } }
query GetUser { user { name } }
query ListUsers { users { id } }
`),
).toEqual(["GetUser", "ListUsers"]);
});
test("returns no operations for invalid in-progress documents", () => {
expect(getGraphQLOperationNames(`query GetUser { user {`)).toEqual([]);
});
test("returns null when parsing invalid in-progress documents", () => {
expect(parseGraphQLOperationNames(`query GetUser { user {`)).toBeNull();
});
});
@@ -1,26 +0,0 @@
import { Kind, parse } from "graphql";
export function getGraphQLOperationNames(query: string): string[] {
return parseGraphQLOperationNames(query) ?? [];
}
export function parseGraphQLOperationNames(query: string): string[] | null {
try {
const names: string[] = [];
for (const definition of parse(query).definitions) {
if (definition.kind !== Kind.OPERATION_DEFINITION || definition.name == null) {
continue;
}
const name = definition.name.value;
if (!names.includes(name)) {
names.push(name);
}
}
return names;
} catch {
return null;
}
}
@@ -1,28 +0,0 @@
import { describe, expect, test } from "vite-plus/test";
import { extractPathPlaceholders } from "./pathPlaceholders";
describe("extractPathPlaceholders", () => {
test("extracts a single placeholder", () => {
expect(extractPathPlaceholders("/users/:id")).toEqual([":id"]);
});
test("extracts multiple placeholders", () => {
expect(extractPathPlaceholders("/users/:id/posts/:postId")).toEqual([":id", ":postId"]);
});
test("stops at a literal `:` in the same segment", () => {
expect(extractPathPlaceholders("/tasks/:id:cancel")).toEqual([":id"]);
});
test("does not match `:foo` mid-segment", () => {
expect(extractPathPlaceholders("/users/abc:def")).toEqual([]);
});
test("does not match `:` in a host port", () => {
expect(extractPathPlaceholders("https://example.com:8080/users/:id")).toEqual([":id"]);
});
test("returns empty for a URL with no placeholders", () => {
expect(extractPathPlaceholders("https://example.com/foo/bar?q=1#hash")).toEqual([]);
});
});
-14
View File
@@ -1,14 +0,0 @@
/**
* Extract `:name`-style path placeholders from a URL string.
*
* A placeholder is `:` followed by one-or-more characters that are not `/`, `?`,
* `#`, or `:`. The `:` boundary means a placeholder ends where a literal colon
* starts in the same segment, e.g. `/tasks/:id:increment-importance` yields one
* placeholder `:id` and `:increment-importance` is literal text.
*
* Only `:` that sits at the start of a `/`-delimited segment counts — `/abc:def`
* has no placeholders. Returned names include the leading colon.
*/
export function extractPathPlaceholders(url: string): string[] {
return Array.from(url.matchAll(/\/(:[^/?#:]+)/g)).map((m) => m[1] ?? "");
}
-3
View File
@@ -1,3 +0,0 @@
export function pricingUrl(intent: string): string {
return `https://yaak.app/pricing?intent=${encodeURIComponent(intent)}`;
}
@@ -1,152 +0,0 @@
import { describe, expect, test } from "vite-plus/test";
import {
BODY_TYPE_BINARY,
BODY_TYPE_FORM_URLENCODED,
BODY_TYPE_GRAPHQL,
BODY_TYPE_JSON,
BODY_TYPE_NONE,
BODY_TYPE_OTHER,
BODY_TYPE_XML,
} from "./model_util";
import { convertRequestBody } from "./requestBodyConversion";
describe("convertRequestBody", () => {
test("converts imported JSON GraphQL bodies to GraphQL shape", () => {
const body = convertRequestBody({
fromBodyType: BODY_TYPE_JSON,
toBodyType: BODY_TYPE_GRAPHQL,
body: {
text: JSON.stringify({
query: "query GetUser($id: ID!) { user(id: $id) { name } }",
variables: { id: "123" },
operationName: "GetUser",
}),
},
});
expect(body).toEqual({
query: "query GetUser($id: ID!) { user(id: $id) { name } }",
variables: '{\n "id": "123"\n}',
operationName: "GetUser",
});
});
test("converts GraphQL bodies to JSON text", () => {
const body = convertRequestBody({
fromBodyType: BODY_TYPE_GRAPHQL,
toBodyType: BODY_TYPE_JSON,
body: {
query: "query GetUser($id: ID!) { user(id: $id) { name } }",
variables: '{ "id": "123" }',
operationName: "GetUser",
},
});
expect(body).toEqual({
text: JSON.stringify(
{
query: "query GetUser($id: ID!) { user(id: $id) { name } }",
variables: { id: "123" },
operationName: "GetUser",
},
null,
2,
),
});
});
test("converts urlencoded forms to urlencoded text for text-like bodies", () => {
const body = convertRequestBody({
fromBodyType: BODY_TYPE_FORM_URLENCODED,
toBodyType: BODY_TYPE_OTHER,
body: {
form: [
{ enabled: true, name: "basic", value: "aaa" },
{ enabled: true, name: "funky stuff", value: "*)%&#$)@ *$#)@&" },
{ enabled: false, name: "disabled", value: "hidden" },
{ enabled: true, name: "", value: "unnamed" },
],
},
});
expect(body).toEqual({
text: "basic=aaa&funky+stuff=*%29%25%26%23%24%29%40+*%24%23%29%40%26",
});
});
test("converts urlencoded forms to JSON text for JSON bodies", () => {
const body = convertRequestBody({
fromBodyType: BODY_TYPE_FORM_URLENCODED,
toBodyType: BODY_TYPE_JSON,
body: {
form: [
{ enabled: true, name: "tag", value: "one" },
{ enabled: true, name: "tag", value: "two" },
{ enabled: true, name: "limit", value: "10" },
],
},
});
expect(body).toEqual({
text: JSON.stringify({ tag: ["one", "two"], limit: "10" }, null, 2),
});
});
test("preserves text when converting to form bodies cannot build form pairs", () => {
const body = convertRequestBody({
fromBodyType: BODY_TYPE_XML,
toBodyType: BODY_TYPE_FORM_URLENCODED,
body: { text: "a=1&b=two+words" },
});
expect(body).toEqual({
text: "a=1&b=two+words",
});
});
test("preserves JSON text that is not a GraphQL envelope", () => {
const body = convertRequestBody({
fromBodyType: BODY_TYPE_JSON,
toBodyType: BODY_TYPE_GRAPHQL,
body: { text: JSON.stringify({ name: "Yaak" }) },
});
expect(body).toEqual({
text: JSON.stringify({ name: "Yaak" }),
});
});
test("preserves JSON arrays and primitives when converting to GraphQL", () => {
for (const text of [JSON.stringify([1, 2, 3]), JSON.stringify("query"), "123", "null"]) {
const body = convertRequestBody({
fromBodyType: BODY_TYPE_JSON,
toBodyType: BODY_TYPE_GRAPHQL,
body: { text },
});
expect(body).toEqual({ text });
}
});
test("preserves text when converting to binary cannot build a file body", () => {
const body = convertRequestBody({
fromBodyType: BODY_TYPE_JSON,
toBodyType: BODY_TYPE_BINARY,
body: { text: '{ "name": "Yaak" }' },
});
expect(body).toEqual({
text: '{ "name": "Yaak" }',
});
});
test("clears body when converting to no body", () => {
const body = convertRequestBody({
fromBodyType: BODY_TYPE_JSON,
toBodyType: BODY_TYPE_NONE,
body: { text: '{ "name": "Yaak" }' },
});
expect(body).toEqual({});
});
});
@@ -1,199 +0,0 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import {
BODY_TYPE_BINARY,
BODY_TYPE_FORM_MULTIPART,
BODY_TYPE_FORM_URLENCODED,
BODY_TYPE_GRAPHQL,
BODY_TYPE_JSON,
BODY_TYPE_NONE,
} from "./model_util";
type Body = HttpRequest["body"];
type BodyType = HttpRequest["bodyType"];
type GraphQLBody = {
query: string;
variables: string | undefined;
operationName?: string;
};
export function convertRequestBody({
body,
fromBodyType,
toBodyType,
}: {
body: Body;
fromBodyType: BodyType;
toBodyType: BodyType;
}): Body {
if (toBodyType === BODY_TYPE_NONE) {
return {};
}
if (toBodyType === BODY_TYPE_GRAPHQL) {
return toGraphQLBody(body) ?? body;
}
if (toBodyType === BODY_TYPE_FORM_URLENCODED || toBodyType === BODY_TYPE_FORM_MULTIPART) {
return toFormBody(body) ?? body;
}
if (toBodyType === BODY_TYPE_BINARY) {
return typeof body.filePath === "string" ? { filePath: body.filePath } : body;
}
return toTextBody(body, fromBodyType, toBodyType) ?? body;
}
export function normalizeGraphQLBody(body: Body): GraphQLBody {
return toGraphQLBody(body) ?? { query: "", variables: undefined };
}
function toGraphQLBody(body: Body): GraphQLBody | null {
if (typeof body.query === "string") {
const result: GraphQLBody = {
query: body.query,
variables: typeof body.variables === "string" ? body.variables : undefined,
};
if (typeof body.operationName === "string") {
result.operationName = body.operationName;
}
return result;
}
if (typeof body.text === "string") {
try {
const parsed: unknown = JSON.parse(body.text);
if (!isRecord(parsed)) {
return null;
}
if (typeof parsed.query !== "string") {
return null;
}
const query = parsed.query;
const variables =
parsed.variables == null ? undefined : JSON.stringify(parsed.variables, null, 2);
const result: GraphQLBody = { query, variables };
if (typeof parsed.operationName === "string") {
result.operationName = parsed.operationName;
}
return result;
} catch {
return { query: body.text, variables: undefined };
}
}
return null;
}
function toFormBody(body: Body): Body | null {
if (Array.isArray(body.form)) {
return {
form: body.form.map((p) => ({
enabled: p.enabled !== false,
name: typeof p.name === "string" ? p.name : "",
value: stringifyFormValue(p.value ?? p.file),
contentType: typeof p.contentType === "string" ? p.contentType : undefined,
filename: typeof p.filename === "string" ? p.filename : undefined,
file: typeof p.file === "string" ? p.file : undefined,
id: typeof p.id === "string" ? p.id : undefined,
})),
};
}
return null;
}
function toTextBody(body: Body, fromBodyType: BodyType, toBodyType: BodyType): Body | null {
const sendJsonComments =
typeof body.sendJsonComments === "boolean" ? { sendJsonComments: body.sendJsonComments } : {};
if (typeof body.text === "string") {
return { text: body.text, ...sendJsonComments };
}
if (Array.isArray(body.form)) {
if (toBodyType === BODY_TYPE_JSON) {
return { text: JSON.stringify(formBodyToObject(body.form), null, 2) };
}
return { text: formBodyToUrlEncodedText(body.form) };
}
if (typeof body.query === "string") {
if (toBodyType === BODY_TYPE_JSON || fromBodyType === BODY_TYPE_GRAPHQL) {
const value: Record<string, unknown> = { query: body.query };
if (typeof body.variables === "string" && body.variables.trim() !== "") {
value.variables = parseJson(body.variables) ?? body.variables;
}
if (typeof body.operationName === "string" && body.operationName.trim() !== "") {
value.operationName = body.operationName;
}
return { text: JSON.stringify(value, null, 2) };
}
return { text: body.query };
}
if (typeof body.filePath === "string") {
return { text: body.filePath };
}
return null;
}
function formBodyToUrlEncodedText(form: unknown[]): string {
const params = new URLSearchParams();
for (const pair of form) {
if (!isRecord(pair)) continue;
if (pair.enabled === false) continue;
if (typeof pair.name !== "string" || pair.name === "") continue;
params.append(pair.name, stringifyFormValue(pair.value));
}
return params.toString();
}
function formBodyToObject(form: unknown[]) {
const result: Record<string, unknown> = {};
for (const pair of form) {
if (!isRecord(pair)) continue;
if (pair.enabled === false) continue;
if (typeof pair.name !== "string" || pair.name === "") continue;
const value = stringifyFormValue(pair.value);
if (pair.name in result) {
const existing = result[pair.name];
result[pair.name] = Array.isArray(existing) ? [...existing, value] : [existing, value];
} else {
result[pair.name] = value;
}
}
return result;
}
function stringifyFormValue(value: unknown): string {
if (value == null) return "";
if (typeof value === "string") return value;
return JSON.stringify(value);
}
function parseJson(text: string): unknown | null {
try {
return JSON.parse(text);
} catch {
return null;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === "object" && !Array.isArray(value);
}
-101
View File
@@ -1,101 +0,0 @@
import type { AnyModel, Workspace } from "@yaakapp-internal/models";
type ModelType = AnyModel["model"];
type WorkspaceRequestSettings = Pick<
Workspace,
| "settingFollowRedirects"
| "settingRequestMessageSize"
| "settingRequestTimeout"
| "settingSendCookies"
| "settingStoreCookies"
| "settingValidateCertificates"
>;
type ModelForType<T extends ModelType> = Extract<AnyModel, { model: T }>;
type ModelTypeWithSetting<K extends RequestSettingKey> = {
[M in ModelType]: K extends keyof ModelForType<M> ? M : never;
}[ModelType];
export type RequestSettingDefinition<
K extends RequestSettingKey = RequestSettingKey,
> = {
defaultValue: WorkspaceRequestSettings[K];
description: string;
modelKey: K;
models: readonly ModelTypeWithSetting<K>[];
title: string;
};
export type RequestSettingKey = keyof WorkspaceRequestSettings;
function defineRequestSetting<const K extends RequestSettingKey>(
setting: RequestSettingDefinition<K>,
) {
return setting;
}
export const SETTING_REQUEST_TIMEOUT = defineRequestSetting({
defaultValue: 0,
description: "Maximum request duration in milliseconds. Set to 0 to disable.",
modelKey: "settingRequestTimeout",
models: ["workspace", "folder", "http_request"],
title: "Request Timeout",
});
export const SETTING_REQUEST_MESSAGE_SIZE = defineRequestSetting({
defaultValue: 64 * 1024 * 1024,
description:
"Maximum gRPC or WebSocket message size in MB. Set to 0 to disable.",
modelKey: "settingRequestMessageSize",
models: ["workspace", "folder", "websocket_request", "grpc_request"],
title: "Message Size Limit",
});
export const SETTING_VALIDATE_CERTIFICATES = defineRequestSetting({
defaultValue: true,
description: "When disabled, skip validation of server certificates.",
modelKey: "settingValidateCertificates",
models: [
"workspace",
"folder",
"http_request",
"websocket_request",
"grpc_request",
],
title: "Validate TLS certificates",
});
export const SETTING_FOLLOW_REDIRECTS = defineRequestSetting({
defaultValue: true,
description: "Follow HTTP redirects automatically.",
modelKey: "settingFollowRedirects",
models: ["workspace", "folder", "http_request"],
title: "Follow redirects",
});
export const SETTING_SEND_COOKIES = defineRequestSetting({
defaultValue: true,
description:
"Attach matching cookies from the active cookie jar to outgoing requests.",
modelKey: "settingSendCookies",
models: ["workspace", "folder", "http_request", "websocket_request"],
title: "Automatically send cookies",
});
export const SETTING_STORE_COOKIES = defineRequestSetting({
defaultValue: true,
description:
"Save cookies from Set-Cookie response headers to the active cookie jar.",
modelKey: "settingStoreCookies",
models: ["workspace", "folder", "http_request", "websocket_request"],
title: "Automatically store cookies",
});
export function modelSupportsSetting<K extends RequestSettingKey>(
model: Pick<AnyModel, "model">,
setting: RequestSettingDefinition<K>,
) {
return setting.models.some((modelType) => modelType === model.model);
}
-68
View File
@@ -1,68 +0,0 @@
import { readFile } from "@tauri-apps/plugin-fs";
import type { HttpResponse } from "@yaakapp-internal/models";
import type { FilterResponse } from "@yaakapp-internal/plugins";
import type { ServerSentEvent, SseSummary } from "@yaakapp-internal/sse";
import { candidateJsonPayloadsFromSseText, computeSseSummary } from "@yaakapp-internal/sse";
import { invokeCmd } from "./tauri";
export async function getResponseBodyText({
response,
filter,
}: {
response: HttpResponse;
filter: string | null;
}): Promise<string | null> {
const result = await invokeCmd<FilterResponse>("cmd_http_response_body", {
response,
filter,
});
if (result.error) {
throw new Error(result.error);
}
return result.content;
}
export async function getResponseBodyEventSource(
response: HttpResponse,
): Promise<ServerSentEvent[]> {
if (!response.bodyPath) return [];
try {
const events = await invokeCmd<ServerSentEvent[]>("cmd_get_sse_events", {
filePath: response.bodyPath,
});
if (events.length > 0) {
return events;
}
} catch {
// Fall back to raw JSON frame parsing for non-standard SSE-like responses.
}
const bytes = await readFile(response.bodyPath);
const text = new TextDecoder("utf-8").decode(bytes);
return candidateJsonPayloadsFromSseText(text).map((data, index) => ({
data,
eventType: "",
id: String(index),
retry: null,
}));
}
export async function getResponseBodySseSummary(
response: HttpResponse,
resultKeyPath: string,
): Promise<SseSummary> {
if (!response.bodyPath) return { fragmentCount: 0, summary: "" };
const bytes = await readFile(response.bodyPath);
const text = new TextDecoder("utf-8").decode(bytes);
return computeSseSummary(text, resultKeyPath);
}
export async function getResponseBodyBytes(
response: HttpResponse,
): Promise<Uint8Array<ArrayBuffer> | null> {
if (!response.bodyPath) return null;
return readFile(response.bodyPath);
}
-36
View File
@@ -1,36 +0,0 @@
import type { GetThemesResponse } from "@yaakapp-internal/plugins";
import {
defaultDarkTheme,
defaultLightTheme,
resolveAppearance,
type Appearance,
} from "@yaakapp-internal/theme";
import { invokeCmd } from "./tauri";
export async function getThemes() {
const themes = (await invokeCmd<GetThemesResponse[]>("cmd_get_themes")).flatMap((t) => t.themes);
themes.sort((a, b) => a.label.localeCompare(b.label));
// Remove duplicates, in case multiple plugins provide the same theme
const uniqueThemes = Array.from(new Map(themes.map((t) => [t.id, t])).values());
return { themes: [defaultDarkTheme, defaultLightTheme, ...uniqueThemes] };
}
export async function getResolvedTheme(
preferredAppearance: Appearance,
appearanceSetting: string,
themeLight: string,
themeDark: string,
) {
const appearance = resolveAppearance(preferredAppearance, appearanceSetting);
const { themes } = await getThemes();
const darkThemes = themes.filter((t) => t.dark);
const lightThemes = themes.filter((t) => !t.dark);
const dark = darkThemes.find((t) => t.id === themeDark) ?? darkThemes[0] ?? defaultDarkTheme;
const light = lightThemes.find((t) => t.id === themeLight) ?? lightThemes[0] ?? defaultLightTheme;
const active = appearance === "dark" ? dark : light;
return { dark, light, active };
}
-3
View File
@@ -1,3 +0,0 @@
module.exports = {
plugins: [require("@tailwindcss/postcss")],
};
-151
View File
@@ -1,151 +0,0 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as WorkspacesIndexRouteImport } from './routes/workspaces/index'
import { Route as WorkspacesWorkspaceIdIndexRouteImport } from './routes/workspaces/$workspaceId/index'
import { Route as WorkspacesWorkspaceIdSettingsRouteImport } from './routes/workspaces/$workspaceId/settings'
import { Route as WorkspacesWorkspaceIdRequestsRequestIdRouteImport } from './routes/workspaces/$workspaceId/requests/$requestId'
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const WorkspacesIndexRoute = WorkspacesIndexRouteImport.update({
id: '/workspaces/',
path: '/workspaces/',
getParentRoute: () => rootRouteImport,
} as any)
const WorkspacesWorkspaceIdIndexRoute =
WorkspacesWorkspaceIdIndexRouteImport.update({
id: '/workspaces/$workspaceId/',
path: '/workspaces/$workspaceId/',
getParentRoute: () => rootRouteImport,
} as any)
const WorkspacesWorkspaceIdSettingsRoute =
WorkspacesWorkspaceIdSettingsRouteImport.update({
id: '/workspaces/$workspaceId/settings',
path: '/workspaces/$workspaceId/settings',
getParentRoute: () => rootRouteImport,
} as any)
const WorkspacesWorkspaceIdRequestsRequestIdRoute =
WorkspacesWorkspaceIdRequestsRequestIdRouteImport.update({
id: '/workspaces/$workspaceId/requests/$requestId',
path: '/workspaces/$workspaceId/requests/$requestId',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/workspaces': typeof WorkspacesIndexRoute
'/workspaces/$workspaceId/settings': typeof WorkspacesWorkspaceIdSettingsRoute
'/workspaces/$workspaceId': typeof WorkspacesWorkspaceIdIndexRoute
'/workspaces/$workspaceId/requests/$requestId': typeof WorkspacesWorkspaceIdRequestsRequestIdRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/workspaces': typeof WorkspacesIndexRoute
'/workspaces/$workspaceId/settings': typeof WorkspacesWorkspaceIdSettingsRoute
'/workspaces/$workspaceId': typeof WorkspacesWorkspaceIdIndexRoute
'/workspaces/$workspaceId/requests/$requestId': typeof WorkspacesWorkspaceIdRequestsRequestIdRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/workspaces/': typeof WorkspacesIndexRoute
'/workspaces/$workspaceId/settings': typeof WorkspacesWorkspaceIdSettingsRoute
'/workspaces/$workspaceId/': typeof WorkspacesWorkspaceIdIndexRoute
'/workspaces/$workspaceId/requests/$requestId': typeof WorkspacesWorkspaceIdRequestsRequestIdRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/workspaces'
| '/workspaces/$workspaceId/settings'
| '/workspaces/$workspaceId'
| '/workspaces/$workspaceId/requests/$requestId'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/workspaces'
| '/workspaces/$workspaceId/settings'
| '/workspaces/$workspaceId'
| '/workspaces/$workspaceId/requests/$requestId'
id:
| '__root__'
| '/'
| '/workspaces/'
| '/workspaces/$workspaceId/settings'
| '/workspaces/$workspaceId/'
| '/workspaces/$workspaceId/requests/$requestId'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
WorkspacesIndexRoute: typeof WorkspacesIndexRoute
WorkspacesWorkspaceIdSettingsRoute: typeof WorkspacesWorkspaceIdSettingsRoute
WorkspacesWorkspaceIdIndexRoute: typeof WorkspacesWorkspaceIdIndexRoute
WorkspacesWorkspaceIdRequestsRequestIdRoute: typeof WorkspacesWorkspaceIdRequestsRequestIdRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/workspaces/': {
id: '/workspaces/'
path: '/workspaces'
fullPath: '/workspaces'
preLoaderRoute: typeof WorkspacesIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/workspaces/$workspaceId/': {
id: '/workspaces/$workspaceId/'
path: '/workspaces/$workspaceId'
fullPath: '/workspaces/$workspaceId'
preLoaderRoute: typeof WorkspacesWorkspaceIdIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/workspaces/$workspaceId/settings': {
id: '/workspaces/$workspaceId/settings'
path: '/workspaces/$workspaceId/settings'
fullPath: '/workspaces/$workspaceId/settings'
preLoaderRoute: typeof WorkspacesWorkspaceIdSettingsRouteImport
parentRoute: typeof rootRouteImport
}
'/workspaces/$workspaceId/requests/$requestId': {
id: '/workspaces/$workspaceId/requests/$requestId'
path: '/workspaces/$workspaceId/requests/$requestId'
fullPath: '/workspaces/$workspaceId/requests/$requestId'
preLoaderRoute: typeof WorkspacesWorkspaceIdRequestsRequestIdRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
WorkspacesIndexRoute: WorkspacesIndexRoute,
WorkspacesWorkspaceIdSettingsRoute: WorkspacesWorkspaceIdSettingsRoute,
WorkspacesWorkspaceIdIndexRoute: WorkspacesWorkspaceIdIndexRoute,
WorkspacesWorkspaceIdRequestsRequestIdRoute:
WorkspacesWorkspaceIdRequestsRequestIdRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
-92
View File
@@ -1,92 +0,0 @@
import { listen } from "@tauri-apps/api/event";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { type as osType } from "@tauri-apps/plugin-os";
import { setWindowTheme } from "@yaakapp-internal/mac-window";
import type { ModelPayload } from "@yaakapp-internal/models";
import type { Appearance } from "@yaakapp-internal/theme";
import {
applyThemeToDocument,
getCSSAppearance,
subscribeToPreferredAppearanceChange,
subscribeToSystemAppearanceChange,
} from "@yaakapp-internal/theme";
import { getSettings } from "./lib/settings";
import { getResolvedTheme } from "./lib/themes";
// NOTE: CSS appearance isn't as accurate as getting it async from the window (next step), but we want
// a good appearance guess so we're not waiting too long
let preferredAppearance: Appearance = getInitialAppearance();
let linuxSystemAppearanceAvailable =
osType() === "linux" && window.__YAAK_INITIAL_APPEARANCE_SOURCE__ === "linux-system";
let configureThemeGeneration = 0;
let windowShown = false;
configureThemeAndShow().catch((err) => console.log("Failed to configure theme", err));
subscribeToPreferredAppearanceChange(async (a) => {
if (linuxSystemAppearanceAvailable) return;
preferredAppearance = a;
await configureThemeAndShow();
});
subscribeToSystemAppearanceChange(async (a) => {
linuxSystemAppearanceAvailable = true;
preferredAppearance = a;
await configureThemeAndShow();
});
async function configureThemeAndShow() {
const applied = await configureTheme();
if (applied && !windowShown) {
windowShown = true;
// To prevent theme flashing, the backend hides new windows by default, so we
// need to show it here, after configuring the theme for the first time.
await getCurrentWebviewWindow().show();
}
}
// Listen for settings changes, the re-compute theme
listen<ModelPayload>("model_write", async (event) => {
if (event.payload.change.type !== "upsert") return;
const model = event.payload.model.model;
if (model !== "settings" && model !== "plugin") return;
await configureThemeAndShow();
}).catch(console.error);
async function configureTheme(): Promise<boolean> {
const generation = ++configureThemeGeneration;
const settings = await getSettings();
const theme = await getResolvedTheme(
preferredAppearance,
settings.appearance,
settings.themeLight,
settings.themeDark,
);
if (generation !== configureThemeGeneration) {
return false;
}
applyThemeToDocument(theme.active);
if (theme.active.base.surface != null) {
setWindowTheme(theme.active.base.surface);
}
return true;
}
function getInitialAppearance(): Appearance {
const initialAppearance = window.__YAAK_INITIAL_APPEARANCE__;
if (initialAppearance === "dark" || initialAppearance === "light") {
return initialAppearance;
}
return getCSSAppearance();
}
declare global {
interface Window {
__YAAK_INITIAL_APPEARANCE__?: Appearance;
__YAAK_INITIAL_APPEARANCE_SOURCE__?: "settings" | "linux-system";
}
}
-65
View File
@@ -1,65 +0,0 @@
// @ts-ignore
import { tanstackRouter } from "@tanstack/router-plugin/vite";
import react from "@vitejs/plugin-react";
import { createRequire } from "node:module";
import path from "node:path";
import { defineConfig, normalizePath } from "vite-plus";
import { viteStaticCopy } from "vite-plugin-static-copy";
import svgr from "vite-plugin-svgr";
import topLevelAwait from "vite-plugin-top-level-await";
import wasm from "vite-plugin-wasm";
const require = createRequire(import.meta.url);
const cMapsDir = normalizePath(
path.join(path.dirname(require.resolve("pdfjs-dist/package.json")), "cmaps"),
);
const standardFontsDir = normalizePath(
path.join(path.dirname(require.resolve("pdfjs-dist/package.json")), "standard_fonts"),
);
// https://vitejs.dev/config/
export default defineConfig(async () => {
return {
plugins: [
wasm(),
tanstackRouter({
target: "react",
routesDirectory: "./routes",
generatedRouteTree: "./routeTree.gen.ts",
autoCodeSplitting: true,
}),
svgr(),
react(),
topLevelAwait(),
viteStaticCopy({
targets: [
{ src: cMapsDir, dest: "" },
{ src: standardFontsDir, dest: "" },
],
}),
],
build: {
target: "esnext",
sourcemap: true,
outDir: "../../dist/apps/yaak-client",
emptyOutDir: true,
rolldownOptions: {
output: {
// Make chunk names readable
chunkFileNames: "assets/chunk-[name]-[hash].js",
entryFileNames: "assets/entry-[name]-[hash].js",
assetFileNames: "assets/asset-[name]-[hash][extname]",
// Vite-Plus/Rolldown 0.1.20 can emit a stale style-mod export when
// top-level var rewriting combines with OXC minification.
topLevelVar: false,
},
},
},
clearScreen: false,
server: {
port: parseInt(process.env.YAAK_CLIENT_DEV_PORT ?? process.env.YAAK_DEV_PORT ?? "1420", 10),
strictPort: true,
},
envPrefix: ["VITE_", "TAURI_"],
};
});
@@ -1,31 +0,0 @@
import type { ActionInvocation } from "@yaakapp-internal/proxy-lib";
import { Button, type ButtonProps } from "@yaakapp-internal/ui";
import { useCallback } from "react";
import { useRpcMutation } from "../hooks/useRpcMutation";
import { useActionMetadata } from "../hooks/useActionMetadata";
type ActionButtonProps = Omit<ButtonProps, "onClick" | "children"> & {
action: ActionInvocation;
/** Override the label from metadata */
children?: React.ReactNode;
};
export function ActionButton({ action, children, ...props }: ActionButtonProps) {
const meta = useActionMetadata(action);
const { mutate, isPending } = useRpcMutation("execute_action");
const onClick = useCallback(() => {
mutate(action);
}, [action, mutate]);
return (
<Button
{...props}
disabled={props.disabled || isPending}
isLoading={isPending}
onClick={onClick}
>
{children ?? meta?.label ?? "…"}
</Button>
);
}
@@ -1,29 +0,0 @@
import type { ActionInvocation } from "@yaakapp-internal/proxy-lib";
import { IconButton, type IconButtonProps } from "@yaakapp-internal/ui";
import { useCallback } from "react";
import { useRpcMutation } from "../hooks/useRpcMutation";
import { useActionMetadata } from "../hooks/useActionMetadata";
type ActionIconButtonProps = Omit<IconButtonProps, "onClick" | "title"> & {
action: ActionInvocation;
title?: string;
};
export function ActionIconButton({ action, ...props }: ActionIconButtonProps) {
const meta = useActionMetadata(action);
const { mutate, isPending } = useRpcMutation("execute_action");
const onClick = useCallback(() => {
mutate(action);
}, [action, mutate]);
return (
<IconButton
{...props}
title={props.title ?? meta?.label ?? "…"}
disabled={props.disabled || isPending}
isLoading={isPending}
onClick={onClick}
/>
);
}
@@ -1,77 +0,0 @@
import type { HttpExchange, ProxyHeader } from "@yaakapp-internal/proxy-lib";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeaderCell,
TableRow,
TruncatedWideTableCell,
} from "@yaakapp-internal/ui";
import classNames from "classnames";
interface Props {
exchanges: HttpExchange[];
style?: React.CSSProperties;
className?: string;
}
export function ExchangesTable({ exchanges, style, className }: Props) {
if (exchanges.length === 0) {
return <p className="text-text-subtlest text-sm">No traffic yet</p>;
}
return (
<div className={className} style={style}>
<Table scrollable className="px-2">
<TableHead>
<TableRow>
<TableHeaderCell>Method</TableHeaderCell>
<TableHeaderCell>URL</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Type</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{exchanges.map((ex) => (
<TableRow key={ex.id}>
<TableCell className="font-mono text-2xs">{ex.method}</TableCell>
<TruncatedWideTableCell className="font-mono text-2xs">
{ex.url}
</TruncatedWideTableCell>
<TableCell>
<StatusBadge status={ex.resStatus} error={ex.error} />
</TableCell>
<TableCell className="text-text-subtle text-xs">
{getContentType(ex.resHeaders)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
function StatusBadge({ status, error }: { status: number | null; error: string | null }) {
if (error) return <span className="text-xs text-danger">Error</span>;
if (status == null) return <span className="text-xs text-text-subtlest"></span>;
const color =
status >= 500
? "text-danger"
: status >= 400
? "text-warning"
: status >= 300
? "text-notice"
: "text-success";
return <span className={classNames("text-xs font-mono", color)}>{status}</span>;
}
function getContentType(headers: ProxyHeader[]): string {
const ct = headers.find((h) => h.name.toLowerCase() === "content-type")?.value;
if (ct == null) return "—";
// Strip parameters (e.g. "; charset=utf-8")
return ct.split(";")[0]?.trim() ?? ct;
}
-146
View File
@@ -1,146 +0,0 @@
import { HeaderSize, IconButton, SidebarLayout, SplitLayout } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import { useState } from "react";
import { useLocalStorage } from "react-use";
import { useRpcQueryWithEvent } from "../hooks/useRpcQueryWithEvent";
import { getOsType } from "../lib/tauri";
import { ActionIconButton } from "./ActionIconButton";
import { ExchangesTable } from "./ExchangesTable";
import { filteredExchangesAtom, Sidebar } from "./Sidebar";
export function ProxyLayout() {
const os = getOsType();
const exchanges = useAtomValue(filteredExchangesAtom);
const [sidebarWidth, setSidebarWidth] = useLocalStorage("sidebar_width", 250);
const [sidebarHidden, setSidebarHidden] = useLocalStorage("sidebar_hidden", false);
const [floatingSidebarHidden, setFloatingSidebarHidden] = useLocalStorage(
"floating_sidebar_hidden",
true,
);
const [floating, setFloating] = useState(false);
const { data: proxyState } = useRpcQueryWithEvent("get_proxy_state", {}, "proxy_state_changed");
const isRunning = proxyState?.state === "running";
const isHidden = floating ? (floatingSidebarHidden ?? true) : (sidebarHidden ?? false);
return (
<div
className={classNames(
"h-full w-full grid grid-rows-[auto_1fr]",
os === "linux" && "border border-border-subtle",
)}
>
<HeaderSize
data-tauri-drag-region
size="lg"
osType={os}
hideWindowControls={false}
useNativeTitlebar={false}
interfaceScale={1}
className="x-theme-appHeader bg-surface"
>
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center w-full h-full pointer-events-none">
<div className="flex items-center pl-1 pointer-events-auto">
<IconButton
size="sm"
title="Toggle sidebar"
icon={isHidden ? "left_panel_hidden" : "left_panel_visible"}
iconColor="secondary"
onClick={() => {
if (floating) {
setFloatingSidebarHidden(!floatingSidebarHidden);
} else {
setSidebarHidden(!sidebarHidden);
}
}}
/>
</div>
<div
data-tauri-drag-region
className="pointer-events-none flex items-center text-sm px-2"
>
Yaak Proxy
</div>
<div className="flex items-center gap-1 pr-1 pointer-events-auto">
{isRunning ? (
<>
<span className="text-2xs text-success">Running :9090</span>
<ActionIconButton
action={{ scope: "global", action: "proxy_stop" }}
icon="circle_stop"
iconColor="secondary"
size="sm"
/>
</>
) : (
<ActionIconButton
action={{ scope: "global", action: "proxy_start" }}
icon="circle_play"
iconColor="secondary"
size="sm"
/>
)}
</div>
</div>
</HeaderSize>
<SidebarLayout
width={sidebarWidth ?? 250}
onWidthChange={setSidebarWidth}
hidden={sidebarHidden ?? false}
onHiddenChange={setSidebarHidden}
floatingHidden={floatingSidebarHidden ?? true}
onFloatingHiddenChange={setFloatingSidebarHidden}
onFloatingChange={setFloating}
sidebar={
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 pl-1"
osType={os}
hideWindowControls={false}
useNativeTitlebar={false}
interfaceScale={1}
>
<IconButton
size="sm"
title="Toggle sidebar"
icon="left_panel_visible"
iconColor="secondary"
onClick={() => setFloatingSidebarHidden(true)}
/>
</HeaderSize>
<Sidebar />
</div>
) : (
<Sidebar />
)
}
>
<SplitLayout
storageKey="proxy_detail"
layout="vertical"
defaultRatio={0.4}
firstSlot={({ style }) => (
<ExchangesTable exchanges={exchanges} style={style} className="overflow-auto" />
)}
secondSlot={({ style }) => (
<div
style={style}
className="p-3 text-text-subtlest text-sm border-t border-border-subtle"
>
Select a request to view details
</div>
)}
/>
</SidebarLayout>
</div>
);
}
-219
View File
@@ -1,219 +0,0 @@
import type { HttpExchange } from "@yaakapp-internal/proxy-lib";
import type { TreeNode } from "@yaakapp-internal/ui";
import { selectedIdsFamily, Tree } from "@yaakapp-internal/ui";
import { atom, useAtomValue } from "jotai";
import { atomFamily } from "jotai-family";
import { useCallback } from "react";
import { httpExchangesAtom } from "../lib/store";
/** A node in the sidebar tree — either a domain or a path segment. */
export type SidebarItem = {
id: string;
label: string;
exchangeIds: string[];
};
const collapsedAtom = atomFamily((_treeId: string) => atom<Record<string, boolean>>({}));
export const SIDEBAR_TREE_ID = "proxy-sidebar";
const sidebarTreeAtom = atom<TreeNode<SidebarItem>>((get) => {
const exchanges = get(httpExchangesAtom);
return buildTree(exchanges);
});
/** Exchanges filtered by the currently selected sidebar node(s). */
export const filteredExchangesAtom = atom((get) => {
const exchanges = get(httpExchangesAtom);
const tree = get(sidebarTreeAtom);
const selectedIds = get(selectedIdsFamily(SIDEBAR_TREE_ID));
// Nothing selected or root selected → show all
if (selectedIds.length === 0 || selectedIds.includes("root")) {
return exchanges;
}
// Collect exchange IDs from all selected nodes
const allowedIds = new Set<string>();
const nodeMap = new Map<string, SidebarItem>();
collectNodes(tree, nodeMap);
for (const selectedId of selectedIds) {
const node = nodeMap.get(selectedId);
if (node) {
for (const id of node.exchangeIds) {
allowedIds.add(id);
}
}
}
return exchanges.filter((ex) => allowedIds.has(ex.id));
});
function collectNodes(node: TreeNode<SidebarItem>, map: Map<string, SidebarItem>) {
map.set(node.item.id, node.item);
for (const child of node.children ?? []) {
collectNodes(child, map);
}
}
/**
* Build a domain → path-segment trie from a flat list of exchanges.
*
* Example: Given URLs
* GET https://api.example.com/v1/users
* GET https://api.example.com/v1/users/123
* POST https://api.example.com/v1/orders
*
* Produces:
* api.example.com
* /v1
* /users
* /123
* /orders
*/
function buildTree(exchanges: HttpExchange[]): TreeNode<SidebarItem> {
const root: SidebarItem = { id: "root", label: "All Traffic", exchangeIds: [] };
const rootNode: TreeNode<SidebarItem> = {
item: root,
parent: null,
depth: 0,
children: [],
draggable: false,
};
// Intermediate trie structure for building
type TrieNode = {
id: string;
label: string;
exchangeIds: string[];
children: Map<string, TrieNode>;
};
const domainMap = new Map<string, TrieNode>();
for (const ex of exchanges) {
let hostname: string;
let segments: string[];
try {
const url = new URL(ex.url);
hostname = url.host;
segments = url.pathname.split("/").filter(Boolean);
} catch {
hostname = ex.url;
segments = [];
}
// Get or create domain node
let domainNode = domainMap.get(hostname);
if (!domainNode) {
domainNode = {
id: `domain:${hostname}`,
label: hostname,
exchangeIds: [],
children: new Map(),
};
domainMap.set(hostname, domainNode);
}
domainNode.exchangeIds.push(ex.id);
// Walk path segments
let current = domainNode;
const pathSoFar: string[] = [];
for (const seg of segments) {
pathSoFar.push(seg);
let child = current.children.get(seg);
if (!child) {
child = {
id: `path:${hostname}/${pathSoFar.join("/")}`,
label: `/${seg}`,
exchangeIds: [],
children: new Map(),
};
current.children.set(seg, child);
}
child.exchangeIds.push(ex.id);
current = child;
}
}
// Convert trie to TreeNode structure
function toTreeNode(
trie: TrieNode,
parent: TreeNode<SidebarItem>,
depth: number,
): TreeNode<SidebarItem> {
const node: TreeNode<SidebarItem> = {
item: {
id: trie.id,
label: trie.label,
exchangeIds: trie.exchangeIds,
},
parent,
depth,
children: [],
draggable: false,
};
const sortedChildren = [...trie.children.values()].sort((a, b) =>
a.label.localeCompare(b.label),
);
for (const child of sortedChildren) {
node.children?.push(toTreeNode(child, node, depth + 1));
}
return node;
}
// Add a "Domains" folder between root and domain nodes
const allExchangeIds = exchanges.map((ex) => ex.id);
const domainsFolder: TreeNode<SidebarItem> = {
item: { id: "domains", label: "Domains", exchangeIds: allExchangeIds },
parent: rootNode,
depth: 1,
children: [],
draggable: false,
};
const sortedDomains = [...domainMap.values()].sort((a, b) => a.label.localeCompare(b.label));
for (const domain of sortedDomains) {
domainsFolder.children?.push(toTreeNode(domain, domainsFolder, 2));
}
rootNode.children?.push(domainsFolder);
return rootNode;
}
function ItemInner({ item }: { item: SidebarItem }) {
const count = item.exchangeIds.length;
return (
<div className="flex items-center gap-2 w-full min-w-0">
<span className="truncate">{item.label}</span>
{count > 0 && <span className="ml-auto text-text-subtlest text-2xs shrink-0">{count}</span>}
</div>
);
}
export function Sidebar() {
const tree = useAtomValue(sidebarTreeAtom);
const treeId = SIDEBAR_TREE_ID;
const getItemKey = useCallback(
(item: SidebarItem) => `${item.id}:${item.exchangeIds.length}`,
[],
);
return (
<aside className="x-theme-sidebar bg-surface h-full w-full min-w-0 overflow-y-auto border-r border-border-subtle">
<div className="pt-2 text-xs">
<Tree
treeId={treeId}
collapsedAtom={collapsedAtom(treeId)}
className="px-2 pb-10"
root={tree}
getItemKey={getItemKey}
ItemInner={ItemInner}
/>
</div>
</aside>
);
}
-2
View File
@@ -1,2 +0,0 @@
// Hardcode font size for now. In the future, this could be configurable.
document.documentElement.style.fontSize = "15px";
@@ -1,32 +0,0 @@
import type { ActionInvocation, ActionMetadata } from "@yaakapp-internal/proxy-lib";
import { useEffect, useState } from "react";
import { rpc } from "../lib/rpc";
/** Look up metadata for a specific action invocation. */
// oxlint-disable-next-line no-redundant-type-constituents -- ActionMetadata resolves at runtime
export function useActionMetadata(action: ActionInvocation): ActionMetadata | null {
// oxlint-disable-next-line no-redundant-type-constituents -- ActionMetadata resolves at runtime
const [meta, setMeta] = useState<ActionMetadata | null>(null);
useEffect(() => {
void getActions().then((actions) => {
const match = actions.find(
([inv]) => inv.scope === action.scope && inv.action === action.action,
);
setMeta(match?.[1] ?? null);
});
}, [action]);
return meta;
}
let cachedActions: [ActionInvocation, ActionMetadata][] | null = null;
/** Fetch and cache all action metadata. */
async function getActions(): Promise<[ActionInvocation, ActionMetadata][]> {
if (!cachedActions) {
const { actions } = await rpc("list_actions", {});
cachedActions = actions;
}
return cachedActions;
}
-15
View File
@@ -1,15 +0,0 @@
import type { RpcEventSchema } from "@yaakapp-internal/proxy-lib";
import { useEffect } from "react";
import { listen } from "../lib/rpc";
/**
* Subscribe to an RPC event. Cleans up automatically on unmount.
*/
export function useRpcEvent<K extends keyof RpcEventSchema>(
event: K & string,
callback: (payload: RpcEventSchema[K]) => void,
) {
useEffect(() => {
return listen(event, callback);
}, [event, callback]);
}
-18
View File
@@ -1,18 +0,0 @@
import { type UseMutationOptions, useMutation } from "@tanstack/react-query";
import type { RpcSchema } from "@yaakapp-internal/proxy-lib";
import { minPromiseMillis } from "@yaakapp-internal/ui";
import type { Req, Res } from "../lib/rpc";
import { rpc } from "../lib/rpc";
/**
* React Query mutation wrapper for RPC commands.
*/
export function useRpcMutation<K extends keyof RpcSchema>(
cmd: K,
opts?: Omit<UseMutationOptions<Res<K>, Error, Req<K>>, "mutationFn">,
) {
return useMutation<Res<K>, Error, Req<K>>({
mutationFn: (payload) => minPromiseMillis(rpc(cmd, payload)),
...opts,
});
}
-20
View File
@@ -1,20 +0,0 @@
import { type UseQueryOptions, useQuery } from "@tanstack/react-query";
import type { RpcSchema } from "@yaakapp-internal/proxy-lib";
import type { Req, Res } from "../lib/rpc";
import { rpc } from "../lib/rpc";
/**
* React Query wrapper for RPC commands.
* Automatically caches by [cmd, payload] and supports all useQuery options.
*/
export function useRpcQuery<K extends keyof RpcSchema>(
cmd: K,
payload: Req<K>,
opts?: Omit<UseQueryOptions<Res<K>>, "queryKey" | "queryFn">,
) {
return useQuery<Res<K>>({
queryKey: [cmd, payload],
queryFn: () => rpc(cmd, payload),
...opts,
});
}
@@ -1,23 +0,0 @@
import { type UseQueryOptions, useQueryClient } from "@tanstack/react-query";
import type { RpcEventSchema, RpcSchema } from "@yaakapp-internal/proxy-lib";
import type { Req, Res } from "../lib/rpc";
import { useRpcEvent } from "./useRpcEvent";
import { useRpcQuery } from "./useRpcQuery";
/**
* Combines useRpcQuery with an event listener that invalidates the query
* whenever the specified event fires, keeping data fresh automatically.
*/
export function useRpcQueryWithEvent<
K extends keyof RpcSchema,
E extends keyof RpcEventSchema,
>(cmd: K, payload: Req<K>, event: E, opts?: Omit<UseQueryOptions<Res<K>>, "queryKey" | "queryFn">) {
const queryClient = useQueryClient();
const query = useRpcQuery(cmd, payload, opts);
useRpcEvent(event, () => {
void queryClient.invalidateQueries({ queryKey: [cmd, payload] });
});
return query;
}
-28
View File
@@ -1,28 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Yaak Proxy</title>
<style>
html,
body {
background-color: white;
}
@media (prefers-color-scheme: dark) {
html,
body {
background-color: #1b1a29;
}
}
</style>
</head>
<body class="text-base">
<div id="root"></div>
<script type="module" src="/font-size.ts"></script>
<script type="module" src="/lib/theme.ts"></script>
<script type="module" src="/main.tsx"></script>
</body>
</html>
-5
View File
@@ -1,5 +0,0 @@
export function fireAndForget(promise: Promise<unknown>) {
promise.catch((err: unknown) => {
console.error("Unhandled async error:", err);
});
}
-63
View File
@@ -1,63 +0,0 @@
import type { ActionInvocation, ActionMetadata } from "@yaakapp-internal/proxy-lib";
import { rpc } from "./rpc";
type ActionBinding = {
invocation: ActionInvocation;
meta: ActionMetadata;
keys: { key: string; ctrl: boolean; shift: boolean; alt: boolean; meta: boolean };
};
/** Parse a hotkey string like "Ctrl+Shift+P" into its parts. */
function parseHotkey(hotkey: string): ActionBinding["keys"] {
const parts = hotkey.split("+").map((p) => p.trim().toLowerCase());
return {
ctrl: parts.includes("ctrl") || parts.includes("control"),
shift: parts.includes("shift"),
alt: parts.includes("alt"),
meta: parts.includes("meta") || parts.includes("cmd") || parts.includes("command"),
key:
parts.filter(
(p) => !["ctrl", "control", "shift", "alt", "meta", "cmd", "command"].includes(p),
)[0] ?? "",
};
}
function matchesEvent(binding: ActionBinding["keys"], e: KeyboardEvent): boolean {
return (
e.ctrlKey === binding.ctrl &&
e.shiftKey === binding.shift &&
e.altKey === binding.alt &&
e.metaKey === binding.meta &&
e.key.toLowerCase() === binding.key
);
}
/** Fetch all actions from Rust and register a global keydown listener. */
export async function initHotkeys(): Promise<() => void> {
const { actions } = await rpc("list_actions", {});
const bindings: ActionBinding[] = actions
.filter(
// oxlint-disable-next-line no-redundant-type-constituents -- ActionMetadata resolves at runtime
(entry): entry is [ActionInvocation, ActionMetadata & { defaultHotkey: string }] =>
entry[1].defaultHotkey != null,
)
.map(([invocation, meta]) => ({
invocation,
meta,
keys: parseHotkey(meta.defaultHotkey),
}));
function onKeyDown(e: KeyboardEvent) {
for (const binding of bindings) {
if (matchesEvent(binding.keys, e)) {
e.preventDefault();
void rpc("execute_action", binding.invocation);
return;
}
}
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}
-17
View File
@@ -1,17 +0,0 @@
import type { RpcEventSchema, RpcSchema } from "@yaakapp-internal/proxy-lib";
import { command, subscribe } from "./tauri";
export type Req<K extends keyof RpcSchema> = RpcSchema[K][0];
export type Res<K extends keyof RpcSchema> = RpcSchema[K][1];
export async function rpc<K extends keyof RpcSchema>(cmd: K, payload: Req<K>): Promise<Res<K>> {
return command<Res<K>>("rpc", { cmd, payload });
}
/** Subscribe to a backend event. Returns an unsubscribe function. */
export function listen<K extends keyof RpcEventSchema>(
event: K & string,
callback: (payload: RpcEventSchema[K]) => void,
): () => void {
return subscribe<RpcEventSchema[K]>(event, callback);
}
-11
View File
@@ -1,11 +0,0 @@
import { createModelStore } from "@yaakapp-internal/model-store";
import type { HttpExchange } from "@yaakapp-internal/proxy-lib";
type ProxyModels = {
http_exchange: HttpExchange;
};
export const { dataAtom, applyChange, replaceAll, listAtom, orderedListAtom } =
createModelStore<ProxyModels>(["http_exchange"]);
export const httpExchangesAtom = orderedListAtom("http_exchange", "createdAt", "desc");
-30
View File
@@ -1,30 +0,0 @@
import { invoke } from "@tauri-apps/api/core";
import { listen as tauriListen } from "@tauri-apps/api/event";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { type as tauriOsType } from "@tauri-apps/plugin-os";
/** Call a Tauri command. */
export function command<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
return invoke(cmd, args) as Promise<T>;
}
/** Subscribe to a Tauri event. Returns an unsubscribe function. */
export function subscribe<T>(event: string, callback: (payload: T) => void): () => void {
let unsub: (() => void) | null = null;
tauriListen<T>(event, (e) => callback(e.payload))
.then((fn) => {
unsub = fn;
})
.catch(console.error);
return () => unsub?.();
}
/** Show the current webview window. */
export function showWindow(): Promise<void> {
return getCurrentWebviewWindow().show();
}
/** Get the current OS type (e.g. "macos", "linux", "windows"). */
export function getOsType() {
return tauriOsType();
}
-35
View File
@@ -1,35 +0,0 @@
import { setWindowTheme } from "@yaakapp-internal/mac-window";
import {
applyThemeToDocument,
defaultDarkTheme,
defaultLightTheme,
getCSSAppearance,
platformFromUserAgent,
setPlatformOnDocument,
subscribeToPreferredAppearance,
type Appearance,
} from "@yaakapp-internal/theme";
import { showWindow } from "./tauri";
setPlatformOnDocument(platformFromUserAgent(navigator.userAgent));
// Apply a quick initial theme based on CSS media query
let preferredAppearance: Appearance = getCSSAppearance();
applyTheme(preferredAppearance);
// Then subscribe to accurate OS appearance detection and changes
subscribeToPreferredAppearance((a) => {
preferredAppearance = a;
applyTheme(preferredAppearance);
});
// Show window after initial theme is applied (window starts hidden to prevent flash)
showWindow().catch(console.error);
function applyTheme(appearance: Appearance) {
const theme = appearance === "dark" ? defaultDarkTheme : defaultLightTheme;
applyThemeToDocument(theme);
if (theme.base.surface != null) {
setWindowTheme(theme.base.surface);
}
}
-92
View File
@@ -1,92 +0,0 @@
@import "tailwindcss";
@import "../../packages/tailwind-config/index.css";
@source "../../packages/ui/src";
@layer base {
html,
body,
#root {
@apply w-full h-full overflow-hidden text-text bg-surface;
}
:root {
--font-family-interface: "";
--font-family-editor: "";
}
:root {
font-variant-ligatures: none;
}
html[data-platform="linux"] {
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
::selection {
@apply bg-selection;
}
:not(a),
:not(input):not(textarea),
:not(input):not(textarea)::after,
:not(input):not(textarea)::before {
@apply select-none cursor-default;
}
input,
textarea {
&::placeholder {
@apply text-placeholder;
}
}
a,
a[href] * {
@apply cursor-pointer!;
}
table th {
@apply text-left;
}
:not(iframe) {
&::-webkit-scrollbar,
&::-webkit-scrollbar-corner {
@apply w-[8px] h-[8px] bg-transparent;
}
&::-webkit-scrollbar-track {
@apply bg-transparent;
}
&::-webkit-scrollbar-thumb {
@apply bg-text-subtlest rounded-[4px] opacity-20;
}
&::-webkit-scrollbar-thumb:hover {
@apply opacity-40!;
}
}
.hide-scrollbars {
&::-webkit-scrollbar-corner,
&::-webkit-scrollbar {
@apply hidden!;
}
}
.rtl {
direction: rtl;
}
:root {
color-scheme: light dark;
--transition-duration: 100ms ease-in-out;
--color-white: 255 100% 100%;
--color-black: 255 0% 0%;
}
}
-44
View File
@@ -1,44 +0,0 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createStore, Provider } from "jotai";
import { LazyMotion, MotionConfig } from "motion/react";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { ProxyLayout } from "./components/ProxyLayout";
import { listen, rpc } from "./lib/rpc";
import { initHotkeys } from "./lib/hotkeys";
import { applyChange, dataAtom, replaceAll } from "./lib/store";
import "./main.css";
const queryClient = new QueryClient();
const jotaiStore = createStore();
// Load initial models from the database
void rpc("list_models", {}).then((res) => {
jotaiStore.set(dataAtom, (prev) => replaceAll(prev, "http_exchange", res.httpExchanges));
});
// Register hotkeys from action metadata
void initHotkeys();
// Subscribe to model change events from the backend
void listen("model_write", (payload) => {
jotaiStore.set(dataAtom, (prev) =>
applyChange(prev, "http_exchange", payload.model, payload.change),
);
});
const motionFeatures = () => import("framer-motion").then((mod) => mod.domAnimation);
createRoot(document.getElementById("root") as HTMLElement).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<Provider store={jotaiStore}>
<LazyMotion strict features={motionFeatures}>
<MotionConfig transition={{ duration: 0.1 }}>
<ProxyLayout />
</MotionConfig>
</LazyMotion>
</Provider>
</QueryClientProvider>
</StrictMode>,
);
-37
View File
@@ -1,37 +0,0 @@
{
"name": "@yaakapp/yaak-proxy",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vp dev --force",
"build": "vp build",
"lint": "tsc --noEmit"
},
"dependencies": {
"@tanstack/react-query": "^5.90.5",
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-os": "^2.3.2",
"@yaakapp-internal/model-store": "^1.0.0",
"@yaakapp-internal/proxy-lib": "^1.0.0",
"@yaakapp-internal/theme": "^1.0.0",
"@yaakapp-internal/ui": "^1.0.0",
"classnames": "^2.5.1",
"jotai": "^2.18.0",
"jotai-family": "^1.0.1",
"motion": "^12.4.7",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@rolldown/plugin-babel": "^0.2.3",
"@types/babel__core": "^7.20.5",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^6.0.1",
"babel-plugin-react-compiler": "^1.0.0",
"typescript": "^5.8.3",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1",
"vite-plus": "^0.2.1"
}
}

Some files were not shown because too many files have changed in this diff Show More