Compare commits

...
Author SHA1 Message Date
Gregory SchierandGitHub d0af512a0c Fix UI freezes with large workspaces (#536) 2026-08-14 10:15:32 -07:00
Gregory SchierandGitHub e032c4c8d6 Add a Yaak CLI skill for coding agents (#535) 2026-08-14 06:16:30 -07:00
c3fc3fb68a Fix Wayland crash on Nvidia by disabling explicit sync (#474)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-13 18:41:50 -07:00
Gregory SchierandGitHub 7e2db7799b Collapse encoded media in editable fields too (#534) 2026-08-13 12:56:32 -07:00
Gregory SchierandGitHub c596e25e5e Identify collapsed values and open them in a viewer (#533) 2026-08-13 12:26:35 -07:00
Gregory SchierandGitHub 74d1b5d6ce HUGE sidebar and typing performance improvements (#516) 2026-08-13 12:23:56 -07:00
WkdXeqtrandGitHub b9d4f76193 Add dropdown to pick header names and values
Adds a chevron on pair editor name and value inputs that opens the same options as inline autocomplete, so suggestions can be found by clicking instead of only by typing. Long values like User-Agent show a short label but insert the full string.

Row action menus move to a vertical ellipsis so the chevron only ever means "pick a value into this field".

Also restores the per-worktree Tauri config handling in run-dev.mjs, which a merge on the branch had reverted.
2026-08-13 10:58:20 -07:00
Gregory Schier d4a963e216 Pass per-worktree Tauri config to dev runs 2026-08-13 09:35:27 -07:00
be765d7bcc Fix nested template function depth tracking (#523)
Co-authored-by: Su <26924933+674019130@users.noreply.github.com>
2026-08-13 09:25:10 -07:00
Gregory Schier d43de3d95a Keep nested worktrees out of test runs
Test runs from the repo root were picking up every git worktree under .claude,
running 648 tests across 102 files instead of 322 across 34. That made results
misleading rather than just noisy: a failure in an unrelated worktree showed up
as a failure here.

Ignores the directory too. Only settings.local.json was listed, while rules.md
stays tracked.
2026-08-13 09:04:02 -07:00
Gregory Schier bc0494fe0e Keep template tag text colored in light themes
Tag text was black in light themes and correctly tinted in dark. Both came from
liftMax(), which pushes lightness to an extreme, and the two extremes are not
symmetric: lightening past the sRGB gamut clips to a still-tinted color, so dark
kept its hue by accident, while darkening to zero lightness collapses every hue
to black.

Stopping short of the extreme keeps the hue in both appearances with no branch
on appearance, and still clears 6:1 against the tag's own surface across every
accent. Dark shifts from washed out toward the accent, so a purple tag now reads
purple rather than near-white pink.
2026-08-13 08:57:25 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c57a64d455 Bump @hono/node-server from 1.19.14 to 2.1.0 (#531)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-13 08:42:24 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
5bebd74b09 Bump nanoid from 5.1.6 to 5.1.16 (#526)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-13 08:42:09 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a6dd058a3d Bump ip-address and express-rate-limit (#525)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-13 08:41:27 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1fe8261fbd Bump hono from 4.12.27 to 4.12.34 (#524)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-13 08:40:50 -07:00
Gregory SchierandGitHub 0e4c355e8b Collapse very long lines in response viewers (#532) 2026-08-13 08:40:04 -07:00
Gregory SchierandGitHub f6d926f4b9 Ignore bundled plugin directories left behind by past renames (#530) 2026-08-12 11:22:08 -07:00
Gregory SchierandGitHub a6be9dbaee Allow renaming URL path parameters from the Params tab (#528) 2026-08-12 10:17:02 -07:00
Gregory SchierandGitHub 67a628d67a Link date-fns format docs from timestamp.format (#529) 2026-08-12 09:50:36 -07:00
Gregory SchierandGitHub 0bf7eaed81 Correct timestamp.format help text to reference date-fns, not dayjs (#527) 2026-08-12 08:12:19 -07:00
784a3d3a32 fix(ci): align workflow Node.js version with package engines (#520)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 08:18:22 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
077bfa87b8 Bump body-parser from 2.2.2 to 2.3.0 (#512)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-01 08:31:02 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
733f2e5929 Bump @hono/node-server from 1.19.14 to 2.0.10 (#513)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-01 08:20:04 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
0f994f89ac Bump hono from 4.12.25 to 4.12.27 (#514)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-01 08:20:02 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
24f76398f9 Bump postcss from 8.5.16 to 8.5.25 (#522)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-01 08:19:59 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
14475915df Bump shell-quote from 1.8.4 to 1.10.0 (#517)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-01 08:19:57 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a50e04f565 Bump seroval from 1.4.2 to 1.5.6 (#518)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-01 08:19:54 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
32fbd66912 Bump fast-uri from 3.1.2 to 3.1.5 (#521)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-01 08:19:52 -07:00
SuandGitHub 3503a9da8e Fix inline rename during IME composition (#519) 2026-08-01 07:45:36 -07:00
Gregory Schier cef6abf5d0 Handle OAuth callback connections concurrently so browser preconnects can't block login 2026-07-29 07:54:59 -07:00
Gregory Schier 3f098f95fe Increase import timeout from 5s to 60s 2026-07-24 07:36:44 -07:00
Gregory Schier 195f89337f Skip contribution policy check for bot PRs 2026-07-21 07:59:19 -07:00
Gregory Schier 57fffe5a41 Fix extracted text flashing on SSE stream updates 2026-07-21 07:59:19 -07:00
gschier 0c24d6562a Deploying to main from @ mountain-loop/yaak@b1ea3e3560 🚀 2026-07-17 15:59:44 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b1ea3e3560 Bump serde_with from 3.12.0 to 3.21.0 (#511)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-15 22:35:58 -07:00
Gregory SchierandGitHub d72b7d7d30 Fix URL autocomplete replacement (#510) 2026-07-15 08:33:14 -07:00
Gregory SchierandGitHub b40e2cdc1b Fix keyboard shortcut help layout (#509) 2026-07-14 11:19:56 -07:00
Gregory SchierandGitHub 19cc6ee6d4 Batch streamed request body history into full-size chunks (#508) 2026-07-14 09:13:50 -07:00
Gregory SchierandGitHub e59ecce886 Fix Insomnia text body imports (#507) 2026-07-14 09:13:47 -07:00
Gregory SchierandGitHub c30f5b767b Close responses before request body history finishes (#498) 2026-07-14 08:55:40 -07:00
Gregory SchierandGitHub 42b22d4c07 Fall back to JWT exp claim when OAuth token response has no expires_in (#506) 2026-07-14 08:24:07 -07:00
Gregory SchierandGitHub e05feba708 Duplicate models from the DB instead of frontend snapshots (#505) 2026-07-14 07:43:14 -07:00
Gregory Schier 6b9b3660de Remap build paths in wasm output and regenerate for wasm-bindgen 0.2.121 2026-07-13 13:31:31 -07:00
Gregory SchierandGitHub e20a184c70 Scope Git watching and status to the sync directory (#503) 2026-07-09 15:26:37 -07:00
Gregory SchierandGitHub 155260521e Detect and surface plugin runtime crashes (#502) 2026-07-09 09:05:34 -07:00
Gregory SchierandGitHub 2fee5ba413 Use generated release notes placeholder (#501) 2026-07-06 09:54:38 -07:00
Gregory Schier 2c1cf5a13c Restrict privileged workflows to official repo 2026-07-05 09:41:14 -07:00
b332a0eba9 Fix startup failure from fd exhaustion when launched via Finder (#500)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 08:49:23 -07:00
Gregory SchierandClaude Fable 5 f2972ee534 Clarify PR template checkboxes that must always be checked
The "when reasonable" checkboxes read as optional, but the contribution
policy workflow requires all boxes checked. Reword them as either/or
statements and accept the legacy wording in the policy script so
existing PRs keep validating.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 08:36:23 -07:00
Gregory SchierandClaude Fable 5 4cad671305 Remove release-notes command
The yaak.app MCP server carries the release workflow in its tool
descriptions and instructions; notes are generated from changelog
items on ship.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 06:56:58 -07:00
Gregory SchierandClaude Fable 5 ea33261e08 Update release docs for generated notes and version-locked CLI
The release command no longer teaches hand-writing GitHub notes: they
are generated from the yaak.app changelog items by release_ship_beta
and release_publish. AGENTS.md tag rule updated for the CLI being
version-locked to app tags.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 06:55:21 -07:00
Gregory SchierandGitHub 4ee080fa49 Add in-app micro-feedback prompts (#497) 2026-07-04 23:21:53 -07:00
Gregory Schier c833aeba78 Fix commit dialog banner layout 2026-07-04 22:24:03 -07:00
Gregory SchierandGitHub eb2a2dd775 Convert request bodies when changing type (#499) 2026-07-04 22:22:35 -07:00
Gregory Schier e52853cc2d Always render commercial use banner placeholder 2026-07-04 14:44:49 -07:00
Gregory Schier 851d0a26f0 Enable release Tauri features in config 2026-07-04 14:09:41 -07:00
Gregory SchierandClaude Fable 5 78de83c754 Track @yaakapp/cli via the latest dist-tag
The CLI is now version-locked to app releases, so the devDependency
follows the latest stable instead of a range that would strand on the
old 0.x line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 10:54:19 -07:00
Gregory SchierandClaude Fable 5 9f3fd46d49 Publish CLI on app release tags (version-locked)
The CLI now publishes to npm on every v* tag at the app's version,
instead of its own yaak-cli-* tag namespace. Stables go to the latest
dist-tag, prereleases to beta/alpha. workflow_dispatch remains for
manual publishes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 10:47:42 -07:00
168 changed files with 8102 additions and 1163 deletions
@@ -1,50 +0,0 @@
---
description: Generate formatted release notes for Yaak releases
allowed-tools: Bash(git tag:*)
---
Generate formatted release notes for Yaak releases by analyzing git history and pull request descriptions.
## What to do
1. Identifies the version tag and previous version
2. Retrieves all commits between versions
- If the version is a beta version, it retrieves commits between the beta version and previous beta version
- If the version is a stable version, it retrieves commits between the stable version and the previous stable version
3. Fetches PR descriptions for linked issues to find:
- Feedback URLs (feedback.yaak.app)
- Additional context and descriptions
- Installation links for plugins
4. Formats the release notes using the standard Yaak format:
- Changelog badge at the top
- Bulleted list of changes with PR links
- Feedback links where available
- Full changelog comparison link at the bottom
## Output Format
The skill generates markdown-formatted release notes following this structure:
```markdown
[![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".
+4 -2
View File
@@ -4,12 +4,14 @@
## Submission
<!-- Check every box below except at most one of the first two (bug fixes only need the first). The last two must be checked even when they do not apply — checking confirms you considered them. -->
- [ ] This PR is a bug fix.
- [ ] If this PR is not a bug fix, I linked the feedback item where @gschier explicitly gave me permission to work on it.
- [ ] I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).
- [ ] I tested this change locally.
- [ ] I added or updated tests when reasonable.
- [ ] I added screenshots or recordings for UI changes when reasonable.
- [ ] I added or updated tests, or tests are not reasonable for this change.
- [ ] I added screenshots or recordings, or this change does not affect the UI.
Explicit permission feedback item (required if not a bug fix):
+37 -11
View File
@@ -53,16 +53,25 @@ 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:
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:
],
readContributing: [
"I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).",
testedLocally: "I tested this change locally.",
testsUpdated: "I added or updated tests when reasonable.",
screenshotsAdded:
],
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) {
@@ -102,8 +111,8 @@ function normalizeCheckboxLabel(label) {
.trim();
}
function checkboxState(body, label) {
const expectedLabel = normalizeCheckboxLabel(label);
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);
@@ -112,7 +121,7 @@ function checkboxState(body, label) {
continue;
}
if (normalizeCheckboxLabel(match[2]) === expectedLabel) {
if (expectedLabels.has(normalizeCheckboxLabel(match[2]))) {
return match[1].toLowerCase() === "x";
}
}
@@ -255,7 +264,8 @@ function analyzePullRequest(pr) {
if (states.testsUpdated !== true) {
blockers.push({
label: LABELS.policyUnmet.name,
message: "Confirm that tests were added or updated when reasonable.",
message:
"Confirm that tests were added or updated, or that tests are not reasonable for this change. Check the box either way.",
});
}
@@ -263,7 +273,7 @@ function analyzePullRequest(pr) {
blockers.push({
label: LABELS.policyUnmet.name,
message:
"Confirm that screenshots or recordings were added for UI changes when reasonable.",
"Confirm that screenshots or recordings were added, or that this change does not affect the UI. Check the box either way.",
});
}
}
@@ -620,6 +630,22 @@ async function checkPullRequest({
const pr = response.data;
const issueNumber = pr.number;
if (pr.user.type === "Bot") {
core.notice(
`Skipping contribution policy for bot PR #${pr.number} from @${pr.user.login}.`,
);
return {
blocked: false,
number: pr.number,
summary: summarizeResult({
pr,
skipped: true,
skipReason: `bot @${pr.user.login}`,
}),
skipped: true,
};
}
if (
minimumAutomaticPullNumber != null &&
pr.number < minimumAutomaticPullNumber
-49
View File
@@ -1,49 +0,0 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'
@@ -30,6 +30,7 @@ permissions:
jobs:
check:
if: github.repository == 'mountain-loop/yaak'
name: Check contribution policy
runs-on: ubuntu-latest
steps:
+2 -1
View File
@@ -13,6 +13,7 @@ permissions:
jobs:
update-flathub:
if: github.repository == 'mountain-loop/yaak'
name: Update Flathub manifest
runs-on: ubuntu-latest
steps:
@@ -34,7 +35,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
node-version: "24"
- name: Install source generators
run: |
+1
View File
@@ -15,6 +15,7 @@ permissions:
jobs:
publish-npm:
if: github.repository == 'mountain-loop/yaak'
name: Publish @yaakapp/api
runs-on: ubuntu-latest
permissions:
+2 -1
View File
@@ -5,6 +5,7 @@ on:
jobs:
build-artifacts:
if: github.repository == 'mountain-loop/yaak'
permissions:
contents: write
@@ -184,7 +185,7 @@ jobs:
tauriScript: "node ../../node_modules/@tauri-apps/cli/tauri.js"
tagName: "v__VERSION__"
releaseName: "Release __VERSION__"
releaseBody: "[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)"
releaseBody: "<!-- generated-by-yaak-releases -->"
releaseDraft: true
prerelease: true
projectPath: ./crates-tauri/yaak-app-client
+4 -3
View File
@@ -2,7 +2,7 @@ name: Release CLI to NPM
on:
push:
tags: [yaak-cli-*]
tags: [v*]
workflow_dispatch:
inputs:
version:
@@ -15,6 +15,7 @@ permissions:
jobs:
prepare-vendored-assets:
if: github.repository == 'mountain-loop/yaak'
name: Prepare vendored plugin assets
runs-on: ubuntu-latest
@@ -118,7 +119,7 @@ jobs:
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="$WORKFLOW_VERSION"
else
VERSION="${GITHUB_REF_NAME#yaak-cli-}"
VERSION="${GITHUB_REF_NAME}"
fi
VERSION="${VERSION#v}"
echo "Building yaak version: $VERSION"
@@ -175,7 +176,7 @@ jobs:
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="$WORKFLOW_VERSION"
else
VERSION="${GITHUB_REF_NAME#yaak-cli-}"
VERSION="${GITHUB_REF_NAME}"
fi
VERSION="${VERSION#v}"
if [[ "$VERSION" == *-* ]]; then
+1
View File
@@ -7,6 +7,7 @@ permissions:
contents: write
jobs:
deploy:
if: github.repository == 'mountain-loop/yaak'
runs-on: ubuntu-latest
steps:
- name: Checkout 🛎️
+1
View File
@@ -58,3 +58,4 @@ flatpak/node-sources.json
# Claude Code local settings
.claude/settings.local.json
.claude/worktrees/
+1 -1
View File
@@ -1,2 +1,2 @@
- Tag safety: app releases use `v*` tags and CLI releases use `yaak-cli-*` tags; always confirm which one is requested before retagging.
- Tag safety: app AND CLI releases both ship from `v*` tags (the CLI is version-locked to the app and publishes to npm on every app tag); `@yaakapp/api` uses `yaak-api-*` tags. Always confirm which is requested before retagging.
- Do not commit, push, or tag without explicit approval
Generated
+81 -13
View File
@@ -862,6 +862,15 @@ dependencies = [
"alloc-stdlib",
]
[[package]]
name = "bs58"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
dependencies = [
"tinyvec",
]
[[package]]
name = "bstr"
version = "1.12.1"
@@ -1752,8 +1761,18 @@ version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
dependencies = [
"darling_core",
"darling_macro",
"darling_core 0.20.11",
"darling_macro 0.20.11",
]
[[package]]
name = "darling"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
dependencies = [
"darling_core 0.23.0",
"darling_macro 0.23.0",
]
[[package]]
@@ -1763,6 +1782,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"syn 2.0.101",
]
[[package]]
name = "darling_core"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
dependencies = [
"ident_case",
"proc-macro2",
"quote",
@@ -1776,7 +1807,18 @@ version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
"darling_core",
"darling_core 0.20.11",
"quote",
"syn 2.0.101",
]
[[package]]
name = "darling_macro"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
dependencies = [
"darling_core 0.23.0",
"quote",
"syn 2.0.101",
]
@@ -3885,9 +3927,9 @@ checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c"
[[package]]
name = "libc"
version = "0.2.172"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libdbus-sys"
@@ -6649,6 +6691,15 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "rlimit"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f35ee2729c56bb610f6dba436bf78135f728b7373bdffae2ec815b2d3eb98cc3"
dependencies = [
"libc",
]
[[package]]
name = "rolldown"
version = "0.1.0"
@@ -7307,6 +7358,18 @@ dependencies = [
"uuid",
]
[[package]]
name = "schemars"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
dependencies = [
"dyn-clone",
"ref-cast",
"serde",
"serde_json",
]
[[package]]
name = "schemars"
version = "1.2.1"
@@ -7375,7 +7438,7 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bae0cbad6ab996955664982739354128c58d16e126114fe88c2a493642502aab"
dependencies = [
"darling",
"darling 0.20.11",
"heck 0.4.1",
"proc-macro2",
"quote",
@@ -7644,17 +7707,19 @@ dependencies = [
[[package]]
name = "serde_with"
version = "3.12.0"
version = "3.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6b6f7f2fcb69f747921f79f3926bd1e203fce4fef62c268dd3abfb6d86029aa"
checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c"
dependencies = [
"base64 0.22.1",
"bs58",
"chrono",
"hex",
"indexmap 1.9.3",
"indexmap 2.14.0",
"serde",
"serde_derive",
"schemars 0.9.0",
"schemars 1.2.1",
"serde_core",
"serde_json",
"serde_with_macros",
"time",
@@ -7662,11 +7727,11 @@ dependencies = [
[[package]]
name = "serde_with_macros"
version = "3.12.0"
version = "3.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d00caa5193a3c8362ac2b73be6b9e768aa5a4b2f721d8f4b339600c3cb51f8e"
checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
dependencies = [
"darling",
"darling 0.23.0",
"proc-macro2",
"quote",
"syn 2.0.101",
@@ -10917,6 +10982,7 @@ dependencies = [
name = "yaak-app-client"
version = "0.0.0"
dependencies = [
"base64 0.22.1",
"charset",
"chrono",
"cookie",
@@ -10932,6 +10998,7 @@ dependencies = [
"r2d2_sqlite",
"rand 0.9.1",
"reqwest 0.12.20",
"rlimit",
"serde",
"serde_json",
"tauri",
@@ -11103,6 +11170,7 @@ dependencies = [
"serde",
"serde_json",
"serde_yaml",
"tempfile",
"thiserror 2.0.17",
"tokio",
"ts-rs",
+1 -1
View File
@@ -20,7 +20,7 @@
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https:&#x2F;&#x2F;github.com&#x2F;MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a>&nbsp;&nbsp;<a href="https://github.com/dharsanb"><img src="https:&#x2F;&#x2F;github.com&#x2F;dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a>&nbsp;&nbsp;<a href="https://github.com/railwayapp"><img src="https:&#x2F;&#x2F;github.com&#x2F;railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a>&nbsp;&nbsp;<a href="https://github.com/caseyamcl"><img src="https:&#x2F;&#x2F;github.com&#x2F;caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a>&nbsp;&nbsp;<a href="https://github.com/bytebase"><img src="https:&#x2F;&#x2F;github.com&#x2F;bytebase.png" width="80px" alt="User avatar: bytebase" /></a>&nbsp;&nbsp;<a href="https://github.com/"><img src="https:&#x2F;&#x2F;raw.githubusercontent.com&#x2F;JamesIves&#x2F;github-sponsors-readme-action&#x2F;dev&#x2F;.github&#x2F;assets&#x2F;placeholder.png" width="80px" alt="User avatar: " /></a>&nbsp;&nbsp;<!-- sponsors-premium -->
</p>
<p align="center">
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https:&#x2F;&#x2F;github.com&#x2F;seanwash.png" width="50px" alt="User avatar: seanwash" /></a>&nbsp;&nbsp;<a href="https://github.com/jerath"><img src="https:&#x2F;&#x2F;github.com&#x2F;jerath.png" width="50px" alt="User avatar: jerath" /></a>&nbsp;&nbsp;<a href="https://github.com/itsa-sh"><img src="https:&#x2F;&#x2F;github.com&#x2F;itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a>&nbsp;&nbsp;<a href="https://github.com/dmmulroy"><img src="https:&#x2F;&#x2F;github.com&#x2F;dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a>&nbsp;&nbsp;<a href="https://github.com/timcole"><img src="https:&#x2F;&#x2F;github.com&#x2F;timcole.png" width="50px" alt="User avatar: timcole" /></a>&nbsp;&nbsp;<a href="https://github.com/VLZH"><img src="https:&#x2F;&#x2F;github.com&#x2F;VLZH.png" width="50px" alt="User avatar: VLZH" /></a>&nbsp;&nbsp;<a href="https://github.com/terasaka2k"><img src="https:&#x2F;&#x2F;github.com&#x2F;terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a>&nbsp;&nbsp;<a href="https://github.com/andriyor"><img src="https:&#x2F;&#x2F;github.com&#x2F;andriyor.png" width="50px" alt="User avatar: andriyor" /></a>&nbsp;&nbsp;<a href="https://github.com/majudhu"><img src="https:&#x2F;&#x2F;github.com&#x2F;majudhu.png" width="50px" alt="User avatar: majudhu" /></a>&nbsp;&nbsp;<a href="https://github.com/axelrindle"><img src="https:&#x2F;&#x2F;github.com&#x2F;axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a>&nbsp;&nbsp;<a href="https://github.com/jirizverina"><img src="https:&#x2F;&#x2F;github.com&#x2F;jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a>&nbsp;&nbsp;<a href="https://github.com/chip-well"><img src="https:&#x2F;&#x2F;github.com&#x2F;chip-well.png" width="50px" alt="User avatar: chip-well" /></a>&nbsp;&nbsp;<a href="https://github.com/GRAYAH"><img src="https:&#x2F;&#x2F;github.com&#x2F;GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a>&nbsp;&nbsp;<a href="https://github.com/flashblaze"><img src="https:&#x2F;&#x2F;github.com&#x2F;flashblaze.png" width="50px" alt="User avatar: flashblaze" /></a>&nbsp;&nbsp;<a href="https://github.com/Frostist"><img src="https:&#x2F;&#x2F;github.com&#x2F;Frostist.png" width="50px" alt="User avatar: Frostist" /></a>&nbsp;&nbsp;<!-- sponsors-base -->
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https:&#x2F;&#x2F;github.com&#x2F;seanwash.png" width="50px" alt="User avatar: seanwash" /></a>&nbsp;&nbsp;<a href="https://github.com/jerath"><img src="https:&#x2F;&#x2F;github.com&#x2F;jerath.png" width="50px" alt="User avatar: jerath" /></a>&nbsp;&nbsp;<a href="https://github.com/itsa-sh"><img src="https:&#x2F;&#x2F;github.com&#x2F;itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a>&nbsp;&nbsp;<a href="https://github.com/dmmulroy"><img src="https:&#x2F;&#x2F;github.com&#x2F;dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a>&nbsp;&nbsp;<a href="https://github.com/timcole"><img src="https:&#x2F;&#x2F;github.com&#x2F;timcole.png" width="50px" alt="User avatar: timcole" /></a>&nbsp;&nbsp;<a href="https://github.com/VLZH"><img src="https:&#x2F;&#x2F;github.com&#x2F;VLZH.png" width="50px" alt="User avatar: VLZH" /></a>&nbsp;&nbsp;<a href="https://github.com/terasaka2k"><img src="https:&#x2F;&#x2F;github.com&#x2F;terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a>&nbsp;&nbsp;<a href="https://github.com/andriyor"><img src="https:&#x2F;&#x2F;github.com&#x2F;andriyor.png" width="50px" alt="User avatar: andriyor" /></a>&nbsp;&nbsp;<a href="https://github.com/majudhu"><img src="https:&#x2F;&#x2F;github.com&#x2F;majudhu.png" width="50px" alt="User avatar: majudhu" /></a>&nbsp;&nbsp;<a href="https://github.com/axelrindle"><img src="https:&#x2F;&#x2F;github.com&#x2F;axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a>&nbsp;&nbsp;<a href="https://github.com/jirizverina"><img src="https:&#x2F;&#x2F;github.com&#x2F;jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a>&nbsp;&nbsp;<a href="https://github.com/chip-well"><img src="https:&#x2F;&#x2F;github.com&#x2F;chip-well.png" width="50px" alt="User avatar: chip-well" /></a>&nbsp;&nbsp;<a href="https://github.com/GRAYAH"><img src="https:&#x2F;&#x2F;github.com&#x2F;GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a>&nbsp;&nbsp;<a href="https://github.com/flashblaze"><img src="https:&#x2F;&#x2F;github.com&#x2F;flashblaze.png" width="50px" alt="User avatar: flashblaze" /></a>&nbsp;&nbsp;<a href="https://github.com/Frostist"><img src="https:&#x2F;&#x2F;github.com&#x2F;Frostist.png" width="50px" alt="User avatar: Frostist" /></a>&nbsp;&nbsp;<a href="https://github.com/PurplProto"><img src="https:&#x2F;&#x2F;github.com&#x2F;PurplProto.png" width="50px" alt="User avatar: PurplProto" /></a>&nbsp;&nbsp;<!-- sponsors-base -->
</p>
![Yaak API Client](https://yaak.app/static/screenshot.png)
@@ -0,0 +1,26 @@
import { useAtomValue } from "jotai";
import { contextMenusAtom, hideContextMenu } from "../lib/contextMenu";
import { ContextMenu } from "./core/Dropdown";
import { ErrorBoundary } from "./ErrorBoundary";
/** Renders menus opened by {@link showContextMenu}, the way {@link Dialogs} renders dialogs. */
export function ContextMenus() {
const menus = useAtomValue(contextMenusAtom);
return (
<>
{menus.map(({ id, items, triggerPosition, triggerRect, triggerEl }) => (
<ErrorBoundary key={id} name={`ContextMenu ${id}`}>
<ContextMenu
items={items}
triggerPosition={triggerPosition}
triggerRect={triggerRect}
// The trigger isn't a React component here, so it arrives as an element and gets
// wrapped to look like the ref the menu expects
triggerRef={{ current: triggerEl ?? null }}
onClose={() => hideContextMenu(id)}
/>
</ErrorBoundary>
))}
</>
);
}
@@ -0,0 +1,76 @@
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,4 +1,9 @@
import { type GrpcRequest, type HttpRequestHeader, patchModel } from "@yaakapp-internal/models";
import {
type GrpcRequest,
type HttpRequestHeader,
patchModel,
patchModelDebounced,
} from "@yaakapp-internal/models";
import { HStack, Icon, useContainerSize, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { CSSProperties } from "react";
@@ -75,11 +80,13 @@ export function GrpcRequestPane({
const { width: paneWidth } = useContainerSize(urlContainerEl);
const handleChangeUrl = useCallback(
(url: string) => patchModel(activeRequest, { url }),
(url: string) => patchModelDebounced(activeRequest, { url }),
[activeRequest],
);
const handleChangeMessage = useCallback(
// Not debounced: handleSend reads message from the store, so a pending
// debounced patch would send stale text
(message: string) => patchModel(activeRequest, { message }),
[activeRequest],
);
@@ -146,12 +153,12 @@ export function GrpcRequestPane({
);
const handleMetadataChange = useCallback(
(metadata: HttpRequestHeader[]) => patchModel(activeRequest, { metadata }),
(metadata: HttpRequestHeader[]) => patchModelDebounced(activeRequest, { metadata }),
[activeRequest],
);
const handleDescriptionChange = useCallback(
(description: string) => patchModel(activeRequest, { description }),
(description: string) => patchModelDebounced(activeRequest, { description }),
[activeRequest],
);
@@ -299,7 +306,7 @@ export function GrpcRequestPane({
className="font-sans text-xl! px-0!"
containerClassName="border-0"
placeholder={resolvedModelName(activeRequest)}
onChange={(name) => patchModel(activeRequest, { name })}
onChange={(name) => patchModelDebounced(activeRequest, { name })}
/>
<MarkdownEditor
name="request-description"
+31 -9
View File
@@ -1,14 +1,17 @@
import type { HttpRequestHeader } from "@yaakapp-internal/models";
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
import { HStack } from "@yaakapp-internal/ui";
import { acceptLanguages } from "../lib/data/acceptLanguages";
import { cacheControlDirectives } from "../lib/data/cacheControl";
import { charsets } from "../lib/data/charsets";
import { connections } from "../lib/data/connections";
import { encodings } from "../lib/data/encodings";
import type { HeaderValuePreset } from "../lib/data/headerValuePresets";
import { headerNames } from "../lib/data/headerNames";
import { mimeTypes } from "../lib/data/mimetypes";
import { userAgents } from "../lib/data/userAgents";
import { CountBadge } from "./core/CountBadge";
import { DetailsBanner } from "./core/DetailsBanner";
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
import type { GenericCompletion, GenericCompletionConfig } from "./core/Editor/genericCompletion";
import type { InputProps } from "./core/Input";
import type { Pair, PairEditorProps } from "./core/PairEditor";
import { PairEditorRow } from "./core/PairEditor";
@@ -105,12 +108,21 @@ export function HeadersEditor({
const MIN_MATCH = 3;
const headerOptionsMap: Record<string, string[]> = {
const headerOptionsMap: Record<string, HeaderValuePreset[]> = {
"content-type": mimeTypes,
accept: ["*/*", ...mimeTypes],
"accept-encoding": encodings,
"content-encoding": encodings,
connection: connections,
"accept-charset": charsets,
"accept-language": acceptLanguages,
"cache-control": cacheControlDirectives,
"user-agent": userAgents,
pragma: ["no-cache"],
te: ["trailers", "compress", "deflate", "gzip"],
dnt: ["1", "0"],
"upgrade-insecure-requests": ["1"],
"x-requested-with": ["XMLHttpRequest"],
};
const valueType = (pair: Pair): InputProps["type"] => {
@@ -132,12 +144,22 @@ const valueType = (pair: Pair): InputProps["type"] => {
const valueAutocomplete = (headerName: string): GenericCompletionConfig | undefined => {
const name = headerName.toLowerCase().trim();
const options: GenericCompletionOption[] =
headerOptionsMap[name]?.map((o) => ({
label: o,
type: "constant",
boost: 1, // Put above other completions
})) ?? [];
const options: GenericCompletion[] =
headerOptionsMap[name]?.map((o) =>
typeof o === "string"
? {
label: o,
type: "constant",
boost: 1, // Put above other completions
}
: {
// Show the short label but insert the full value (e.g. User-Agent)
label: o.label,
apply: o.value,
type: "constant",
boost: 1,
},
) ?? [];
return { minMatch: MIN_MATCH, options };
};
+54 -47
View File
@@ -1,12 +1,11 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models";
import { getModel, patchModel, patchModelDebounced } from "@yaakapp-internal/models";
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
import classNames from "classnames";
import { atom, useAtomValue } from "jotai";
import type { CSSProperties } from "react";
import { lazy, Suspense, useCallback, useMemo, useRef, useState } from "react";
import { activeRequestIdAtom } from "../hooks/useActiveRequestId";
import { allRequestsAtom } from "../hooks/useAllRequests";
import { allRequestUrlsAtom } from "../hooks/useAllRequests";
import { useAuthTab } from "../hooks/useAuthTab";
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
import { useHeadersTab } from "../hooks/useHeadersTab";
@@ -16,10 +15,10 @@ import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
import { deepEqualAtom } from "../lib/atoms";
import { languageFromContentType } from "../lib/contentType";
import { generateId } from "../lib/generateId";
import { extractPathPlaceholders } from "../lib/pathPlaceholders";
import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
import { convertRequestBody } from "../lib/requestBodyConversion";
import {
BODY_TYPE_BINARY,
BODY_TYPE_FORM_MULTIPART,
@@ -38,9 +37,9 @@ import { BinaryFileEditor } from "./BinaryFileEditor";
import { ConfirmLargeRequestBody } from "./ConfirmLargeRequestBody";
import { CountBadge } from "./core/CountBadge";
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
import { getUrlCompletionConfig } from "./core/Editor/url/completion";
import { Editor } from "./core/Editor/LazyEditor";
import { InlineCode } from "@yaakapp-internal/ui";
import type { Pair } from "./core/PairEditor";
import { PlainInput } from "./core/PlainInput";
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs";
@@ -75,15 +74,12 @@ const TAB_SETTINGS = "settings";
const TAB_DESCRIPTION = "description";
const TABS_STORAGE_KEY = "http_request_tabs";
const nonActiveRequestUrlsAtom = atom((get) => {
const activeRequestId = get(activeRequestIdAtom);
const requests = get(allRequestsAtom);
return requests
.filter((r) => r.id !== activeRequestId)
.map((r): GenericCompletionOption => ({ type: "constant", label: r.url }));
});
const memoNotActiveRequestUrlsAtom = deepEqualAtom(nonActiveRequestUrlsAtom);
// Derived from the identity-stable URL list so this only recomputes when a URL
// actually changes. The active request's own URL is included, but exact matches
// are filtered out at completion time by genericCompletion.
const requestUrlOptionsAtom = atom((get): GenericCompletionOption[] =>
get(allRequestUrlsAtom).map((url) => ({ type: "constant", label: url })),
);
export function HttpRequestPane({ style, fullHeight, className, activeRequest }: Props) {
const activeRequestId = activeRequest.id;
@@ -131,20 +127,33 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
[activeRequest],
);
const { urlParameterPairs, urlParametersKey } = useMemo(() => {
const placeholderNames = extractPathPlaceholders(activeRequest.url);
const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);
const items: Pair[] = [...nonEmptyParameters];
for (const name of placeholderNames) {
const item = items.find((p) => p.name === name);
if (item) {
item.readOnlyName = true;
} else {
items.push({ name, value: "", enabled: true, readOnlyName: true, id: generateId() });
}
}
return { urlParameterPairs: items, urlParametersKey: placeholderNames.join(",") };
}, [activeRequest.url, activeRequest.urlParameters]);
// Renaming a path placeholder has to rewrite the URL and rename the parameter together, or the
// value detaches from the placeholder.
// NOTE: Reads the request fresh rather than closing over `activeRequest`. The row that calls this
// holds onto it until the URL's placeholders change, so a captured request would go stale and
// patch its parameter list back over newer edits.
const handleRenamePathPlaceholder = useCallback(
(oldName: string, newName: string) => {
const request = getModel("http_request", activeRequestId);
if (request == null) return false;
const patch = renamePathPlaceholder(request, oldName, newName);
if (patch == null) return false; // Unusable name, so the editor reverts the field
void patchModel(request, patch);
return true;
},
[activeRequestId],
);
const { urlParameterPairs, urlParametersKey } = useMemo(
() =>
derivePathPlaceholderPairs(
activeRequest.url,
activeRequest.urlParameters,
handleRenamePathPlaceholder,
),
[activeRequest.url, activeRequest.urlParameters, handleRenamePathPlaceholder],
);
let numParams = 0;
if (
@@ -195,7 +204,14 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
});
};
const patch: Partial<HttpRequest> = { bodyType };
const patch: Partial<HttpRequest> = {
bodyType,
body: convertRequestBody({
body: activeRequest.body,
fromBodyType: activeRequest.bodyType,
toBodyType: bodyType,
}),
};
let newContentType: string | null | undefined;
if (bodyType === BODY_TYPE_NONE) {
newContentType = null;
@@ -265,28 +281,19 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
const { mutate: importCurl } = useImportCurl();
const handleBodyChange = useCallback(
(body: HttpRequest["body"]) => patchModel(activeRequest, { body }),
(body: HttpRequest["body"]) => patchModelDebounced(activeRequest, { body }),
[activeRequest],
);
const handleBodyTextChange = useCallback(
(text: string) => patchModel(activeRequest, { body: { ...activeRequest.body, text } }),
(text: string) => patchModelDebounced(activeRequest, { body: { ...activeRequest.body, text } }),
[activeRequest],
);
const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom);
const autocompleteUrls = useAtomValue(requestUrlOptionsAtom);
const autocomplete: GenericCompletionConfig = useMemo(
() => ({
minMatch: 3,
options:
autocompleteUrls.length > 0
? autocompleteUrls
: [
{ label: "http://", type: "constant" },
{ label: "https://", type: "constant" },
],
}),
() => getUrlCompletionConfig(autocompleteUrls),
[autocompleteUrls],
);
@@ -323,7 +330,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
);
const handleUrlChange = useCallback(
(url: string) => patchModel(activeRequest, { url }),
(url: string) => patchModelDebounced(activeRequest, { url }),
[activeRequest],
);
@@ -369,7 +376,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
forceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`}
headers={activeRequest.headers}
stateKey={`headers.${activeRequest.id}`}
onChange={(headers) => patchModel(activeRequest, { headers })}
onChange={(headers) => patchModelDebounced(activeRequest, { headers })}
/>
</TabContent>
<TabContent value={TAB_PARAMS}>
@@ -377,7 +384,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
stateKey={`params.${activeRequest.id}`}
forceUpdateKey={forceUpdateKey + urlParametersKey}
pairs={urlParameterPairs}
onChange={(urlParameters) => patchModel(activeRequest, { urlParameters })}
onChange={(urlParameters) => patchModelDebounced(activeRequest, { urlParameters })}
/>
</TabContent>
<TabContent value={TAB_SETTINGS}>
@@ -429,7 +436,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
requestId={activeRequest.id}
contentType={contentType}
body={activeRequest.body}
onChange={(body) => patchModel(activeRequest, { body })}
onChange={(body) => patchModelDebounced(activeRequest, { body })}
onChangeContentType={handleContentTypeChange}
/>
) : typeof activeRequest.bodyType === "string" ? (
@@ -0,0 +1,274 @@
import { formatSize } from "@yaakapp-internal/lib/formatSize";
import { Banner, Button, HStack, LoadingIcon } from "@yaakapp-internal/ui";
import type { ReactNode } from "react";
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import type { SniffedValue } from "./core/Editor/sniffValue";
import { showDialog } from "../lib/dialog";
import { decodeValue, largeValueActions } from "../lib/largeValue";
import { Dropdown } from "./core/Dropdown";
import { AudioViewer } from "./responseViewers/AudioViewer";
import { ImageViewer } from "./responseViewers/ImageViewer";
import { SvgViewer } from "./responseViewers/SvgViewer";
import { VideoViewer } from "./responseViewers/VideoViewer";
const PdfViewer = lazy(() =>
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
);
interface Props {
/** The whole value, exactly as it reads in the document */
text: string;
sniffed: SniffedValue | null;
}
/**
* Shows a value the editor collapsed, in whatever viewer its type calls for.
*
* The editor only ever read the value's head, so this is where it is decoded in full — on an
* explicit click, behind a spinner, rather than during layout.
*/
export function LargeValueDialog({ text, sniffed }: Props) {
const viewer = sniffed == null ? null : viewerFor(sniffed.mime);
const decoded = useDecoded(text, viewer == null ? null : sniffed);
// Nothing recognised it, so there is nothing to decode it into
if (sniffed == null || viewer == null) {
return (
<PagedText text={text}>
{sniffed != null && (
<Banner color="info" className="mb-3">
{sniffed.label} content cannot be previewed, so it is shown as it appears in the
response.
</Banner>
)}
</PagedText>
);
}
if (decoded == null) {
return (
<HStack className="h-full" alignItems="center" justifyContent="center">
<LoadingIcon />
</HStack>
);
}
if ("error" in decoded) {
return (
<PagedText text={text}>
<Banner color="danger" className="mb-3">
Failed to decode this {sniffed.label} value: {decoded.error}
</Banner>
</PagedText>
);
}
const { bytes } = decoded;
switch (viewer) {
case "svg":
return <DecodedSvg bytes={bytes} />;
case "image":
return (
<div className="h-full overflow-auto flex items-center justify-center">
<ImageViewer data={toArrayBuffer(bytes)} mimeType={sniffed.mime} />
</div>
);
case "audio":
return <AudioViewer data={bytes} mimeType={sniffed.mime} />;
case "video":
return <VideoViewer data={bytes} mimeType={sniffed.mime} />;
case "pdf":
return (
<Suspense fallback={<LoadingIcon />}>
<PdfViewer data={bytes} />
</Suspense>
);
case "text":
return <DecodedText bytes={bytes} />;
}
}
/**
* Decoding megabytes is worth doing once, not on every render — and the viewers below key their
* blob URLs off the string, so a fresh one each time would rebuild them for nothing.
*/
function DecodedSvg({ bytes }: { bytes: Uint8Array }) {
const text = useMemo(() => new TextDecoder().decode(bytes), [bytes]);
return <SvgViewer text={text} />;
}
function DecodedText({ bytes }: { bytes: Uint8Array }) {
const text = useMemo(() => new TextDecoder().decode(bytes), [bytes]);
return <PagedText text={text} />;
}
/**
* The same menu the tag in the editor carries, minus the one entry that would only reopen this.
*
* It lives in the dialog's title rather than above the content, which would cost a band of
* whitespace the width of the dialog to hold one small button.
*/
function LargeValueActions({ text, sniffed }: Props) {
const items = useMemo(
() =>
largeValueActions({
value: () => text,
sniffed,
// The tag copies only what it hides; in here, what you see is the whole value
copyText: () => text,
}),
[text, sniffed],
);
return (
<Dropdown items={items}>
<Button size="xs" variant="border" forDropdown>
Actions
</Button>
</Dropdown>
);
}
type Viewer = "image" | "svg" | "audio" | "video" | "pdf" | "text";
/** Which viewer a media type calls for, or null if none of them can show it. */
function viewerFor(mime: string): Viewer | null {
if (mime.startsWith("image/svg")) return "svg";
if (mime.startsWith("image/")) return "image";
if (mime.startsWith("audio/")) return "audio";
if (mime.startsWith("video/")) return "video";
if (mime === "application/pdf") return "pdf";
if (mime.startsWith("text/") || /\b(json|xml|javascript|csv)\b/.test(mime)) return "text";
return null;
}
type Decoded = { bytes: Uint8Array } | { error: string };
/**
* The value's bytes, once they exist.
*
* Decoding a few megabytes takes long enough to be seen, so it happens in an effect rather than
* during render — the dialog paints with a spinner first, instead of opening late.
*/
function useDecoded(text: string, sniffed: SniffedValue | null): Decoded | null {
const [decoded, setDecoded] = useState<Decoded | null>(null);
useEffect(() => {
if (sniffed == null) return;
setDecoded(null);
let cancelled = false;
const timer = setTimeout(() => {
let result: Decoded;
try {
result = { bytes: decodeValue(text, sniffed) };
} catch (err) {
result = { error: err instanceof Error ? err.message : String(err) };
}
if (!cancelled) setDecoded(result);
});
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [text, sniffed]);
return decoded;
}
/** A copy, since a `Uint8Array` may be a view onto a larger buffer */
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
return bytes.slice().buffer;
}
/** Characters shown at once. A page this size lays out instantly once its lines are short. */
const PAGE_CHARS = 50_000;
/** Where a line is broken. The long-line cost this whole feature avoids is per line. */
const WRAP_CHARS = 120;
/**
* The raw text, in pages of short lines.
*
* What is left when nothing can render the value. Handing it to an editor whole would walk
* straight back into the layout stall that collapsed it in the first place, so it is paged and
* hard-wrapped instead: no line is ever long, and no page is ever big.
*/
function PagedText({ text, children }: { text: string; children?: ReactNode }) {
const [page, setPage] = useState(0);
const pages = Math.max(1, Math.ceil(text.length / PAGE_CHARS));
const from = page * PAGE_CHARS;
const to = Math.min(from + PAGE_CHARS, text.length);
const body = useMemo(() => wrap(text.slice(from, to)), [text, from, to]);
return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)_auto] gap-3">
<div>{children}</div>
<pre className="overflow-auto font-mono text-sm text-text-subtle select-text">{body}</pre>
<HStack space={2} alignItems="center" className="text-sm text-text-subtle">
<span>
{(to - from).toLocaleString()} of {text.length.toLocaleString()} characters
</span>
{pages > 1 && (
<HStack space={2} alignItems="center" className="ml-auto">
<Button
size="xs"
variant="border"
disabled={page === 0}
onClick={() => setPage((p) => p - 1)}
>
Previous
</Button>
<span>
Page {page + 1} of {pages.toLocaleString()}
</span>
<Button
size="xs"
variant="border"
disabled={page >= pages - 1}
onClick={() => setPage((p) => p + 1)}
>
Next
</Button>
</HStack>
)}
</HStack>
</div>
);
}
/** Breaks every line at {@link WRAP_CHARS}, leaving the ones already shorter alone. */
function wrap(text: string): string {
const lines: string[] = [];
for (const line of text.split("\n")) {
if (line.length <= WRAP_CHARS) {
lines.push(line);
continue;
}
for (let i = 0; i < line.length; i += WRAP_CHARS) {
lines.push(line.slice(i, i + WRAP_CHARS));
}
}
return lines.join("\n");
}
export function showLargeValueDialog({ text, sniffed }: Props) {
showDialog({
id: "large-value",
size: "lg",
className: "h-[calc(100vh-10rem)]",
// Everything in one line: the same words the tag uses, and the same menu it carries. A
// separate description and a row of its own for the button cost three bands to say this.
title: (
<HStack space={3} alignItems="center">
<span>
{sniffed == null ? "Hidden Value" : sniffed.label} · {formatSize(text.length)}
</span>
<LargeValueActions text={text} sniffed={sniffed} />
</HStack>
),
render: () => <LargeValueDialog text={text} sniffed={sniffed} />,
});
}
@@ -92,7 +92,9 @@ export const RecentHttpResponsesDropdown = function ResponsePane({
</HStack>
),
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => onPinnedResponseId(r.id),
onSelect: () => {
onPinnedResponseId(r.id);
},
});
}
@@ -112,6 +112,17 @@ export function SettingsGeneral() {
</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"
+18 -6
View File
@@ -1,5 +1,6 @@
import type { Extension } from "@codemirror/state";
import { Compartment } from "@codemirror/state";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { debounce } from "@yaakapp-internal/lib";
import { gitMutations } from "@yaakapp-internal/git";
import type { GitStatus } from "@yaakapp-internal/git";
@@ -112,6 +113,7 @@ function Sidebar({ className }: { className?: string }) {
const treeId = `tree.${activeWorkspaceId ?? "unknown"}`;
const filterText = useAtomValue(sidebarFilterAtom);
const [tree, allFields, emptyFilterSuggestions] = useAtomValue(sidebarTreeAtom) ?? [];
const wrapperRef = useRef<HTMLElement>(null);
const treeRef = useRef<TreeHandle>(null);
const filterRef = useRef<InputHandle>(null);
@@ -130,11 +132,17 @@ function Sidebar({ className }: { className?: string }) {
if (!didFocus) filterRef.current?.focus();
}, []);
// Focus any new sidebar models when created
useListenToTauriEvent<ModelPayload>("model_write", ({ payload }) => {
if (!isSidebarLeafModel(payload.model)) return;
if (!(payload.change.type === "upsert" && payload.change.created)) return;
treeRef.current?.selectItem(payload.model.id, true);
// Focus new sidebar models created by the user in this window. Writes from other
// sources (import, sync, CLI) can carry thousands of models and shouldn't move
// the selection.
useListenToTauriEvent<ModelPayload[]>("model_writes", ({ payload: payloads }) => {
for (const payload of payloads) {
if (payload.updateSource.type !== "window") continue;
if (payload.updateSource.label !== getCurrentWebviewWindow().label) continue;
if (!isSidebarLeafModel(payload.model)) continue;
if (!(payload.change.type === "upsert" && payload.change.created)) continue;
treeRef.current?.selectItem(payload.model.id, true);
}
});
useEffect(() => {
@@ -724,7 +732,11 @@ function Sidebar({ className }: { className?: string }) {
);
}
export default Sidebar;
// Memoized so route navigations (which re-render the workspace layout) don't
// re-render the sidebar subtree. In large workspaces a sidebar re-render is
// very expensive: it re-renders DndContext, whose context churn re-renders
// every visible TreeItem regardless of their memo comparators.
export default memo(Sidebar);
function getGitContextMenuItems({
items,
@@ -1,15 +1,14 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import { VStack } from "@yaakapp-internal/ui";
import { useCallback, useRef } from "react";
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
import type { PairEditorHandle, PairEditorProps } from "./core/PairEditor";
import type { EditablePair, PairEditorHandle, PairEditorProps } from "./core/PairEditor";
import { PairOrBulkEditor } from "./core/PairOrBulkEditor";
type Props = {
forceUpdateKey: string;
pairs: HttpRequest["headers"];
pairs: EditablePair[];
stateKey: PairEditorProps["stateKey"];
onChange: (headers: HttpRequest["urlParameters"]) => void;
onChange: PairEditorProps["onChange"];
};
export function UrlParametersEditor({ pairs, forceUpdateKey, onChange, stateKey }: Props) {
@@ -1,5 +1,10 @@
import type { WebsocketRequest } from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models";
import {
flushAllModelWrites,
getModel,
patchModel,
patchModelDebounced,
} from "@yaakapp-internal/models";
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
import { closeWebsocket, connectWebsocket, sendWebsocket } from "@yaakapp-internal/ws";
import classNames from "classnames";
@@ -8,8 +13,7 @@ import type { CSSProperties } from "react";
import { useCallback, useMemo, useRef } from "react";
import { getActiveCookieJar } from "../hooks/useActiveCookieJar";
import { getActiveEnvironment } from "../hooks/useActiveEnvironment";
import { activeRequestIdAtom } from "../hooks/useActiveRequestId";
import { allRequestsAtom } from "../hooks/useAllRequests";
import { allRequestUrlsAtom } from "../hooks/useAllRequests";
import { useAuthTab } from "../hooks/useAuthTab";
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
import { useHeadersTab } from "../hooks/useHeadersTab";
@@ -18,17 +22,15 @@ import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { activeWebsocketConnectionAtom } from "../hooks/usePinnedWebsocketConnection";
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
import { deepEqualAtom } from "../lib/atoms";
import { languageFromContentType } from "../lib/contentType";
import { generateId } from "../lib/generateId";
import { extractPathPlaceholders } from "../lib/pathPlaceholders";
import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
import { prepareImportQuerystring } from "../lib/prepareImportQuerystring";
import { resolvedModelName } from "../lib/resolvedModelName";
import { CountBadge } from "./core/CountBadge";
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
import { getUrlCompletionConfig } from "./core/Editor/url/completion";
import { Editor } from "./core/Editor/LazyEditor";
import { IconButton } from "./core/IconButton";
import type { Pair } from "./core/PairEditor";
import { PlainInput } from "./core/PlainInput";
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs";
@@ -54,15 +56,12 @@ const TAB_SETTINGS = "settings";
const TAB_DESCRIPTION = "description";
const TABS_STORAGE_KEY = "websocket_request_tabs";
const nonActiveRequestUrlsAtom = atom((get) => {
const activeRequestId = get(activeRequestIdAtom);
const requests = get(allRequestsAtom);
return requests
.filter((r) => r.id !== activeRequestId)
.map((r): GenericCompletionOption => ({ type: "constant", label: r.url }));
});
const memoNotActiveRequestUrlsAtom = deepEqualAtom(nonActiveRequestUrlsAtom);
// Derived from the identity-stable URL list so this only recomputes when a URL
// actually changes. The active request's own URL is included, but exact matches
// are filtered out at completion time by genericCompletion.
const requestUrlOptionsAtom = atom((get): GenericCompletionOption[] =>
get(allRequestUrlsAtom).map((url) => ({ type: "constant", label: url })),
);
export function WebsocketRequestPane({ style, fullHeight, className, activeRequest }: Props) {
const activeRequestId = activeRequest.id;
@@ -83,20 +82,33 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
[],
);
const { urlParameterPairs, urlParametersKey } = useMemo(() => {
const placeholderNames = extractPathPlaceholders(activeRequest.url);
const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);
const items: Pair[] = [...nonEmptyParameters];
for (const name of placeholderNames) {
const item = items.find((p) => p.name === name);
if (item) {
item.readOnlyName = true;
} else {
items.push({ name, value: "", enabled: true, readOnlyName: true, id: generateId() });
}
}
return { urlParameterPairs: items, urlParametersKey: placeholderNames.join(",") };
}, [activeRequest.url, activeRequest.urlParameters]);
// Renaming a path placeholder has to rewrite the URL and rename the parameter together, or the
// value detaches from the placeholder.
// NOTE: Reads the request fresh rather than closing over `activeRequest`. The row that calls this
// holds onto it until the URL's placeholders change, so a captured request would go stale and
// patch its parameter list back over newer edits.
const handleRenamePathPlaceholder = useCallback(
(oldName: string, newName: string) => {
const request = getModel("websocket_request", activeRequestId);
if (request == null) return false;
const patch = renamePathPlaceholder(request, oldName, newName);
if (patch == null) return false; // Unusable name, so the editor reverts the field
void patchModel(request, patch);
return true;
},
[activeRequestId],
);
const { urlParameterPairs, urlParametersKey } = useMemo(
() =>
derivePathPlaceholderPairs(
activeRequest.url,
activeRequest.urlParameters,
handleRenamePathPlaceholder,
),
[activeRequest.url, activeRequest.urlParameters, handleRenamePathPlaceholder],
);
const tabs = useMemo<TabItem[]>(() => {
return [
@@ -127,23 +139,15 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
const connection = useAtomValue(activeWebsocketConnectionAtom);
const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom);
const autocompleteUrls = useAtomValue(requestUrlOptionsAtom);
const autocomplete: GenericCompletionConfig = useMemo(
() => ({
minMatch: 3,
options:
autocompleteUrls.length > 0
? autocompleteUrls
: [
{ label: "http://", type: "constant" },
{ label: "https://", type: "constant" },
],
}),
() => getUrlCompletionConfig(autocompleteUrls),
[autocompleteUrls],
);
const handleConnect = useCallback(async () => {
await flushAllModelWrites(); // The backend reads the request from the DB
await connectWebsocket({
requestId: activeRequest.id,
environmentId: getActiveEnvironment()?.id ?? null,
@@ -153,6 +157,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
const handleSend = useCallback(async () => {
if (connection == null) return;
await flushAllModelWrites(); // The backend reads the message from the DB
await sendWebsocket({
connectionId: connection?.id,
environmentId: getActiveEnvironment()?.id ?? null,
@@ -165,7 +170,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
}, [connection]);
const handleUrlChange = useCallback(
(url: string) => patchModel(activeRequest, { url }),
(url: string) => patchModelDebounced(activeRequest, { url }),
[activeRequest],
);
@@ -249,7 +254,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
forceUpdateKey={forceUpdateKey}
headers={activeRequest.headers}
stateKey={`headers.${activeRequest.id}`}
onChange={(headers) => patchModel(activeRequest, { headers })}
onChange={(headers) => patchModelDebounced(activeRequest, { headers })}
/>
</TabContent>
<TabContent value={TAB_PARAMS}>
@@ -257,7 +262,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
stateKey={`params.${activeRequest.id}`}
forceUpdateKey={forceUpdateKey + urlParametersKey}
pairs={urlParameterPairs}
onChange={(urlParameters) => patchModel(activeRequest, { urlParameters })}
onChange={(urlParameters) => patchModelDebounced(activeRequest, { urlParameters })}
/>
</TabContent>
<TabContent value={TAB_MESSAGE}>
@@ -269,7 +274,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
heightMode={fullHeight ? "full" : "auto"}
defaultValue={activeRequest.message}
language={messageLanguage}
onChange={(message) => patchModel(activeRequest, { message })}
onChange={(message) => patchModelDebounced(activeRequest, { message })}
stateKey={`json.${activeRequest.id}`}
/>
</TabContent>
@@ -286,7 +291,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
className="font-sans text-xl! px-0!"
containerClassName="border-0"
placeholder={resolvedModelName(activeRequest)}
onChange={(name) => patchModel(activeRequest, { name })}
onChange={(name) => patchModelDebounced(activeRequest, { name })}
/>
<MarkdownEditor
name="request-description"
@@ -294,7 +299,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
defaultValue={activeRequest.description}
stateKey={`description.${activeRequest.id}`}
forceUpdateKey={forceUpdateKey}
onChange={(description) => patchModel(activeRequest, { description })}
onChange={(description) => patchModelDebounced(activeRequest, { description })}
/>
</div>
</TabContent>
@@ -16,6 +16,7 @@ import { useCreateWorkspace } from "../hooks/useCreateWorkspace";
import { useDeleteSendHistory } from "../hooks/useDeleteSendHistory";
import { useWorkspaceActions } from "../hooks/useWorkspaceActions";
import { showDialog } from "../lib/dialog";
import { importData } from "../lib/importData";
import { jotaiStore } from "../lib/jotai";
import { revealInFinderText } from "../lib/reveal";
import { CloneGitRepositoryDialog } from "./CloneGitRepositoryDialog";
@@ -90,6 +91,11 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
leftSlot: <Icon icon="hard_drive_download" />,
onSelect: openCloneGitRepositoryDialog,
},
{
label: "Import Data",
leftSlot: <Icon icon="folder_input" />,
onSelect: () => importData.mutate(),
},
],
},
];
+32 -9
View File
@@ -233,23 +233,40 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
export interface ContextMenuProps {
triggerPosition: { x: number; y: number } | null;
/**
* The box the menu belongs to, when the trigger has one.
*
* Placement aligns to the trigger's left or right edge depending on the room beside it, so a
* menu opened from an element wants its real rect. Without one the position is treated as a
* zero-width point, which is right for a menu opened at the cursor and wrong for one opened
* from a button.
*/
triggerRect?: Pick<DOMRect, "top" | "bottom" | "left" | "right">;
/**
* The element the menu belongs to, so a click on it doesn't count as a click outside.
*
* Without this the trigger gets both: the outside-click handler closes the menu on mousedown,
* then the trigger's own click opens it again, and pressing it looks like it does nothing.
*/
triggerRef?: RefObject<HTMLElement | null>;
className?: string;
items: DropdownProps["items"];
onClose: () => void;
}
export const ContextMenu = forwardRef<DropdownRef, ContextMenuProps>(function ContextMenu(
{ triggerPosition, className, items, onClose },
{ triggerPosition, triggerRect, triggerRef, className, items, onClose },
ref,
) {
const triggerShape = useMemo(
() => ({
top: triggerPosition?.y ?? 0,
bottom: triggerPosition?.y ?? 0,
left: triggerPosition?.x ?? 0,
right: triggerPosition?.x ?? 0,
}),
[triggerPosition],
() =>
triggerRect ?? {
top: triggerPosition?.y ?? 0,
bottom: triggerPosition?.y ?? 0,
left: triggerPosition?.x ?? 0,
right: triggerPosition?.x ?? 0,
},
[triggerPosition, triggerRect],
);
if (triggerPosition == null) return null;
@@ -259,9 +276,13 @@ export const ContextMenu = forwardRef<DropdownRef, ContextMenuProps>(function Co
isOpen={true} // Always open because we return null if not
className={className}
defaultSelectedIndex={null}
// A menu opened from an element points back at it. One opened at the cursor has nothing
// to point at, so it goes without.
showTriangle={triggerRect != null}
ref={ref}
items={items}
onClose={onClose}
triggerRef={triggerRef}
triggerShape={triggerShape}
/>
);
@@ -277,7 +298,9 @@ interface MenuProps {
fullWidth?: boolean;
isOpen: boolean;
items: DropdownItem[];
triggerRef?: RefObject<HTMLButtonElement | null>;
// Any element, not just a button: a menu can be opened from anything, and this is only ever
// used to ask whether a click landed on the trigger
triggerRef?: RefObject<HTMLElement | null>;
isSubmenu?: boolean;
}
@@ -121,6 +121,24 @@
@apply cursor-default;
}
/* The chevron on a tag that opens a menu. The tag itself is the button, so this is
only the affordance that says so. */
.tag-action {
@apply inline-flex items-center align-middle ml-1;
@apply text-text-subtlest;
svg {
@apply w-[0.9em] h-[0.9em];
}
}
/* A preview of the value a tag stands for. Sized before the image loads, so arriving
never changes the line's height. */
.tag-thumbnail {
@apply inline-block align-middle mr-1 rounded-xs;
@apply w-[1.1em] h-[1.1em] object-cover bg-surface-highlight;
}
.fn {
@apply inline-block;
.fn-inner {
@@ -8,6 +8,7 @@ import { emacs } from "@replit/codemirror-emacs";
import { vim } from "@replit/codemirror-vim";
import { vscodeKeymap } from "@replit/codemirror-vscode-keymap";
import { debounce } from "@yaakapp-internal/lib";
import type { EditorKeymap } from "@yaakapp-internal/models";
import { settingsAtom } from "@yaakapp-internal/models";
import type { EditorLanguage, TemplateFunction } from "@yaakapp-internal/plugins";
@@ -15,7 +16,6 @@ import { HStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { GraphQLSchema } from "graphql";
import { useAtomValue } from "jotai";
import { md5 } from "js-md5";
import type { ReactNode, RefObject } from "react";
import {
Children,
@@ -33,6 +33,7 @@ import { useEnvironmentVariables } from "../../../hooks/useEnvironmentVariables"
import { eventMatchesHotkey } from "../../../hooks/useHotKey";
import { useRequestEditor } from "../../../hooks/useRequestEditor";
import { useTemplateFunctionCompletionOptions } from "../../../hooks/useTemplateFunctions";
import { docFingerprint } from "../../../lib/docFingerprint";
import { editEnvironment } from "../../../lib/editEnvironment";
import { tryFormatJson, tryFormatXml } from "../../../lib/formatters";
import { jotaiStore } from "../../../lib/jotai";
@@ -42,6 +43,7 @@ import { IconButton } from "../IconButton";
import "./Editor.css";
import {
baseExtensions,
editableExtensions,
getLanguageExtension,
multiLineExtensions,
readonlyExtensions,
@@ -288,7 +290,7 @@ function EditorInner({
function configureReadOnly() {
if (cm.current === null) return;
const current = readOnlyCompartment.current.get(cm.current.view.state) ?? emptyExtension;
const next = readOnly ? readonlyExtensions : emptyExtension;
const next = readOnly ? readonlyExtensions : editableExtensions;
// PERF: This is expensive with hundreds of editors on screen, so only do it when necessary
if (current === next) return;
@@ -381,6 +383,7 @@ function EditorInner({
const initEditorRef = useCallback(
function initEditorRef(container: HTMLDivElement | null) {
if (container === null) {
flushCachedEditorState(stateKey);
cm.current?.view.destroy();
cm.current = null;
return;
@@ -410,7 +413,7 @@ function EditorInner({
keymapCompartment.current.of(
keymapExtensions[settings.editorKeymap] ?? keymapExtensions.default,
),
readOnlyCompartment.current.of(readOnly ? readonlyExtensions : emptyExtension),
readOnlyCompartment.current.of(readOnly ? readonlyExtensions : editableExtensions),
...getExtensions({
container,
singleLine,
@@ -430,7 +433,7 @@ function EditorInner({
: []),
];
const cachedJsonState = getCachedEditorState(defaultValue ?? "", stateKey);
const cachedJsonState = getCachedEditorState(defaultValue ?? "", stateKey, !!readOnly);
const doc = `${defaultValue ?? ""}`;
const config: EditorStateConfig = { extensions, doc };
@@ -639,7 +642,7 @@ function getExtensions({
onChange.current?.(update.state.doc.toString());
}
saveCachedEditorState(stateKey, update.state);
saveCachedEditorStateDebounced(stateKey, update.state);
}),
];
}
@@ -652,13 +655,53 @@ const placeholderElFromText = (text: string | undefined) => {
return el;
};
// Caching the state is too expensive to do on every update (every keystroke and cursor move),
// so debounce it per state key and flush when the editor unmounts.
//
// The cost scales with the document, and every update pays it in full:
// - `state.toJSON` flattens the whole rope into a string, ~0.13 ms per 200 KB
// - the fingerprint is an exact md5 for editable documents, ~0.3 ms per 200 KB. `docFingerprint`
// only samples read-only ones, so editing a large body still hashes all of it
// - `sessionStorage.setItem` is synchronous and blocks the main thread
// Typing in a 200 KB body costs ~0.4 ms per keystroke before the storage write, and a 1 MB one
// ~2.3 ms. Read-only documents skip most of the hashing but still serialize, which is the larger
// half once documents get into the megabytes.
const SAVE_STATE_DEBOUNCE_MS = 500;
const stateSavers = new Map<string, ReturnType<typeof debounce>>();
function saveCachedEditorStateDebounced(stateKey: string | null, state: EditorState) {
if (!stateKey) return;
let saver = stateSavers.get(stateKey);
if (saver == null) {
saver = debounce(
(s: EditorState) => saveCachedEditorState(stateKey, s),
SAVE_STATE_DEBOUNCE_MS,
);
stateSavers.set(stateKey, saver);
}
saver(state);
}
// NOTE: Only called when an editor unmounts, so the saver is dropped rather than left in the map
// for every state key the session has ever shown. A pending saver holds the last EditorState,
// which holds the whole document.
function flushCachedEditorState(stateKey: string | null) {
if (!stateKey) return;
const saver = stateSavers.get(stateKey);
if (saver == null) return;
saver.flush();
stateSavers.delete(stateKey);
}
function saveCachedEditorState(stateKey: string | null, state: EditorState | null) {
if (!stateKey || state == null) return;
const stateObj = state.toJSON(stateFields);
// Save state in sessionStorage by removing doc and saving the hash of it instead.
// Save state in sessionStorage by removing doc and saving a fingerprint of it instead.
// This will be checked on restore and put back in if it matches.
stateObj.docHash = md5(stateObj.doc);
// Editable documents get the exact hash, so a collision can never restore undo history
// belonging to other content
stateObj.docHash = docFingerprint(stateObj.doc, { exact: !state.readOnly });
stateObj.doc = undefined;
try {
@@ -668,7 +711,7 @@ function saveCachedEditorState(stateKey: string | null, state: EditorState | nul
}
}
function getCachedEditorState(doc: string, stateKey: string | null) {
function getCachedEditorState(doc: string, stateKey: string | null, readOnly: boolean) {
if (stateKey == null) return;
try {
@@ -678,7 +721,7 @@ function getCachedEditorState(doc: string, stateKey: string | null) {
const { docHash, ...state } = JSON.parse(stateStr);
// Ensure the doc matches the one that was used to save the state
if (docHash !== md5(doc)) {
if (docHash !== docFingerprint(doc, { exact: !readOnly })) {
return null;
}
@@ -59,6 +59,7 @@ import { pluralizeCount } from "../../../lib/pluralize";
import { showGraphQLDocExplorerAtom } from "../../graphql/graphqlAtoms";
import type { EditorProps } from "./Editor";
import { jsonParseLinter } from "./json-lint";
import { largeValues, mediaValues } from "./largeValues";
import { pairs } from "./pairs/extension";
import { searchMatchCount } from "./searchMatchCount";
import { text } from "./text/extension";
@@ -258,8 +259,19 @@ export const baseExtensions = [
export const readonlyExtensions = [
EditorState.readOnly.of(true),
EditorView.contentAttributes.of({ tabindex: "-1" }),
// Nobody is editing this, so every rule applies
largeValues,
];
/**
* The counterpart for a document being edited.
*
* Only encoded media collapses here: it is pasted rather than typed, and replaced rather than
* edited, so a tag stands in for it without hiding anything anyone meant to read. The rules that
* hide text by length alone stay out. See {@link mediaValues}.
*/
export const editableExtensions = [mediaValues];
export const multiLineExtensions = ({ hideGutter }: { hideGutter?: boolean }) => [
search({ top: true }),
searchMatchCount(),
@@ -1,10 +1,14 @@
import type { CompletionContext } from "@codemirror/autocomplete";
import type { Completion, CompletionContext } from "@codemirror/autocomplete";
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
import { defaultBoost } from "./twig/completion";
export type GenericCompletion = GenericCompletionOption & {
apply?: Completion["apply"];
};
export interface GenericCompletionConfig {
minMatch?: number;
options: GenericCompletionOption[];
options: GenericCompletion[];
}
/**
@@ -0,0 +1,500 @@
import { EditorState } from "@codemirror/state";
import { jsonc } from "@shopify/lang-jsonc";
import { text } from "./text/extension";
import { twig } from "./twig/extension";
import { describe, expect, test } from "vite-plus/test";
import {
COLLAPSE_MEDIA_CHARS,
COLLAPSE_TOKEN_CHARS,
collapseDecorations,
largeValues,
MAX_VISIBLE_LINE_CHARS,
} from "./largeValues";
import type { SniffedValue } from "./sniffValue";
const BIG = "A".repeat(1_000_000);
/** With a grammar, so tokens can be collapsed individually */
const jsonState = (doc: string) => EditorState.create({ doc, extensions: [jsonc(), largeValues] });
/** Without a grammar, so only the column rule applies */
const plainState = (doc: string) => EditorState.create({ doc, extensions: largeValues });
/**
* The decorations as if the whole document were on screen.
*
* In the editor the plugin passes the viewport instead, which is the same call with narrower
* ranges — the rules themselves don't know the difference.
*/
function decorationsFor(state: EditorState) {
return collapseDecorations(state, [{ from: 0, to: state.doc.length }]);
}
function collapsedRanges(state: EditorState) {
const ranges: { from: number; to: number }[] = [];
const iter = decorationsFor(state).iter();
while (iter.value != null) {
ranges.push({ from: iter.from, to: iter.to });
iter.next();
}
return ranges;
}
/** How much of each line is still rendered */
function visibleLineLengths(state: EditorState) {
const hidden = collapsedRanges(state);
const lengths: number[] = [];
for (let n = 1; n <= state.doc.lines; n++) {
const line = state.doc.line(n);
const covered = hidden
.filter((h) => h.from >= line.from && h.to <= line.to)
.reduce((sum, h) => sum + (h.to - h.from), 0);
lengths.push(line.length - covered);
}
return lengths;
}
describe("collapsing", () => {
test("leaves an ordinary body alone", () => {
expect(collapsedRanges(jsonState('{"hello":"world"}'))).toEqual([]);
});
test("leaves a large body of short lines alone", () => {
const doc = Array.from({ length: 20_000 }, (_, i) => ` { "id": ${i} },`).join("\n");
expect(doc.length).toBeGreaterThan(MAX_VISIBLE_LINE_CHARS);
expect(collapsedRanges(jsonState(doc))).toEqual([]);
});
test("leaves a line just under the column limit alone", () => {
expect(collapsedRanges(plainState("x".repeat(MAX_VISIBLE_LINE_CHARS)))).toEqual([]);
});
test("never renders more than the column limit per line", () => {
for (const state of [jsonState(`{"image":"${BIG}"}`), plainState(BIG)]) {
for (const length of visibleLineLengths(state)) {
expect(length).toBeLessThanOrEqual(MAX_VISIBLE_LINE_CHARS);
}
}
});
test("keeps the document text intact", () => {
const doc = `{"image":"${BIG}"}`;
expect(jsonState(doc).sliceDoc()).toBe(doc);
expect(jsonState(doc).doc.length).toBe(doc.length);
});
});
describe("token collapsing, with a grammar", () => {
test("hides the whole value, leaving its quotes visible", () => {
const doc = `{"name":"a.png","image":"${BIG}","size":12}`;
const state = jsonState(doc);
const ranges = collapsedRanges(state);
expect(ranges).toHaveLength(1);
// Exactly the text between the quotes, so the line reads as `"image":"<tag>"`
expect(state.sliceDoc(ranges[0]!.from, ranges[0]!.to)).toBe(BIG);
expect(doc[ranges[0]!.from - 1]).toBe('"');
expect(doc[ranges[0]!.to]).toBe('"');
// Everything after the value is still rendered, unlike a plain column cut
expect(doc.slice(ranges[0]!.to)).toContain('"size":12}');
});
// Small enough that the parse always finishes inside PARSE_TIMEOUT_MS, even on a slow
// machine. With a bigger body this falls back to the column cut, which is by design but
// makes the assertion depend on how fast the runner is.
test("keeps every key visible in a minified body with several large values", () => {
const chunk = "B".repeat(20_000);
const doc = `{${["a", "b", "c", "d", "e"].map((k) => `"${k}":"${chunk}"`).join(",")}}`;
const state = jsonState(doc);
const ranges = collapsedRanges(state);
expect(ranges).toHaveLength(5);
for (const key of ["a", "b", "c", "d", "e"]) {
// No collapse swallows the key
const at = doc.indexOf(`"${key}":`);
expect(ranges.some((r) => r.from <= at && r.to > at)).toBe(false);
}
expect(visibleLineLengths(state)[0]).toBeLessThanOrEqual(MAX_VISIBLE_LINE_CHARS);
});
test("collapses a value on a pretty-printed line", () => {
const doc = `{\n "name": "a.png",\n "image": "${BIG}"\n}`;
const state = jsonState(doc);
const ranges = collapsedRanges(state);
expect(ranges).toHaveLength(1);
expect(state.sliceDoc(ranges[0]!.from, ranges[0]!.to)).toBe(BIG);
// Only the long line is touched, and all that is left of it is ` "image": ""`
expect(visibleLineLengths(state)).toEqual([1, 18, 13, 1]);
expect(state.doc.line(2).text).toBe(' "name": "a.png",');
});
test("ignores tokens under the collapse threshold", () => {
// Under the threshold once the surrounding quotes are counted
const short = "C".repeat(COLLAPSE_TOKEN_CHARS - 10);
const doc = `{${Array.from({ length: 4 }, (_, i) => `"k${i}":"${short}"`).join(",")}}`;
expect(doc.length).toBeGreaterThan(MAX_VISIBLE_LINE_CHARS);
// Nothing is big enough to collapse on its own, so the column rule takes over
const ranges = collapsedRanges(jsonState(doc));
expect(ranges).toHaveLength(1);
expect(ranges[0]!.to).toBe(doc.length);
});
});
describe("column collapsing, without a grammar", () => {
test("collapses everything past the limit", () => {
const ranges = collapsedRanges(plainState(BIG));
expect(ranges).toHaveLength(1);
expect(ranges[0]!.from).toBe(MAX_VISIBLE_LINE_CHARS);
expect(ranges[0]!.to).toBe(BIG.length);
});
test("handles a long line of many short tokens", () => {
// A single-line CSV row: no token is long enough to collapse on its own
const row = Array.from({ length: 40_000 }, (_, i) => `value ${i}`).join(", ");
const ranges = collapsedRanges(plainState(row));
expect(ranges).toHaveLength(1);
expect(ranges[0]!.from).toBe(MAX_VISIBLE_LINE_CHARS);
});
test("collapses each long line independently", () => {
const doc = `${BIG}\nshort\n${BIG}`;
const ranges = collapsedRanges(plainState(doc));
expect(ranges).toHaveLength(2);
for (const length of visibleLineLengths(plainState(doc))) {
expect(length).toBeLessThanOrEqual(MAX_VISIBLE_LINE_CHARS);
}
});
test("never hides a line break", () => {
const doc = `${BIG}\nshort`;
const state = plainState(doc);
for (const { from, to } of collapsedRanges(state)) {
expect(state.sliceDoc(from, to)).not.toContain("\n");
}
expect(state.doc.lines).toBe(2);
});
});
describe("undelimited tokens", () => {
// The text grammar parses a whole line as one token. Collapsing it whole would leave the
// line with nothing on it but a tag, so the column cut handles it instead.
const textState = (doc: string) => EditorState.create({ doc, extensions: [text(), largeValues] });
test("falls back to the column cut for a long line of plain text", () => {
const doc = Array.from({ length: 90_000 }, (_, i) => `word${i}`).join(" ");
const ranges = collapsedRanges(textState(doc));
expect(ranges).toHaveLength(1);
expect(ranges[0]!.from).toBe(MAX_VISIBLE_LINE_CHARS);
expect(ranges[0]!.to).toBe(doc.length);
});
test("leaves the start of the line readable", () => {
const doc = `IMPORTANT-PREFIX ${"z".repeat(500_000)}`;
const state = textState(doc);
const ranges = collapsedRanges(state);
expect(ranges[0]!.from).toBe(MAX_VISIBLE_LINE_CHARS);
expect(state.sliceDoc(0, 16)).toBe("IMPORTANT-PREFIX");
});
});
describe("sniffing the collapsed value", () => {
/** The widget standing in for each collapse, in document order */
function widgets(state: EditorState) {
const found: { valueFrom: number; sniffed: SniffedValue | null }[] = [];
const iter = decorationsFor(state).iter();
while (iter.value != null) {
found.push(iter.value.spec.widget);
iter.next();
}
return found;
}
/** A base64 value that starts with a PNG signature and runs on well past the limits */
function pngBase64(length = 1_000_000) {
const bytes = new Uint8Array(length);
bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}
test("names the format of a base64 value inside JSON", () => {
const state = jsonState(`{"image":"${pngBase64()}"}`);
expect(widgets(state)[0]?.sniffed).toMatchObject({ mime: "image/png", label: "PNG" });
});
test("names the format of a data URI", () => {
const doc = `{"image":"data:image/jpeg;base64,${BIG}"}`;
expect(widgets(jsonState(doc))[0]?.sniffed).toMatchObject({ label: "JPEG" });
});
test("names nothing when the value is just text", () => {
expect(widgets(jsonState(`{"image":"${BIG}"}`))[0]?.sniffed).toBeNull();
expect(widgets(plainState(BIG))[0]?.sniffed).toBeNull();
});
test("reads a column cut from the start of its line, not from the cut", () => {
// A body that is nothing but one base64 blob: the tail is hidden, but the value it
// belongs to begins at the start of the line, which is where the signature is
const state = plainState(pngBase64());
const [widget] = widgets(state);
expect(widget?.valueFrom).toBe(0);
expect(widget?.sniffed).toMatchObject({ label: "PNG" });
});
test("reads a token collapse from the value itself", () => {
const doc = `{"image":"${pngBase64()}"}`;
const [widget] = widgets(jsonState(doc));
// Just past the opening quote, so the signature is the first thing it sees
expect(widget?.valueFrom).toBe(doc.indexOf('"', doc.indexOf("image") + 6) + 1);
expect(widget?.sniffed).toMatchObject({ label: "PNG" });
});
test("finds a run that starts partway through a line, and names it", () => {
// No grammar here, and the value is not the whole line. The run is found by its alphabet,
// so the prefix stays on screen and the blob is still named.
const prefix = "some prefix text ";
const state = plainState(`${prefix}${pngBase64()}`);
const [widget] = widgets(state);
expect(widget?.valueFrom).toBe(prefix.length);
expect(widget?.sniffed).toMatchObject({ label: "PNG" });
});
});
describe("recomputing", () => {
test("updates when the document changes", () => {
const state = plainState('{"image":"short"}');
expect(collapsedRanges(state)).toEqual([]);
const next = state.update({
changes: { from: 0, to: state.doc.length, insert: BIG },
}).state;
expect(collapsedRanges(next)).toHaveLength(1);
});
});
describe("media collapsing, below the length rules", () => {
/** A real PNG signature, at a size that no length rule would touch */
function png(bytes: number) {
const b = new Uint8Array(bytes);
b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
let binary = "";
for (const byte of b) binary += String.fromCharCode(byte);
return btoa(binary);
}
/** Well under every length threshold, so only the media rule can collapse it */
const SMALL = png(3_000);
test("collapses a small image on a short line", () => {
expect(SMALL.length).toBeLessThan(MAX_VISIBLE_LINE_CHARS);
expect(SMALL.length).toBeLessThan(COLLAPSE_TOKEN_CHARS);
const state = jsonState(`{\n "avatar": "${SMALL}"\n}`);
const ranges = collapsedRanges(state);
expect(ranges).toHaveLength(1);
expect(state.sliceDoc(ranges[0]!.from, ranges[0]!.to)).toBe(SMALL);
});
test("names it, so the tag can say what it is", () => {
const state = jsonState(`{"avatar":"${SMALL}"}`);
const iter = decorationsFor(state).iter();
expect(iter.value?.spec.widget.sniffed).toMatchObject({ mime: "image/png", label: "PNG" });
});
test("leaves a value of the same size alone when nothing recognises it", () => {
// The only difference from the case above is what the bytes turn out to be
const prose = "x".repeat(SMALL.length);
expect(collapsedRanges(jsonState(`{"note":"${prose}"}`))).toEqual([]);
});
test("leaves anything under the media threshold alone, recognised or not", () => {
const tiny = png(400);
expect(tiny.length).toBeLessThan(COLLAPSE_MEDIA_CHARS);
expect(collapsedRanges(jsonState(`{"icon":"${tiny}"}`))).toEqual([]);
});
test("leaves text alone even when its first bytes match a signature", () => {
// `Qk0` decodes to `BM`, the whole of the BMP signature. Two bytes come up by chance often
// enough that the sniff alone must not be allowed to hide something.
const prose = `Qk0 ${"the quick brown fox jumps over the lazy dog. ".repeat(40)}`;
expect(prose.length).toBeGreaterThan(COLLAPSE_MEDIA_CHARS);
expect(collapsedRanges(jsonState(`{"note":"${prose}"}`))).toEqual([]);
});
test("still collapses it once it is long enough to be a rendering problem", () => {
// Past the length rule the sniff no longer decides anything, so this is hidden for its size
const prose = "Qk0 " + "words and more words ".repeat(COLLAPSE_TOKEN_CHARS);
expect(collapsedRanges(jsonState(`{"note":"${prose}"}`))).toHaveLength(1);
});
test("collapses a data URI on a short line", () => {
const state = jsonState(`{"avatar":"data:image/jpeg;base64,${SMALL}"}`);
expect(collapsedRanges(state)).toHaveLength(1);
});
test("still leaves an ordinary document completely untouched", () => {
const doc = Array.from({ length: 5_000 }, (_, i) => ` { "id": ${i}, "name": "item" },`).join(
"\n",
);
expect(collapsedRanges(jsonState(doc))).toEqual([]);
});
});
describe("media rule only, for a document being edited", () => {
/** What an editable editor gets: rule 2 alone */
function editableRanges(state: EditorState) {
const ranges: { from: number; to: number }[] = [];
const iter = collapseDecorations(state, [{ from: 0, to: state.doc.length }], "media").iter();
while (iter.value != null) {
ranges.push({ from: iter.from, to: iter.to });
iter.next();
}
return ranges;
}
function png(bytes: number) {
const b = new Uint8Array(bytes);
b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
let binary = "";
for (const byte of b) binary += String.fromCharCode(byte);
return btoa(binary);
}
test("collapses a pasted image, the one thing it is for", () => {
const value = png(3_000);
const state = jsonState(`{\n "avatar": "${value}"\n}`);
expect(editableRanges(state)).toHaveLength(1);
expect(state.sliceDoc(editableRanges(state)[0]!.from, editableRanges(state)[0]!.to)).toBe(
value,
);
});
test("leaves a long value alone when nothing can name it", () => {
// Rule 1 would take this on a stalling line. Editing it is plausible, so it stays.
const doc = `{"blob":"${"Z".repeat(COLLAPSE_TOKEN_CHARS * 3)}"}`;
expect(doc.length).toBeGreaterThan(MAX_VISIBLE_LINE_CHARS);
expect(editableRanges(jsonState(doc))).toEqual([]);
});
test("never cuts at the column limit, which would strand text out of reach", () => {
// Rule 3 territory: a minified body with no value big enough to collapse on its own
const doc = `[${Array.from({ length: 4_000 }, (_, i) => `"word-${i}"`).join(",")}]`;
expect(doc.length).toBeGreaterThan(MAX_VISIBLE_LINE_CHARS);
expect(editableRanges(jsonState(doc))).toEqual([]);
// The read-only rules still cut it
expect(collapsedRanges(jsonState(doc))).toHaveLength(1);
});
test("never swallows a template tag, collapsing only the run beside it", () => {
// A brace is not in the base64 alphabet, so the run starts after the tag and the tag stays
// on screen where it can still be read and edited
const tag = "{{ image }}";
const doc = `{"avatar":"${tag}${png(3_000)}"}`;
const ranges = editableRanges(jsonState(doc));
expect(ranges).toHaveLength(1);
expect(ranges[0]!.from).toBe(doc.indexOf(tag) + tag.length);
expect(doc.slice(ranges[0]!.from, ranges[0]!.to)).not.toContain("{");
});
test("finds a data URI whole, header and all", () => {
const value = `data:image/jpeg;base64,${png(3_000)}`;
const doc = `{"avatar":"${value}"}`;
const ranges = editableRanges(jsonState(doc));
expect(ranges).toHaveLength(1);
expect(doc.slice(ranges[0]!.from, ranges[0]!.to)).toBe(value);
});
test("hides no line break, so the plugin may provide it from the viewport", () => {
const state = jsonState(`{\n "a": "${png(3_000)}",\n "b": 1\n}`);
for (const { from, to } of editableRanges(state)) {
expect(state.sliceDoc(from, to)).not.toContain("\n");
}
});
});
describe("templated fields, where the grammar is an overlay", () => {
// Every editable field mixes its language with twig, which mounts the base language as an
// overlay. Overlays are not traversed by `Tree.iterate`, so a rule that walks the tree sees
// one enormous Text node and finds nothing. Rule 2 reads the text instead, and this is the
// case that has to keep working: an image pasted into a JSON request body.
const twigState = (doc: string) =>
EditorState.create({
doc,
extensions: [
twig({
base: jsonc(),
environmentVariables: [],
completionOptions: [],
onClickVariable: () => {},
onClickMissingVariable: () => {},
onClickPathParameter: () => {},
extraExtensions: [],
}),
largeValues,
],
});
function png(bytes: number) {
const b = new Uint8Array(bytes);
b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
let binary = "";
for (const byte of b) binary += String.fromCharCode(byte);
return btoa(binary);
}
test("collapses a pasted image in a templated body", () => {
const value = png(3_000);
const state = twigState(`{\n "avatar": "${value}"\n}`);
const ranges: { from: number; to: number }[] = [];
const iter = collapseDecorations(state, [{ from: 0, to: state.doc.length }], "media").iter();
while (iter.value != null) {
ranges.push({ from: iter.from, to: iter.to });
iter.next();
}
expect(ranges).toHaveLength(1);
expect(state.sliceDoc(ranges[0]!.from, ranges[0]!.to)).toBe(value);
});
});
describe("viewport scoping", () => {
const doc = () => {
const value = "A".repeat(COLLAPSE_TOKEN_CHARS * 2);
return `{\n${["a", "b", "c"].map((k) => ` "${k}": "${value}"`).join(",\n")}\n}`;
};
test("decorates only the ranges it is given", () => {
const state = jsonState(doc());
const secondLine = state.doc.line(3);
const all = collapseDecorations(state, [{ from: 0, to: state.doc.length }]);
const one = collapseDecorations(state, [{ from: secondLine.from, to: secondLine.to }]);
expect(all.size).toBe(3);
expect(one.size).toBe(1);
});
test("covers a line the range only partly overlaps", () => {
// The viewport can start mid-line; the collapse still has to span the whole value
const state = jsonState(doc());
const line = state.doc.line(2);
const partial = collapseDecorations(state, [{ from: line.from + 5, to: line.from + 6 }]);
expect(partial.size).toBe(1);
const iter = partial.iter();
expect(iter.to).toBeGreaterThan(line.from + 6);
});
});
@@ -0,0 +1,584 @@
import { ensureSyntaxTree, syntaxTree } from "@codemirror/language";
import { formatSize } from "@yaakapp-internal/lib/formatSize";
import type { EditorState, Extension, Range } from "@codemirror/state";
import type { Tree as SyntaxTree } from "@lezer/common";
import type { DecorationSet, ViewUpdate } from "@codemirror/view";
import { Decoration, EditorView, ViewPlugin, WidgetType } from "@codemirror/view";
import { fireAndForget } from "../../../lib/fireAndForget";
import type { SniffedValue } from "./sniffValue";
import { isEncodedRun, SNIFF_HEAD_CHARS, sniffValue } from "./sniffValue";
/**
* How much of a line may be rendered before the rest is collapsed.
*
* VS Code draws nothing past column 10,000 (`editor.stopRenderingLineAfter`) for the same
* reason. It can afford to be blunt about it because it doesn't soft wrap by default; we
* collapse to a placeholder that can be opened instead.
*/
export const MAX_VISIBLE_LINE_CHARS = 10_000;
/**
* A quoted value longer than this is collapsed whole, whatever it turns out to hold. Long
* enough that nothing a person might actually read is ever hidden on length alone.
*/
export const COLLAPSE_TOKEN_CHARS = 5_000;
/**
* A quoted value longer than this is collapsed too, but only when {@link sniffValue} can say
* what it is. Encoded media is never worth reading, so once we can name it and offer a viewer,
* a tag beats a wall of base64 well below the length that would make it a rendering problem —
* this covers the avatars and icons that make up most base64 in real payloads. Anything we
* can't identify stays visible until it's big enough to be a problem on its own.
*/
export const COLLAPSE_MEDIA_CHARS = 1_000;
/**
* Collapses what can't be read: over-long lines, which stall layout, and encoded media, which
* is only noise on screen.
*
* Layout cost tracks the length of the longest line, not the size of the document. A 1 MB
* response of ordinary multi-line JSON renders fine, while the same 1 MB on a single line stalls
* the UI, because soft wrap has to measure the whole line end to end to find its break points.
* Measured in WKWebView, the engine macOS ships: 221 ms per render pass at 1 MB and 968 ms at
* 3 MB, against 51 ms and 138 ms once collapsed, with soft wrap left on.
*
* Three rules:
*
* 1. Collapse quoted values over {@link COLLAPSE_TOKEN_CHARS} whole, so a base64 string reads
* as `"image": "PNG · 999.8 KB"` with its key intact. A minified body with several large
* values keeps every one of its keys. Needs a grammar to find the value.
* 2. Collapse quoted values over {@link COLLAPSE_MEDIA_CHARS} that sniff as a known format,
* which is what catches an ordinary embedded image long before it is a rendering problem.
* 3. On a line still over {@link MAX_VISIBLE_LINE_CHARS}, collapse whatever is left past the
* column limit. This needs no grammar, so it covers plain text, undelimited tokens, and any
* line that is simply long.
*
* Only what the viewport covers is examined, and only lines long enough to hold a collapse are
* looked at, so a document of ordinary short lines costs a length check per visible line and
* nothing else — no grammar is consulted and no tree is walked.
*
* Nothing leaves the document. Copy, filter and save all still see the full text, and the tag
* itself is a button, opening a menu that views, copies or saves exactly what it stands for.
*
* The value's own head says what it is — a `data:` URI names its media type, and raw base64
* gives its format up in the first dozen bytes — so the tag can name the format and open the
* value in the matching viewer without any of it being read. See {@link sniffValue}.
*
* An editable document gets rule 2 alone, as {@link mediaValues}. Encoded media is never typed
* by hand: it arrives pasted, and the only edit anyone makes to it is replacing it wholesale,
* which the tag already supports — {@link EditorView.atomicRanges} makes it delete as one unit.
* So collapsing it hides nothing anyone was going to read. The other two rules stay out, since
* they hide text by length alone, without being able to say what it is: under rule 3 a minified
* body would become uneditable past the column limit, which really would be editing text you
* can't see.
*/
/**
* Which of the three rules to apply.
*
* `media` is the subset safe for a document being edited: collapse only what we can name.
*/
export type CollapseRules = "all" | "media";
/**
* A hidden range, the value it belongs to, and what that value turned out to be.
*
* The hidden range and the value differ under the column rule, where the value starts at the
* beginning of the line but only what runs past the limit is hidden. The tag stands in for
* [from, to) and copies exactly that; the viewer needs the whole value, [valueFrom, to).
*/
interface Collapse {
from: number;
to: number;
valueFrom: number;
sniffed: SniffedValue | null;
}
/**
* Lucide's `chevron-down`, inlined because the widget builds its DOM synchronously and
* rendering React here would leave it empty while CodeMirror measures line heights.
*/
const CHEVRON_DOWN =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" ' +
'stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" ' +
'aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>';
/**
* Marks the tag as the thing that opens the menu.
*
* The whole tag is the target rather than the chevron alone: there is only one action, and a
* label you can't click next to a hit area a few pixels wide is a worse button than the tag
* itself. The chevron stays as the affordance that says so.
*
* A span with a role rather than a real button, because a button's box model makes the line
* taller — the one thing this extension exists to keep from happening.
*/
function makeTagButton(el: HTMLElement, title: string, onOpen: () => void) {
el.role = "button";
el.ariaHasPopup = "menu";
el.tabIndex = 0;
el.title = title;
el.ariaLabel = title;
// Keep the editor from moving the cursor when the tag is pressed
el.addEventListener("mousedown", (e) => {
e.preventDefault();
e.stopPropagation();
});
el.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
onOpen();
});
el.addEventListener("keydown", (e) => {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
e.stopPropagation();
onOpen();
});
}
class LargeValueWidget extends WidgetType {
constructor(
private readonly from: number,
private readonly to: number,
private readonly valueFrom: number,
private readonly sniffed: SniffedValue | null,
) {
super();
}
eq(other: LargeValueWidget) {
return (
other.from === this.from &&
other.to === this.to &&
other.valueFrom === this.valueFrom &&
other.sniffed?.mime === this.sniffed?.mime
);
}
toDOM(view: EditorView) {
const length = this.to - this.from;
const el = document.createElement("span");
// The same neutral tag styling a path parameter uses. The theme scope matters: these
// tokens resolve against the tag palette, not the editor's ambient one, where a
// tag-sized border is meant to be near invisible.
el.className = "x-theme-templateTag x-theme-templateTag--secondary template-tag";
const thumbnail = buildThumbnail(view, this.valueFrom, this.to, this.sniffed);
if (thumbnail != null) {
el.appendChild(thumbnail);
}
const label = document.createElement("span");
// A named type reads as a stand-in for the value, so it only needs to say what and how big.
// Without a name there is nothing to show but the fact that something is missing.
label.textContent =
this.sniffed == null
? `${formatSize(length)} hidden…`
: `${this.sniffed.label} · ${formatSize(length)}`;
el.appendChild(label);
const chevron = document.createElement("span");
chevron.className = "tag-action";
chevron.ariaHidden = "true";
chevron.innerHTML = CHEVRON_DOWN;
el.appendChild(chevron);
makeTagButton(
el,
this.sniffed == null ? "Value actions" : `${this.sniffed.label} actions`,
() => fireAndForget(this.openMenu(view, el)),
);
return el;
}
/**
* Copy, view and save, in a menu opened against the button.
*
* Everything the menu needs is pulled in on click. The editor loads on every response, and
* neither the React menu nor the viewers behind the dialog are worth carrying until someone
* asks for them.
*/
private async openMenu(view: EditorView, tag: HTMLElement) {
const [{ toggleContextMenu }, { showLargeValueDialog }, { encodingLabel, largeValueActions }] =
await Promise.all([
import("../../../lib/contextMenu"),
import("../../LargeValueDialog"),
import("../../../lib/largeValue"),
]);
const { sniffed } = this;
// Sliced when an action runs rather than now, so opening the menu never touches the value
const value = () => view.state.sliceDoc(this.valueFrom, this.to);
const head = view.state.sliceDoc(
this.valueFrom,
Math.min(this.valueFrom + SNIFF_HEAD_CHARS, this.to),
);
// The whole tag, so the menu can align to whichever edge has room beside it
const rect = tag.getBoundingClientRect();
toggleContextMenu({
id: "large-value",
triggerPosition: { x: rect.left, y: rect.bottom },
triggerRect: rect,
triggerEl: tag,
items: [
{ type: "separator", label: encodingLabel(head, sniffed, this.to - this.from) },
...largeValueActions({
value,
sniffed,
// Exactly what the tag stands in for, which under the column rule is only the tail
copyText: () => view.state.sliceDoc(this.from, this.to),
onView: () => showLargeValueDialog({ text: value(), sniffed }),
}),
],
});
}
ignoreEvent() {
return false;
}
}
/**
* How much encoded image a tag will preview. Roughly 3 MB of file, base64 being 4 bytes to 3.
*
* The decode runs on WebKit's image thread, but the bitmap it produces is sized by the image's
* own dimensions, not by the box we draw it in — a 4000×3000 photo costs 48 MB however small the
* thumbnail. So there has to be a limit, even though encoded size is only a proxy for the one
* that matters: a well-compressed photo can decode larger than a lossless screenshot twice its
* file size. This is set high enough to cover ordinary screenshots and wallpapers, since a tag
* that previews some images and not others is worse than one that previews none. Only widgets in
* the viewport are ever built, so a screenful is the most that decode at once.
*/
const THUMBNAIL_MAX_CHARS = 4_000_000;
/**
* A preview of the value, at a size fixed before it loads.
*
* The box is a fixed square from the moment it's inserted, so the image arriving never changes
* the line's height or the tag's width. A growing line box is the layout cost this whole
* extension exists to avoid, and an image of unknown dimensions is the classic way to cause one.
*
* The src is the value's own text handed straight to the decoder as a data URI. Decoding the
* base64 ourselves first would mean walking megabytes on the main thread, which is the one
* thing that must not happen here.
*/
function buildThumbnail(
view: EditorView,
valueFrom: number,
to: number,
sniffed: SniffedValue | null,
): HTMLElement | null {
if (sniffed == null || !sniffed.mime.startsWith("image/")) return null;
if (sniffed.encoding !== "base64") return null;
if (to - valueFrom > THUMBNAIL_MAX_CHARS) return null;
const img = document.createElement("img");
img.className = "tag-thumbnail";
img.alt = "";
img.ariaHidden = "true";
img.decoding = "async";
// A magic number can be wrong, and a data URI can lie. Drop the box rather than leave a
// broken-image glyph sitting in the tag.
img.addEventListener("error", () => img.remove());
// Read the document and build the data URI after the widget is measured and on screen
requestAnimationFrame(() => {
if (!img.isConnected) return;
const payload = view.state.sliceDoc(valueFrom + sniffed.offset, to);
img.src = `data:${sniffed.mime};base64,${payload}`;
});
return img;
}
interface Line {
from: number;
to: number;
}
/**
* The lines the given ranges touch, in document order and each listed once.
*
* Only lines long enough to hold a collapse are returned. That check is what keeps this free on
* ordinary documents: no grammar is consulted and no tree is walked for a screen of short lines.
*/
function collapsibleLines(state: EditorState, ranges: readonly { from: number; to: number }[]) {
const lines: Line[] = [];
let lastFrom = -1;
for (const range of ranges) {
let pos = range.from;
for (;;) {
const line = state.doc.lineAt(pos);
if (line.from > lastFrom) {
lastFrom = line.from;
if (line.to - line.from >= COLLAPSE_MEDIA_CHARS) {
lines.push({ from: line.from, to: line.to });
}
}
if (line.to >= range.to) break;
pos = line.to + 1;
}
}
return lines;
}
/**
* How long to spend parsing before falling back to the column rule.
*
* The initial parse is budgeted by time, so it stops partway through a document with several
* large values, and we'd only find the first one. Parsing the rest of a 1 MB body costs about
* 12 ms, against the 171 ms of layout it saves.
*/
const PARSE_TIMEOUT_MS = 100;
/**
* The parsed tree covering these lines.
*
* Parsing is only ever forced for a line long enough to stall layout, where finding the values
* inside it is what saves the frame. For everything else we take whatever the background parse
* has reached and decorate again when it advances — a forced parse runs from the top of the
* document, so making one happen on every scroll would cost far more than the tags are worth.
*/
function treeForLines(state: EditorState, lines: Line[]): SyntaxTree {
const last = lines[lines.length - 1];
const stalls = lines.some((l) => l.to - l.from > MAX_VISIBLE_LINE_CHARS);
if (last == null || !stalls) {
return syntaxTree(state);
}
return ensureSyntaxTree(state, last.to, PARSE_TIMEOUT_MS) ?? syntaxTree(state);
}
const QUOTES = ['"', "'", "`"];
/** A run of base64, long enough to be worth looking at. Nothing else can be encoded media. */
const ENCODED_RUN = new RegExp(`[A-Za-z0-9+/=_-]{${COLLAPSE_MEDIA_CHARS},}`, "g");
/** The header that turns a bare run into a data URI, when one sits right before it. */
const DATA_URI_HEAD = /data:[^,;\s"']*(?:;[^,;\s"']*)*;base64,$/;
/**
* Encoded media on this line, found by reading the text rather than the grammar.
*
* Deliberately grammar-free. Every editable field in the app mixes its language with the twig
* parser, which mounts the base language as an *overlay*, and overlays are not traversed by
* `Tree.iterate` — so a rule that walks the tree finds one enormous `Text` node and nothing
* inside it. Rule 2 has to work in those fields above all, since that is where images get
* pasted, and a run of base64 is a text pattern anyway: no grammar can describe it better than
* the alphabet does.
*
* A `data:` header is picked up by looking backwards from the run, so the whole URI collapses
* as one thing and its declared type is what names it.
*/
function findMediaRuns(state: EditorState, line: Line): Collapse[] {
const text = state.sliceDoc(line.from, line.to);
const collapses: Collapse[] = [];
ENCODED_RUN.lastIndex = 0;
for (let m = ENCODED_RUN.exec(text); m != null; m = ENCODED_RUN.exec(text)) {
let start = m.index;
// A data URI's own header breaks the alphabet, so the run starts after it
const header = DATA_URI_HEAD.exec(text.slice(0, start));
if (header != null) {
start = header.index;
}
const valueFrom = line.from + start;
const valueTo = line.from + m.index + m[0].length;
const head = state.sliceDoc(valueFrom, Math.min(valueFrom + SNIFF_HEAD_CHARS, valueTo));
const sniffed = sniffValue(head);
// Only hide what we can name. A short magic number matches by chance now and then, so the
// run itself has to look encoded too.
if (sniffed != null && sniffed.encoding === "base64" && isEncodedRun(head, sniffed.offset)) {
collapses.push({ from: valueFrom, to: valueTo, valueFrom, sniffed });
}
}
return collapses;
}
/**
* Quoted values big enough to be a rendering problem on their own, whatever they hold.
*
* Needs the grammar, to find where the value starts and ends, and so only runs for a document
* nobody is editing — which is also the only place this rule applies.
*/
function findLargeTokens(state: EditorState, tree: SyntaxTree, line: Line): Collapse[] {
const collapses: Collapse[] = [];
tree.iterate({
from: line.from,
to: line.to,
enter: (node) => {
// A node this small can't contain anything worth collapsing, and neither can its children
if (node.to - node.from < COLLAPSE_TOKEN_CHARS) return false;
// Only leaves, so we collapse the string itself rather than the object holding it
if (node.node.firstChild != null) return true;
const from = Math.max(node.from, line.from);
const to = Math.min(node.to, line.to);
const open = state.sliceDoc(from, from + 1);
const quoted = to - from >= 2 && QUOTES.includes(open) && state.sliceDoc(to - 1, to) === open;
if (!quoted) return false;
// Everything inside the quotes. The whole value is hidden, so it is its own value range
const valueFrom = from + 1;
const valueTo = to - 1;
if (valueTo - valueFrom < COLLAPSE_TOKEN_CHARS) return false;
const head = state.sliceDoc(valueFrom, Math.min(valueFrom + SNIFF_HEAD_CHARS, valueTo));
collapses.push({ from: valueFrom, to: valueTo, valueFrom, sniffed: sniffValue(head) });
return false;
},
});
return collapses;
}
/**
* Both rules' findings as one list, in document order, with nothing overlapping.
*
* A big quoted base64 value satisfies both rules, and two decorations over the same text is an
* error. The token wins where they collide, because its range stops at the quotes and so leaves
* the structure around it readable.
*/
function mergeCollapses(tokens: Collapse[], runs: Collapse[]): Collapse[] {
const merged = [...tokens];
for (const run of runs) {
if (!tokens.some((t) => run.from < t.to && t.from < run.to)) {
merged.push(run);
}
}
return merged.sort((a, b) => a.from - b.from);
}
/**
* Where the line runs past the column limit, counting only what is still visible after the
* token collapses, or -1 if it fits.
*/
function findColumnCut(line: Line, tokens: Collapse[]): number {
let visible = 0;
let pos = line.from;
for (const token of [...tokens, null]) {
const segmentEnd = token == null ? line.to : token.from;
if (segmentEnd > pos) {
if (visible + (segmentEnd - pos) > MAX_VISIBLE_LINE_CHARS) {
return pos + (MAX_VISIBLE_LINE_CHARS - visible);
}
visible += segmentEnd - pos;
}
if (token != null) {
pos = token.to;
}
}
return -1;
}
function collapsesForLine(
state: EditorState,
tree: SyntaxTree,
line: Line,
rules: CollapseRules,
): Collapse[] {
const runs = findMediaRuns(state, line);
// Length alone only hides things on a line that would stall without it, and never in a
// document being edited, where hiding text by size would put it out of reach
const stalls = rules === "all" && line.to - line.from > MAX_VISIBLE_LINE_CHARS;
const tokens = mergeCollapses(stalls ? findLargeTokens(state, tree, line) : [], runs);
// Short enough to render, so there is nothing left to cut
if (!stalls) {
return tokens;
}
const cut = findColumnCut(line, tokens);
if (cut < 0) {
return tokens;
}
// The cut always lands in a visible stretch, so it never splits a token collapse
const kept = tokens.filter((t) => t.to <= cut);
// Only the tail is hidden, but the value it belongs to runs from the start of the line — a
// body that is nothing but one base64 blob is still recognisable from there
const head = state.sliceDoc(line.from, Math.min(line.from + SNIFF_HEAD_CHARS, line.to));
kept.push({ from: cut, to: line.to, valueFrom: line.from, sniffed: sniffValue(head) });
return kept;
}
/**
* The collapses covering the given document ranges.
*
* Kept separate from the plugin so the rules can be exercised against a state directly, with no
* view and no DOM.
*/
export function collapseDecorations(
state: EditorState,
ranges: readonly { from: number; to: number }[],
rules: CollapseRules = "all",
): DecorationSet {
const lines = collapsibleLines(state, ranges);
if (lines.length === 0) {
return Decoration.none;
}
const tree = treeForLines(state, lines);
const decorations: Range<Decoration>[] = [];
for (const line of lines) {
for (const { from, to, valueFrom, sniffed } of collapsesForLine(state, tree, line, rules)) {
const widget = new LargeValueWidget(from, to, valueFrom, sniffed);
decorations.push(Decoration.replace({ widget }).range(from, to));
}
}
return Decoration.set(decorations);
}
/**
* Scoped to the viewport, so the cost is a screenful however big the document is.
*
* Recomputed when the document changes, when the viewport moves, and when background parsing
* advances — a value can only be found once the grammar has reached it.
*/
function collapsePlugin(rules: CollapseRules) {
return ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = collapseDecorations(view.state, view.visibleRanges, rules);
}
update(update: ViewUpdate) {
if (
update.docChanged ||
update.viewportChanged ||
syntaxTree(update.startState) !== syntaxTree(update.state)
) {
this.decorations = collapseDecorations(
update.view.state,
update.view.visibleRanges,
rules,
);
}
}
},
{
decorations: (v) => v.decorations,
// Step the cursor over a placeholder instead of stranding it inside, and delete it whole
provide: (plugin) =>
EditorView.atomicRanges.of((view) => view.plugin(plugin)?.decorations ?? Decoration.none),
},
);
}
/** All three rules. For a document nobody is editing. */
export const largeValues: Extension = [collapsePlugin("all")];
/** Only what we can name, which is the part that is safe to hide while someone is editing. */
export const mediaValues: Extension = [collapsePlugin("media")];
@@ -0,0 +1,170 @@
import { describe, expect, test } from "vite-plus/test";
import { decodeBase64Prefix, labelForMime, SNIFF_HEAD_CHARS, sniffValue } from "./sniffValue";
/** A base64 value that starts with `magic` and runs on for a while, as a real one would */
function base64Of(magic: number[], length = 2_000): string {
const bytes = new Uint8Array(length);
bytes.set(magic);
for (let i = magic.length; i < length; i++) {
bytes[i] = i % 251;
}
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary);
}
/** One character, one byte. These are all ASCII signatures. */
function ascii(s: string): number[] {
const bytes: number[] = [];
for (let i = 0; i < s.length; i++) {
bytes.push(s.charCodeAt(i));
}
return bytes;
}
/** Four bytes no signature looks at: a RIFF chunk length, or an ISO-BMFF box size */
const IGNORED = [0x00, 0x00, 0x00, 0x20];
const PNG = [0x89, ...ascii("PNG"), 0x0d, 0x0a, 0x1a, 0x0a];
const JPEG = [0xff, 0xd8, 0xff, 0xe0];
const PDF = ascii("%PDF-1.7");
describe("magic numbers", () => {
const cases: [string, number[], string, string][] = [
["PNG", PNG, "image/png", "PNG"],
["JPEG", JPEG, "image/jpeg", "JPEG"],
["GIF", ascii("GIF89a"), "image/gif", "GIF"],
["PDF", PDF, "application/pdf", "PDF"],
["WEBP", [...ascii("RIFF"), ...IGNORED, ...ascii("WEBP")], "image/webp", "WEBP"],
["WAV", [...ascii("RIFF"), ...IGNORED, ...ascii("WAVE")], "audio/wav", "WAV"],
["ZIP", [...ascii("PK"), 0x03, 0x04], "application/zip", "ZIP"],
["GZIP", [0x1f, 0x8b, 0x08], "application/gzip", "GZIP"],
["MP3", [...ascii("ID3"), 0x04], "audio/mpeg", "MP3"],
["OGG", ascii("OggS"), "audio/ogg", "OGG"],
["FLAC", ascii("fLaC"), "audio/flac", "FLAC"],
["WEBM", [0x1a, 0x45, 0xdf, 0xa3], "video/webm", "WEBM"],
["MP4", [...IGNORED, ...ascii("ftypisom")], "video/mp4", "MP4"],
["AVIF", [...IGNORED, ...ascii("ftypavif")], "image/avif", "AVIF"],
["HEIC", [...IGNORED, ...ascii("ftypheic")], "image/heic", "HEIC"],
["M4A", [...IGNORED, ...ascii("ftypM4A ")], "audio/mp4", "M4A"],
["MOV", [...IGNORED, ...ascii("ftypqt ")], "video/quicktime", "MOV"],
["BMP", ascii("BMxx"), "image/bmp", "BMP"],
["TIFF", [...ascii("II"), 0x2a, 0x00], "image/tiff", "TIFF"],
];
for (const [name, magic, mime, label] of cases) {
test(`recognises ${name}`, () => {
expect(sniffValue(base64Of(magic))).toEqual({ mime, label, offset: 0, encoding: "base64" });
});
}
test("recognises nothing in arbitrary base64", () => {
expect(sniffValue(btoa("just some text that happens to be encoded"))).toBeNull();
});
test("recognises nothing in text that isn't base64", () => {
expect(sniffValue("the quick brown fox jumps over the lazy dog".repeat(10))).toBeNull();
});
test("recognises nothing in a JWT-shaped value", () => {
expect(sniffValue(`${btoa('{"alg":"HS256"}')}.${btoa('{"sub":"1"}')}.c2ln`)).toBeNull();
});
test("reads the url-safe alphabet", () => {
const urlSafe = base64Of(PNG).replaceAll("+", "-").replaceAll("/", "_");
expect(sniffValue(urlSafe)?.label).toBe("PNG");
});
test("ignores leading whitespace, and counts it in the offset", () => {
expect(sniffValue(` ${base64Of(PNG)}`)).toMatchObject({ label: "PNG", offset: 3 });
});
});
describe("data URIs", () => {
test("takes the declared type over the bytes", () => {
const uri = `data:image/svg+xml;base64,${btoa("<svg/>")}`;
expect(sniffValue(uri)).toEqual({
mime: "image/svg+xml",
label: "SVG",
offset: "data:image/svg+xml;base64,".length,
encoding: "base64",
});
});
test("points past the header, so the payload can be decoded from there", () => {
const payload = base64Of(PNG);
const uri = `data:image/png;base64,${payload}`;
const sniffed = sniffValue(uri)!;
expect(uri.slice(sniffed.offset, sniffed.offset + 8)).toBe(payload.slice(0, 8));
});
test("falls back to the bytes when the declared type says nothing", () => {
const uri = `data:application/octet-stream;base64,${base64Of(PDF)}`;
expect(sniffValue(uri)).toMatchObject({ mime: "application/pdf", label: "PDF" });
});
test("handles a header with no type at all", () => {
expect(sniffValue(`data:;base64,${base64Of(JPEG)}`)).toMatchObject({ mime: "image/jpeg" });
});
test("handles extra parameters", () => {
const uri = `data:text/plain;charset=utf-8;base64,${btoa("hello")}`;
expect(sniffValue(uri)).toMatchObject({ mime: "text/plain", encoding: "base64" });
});
test("handles a percent-encoded payload", () => {
expect(sniffValue("data:text/html,%3Ch1%3Ehi%3C/h1%3E")).toEqual({
mime: "text/html",
label: "HTML",
offset: "data:text/html,".length,
encoding: "percent",
});
});
test("defaults a bare percent-encoded payload to text", () => {
expect(sniffValue("data:,hello%20there")).toMatchObject({
mime: "text/plain",
encoding: "percent",
});
});
});
describe("decoding only the head", () => {
test("reads no more of the value than the bytes asked for", () => {
const big = base64Of(PNG, 3_000_000);
// Truncating to the head must not change what is found, which is the whole point:
// callers only ever hand it a slice
expect(sniffValue(big.slice(0, SNIFF_HEAD_CHARS))).toEqual(sniffValue(big));
});
test("decodes whole 4-character groups only", () => {
// 12 bytes needs 16 characters; a 15-character slice yields the 12 bytes of 3 whole groups
expect(decodeBase64Prefix(base64Of(PNG), 0, 12)).toHaveLength(12);
expect(decodeBase64Prefix(base64Of(PNG).slice(0, 15), 0, 12)).toHaveLength(9);
});
test("declines a value too short to hold a group", () => {
expect(decodeBase64Prefix("abc", 0, 12)).toBeNull();
});
test("declines text outside the alphabet", () => {
expect(decodeBase64Prefix("hello world, not base64!", 0, 12)).toBeNull();
});
});
describe("labels", () => {
test.each([
["image/png", "PNG"],
["image/svg+xml", "SVG"],
["text/plain", "TEXT"],
["application/vnd.ms-excel", "MS-EXCEL"],
["image/x-icon", "ICON"],
["application/octet-stream", "APPLICATION"],
["font/woff2", "WOFF2"],
["application/vnd.openxmlformats-officedocument.wordprocessingml.document", "DOCUMENT"],
])("%s reads as %s", (mime, label) => {
expect(labelForMime(mime)).toBe(label);
});
});
@@ -0,0 +1,219 @@
/**
* What a collapsed value turns out to be.
*
* Sniffing reads the head of the value only. A `data:` URI states its own media type, and raw
* base64 gives up its type in the first few bytes, so neither needs the rest — which is the
* point, since the whole reason these values are collapsed is that touching all of one costs
* enough to stall the UI.
*/
export interface SniffedValue {
/** Full media type, e.g. `image/png`. What the dialog routes on. */
mime: string;
/** A word for the tag, e.g. `PNG`. */
label: string;
/** Where the encoded payload starts in the value, past any `data:` header. */
offset: number;
encoding: "base64" | "percent";
}
/**
* How much of a value {@link sniffValue} needs. Enough for a `data:` header of realistic
* length, and far more than the 24 base64 characters the magic numbers are read from.
*/
export const SNIFF_HEAD_CHARS = 256;
/** Bytes the longest signature needs: `RIFF????WEBP`, and an ISO-BMFF brand at offset 8. */
const SNIFF_BYTES = 12;
/** `data:[<mime>][;<param>…],` — the payload follows the comma. */
const DATA_URI = /^data:([^,;]*)((?:;[^,;]*)*),/;
/** Media types that name no format, so the bytes are worth a look even when one is declared. */
const UNINFORMATIVE = ["", "application/octet-stream", "binary/octet-stream"];
interface Signature {
label: string;
mime: string;
offset?: number;
/** Byte values, where `null` matches anything */
magic: readonly (number | null)[];
}
/**
* A magic number written as the ASCII it reads as, with `?` for a byte that varies.
*
* Indexed rather than iterated, because a signature is a sequence of bytes: one character here
* is one byte, and splitting into code points would be the wrong unit for that.
*/
function ascii(pattern: string): (number | null)[] {
const bytes: (number | null)[] = [];
for (let i = 0; i < pattern.length; i++) {
bytes.push(pattern[i] === "?" ? null : pattern.charCodeAt(i));
}
return bytes;
}
const SIGNATURES: readonly Signature[] = [
{ label: "PNG", mime: "image/png", magic: [0x89, ...ascii("PNG"), 0x0d, 0x0a, 0x1a, 0x0a] },
{ label: "JPEG", mime: "image/jpeg", magic: [0xff, 0xd8, 0xff] },
{ label: "GIF", mime: "image/gif", magic: ascii("GIF8") },
{ label: "BMP", mime: "image/bmp", magic: ascii("BM") },
{ label: "WEBP", mime: "image/webp", magic: ascii("RIFF????WEBP") },
{ label: "TIFF", mime: "image/tiff", magic: [...ascii("II"), 0x2a, 0x00] },
{ label: "TIFF", mime: "image/tiff", magic: [...ascii("MM"), 0x00, 0x2a] },
{ label: "ICO", mime: "image/x-icon", magic: [0x00, 0x00, 0x01, 0x00] },
{ label: "PDF", mime: "application/pdf", magic: ascii("%PDF-") },
{ label: "WAV", mime: "audio/wav", magic: ascii("RIFF????WAVE") },
{ label: "AVI", mime: "video/x-msvideo", magic: ascii("RIFF????AVI ") },
{ label: "MP3", mime: "audio/mpeg", magic: ascii("ID3") },
// A bare MPEG frame header: 11 sync bits, then a layer III / II / I version pair
{ label: "MP3", mime: "audio/mpeg", magic: [0xff, 0xfb] },
{ label: "MP3", mime: "audio/mpeg", magic: [0xff, 0xf3] },
{ label: "MP3", mime: "audio/mpeg", magic: [0xff, 0xf2] },
{ label: "OGG", mime: "audio/ogg", magic: ascii("OggS") },
{ label: "FLAC", mime: "audio/flac", magic: ascii("fLaC") },
{ label: "WEBM", mime: "video/webm", magic: [0x1a, 0x45, 0xdf, 0xa3] },
{ label: "ZIP", mime: "application/zip", magic: [...ascii("PK"), 0x03, 0x04] },
{ label: "GZIP", mime: "application/gzip", magic: [0x1f, 0x8b] },
{ label: "7Z", mime: "application/x-7z-compressed", magic: [...ascii("7z"), 0xbc, 0xaf, 0x27] },
{ label: "RAR", mime: "application/vnd.rar", magic: ascii("Rar!") },
{ label: "GLTF", mime: "model/gltf-binary", magic: ascii("glTF") },
];
/**
* ISO base media files all start `????ftyp`, so the format is in the brand that follows rather
* than in the signature itself. Anything unlisted is some flavour of MP4.
*/
const ISO_BRANDS: Record<string, { mime: string; label: string }> = {
avif: { mime: "image/avif", label: "AVIF" },
avis: { mime: "image/avif", label: "AVIF" },
heic: { mime: "image/heic", label: "HEIC" },
heix: { mime: "image/heic", label: "HEIC" },
hevc: { mime: "image/heic", label: "HEIC" },
mif1: { mime: "image/heif", label: "HEIF" },
msf1: { mime: "image/heif", label: "HEIF" },
"M4A ": { mime: "audio/mp4", label: "M4A" },
"qt ": { mime: "video/quicktime", label: "MOV" },
};
function matches(bytes: Uint8Array, { magic, offset = 0 }: Signature): boolean {
if (bytes.length < offset + magic.length) return false;
return magic.every((b, i) => b == null || bytes[offset + i] === b);
}
function readAscii(bytes: Uint8Array, from: number, length: number): string {
return String.fromCharCode(...bytes.subarray(from, from + length));
}
/** The format the first bytes of a file identify it as, or null if they identify nothing. */
export function sniffBytes(bytes: Uint8Array): { mime: string; label: string } | null {
if (readAscii(bytes, 4, 4) === "ftyp") {
return ISO_BRANDS[readAscii(bytes, 8, 4)] ?? { mime: "video/mp4", label: "MP4" };
}
const sig = SIGNATURES.find((s) => matches(bytes, s));
return sig == null ? null : { mime: sig.mime, label: sig.label };
}
/**
* The first `bytes` bytes of a base64 payload, or null if it isn't base64 after all.
*
* Only the characters those bytes need are decoded. The trailing partial group is dropped
* rather than padded, since `atob` rejects a group of one or two characters outright and we
* have no use for the byte a three-character group would add.
*/
export function decodeBase64Prefix(text: string, offset: number, bytes: number): Uint8Array | null {
const wanted = Math.ceil(bytes / 3) * 4;
let b64 = text.slice(offset, offset + wanted);
b64 = b64.slice(0, b64.length - (b64.length % 4));
if (b64.length === 0) return null;
// The URL-safe alphabet stands for the same bytes
b64 = b64.replaceAll("-", "+").replaceAll("_", "/");
if (!/^[A-Za-z0-9+/]+$/.test(b64)) return null;
try {
const binary = atob(b64);
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
out[i] = binary.charCodeAt(i);
}
return out;
} catch {
return null;
}
}
/**
* What a value is, from its head alone, or null if nothing recognises it.
*
* `head` need only be the first {@link SNIFF_HEAD_CHARS} characters — pass more and the rest is
* ignored. The offsets in the result are relative to the start of the whole value.
*/
export function sniffValue(head: string): SniffedValue | null {
const lead = head.length - head.trimStart().length;
const value = head.slice(lead);
const dataUri = DATA_URI.exec(value);
if (dataUri != null) {
const declared = dataUri[1]!.toLowerCase();
const offset = lead + dataUri[0].length;
if (!dataUri[2]!.split(";").includes("base64")) {
// A percent-encoded payload states its own type or is text by definition
const mime = declared === "" ? "text/plain" : declared;
return { mime, label: labelForMime(mime), offset, encoding: "percent" };
}
if (!UNINFORMATIVE.includes(declared)) {
return { mime: declared, label: labelForMime(declared), offset, encoding: "base64" };
}
return sniffBase64At(head, offset);
}
return sniffBase64At(head, lead);
}
function sniffBase64At(head: string, offset: number): SniffedValue | null {
const bytes = decodeBase64Prefix(head, offset, SNIFF_BYTES);
const sniffed = bytes == null ? null : sniffBytes(bytes);
return sniffed == null ? null : { ...sniffed, offset, encoding: "base64" };
}
/** The shortest run of base64 that {@link isEncodedRun} will accept as convincing */
const ENCODED_RUN_CHARS = 64;
/**
* Whether a value is one unbroken run of base64, rather than text that merely opens like it.
*
* A magic number is only a few bytes, so short ones match by chance — `BM` is two, which comes
* up about once in every 65,000 values. That was harmless while a value had to be long enough
* to hurt rendering before anything was hidden, and the sniff only chose the label. It is not
* harmless when the sniff itself decides to hide something, so that rule asks for this as well:
* prose leaves the alphabet within a few characters, and an encoded blob never does.
*/
export function isEncodedRun(head: string, offset: number): boolean {
const run = head.slice(offset);
return run.length >= ENCODED_RUN_CHARS && /^[A-Za-z0-9+/\-_]+={0,2}$/.test(run);
}
/** Subtypes whose own name makes a poor label */
const LABEL_OVERRIDES: Record<string, string> = {
"text/plain": "TEXT",
};
/**
* A word short enough for a tag. The distinguishing part of a subtype is its last dotted
* segment before any `+suffix`, so `image/svg+xml` reads as SVG and `application/vnd.ms-excel`
* as MS-EXCEL. Anything still too long falls back to the top-level type.
*/
export function labelForMime(mime: string): string {
const override = LABEL_OVERRIDES[mime];
if (override != null) {
return override;
}
const [type, subtype] = mime.split("/");
const word = (subtype ?? "").split("+")[0]!.split(".").pop()!.replace(/^x-/, "");
if (word.length > 0 && word.length <= 10 && word !== "octet-stream") {
return word.toUpperCase();
}
return (type ?? mime).toUpperCase();
}
@@ -0,0 +1,49 @@
import type { Completion } from "@codemirror/autocomplete";
import { EditorState, type TransactionSpec } from "@codemirror/state";
import type { EditorView } from "@codemirror/view";
import { describe, expect, test } from "vite-plus/test";
import { applyUrlCompletion, getUrlCompletionConfig } from "./completion";
describe("applyUrlCompletion", () => {
test("consumes an existing protocol suffix and preserves the rest of the URL", () => {
expect(applyCompletion("http://rickandmortyapi.com/api/character", "http://", 4)).toBe(
"http://rickandmortyapi.com/api/character",
);
});
test("inserts a protocol when there is no existing suffix", () => {
expect(applyCompletion("htt", "http://", 3)).toBe("http://");
});
test("replaces the full URL when accepting a saved URL", () => {
expect(applyCompletion("htt://old.example/path", "https://new.example/api", 3)).toBe(
"https://new.example/api",
);
});
});
describe("getUrlCompletionConfig", () => {
test("always includes protocols alongside saved URL options", () => {
const config = getUrlCompletionConfig([{ label: "https://example.com" }]);
expect(config.options.map((option) => option.label)).toEqual([
"http://",
"https://",
"https://example.com",
]);
expect(config.options.every((option) => option.apply === applyUrlCompletion)).toBe(true);
});
});
function applyCompletion(document: string, label: string, cursor: number) {
let state = EditorState.create({ doc: document, selection: { anchor: cursor } });
const view = {
state,
dispatch: (spec: TransactionSpec) => {
state = state.update(spec).state;
},
} as unknown as EditorView;
applyUrlCompletion(view, { label } satisfies Completion, 0, cursor);
return state.doc.toString();
}
@@ -1,9 +1,51 @@
import { genericCompletion } from "../genericCompletion";
import { insertCompletionText, pickedCompletion, type Completion } from "@codemirror/autocomplete";
import type { EditorView } from "@codemirror/view";
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
import {
genericCompletion,
type GenericCompletion,
type GenericCompletionConfig,
} from "../genericCompletion";
export const completions = genericCompletion({
options: [
{ label: "http://", type: "constant" },
{ label: "https://", type: "constant" },
],
minMatch: 1,
});
const protocolOptions: GenericCompletionOption[] = [
{ label: "http://", type: "constant" },
{ label: "https://", type: "constant" },
];
export function getUrlCompletionConfig(
options: GenericCompletionOption[],
minMatch = 3,
): GenericCompletionConfig {
const urlOptions = [
...protocolOptions,
...options.filter(
(option) => !protocolOptions.some((protocol) => protocol.label === option.label),
),
];
return {
minMatch,
options: urlOptions.map<GenericCompletion>((option) => ({
...option,
apply: applyUrlCompletion,
})),
};
}
export function applyUrlCompletion(
view: EditorView,
completion: Completion,
from: number,
to: number,
) {
const isProtocol = /^https?:\/\/$/.test(completion.label);
const replaceTo = isProtocol
? to + (view.state.sliceDoc(to, to + 3) === "://" ? 3 : 0)
: view.state.doc.length;
view.dispatch({
...insertCompletionText(view.state, completion.label, from, replaceTo),
annotations: pickedCompletion.of(completion),
});
}
export const completions = genericCompletion(getUrlCompletionConfig([], 1));
@@ -0,0 +1,25 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, test, vi } from "vite-plus/test";
import type { HotkeyAction } from "../../hooks/useHotKey";
import { HotkeyList } from "./HotkeyList";
vi.mock("./Hotkey", () => ({
Hotkey: ({ action }: { action: HotkeyAction }) =>
action === "sidebar.selected.move" ? null : <span>{action}</span>,
}));
vi.mock("./HotkeyLabel", () => ({
HotkeyLabel: ({ action }: { action: HotkeyAction }) => <span>{action}</span>,
}));
describe("HotkeyList", () => {
test("keeps a grid cell for actions without a shortcut", () => {
const markup = renderToStaticMarkup(
<HotkeyList hotkeys={["sidebar.selected.move", "request.send"]} />,
);
expect(markup).toContain(
'<span>sidebar.selected.move</span><div class="ml-4"></div><span>request.send</span>',
);
});
});
@@ -18,7 +18,10 @@ export const HotkeyList = ({ hotkeys, bottomSlot, className }: Props) => {
{hotkeys.map((hotkey) => (
<Fragment key={hotkey}>
<HotkeyLabel className="truncate" action={hotkey} />
<Hotkey className="ml-4" action={hotkey} />
{/* Keep this grid cell when Hotkey renders nothing so later rows stay aligned. */}
<div className="ml-4">
<Hotkey action={hotkey} />
</div>
</Fragment>
))}
{bottomSlot}
@@ -22,6 +22,7 @@ import { Button } from "./Button";
import type { DropdownItem } from "./Dropdown";
import { Dropdown } from "./Dropdown";
import type { EditorProps } from "./Editor/Editor";
import type { GenericCompletion } from "./Editor/genericCompletion";
import { Editor } from "./Editor/LazyEditor";
import { IconButton } from "./IconButton";
import { IconTooltip } from "./IconTooltip";
@@ -63,6 +64,13 @@ export type InputProps = Pick<
placeholder?: string;
required?: boolean;
rightSlot?: ReactNode;
/**
* Show a chevron that opens the `autocomplete` options as a menu, so a value can be picked
* without knowing what to type. Opt-in, because plenty of inputs pass `autocomplete` without
* wanting the affordance (the URL bar completes from history, and a chevron there would be
* noise). Renders nothing when there are no options to show.
*/
showOptionsPicker?: boolean;
size?: "2xs" | "xs" | "sm" | "md" | "auto";
stateKey: EditorProps["stateKey"];
extraExtensions?: EditorProps["extraExtensions"];
@@ -115,6 +123,7 @@ function BaseInput({
readOnly,
required,
rightSlot,
showOptionsPicker,
size = "md",
stateKey,
tint,
@@ -156,6 +165,17 @@ function BaseInput({
[],
);
// Replace the whole value with a picked option. Dispatching runs it through the editor's
// onChange, same as typing, so callers see one consistent change path.
const insertOption = useCallback((value: string) => {
const view = editorRef.current;
if (view == null) return;
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value } });
view.focus();
}, []);
const pickerOptions = showOptionsPicker && !disabled ? (props.autocomplete?.options ?? []) : [];
const setEditorRef = useCallback(
(h: EditorView | null) => {
editorRef.current = h;
@@ -318,6 +338,7 @@ function BaseInput({
editorClassName,
multiLine && size === "md" && "py-1.5",
multiLine && size === "sm" && "py-1",
multiLine && (size === "xs" || size === "2xs") && "py-0.5",
)}
onFocus={handleFocus}
onBlur={handleBlur}
@@ -349,12 +370,55 @@ function BaseInput({
onClick={() => setObscured((o) => !o)}
/>
)}
{pickerOptions.length > 0 && (
<OptionsPickerDropdown options={pickerOptions} onSelect={insertOption} />
)}
{rightSlot}
</HStack>
</div>
);
}
/**
* Chevron that opens the input's autocomplete options as a menu, for browsing values rather than
* typing them. Shows `label` but inserts `apply` when set, so a long value can be listed under a
* short name (e.g. a User-Agent).
*
* Skipped in the tab order: once focus is in the input, inline autocomplete offers the same
* options, so this would only add a stop between fields. Hidden when the nearest container is
* narrow and the input needs the width more — written as a `max` query so it stays visible for
* inputs that have no container ancestor at all.
*/
function OptionsPickerDropdown({
options,
onSelect,
}: {
options: GenericCompletion[];
onSelect: (value: string) => void;
}) {
const items = useMemo<DropdownItem[]>(
() =>
options.map((o) => ({
label: o.label,
onSelect: () => onSelect(typeof o.apply === "string" ? o.apply : o.label),
})),
[options, onSelect],
);
return (
<Dropdown items={items}>
<IconButton
tabIndex={-1}
iconSize="sm"
size="xs"
icon="chevron_down"
title="Show suggestions"
className="mr-0.5 h-auto! my-0.5 text-text-subtlest @max-[24rem]:hidden"
/>
</Dropdown>
);
}
function validateRequire(v: string) {
return v.length > 0;
}
+131 -25
View File
@@ -36,10 +36,19 @@ import type { RadioDropdownItem } from "./RadioDropdown";
import { RadioDropdown } from "./RadioDropdown";
export interface PairEditorHandle {
/**
* Focus a row's name field once it's able to take focus. Focus can't land immediately when the
* row isn't mounted yet or the editor is hidden — eg. sitting in a tab that's still becoming
* active — so this retries for up to ~1s. A newer focus request cancels a pending one.
*/
focusName(id: string): void;
/** Focus a row's value field. See {@link PairEditorHandle.focusName} for timing. */
focusValue(id: string): void;
}
/** ~1s at 60fps, plenty for a tab switch to land without spinning forever if it never does */
const MAX_FOCUS_ATTEMPTS = 60;
export type PairEditorProps = {
allowFileValues?: boolean;
allowMultilineValues?: boolean;
@@ -53,7 +62,7 @@ export type PairEditorProps = {
nameValidate?: InputProps["validate"];
noScroll?: boolean;
onChange: (pairs: PairWithId[]) => void;
pairs: Pair[];
pairs: EditablePair[];
stateKey: InputProps["stateKey"];
setRef?: (n: PairEditorHandle) => void;
valueAutocomplete?: (name: string) => GenericCompletionConfig | undefined;
@@ -72,13 +81,35 @@ export type Pair = {
contentType?: string;
filename?: string;
isFile?: boolean;
readOnlyName?: boolean;
};
export type PairWithId = Pair & {
id: string;
};
/**
* A pair as handed to the editor. Adds behaviour that only the editor cares about, so the plain
* `Pair` stays the shape that gets written to models.
*/
export type EditablePair = Pair & {
/**
* When set, name edits are held until the field blurs and then committed through this, instead
* of calling `onChange` on every keystroke. Return false to reject the new name, which reverts
* the field. For names that can't be written directly, like a URL path placeholder that lives
* in the URL itself.
*/
commitName?: (name: string) => boolean;
};
type EditablePairWithId = EditablePair & {
id: string;
};
/** Strip the editor-only fields, so they can never reach a model write */
function toPairData({ commitName: _commitName, ...pair }: EditablePairWithId): PairWithId {
return pair;
}
/** Max number of pairs to show before prompting the user to reveal the rest */
const MAX_INITIAL_PAIRS = 30;
@@ -106,8 +137,8 @@ export function PairEditor({
setRef,
}: PairEditorProps) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [isDragging, setIsDragging] = useState<PairWithId | null>(null);
const [pairs, setPairs] = useState<PairWithId[]>([]);
const [isDragging, setIsDragging] = useState<EditablePairWithId | null>(null);
const [pairs, setPairs] = useState<EditablePairWithId[]>([]);
const [showAll, toggleShowAll] = useToggle(false);
// NOTE: Use local force update key because we trigger an effect on forceUpdateKey change. If
// we simply pass forceUpdateKey to the editor, the data set by useEffect will be stale.
@@ -115,16 +146,38 @@ export function PairEditor({
const rowsRef = useRef<Record<string, RowHandle | null>>({});
const pendingFocusFrame = useRef<number | null>(null);
useEffect(
() => () => {
if (pendingFocusFrame.current != null) cancelAnimationFrame(pendingFocusFrame.current);
},
[],
);
const focusWhenReady = useCallback((id: string, field: "name" | "value") => {
if (pendingFocusFrame.current != null) cancelAnimationFrame(pendingFocusFrame.current);
let attemptsLeft = MAX_FOCUS_ATTEMPTS;
const attempt = () => {
pendingFocusFrame.current = null;
const row = rowsRef.current[id];
const landed = field === "name" ? row?.focusName() : row?.focusValue();
if (landed || --attemptsLeft <= 0) return;
pendingFocusFrame.current = requestAnimationFrame(attempt);
};
attempt();
}, []);
const handle = useMemo<PairEditorHandle>(
() => ({
focusName(id: string) {
rowsRef.current[id]?.focusName();
focusWhenReady(id, "name");
},
focusValue(id: string) {
rowsRef.current[id]?.focusValue();
focusWhenReady(id, "value");
},
}),
[],
[focusWhenReady],
);
const initPairEditorRow = useCallback(
@@ -147,7 +200,7 @@ export function PairEditor({
// oxlint-disable-next-line react-hooks/exhaustive-deps -- Only care about forceUpdateKey
useEffect(() => {
// Remove empty headers on initial render and ensure they all have valid ids (pairs didn't use to have IDs)
const newPairs: PairWithId[] = [];
const newPairs: EditablePairWithId[] = [];
for (let i = 0; i < originalPairs.length; i++) {
const p = originalPairs[i];
if (!p) continue; // Make TS happy
@@ -155,6 +208,21 @@ export function PairEditor({
newPairs.push(ensurePairId(p));
}
// When the reset holds the exact same rows (eg. renaming a URL path placeholder, which keeps
// every row id), swap the data in without rebuilding the row editors. Unfocused inputs re-seed
// themselves when `defaultValue` changes, and a rebuild would drop the user's focus and
// selection — like tabbing from a placeholder's name into its value.
const trailingPair = pairs[pairs.length - 1];
const sameRows =
trailingPair != null &&
isPairEmpty(trailingPair) &&
pairs.length === newPairs.length + 1 &&
newPairs.every((p, i) => p.id === pairs[i]?.id);
if (sameRows) {
setPairs([...newPairs, trailingPair]);
return;
}
// Add empty last pair if there is none
const lastPair = newPairs[newPairs.length - 1];
if (lastPair == null || !isPairEmpty(lastPair)) {
@@ -166,10 +234,10 @@ export function PairEditor({
}, [forceUpdateKey]);
const setPairsAndSave = useCallback(
(fn: (pairs: PairWithId[]) => PairWithId[]) => {
(fn: (pairs: EditablePairWithId[]) => EditablePairWithId[]) => {
setPairs((oldPairs) => {
const pairs = fn(oldPairs);
onChange(pairs);
onChange(pairs.map(toPairData));
return pairs;
});
},
@@ -177,7 +245,7 @@ export function PairEditor({
);
const handleChange = useCallback(
(pair: PairWithId) =>
(pair: EditablePairWithId) =>
setPairsAndSave((pairs) => pairs.map((p) => (pair.id !== p.id ? p : pair))),
[setPairsAndSave],
);
@@ -362,14 +430,14 @@ export function PairEditor({
type PairEditorRowProps = {
className?: string;
pair: PairWithId;
pair: EditablePairWithId;
forceFocusNamePairId?: string | null;
forceFocusValuePairId?: string | null;
onChange?: (pair: PairWithId) => void;
onDelete?: (pair: PairWithId, focusPrevious: boolean) => void;
onFocusName?: (pair: PairWithId) => void;
onFocusValue?: (pair: PairWithId) => void;
onSubmit?: (pair: PairWithId) => void;
onChange?: (pair: EditablePairWithId) => void;
onDelete?: (pair: EditablePairWithId, focusPrevious: boolean) => void;
onFocusName?: (pair: EditablePairWithId) => void;
onFocusValue?: (pair: EditablePairWithId) => void;
onSubmit?: (pair: EditablePairWithId) => void;
isLast?: boolean;
disabled?: boolean;
disableDrag?: boolean;
@@ -397,8 +465,8 @@ type PairEditorRowProps = {
>;
interface RowHandle {
focusName(): void;
focusValue(): void;
focusName(): boolean;
focusValue(): boolean;
}
export function PairEditorRow({
@@ -436,9 +504,11 @@ export function PairEditorRow({
const handle = useRef<RowHandle>({
focusName() {
nameInputRef.current?.focus();
return nameInputRef.current?.isFocused() ?? false;
},
focusValue() {
valueInputRef.current?.focus();
return valueInputRef.current?.isFocused() ?? false;
},
});
@@ -471,11 +541,37 @@ export function PairEditorRow({
[onChange, pair],
);
// The name being typed into a deferred-commit field, before it's committed or reverted
const pendingName = useRef<string | null>(null);
const handleChangeName = useMemo(
() => (name: string) => onChange?.({ ...pair, name }),
() => (name: string) => {
// Keep the edit local until commit. Writing on every keystroke would reset the editor from
// beneath the cursor, since the pairs are derived from the name being edited.
if (pair.commitName != null) pendingName.current = name;
else onChange?.({ ...pair, name });
},
[onChange, pair],
);
const revertName = useCallback(() => {
const nameInput = nameInputRef.current;
if (nameInput != null) {
const changes = { from: 0, to: nameInput.value().length, insert: pair.name };
nameInput.dispatch({ changes });
}
pendingName.current = null;
}, [pair.name]);
const handleBlurName = useCallback(() => {
if (pair.commitName == null) return;
const name = pendingName.current;
pendingName.current = null;
if (name == null || name === pair.name) return;
if (!pair.commitName(name)) revertName();
}, [pair, revertName]);
const handleChangeValueText = useMemo(
() => (value: string) => onChange?.({ ...pair, value, isFile: false }),
[onChange, pair],
@@ -551,6 +647,10 @@ export function PairEditorRow({
[setDraggableRef, setDroppableRef],
);
// Skip the trailing placeholder row. It exists to start a new pair, so a picker there would
// imply it's already a real row. `Input` handles the rest, including having no options to show.
const showOptionsPicker = !isLast;
return (
<div
ref={handleSetRef}
@@ -596,7 +696,7 @@ export function PairEditorRow({
stateKey={`name.${pair.id}.${stateKey}`}
disabled={disabled}
wrapLines={false}
readOnly={pair.readOnlyName || isDraggingGlobal}
readOnly={isDraggingGlobal}
size="sm"
required={!isLast && !!pair.enabled && !!pair.value}
validate={nameValidate}
@@ -606,12 +706,14 @@ export function PairEditorRow({
defaultValue={pair.name}
label="Name"
name={`name[${index}]`}
onBlur={handleBlurName}
onChange={handleChangeName}
onFocus={handleFocusName}
placeholder={namePlaceholder ?? "name"}
autocomplete={nameAutocomplete}
autocompleteVariables={nameAutocompleteVariables}
autocompleteFunctions={nameAutocompleteFunctions}
showOptionsPicker={showOptionsPicker}
/>
<div className="w-full grid grid-cols-[minmax(0,1fr)_auto] gap-1 items-center">
{pair.isFile ? (
@@ -656,6 +758,7 @@ export function PairEditorRow({
autocomplete={valueAutocomplete?.(pair.name)}
autocompleteFunctions={valueAutocompleteFunctions}
autocompleteVariables={valueAutocompleteVariablesFiltered}
showOptionsPicker={showOptionsPicker}
/>
)}
</div>
@@ -675,8 +778,10 @@ export function PairEditorRow({
<IconButton
iconSize="sm"
size="xs"
icon={isLast || disabled ? "empty" : "chevron_down"}
title="Select form data type"
// Ellipsis rather than a chevron: the name and value fields now carry chevrons for
// picking a suggestion, and this menu acts on the row instead of filling in a field.
icon={isLast || disabled ? "empty" : "ellipsis_vertical"}
title="More actions"
className="text-text-subtlest"
/>
</Dropdown>
@@ -800,7 +905,8 @@ function FileActionsDropdown({
<IconButton
iconSize="sm"
size="xs"
icon="chevron_down"
// Matches the plain row menu, so the actions column reads the same for file and text rows
icon="ellipsis_vertical"
title="Select form data type"
className="text-text-subtlest"
/>
@@ -808,7 +914,7 @@ function FileActionsDropdown({
);
}
function emptyPair(): PairWithId {
function emptyPair(): EditablePairWithId {
return ensurePairId({ enabled: true, name: "", value: "" });
}
@@ -1,9 +1,10 @@
import { generateId } from "../../lib/generateId";
import type { Pair, PairWithId } from "./PairEditor";
export function ensurePairId(p: Pair): PairWithId {
// NOTE: Generic so callers keep whatever they passed in (eg. an EditablePair stays editable)
export function ensurePairId<T extends Pair>(p: T): T & PairWithId {
if (typeof p.id === "string") {
return p as PairWithId;
return p as T & PairWithId;
}
return { ...p, id: p.id ?? generateId() };
}
+69 -11
View File
@@ -3,6 +3,7 @@ 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";
@@ -15,6 +16,12 @@ export interface ToastProps {
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> = {
@@ -28,7 +35,47 @@ const ICONS: Record<NonNullable<ToastProps["color"] | "custom">, IconProps["icon
warning: "alert_triangle",
};
export function Toast({ children, open, onClose, timeout, action, icon, color }: ToastProps) {
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",
() => {
@@ -56,8 +103,17 @@ export function Toast({ children, open, onClose, timeout, action, icon, color }:
"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="pl-3 py-3 pr-10 flex items-start gap-2 w-full max-h-44 overflow-auto">
<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>
@@ -65,16 +121,18 @@ export function Toast({ children, open, onClose, timeout, action, icon, color }:
</VStack>
</div>
<IconButton
color={color}
variant="border"
className="opacity-60 border-0 absolute! top-2 right-2"
title="Dismiss"
icon="x"
onClick={onClose}
/>
{!hideDismiss && (
<IconButton
color={color}
variant="border"
className="opacity-60 border-0 absolute! top-2 right-2"
title="Dismiss"
icon="x"
onClick={onClose}
/>
)}
{timeout != null && (
{timeout != null && !autoHideCanceled && (
<div className="w-full absolute bottom-0 left-0 right-0">
<m.div
className="bg-surface-highlight h-[3px]"
@@ -12,6 +12,7 @@ import { Banner, HStack, Icon, InlineCode, SplitLayout } from "@yaakapp-internal
import classNames from "classnames";
import { useCallback, useMemo, useState } from "react";
import { modelToYaml } from "../../lib/diffYaml";
import { trackFeatureUsage } from "../../lib/featureFeedback";
import { resolvedModelName } from "../../lib/resolvedModelName";
import { showConfirm } from "../../lib/confirm";
import { showErrorToast } from "../../lib/toast";
@@ -55,6 +56,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
setCommitError(null);
try {
await commit.mutateAsync({ message });
trackFeatureUsage("git-sync");
onDone();
} catch (err) {
setCommitError(String(err));
@@ -66,6 +68,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
try {
const r = await commitAndPush.mutateAsync({ message });
handlePushResult(r);
trackFeatureUsage("git-sync");
onDone();
} catch (err) {
showErrorToast({
@@ -206,9 +209,10 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
layout="horizontal"
defaultRatio={0.6}
firstSlot={({ style }) => (
<div style={style} className="h-full px-4 grid grid-rows-[auto_minmax(0,1fr)] gap-3">
<div style={style} className="h-full px-4 flex flex-col gap-3">
<CommercialUseBanner source="git-commit" title="Using Git for work?" />
<SplitLayout
className="min-h-0 flex-1"
storageKey="commit-vertical"
layout="vertical"
defaultRatio={0.35}
@@ -226,11 +230,12 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
/>
{externalEntries.find((e) => e.status !== "current") && (
<>
<Separator className="mt-3 mb-1">External file changes</Separator>
<Separator className="mt-3 mb-1">Other files</Separator>
{externalEntries.map((entry) => (
<ExternalTreeNode
key={entry.relaPath + entry.status}
entry={entry}
relaDir={status.data?.relaDir ?? ""}
onCheck={checkEntry}
/>
))}
@@ -239,7 +244,10 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
</div>
)}
secondSlot={({ style: innerStyle }) => (
<div style={innerStyle} className="grid grid-rows-[minmax(0,1fr)_auto] gap-3 pb-2">
<div
style={innerStyle}
className="grid grid-rows-[minmax(0,1fr)_auto] gap-3 pb-2"
>
<Input
className="text-base! font-sans rounded-md"
placeholder="Commit message..."
@@ -388,15 +396,22 @@ function TreeNodeChildren({
function ExternalTreeNode({
entry,
relaDir,
onCheck,
}: {
entry: GitStatusEntry;
relaDir: string;
onCheck: (entry: GitStatusEntry) => void;
}) {
if (entry.status === "current") {
return null;
}
// Show paths relative to the sync directory when inside it
const displayPath = entry.relaPath.startsWith(`${relaDir}/`)
? entry.relaPath.slice(relaDir.length + 1)
: entry.relaPath;
return (
<Checkbox
fullWidth
@@ -406,7 +421,7 @@ function ExternalTreeNode({
title={
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] gap-1 w-full items-center">
<Icon color="secondary" icon="file_code" />
<div className="truncate">{entry.relaPath}</div>
<div className="truncate">{displayPath}</div>
<InlineCode
className={classNames(
"py-0 ml-auto bg-transparent w-24 text-center",
@@ -1,7 +1,7 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import { useAtom } from "jotai";
import { useCallback, useMemo } from "react";
import { useCallback, useEffect, useMemo } from "react";
import { useLocalStorage } from "react-use";
import { useIntrospectGraphQL } from "../../hooks/useIntrospectGraphQL";
import { useStateWithDeps } from "../../hooks/useStateWithDeps";
@@ -11,9 +11,13 @@ 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"> & {
@@ -22,6 +26,8 @@ type Props = Pick<EditorProps, "heightMode" | "className" | "forceUpdateKey"> &
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.
@@ -38,25 +44,25 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
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
if ("text" in request.body) {
const b = tryParseJson(request.body.text, {});
const variables = JSON.stringify(b.variables || undefined, null, 2);
return { query: b.query ?? "", variables };
}
return { query: request.body.query ?? "", variables: request.body.variables ?? "" };
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 }) => {
const newBody = { query, variables };
setCurrentBody(({ variables, operationName }) => {
const newBody = buildGraphQLBody({ query, variables, operationName });
onChange(newBody);
return newBody;
});
@@ -66,8 +72,8 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
const handleChangeVariables = useCallback(
(variables: string) => {
setCurrentBody(({ query }) => {
const newBody = { query, variables: variables || undefined };
setCurrentBody(({ query, operationName }) => {
const newBody = buildGraphQLBody({ query, variables, operationName });
onChange(newBody);
return newBody;
});
@@ -75,125 +81,196 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
[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"]>(
() => [
<div key="actions" className="flex flex-row opacity-100! shadow!">
<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>
)}
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,
@@ -237,10 +314,23 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
);
}
function tryParseJson(text: string, fallback: unknown) {
try {
return JSON.parse(text);
} catch {
return fallback;
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;
}
@@ -4,23 +4,26 @@ import { useEffect, useState } from "react";
interface Props {
bodyPath?: string;
data?: Uint8Array;
mimeType?: string;
}
export function AudioViewer({ bodyPath, data }: Props) {
export function AudioViewer({ bodyPath, data, mimeType }: Props) {
const [src, setSrc] = useState<string>();
useEffect(() => {
if (bodyPath) {
setSrc(convertFileSrc(bodyPath));
} else if (data) {
const blob = new Blob([new Uint8Array(data)], { type: "audio/mpeg" });
// The type matters here in a way it doesn't for an image: a media element goes by what
// the blob declares rather than sniffing it, so an Ogg labelled as MP3 won't play
const blob = new Blob([new Uint8Array(data)], { type: mimeType ?? "audio/mpeg" });
const url = URL.createObjectURL(blob);
setSrc(url);
return () => URL.revokeObjectURL(url);
} else {
setSrc(undefined);
}
}, [bodyPath, data]);
}, [bodyPath, data, mimeType]);
// oxlint-disable-next-line jsx-a11y/media-has-caption
return <audio className="w-full" controls src={src} />;
@@ -71,6 +71,7 @@ function ActualEventStreamViewer({ response }: Props) {
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;
@@ -218,7 +219,7 @@ function ActualEventStreamViewer({ response }: Props) {
<SseSummaryFooter
style={style}
error={summary.error ? String(summary.error) : null}
isLoading={summary.isLoading}
isLoading={summary.isLoading && summary.data == null}
onRenderMarkdownChange={renderMarkdownSetting.set}
renderMarkdown={renderMarkdown}
resultKeyPath={summarySettings.resultKeyPath ?? ""}
@@ -2,7 +2,7 @@ import { convertFileSrc } from "@tauri-apps/api/core";
import classNames from "classnames";
import { useEffect, useState } from "react";
type Props = { className?: string } & (
type Props = { className?: string; mimeType?: string } & (
| {
bodyPath: string;
}
@@ -11,7 +11,7 @@ type Props = { className?: string } & (
}
);
export function ImageViewer({ className, ...props }: Props) {
export function ImageViewer({ className, mimeType, ...props }: Props) {
const [src, setSrc] = useState<string>();
const bodyPath = "bodyPath" in props ? props.bodyPath : null;
const data = "data" in props ? props.data : null;
@@ -20,14 +20,14 @@ export function ImageViewer({ className, ...props }: Props) {
if (bodyPath != null) {
setSrc(convertFileSrc(bodyPath));
} else if (data != null) {
const blob = new Blob([data], { type: "image/png" });
const blob = new Blob([data], { type: mimeType ?? "image/png" });
const url = URL.createObjectURL(blob);
setSrc(url);
return () => URL.revokeObjectURL(url);
} else {
setSrc(undefined);
}
}, [bodyPath, data]);
}, [bodyPath, data, mimeType]);
return (
<img
@@ -3,7 +3,7 @@ import "react-pdf/dist/Page/AnnotationLayer.css";
import { convertFileSrc } from "@tauri-apps/api/core";
import "./PdfViewer.css";
import type { PDFDocumentProxy } from "pdfjs-dist";
import { useEffect, useRef, useState } from "react";
import { useMemo, useRef, useState } from "react";
import { Document, Page } from "react-pdf";
import { useContainerSize } from "@yaakapp-internal/ui";
import { fireAndForget } from "../../lib/fireAndForget";
@@ -30,26 +30,32 @@ const options = {
export function PdfViewer({ bodyPath, data }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const [numPages, setNumPages] = useState<number>();
const [src, setSrc] = useState<string | { data: Uint8Array }>();
const { width: containerWidth } = useContainerSize(containerRef);
useEffect(() => {
// During render, not in an effect: an effect leaves the first paint with no file, and
// `Document` renders its "Failed to load PDF file" state for that frame before recovering
const src = useMemo(() => {
if (bodyPath) {
setSrc(convertFileSrc(bodyPath));
} else if (data) {
return convertFileSrc(bodyPath);
}
if (data) {
// Create a copy to avoid "Buffer is already detached" errors
// This happens when the ArrayBuffer is transferred/detached elsewhere
const dataCopy = new Uint8Array(data);
setSrc({ data: dataCopy });
} else {
setSrc(undefined);
return { data: new Uint8Array(data) };
}
return undefined;
}, [bodyPath, data]);
const onDocumentLoadSuccess = ({ numPages: nextNumPages }: PDFDocumentProxy): void => {
setNumPages(nextNumPages);
};
// Nothing to show yet, rather than the failure state `Document` renders for an empty file
if (src == null) {
return null;
}
return (
<div ref={containerRef} className="w-full h-full overflow-y-auto">
<Document
@@ -4,23 +4,25 @@ import { useEffect, useState } from "react";
interface Props {
bodyPath?: string;
data?: Uint8Array;
mimeType?: string;
}
export function VideoViewer({ bodyPath, data }: Props) {
export function VideoViewer({ bodyPath, data, mimeType }: Props) {
const [src, setSrc] = useState<string>();
useEffect(() => {
if (bodyPath) {
setSrc(convertFileSrc(bodyPath));
} else if (data) {
const blob = new Blob([new Uint8Array(data)], { type: "video/mp4" });
// As in AudioViewer: a media element trusts the declared type instead of sniffing
const blob = new Blob([new Uint8Array(data)], { type: mimeType ?? "video/mp4" });
const url = URL.createObjectURL(blob);
setSrc(url);
return () => URL.revokeObjectURL(url);
} else {
setSrc(undefined);
}
}, [bodyPath, data]);
}, [bodyPath, data, mimeType]);
// oxlint-disable-next-line jsx-a11y/media-has-caption
return <video className="w-full" controls src={src} />;
+6 -4
View File
@@ -8,10 +8,12 @@ function setFontSizeOnDocument(fontSize: number) {
document.documentElement.style.fontSize = `${fontSize}px`;
}
listen<ModelPayload>("model_write", async (event) => {
if (event.payload.change.type !== "upsert") return;
if (event.payload.model.model !== "settings") return;
setFontSizeOnDocument(event.payload.model.interfaceFontSize);
listen<ModelPayload[]>("model_writes", async (event) => {
for (const payload of event.payload) {
if (payload.change.type !== "upsert") continue;
if (payload.model.model !== "settings") continue;
setFontSizeOnDocument(payload.model.interfaceFontSize);
}
}).catch(console.error);
fireAndForget(getSettings().then((settings) => setFontSizeOnDocument(settings.interfaceFontSize)));
+6 -4
View File
@@ -12,10 +12,12 @@ function setFonts(settings: Settings) {
);
}
listen<ModelPayload>("model_write", async (event) => {
if (event.payload.change.type !== "upsert") return;
if (event.payload.model.model !== "settings") return;
setFonts(event.payload.model);
listen<ModelPayload[]>("model_writes", async (event) => {
for (const payload of event.payload) {
if (payload.change.type !== "upsert") continue;
if (payload.model.model !== "settings") continue;
setFonts(payload.model);
}
}).catch(console.error);
fireAndForget(getSettings().then((settings) => setFonts(settings)));
+24
View File
@@ -4,6 +4,7 @@ import {
websocketRequestsAtom,
} from "@yaakapp-internal/models";
import { atom, useAtomValue } from "jotai";
import { selectAtom } from "jotai/utils";
export const allRequestsAtom = atom((get) => [
...get(httpRequestsAtom),
@@ -14,3 +15,26 @@ export const allRequestsAtom = atom((get) => [
export function useAllRequests() {
return useAtomValue(allRequestsAtom);
}
const stringArrayEqual = (a: string[], b: string[]) =>
a.length === b.length && a.every((v, i) => v === b[i]);
// Identity-stable derivations so subscribers don't recompute or re-render when
// unrelated request fields change (eg. every debounced edit of a request)
export const allRequestIdsAtom = selectAtom(
allRequestsAtom,
(requests) => requests.map((r) => r.id),
stringArrayEqual,
);
export const allRequestUrlsAtom = selectAtom(
allRequestsAtom,
(requests) => {
const urls = new Set<string>();
for (const r of requests) {
if (r.url) urls.add(r.url);
}
return Array.from(urls);
},
stringArrayEqual,
);
+9 -2
View File
@@ -1,6 +1,7 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { emit } from "@tauri-apps/api/event";
import type { GrpcConnection, GrpcRequest } from "@yaakapp-internal/models";
import { flushAllModelWrites } from "@yaakapp-internal/models";
import { jotaiStore } from "../lib/jotai";
import { minPromiseMillis } from "../lib/minPromiseMillis";
import { invokeCmd } from "../lib/tauri";
@@ -22,8 +23,14 @@ export function useGrpc(
const go = useMutation<void, string>({
mutationKey: ["grpc_go", conn?.id],
mutationFn: () =>
invokeCmd<void>("cmd_grpc_go", { requestId, environmentId: environment?.id, protoFiles }),
mutationFn: async () => {
await flushAllModelWrites(); // The backend reads the request from the DB
return invokeCmd<void>("cmd_grpc_go", {
requestId,
environmentId: environment?.id,
protoFiles,
});
},
});
const send = useMutation({
+8 -10
View File
@@ -6,21 +6,19 @@ import { useMemo } from "react";
export function useParentFolders(m: Folder | HttpRequest | GrpcRequest | WebsocketRequest | null) {
const folders = useAtomValue(foldersAtom);
return useMemo(() => getParentFolders(folders, m), [folders, m]);
// Key on folderId, not the model itself, so edits to the model (eg. every URL
// keystroke replacing the active request) don't produce a new array identity
const folderId = m?.folderId ?? null;
return useMemo(() => getParentFolders(folders, folderId), [folders, folderId]);
}
function getParentFolders(
folders: Folder[],
currentModel: Folder | HttpRequest | GrpcRequest | WebsocketRequest | null,
): Folder[] {
if (currentModel == null) return [];
function getParentFolders(folders: Folder[], folderId: string | null): Folder[] {
if (folderId == null) return [];
const parentFolder = currentModel.folderId
? folders.find((f) => f.id === currentModel.folderId)
: null;
const parentFolder = folders.find((f) => f.id === folderId);
if (parentFolder == null) {
return [];
}
return [parentFolder, ...getParentFolders(folders, parentFolder)];
return [parentFolder, ...getParentFolders(folders, parentFolder.folderId ?? null)];
}
+22 -13
View File
@@ -7,24 +7,33 @@ import { jotaiStore } from "../lib/jotai";
const requestUpdateKeyAtom = atom<Record<string, string>>({});
getCurrentWebviewWindow()
.listen<ModelPayload>("model_write", ({ payload }) => {
if (payload.change.type !== "upsert") return;
.listen<ModelPayload[]>("model_writes", ({ payload: payloads }) => {
const changedIds: string[] = [];
for (const payload of payloads) {
if (payload.change.type !== "upsert") continue;
if (
(payload.model.model === "http_request" ||
payload.model.model === "grpc_request" ||
payload.model.model === "websocket_request") &&
((payload.updateSource.type === "window" &&
payload.updateSource.label !== getCurrentWebviewWindow().label) ||
payload.updateSource.type !== "window")
) {
wasUpdatedExternally(payload.model.id);
if (
(payload.model.model === "http_request" ||
payload.model.model === "grpc_request" ||
payload.model.model === "websocket_request") &&
((payload.updateSource.type === "window" &&
payload.updateSource.label !== getCurrentWebviewWindow().label) ||
payload.updateSource.type !== "window")
) {
changedIds.push(payload.model.id);
}
}
if (changedIds.length > 0) wasUpdatedExternally(changedIds);
})
.catch(console.error);
export function wasUpdatedExternally(changedRequestId: string) {
jotaiStore.set(requestUpdateKeyAtom, (m) => ({ ...m, [changedRequestId]: generateId() }));
export function wasUpdatedExternally(changedRequestIds: string | string[]) {
const ids = Array.isArray(changedRequestIds) ? changedRequestIds : [changedRequestIds];
jotaiStore.set(requestUpdateKeyAtom, (m) => {
const next = { ...m };
for (const id of ids) next[id] = generateId();
return next;
});
}
export function useRequestUpdateKey(requestId: string | null) {
@@ -6,6 +6,7 @@ import { getResponseBodySseSummary } from "../lib/responseBody";
export function useResponseBodySseSummary(response: HttpResponse, resultKeyPath: string | null) {
return useQuery<SseSummary>({
enabled: resultKeyPath != null,
placeholderData: (prev) => prev, // Keep previous data on refetch
queryKey: [
"response-body-sse-summary",
response.id,
+2 -2
View File
@@ -33,8 +33,8 @@ const syncAfterModelWrite = eagerDebounceAsync(sync, 1000);
* simply add long-lived subscribers for the lifetime of the app.
*/
function initModelListeners() {
listenToTauriEvent<ModelPayload>("model_write", (p) => {
if (isModelRelevant(p.payload.model)) syncAfterModelWrite();
listenToTauriEvent<ModelPayload[]>("model_writes", (p) => {
if (p.payload.some((payload) => isModelRelevant(payload.model))) syncAfterModelWrite();
});
}
+46
View File
@@ -0,0 +1,46 @@
import { atom } from "jotai";
import type { DropdownItem } from "../components/core/Dropdown";
import { jotaiStore } from "./jotai";
/**
* A menu opened from somewhere that can't render React.
*
* Most menus in the app are a `Dropdown` wrapped around their own trigger. This is for the
* cases where the trigger isn't a React component at all — a CodeMirror widget builds its DOM
* synchronously, so it can only hand over a position and a list of items. Same shape as
* {@link showDialog}.
*/
export interface ContextMenuInstance {
id: string;
/** Where to open, in viewport coordinates */
triggerPosition: { x: number; y: number };
/** The trigger's box, when it has one, so placement can align to its edges */
triggerRect?: Pick<DOMRect, "top" | "bottom" | "left" | "right">;
/** The element it belongs to, so clicking that doesn't read as a click outside */
triggerEl?: HTMLElement | null;
items: DropdownItem[];
}
export const contextMenusAtom = atom<ContextMenuInstance[]>([]);
export function showContextMenu({ id, ...props }: ContextMenuInstance) {
jotaiStore.set(contextMenusAtom, (m) => [...m.filter((c) => c.id !== id), { id, ...props }]);
}
/**
* Opens the menu, or closes it if this one is already open.
*
* What a trigger wants: pressing it a second time should put the menu away. Same shape as
* {@link toggleDialog}.
*/
export function toggleContextMenu({ id, ...props }: ContextMenuInstance) {
if (jotaiStore.get(contextMenusAtom).some((c) => c.id === id)) {
hideContextMenu(id);
} else {
showContextMenu({ id, ...props });
}
}
export function hideContextMenu(id: string) {
jotaiStore.set(contextMenusAtom, (m) => m.filter((c) => c.id !== id));
}
@@ -0,0 +1,15 @@
// Common Accept-Language values.
export const acceptLanguages = [
"*",
"en",
"en-US",
"en-US,en;q=0.9",
"en-GB,en;q=0.9",
"de-DE,de;q=0.9,en;q=0.8",
"fr-FR,fr;q=0.9,en;q=0.8",
"es-ES,es;q=0.9,en;q=0.8",
"ru-RU,ru;q=0.9,en;q=0.8",
"uk-UA,uk;q=0.9,en;q=0.8",
"zh-CN,zh;q=0.9,en;q=0.8",
"ja-JP,ja;q=0.9,en;q=0.8",
];
+16
View File
@@ -0,0 +1,16 @@
// Common Cache-Control directives (request and response).
export const cacheControlDirectives = [
"no-cache",
"no-store",
"no-transform",
"max-age=0",
"max-age=3600",
"max-age=86400",
"s-maxage=3600",
"must-revalidate",
"proxy-revalidate",
"stale-while-revalidate=60",
"public",
"private",
"immutable",
];
@@ -0,0 +1,7 @@
/**
* A suggested value for a header. A plain string is used when the displayed
* label and the inserted value are the same (e.g. mime types). The object form
* lets us show a short, readable `label` (e.g. "Chrome (Windows)") while
* inserting a longer `value` (the full User-Agent string).
*/
export type HeaderValuePreset = string | { label: string; value: string };
+51
View File
@@ -0,0 +1,51 @@
import type { HeaderValuePreset } from "./headerValuePresets";
// Common, real-world User-Agent strings. The short `label` is shown in the
// dropdown/autocomplete, while the full UA string is what gets inserted.
//
// Browsers freeze parts of their UA: Chrome reports its minor version as 0.0.0
// and Safari reports macOS as 10_15_7, regardless of the actual version.
export const userAgents: HeaderValuePreset[] = [
{
label: "Chrome 151 · Windows 10/11 · x64",
value:
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36",
},
{
label: "Edge 151 · Windows 10/11 · x64",
value:
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36 Edg/151.0.0.0",
},
{
label: "Firefox 153 · Windows 10/11 · x64",
value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:153.0) Gecko/20100101 Firefox/153.0",
},
{
label: "Chrome 151 · macOS",
value:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36",
},
{
label: "Safari 27 · macOS",
value:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/27.0 Safari/605.1.15",
},
{
label: "Chrome 151 · Linux · x64",
value:
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36",
},
{
label: "Safari 27 · iOS 27 · iPhone",
value:
"Mozilla/5.0 (iPhone; CPU iPhone OS 27_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/27.0 Mobile/15E148 Safari/604.1",
},
{
label: "Chrome 151 · Android 16 · Pixel 10",
value:
"Mozilla/5.0 (Linux; Android 16; Pixel 10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Mobile Safari/537.36",
},
{ label: "curl 8.21.0 · CLI", value: "curl/8.21.0" },
{ label: "Postman 7.56 · API client", value: "PostmanRuntime/7.56.1" },
{ label: "Insomnia 13.1 · API client", value: "insomnia/13.1.0" },
];
@@ -0,0 +1,67 @@
import { describe, expect, test } from "vite-plus/test";
import { docFingerprint } from "./docFingerprint";
const sampled = (text: string) => docFingerprint(text, { exact: false });
const exact = (text: string) => docFingerprint(text, { exact: true });
/** Same length as `text`, with one character changed at `at` */
const changeAt = (text: string, at: number) =>
`${text.slice(0, at)}${text[at] === "b" ? "c" : "b"}${text.slice(at + 1)}`;
describe("docFingerprint", () => {
test("is stable for the same text", () => {
expect(sampled("hello")).toBe(sampled("hello"));
expect(exact("hello")).toBe(exact("hello"));
});
test("differs on different short text", () => {
expect(sampled("hello")).not.toBe(sampled("world"));
});
test("differs on length alone", () => {
expect(sampled("a".repeat(1_000_000))).not.toBe(sampled("a".repeat(1_000_001)));
});
test("hashes small documents in full, so any change is caught", () => {
const doc = "a".repeat(100);
for (let i = 0; i < doc.length; i++) {
expect(sampled(doc)).not.toBe(sampled(changeAt(doc, i)));
}
});
describe("sampled", () => {
const doc = "a".repeat(1_000_000);
test("notices a change at the start", () => {
expect(sampled(doc)).not.toBe(sampled(changeAt(doc, 0)));
});
test("notices a change at the end", () => {
expect(sampled(doc)).not.toBe(sampled(changeAt(doc, doc.length - 1)));
});
test("notices a change in the middle", () => {
expect(sampled(doc)).not.toBe(sampled(changeAt(doc, doc.length / 2)));
});
test("misses a same-length change between the samples", () => {
// The documented cost of sampling. Read-only editors accept it because the worst a
// collision can do there is restore a fold or the cursor to the wrong place.
expect(sampled(doc)).toBe(sampled(changeAt(doc, 250_000)));
});
});
describe("exact", () => {
const doc = "a".repeat(1_000_000);
test("catches a same-length change anywhere, which is why editable docs use it", () => {
for (const at of [0, 250_000, doc.length / 2, 750_000, doc.length - 1]) {
expect(exact(doc)).not.toBe(exact(changeAt(doc, at)));
}
});
test("does not collide with the sampled fingerprint of the same text", () => {
expect(exact(doc)).not.toBe(sampled(doc));
});
});
});
+33
View File
@@ -0,0 +1,33 @@
import { md5 } from "js-md5";
/** How much of each end and the middle to hash */
const SAMPLE_CHARS = 512;
/**
* Identifies a document, so a cached editor state is never restored onto different content.
*
* Hashing the whole document costs about 4 ms per megabyte and is paid on every editor update
* as well as on restore, which is a lot of work for a document nobody can edit.
*
* `exact` decides how much certainty that buys. Editable documents get a full hash, because a
* collision there would restore undo history belonging to other content and let an undo write
* nonsense into the body. Read-only documents get a sampled one: they hold the megabyte-sized
* responses this exists for, their history can never be applied, and the worst a collision can
* do is put a fold or the cursor in the wrong place.
*
* Sampling still pins the exact length plus three windows, so a colliding pair has to agree on
* all four and differ only in between. Documents small enough to hash outright still are.
*/
export function docFingerprint(text: string, { exact }: { exact: boolean }): string {
if (exact || text.length <= SAMPLE_CHARS * 3) {
return `${text.length}:${md5(text)}`;
}
const middle = Math.floor((text.length - SAMPLE_CHARS) / 2);
return [
text.length,
md5(text.slice(0, SAMPLE_CHARS)),
md5(text.slice(middle, middle + SAMPLE_CHARS)),
md5(text.slice(-SAMPLE_CHARS)),
].join(":");
}
+103
View File
@@ -0,0 +1,103 @@
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);
}
@@ -0,0 +1,8 @@
// 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;
@@ -0,0 +1,37 @@
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();
});
});
@@ -0,0 +1,26 @@
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;
}
}
+246
View File
@@ -0,0 +1,246 @@
import { save } from "@tauri-apps/plugin-dialog";
import { Icon } from "@yaakapp-internal/ui";
import mime from "mime";
import { createElement } from "react";
import type { DropdownItem } from "../components/core/Dropdown";
import type { SniffedValue } from "../components/core/Editor/sniffValue";
import { isEncodedRun } from "../components/core/Editor/sniffValue";
import { copyToClipboard } from "./copy";
import { fireAndForget } from "./fireAndForget";
import { invokeCmd } from "./tauri";
import { showToast } from "./toast";
/**
* How the value is written, which is the thing worth knowing about it — that it is base64
* explains why it reads as gibberish far better than its length does.
*
* A value we couldn't identify may still be plainly encoded, so it is checked here too: an
* unrecognised format and an unrecognised encoding are different things to be told.
*
* `head` need only be the first {@link SNIFF_HEAD_CHARS} characters of the value.
*/
export function encodingLabel(head: string, sniffed: SniffedValue | null, chars: number): string {
const size = `${chars.toLocaleString()} chars`;
if (sniffed?.encoding === "percent") {
return `Percent-encoded · ${size}`;
}
const encoded = sniffed?.encoding === "base64" || isEncodedRun(head, sniffed?.offset ?? 0);
return encoded ? `Base64 · ${size}` : size;
}
interface ActionOptions {
/** The whole value. A function, so opening a menu never copies megabytes to build it. */
value: () => string;
sniffed: SniffedValue | null;
/** What to copy. The tag copies only what it hides; the viewer copies what it shows. */
copyText: () => string;
/** Left out inside the viewer itself, where there is nothing further to open */
onView?: () => void;
}
/**
* The things you can do with a collapsed value, as menu items.
*
* Shared so the tag in the editor and the dialog it opens offer the same list, rather than
* drifting apart.
*/
export function largeValueActions({
value,
sniffed,
copyText,
onView,
}: ActionOptions): DropdownItem[] {
const items: DropdownItem[] = [];
if (onView != null) {
items.push({
label: sniffed == null ? "View" : `View ${sniffed.label}`,
leftSlot: createElement(Icon, { icon: "eye" }),
onSelect: onView,
});
}
// Two ways to copy a picture, because either can be the one you wanted: the image to paste
// somewhere that takes one, or the text to paste back into a request
const image = isCopyableImage(sniffed);
if (image) {
items.push({
label: "Copy Image",
leftSlot: createElement(Icon, { icon: "copy" }),
onSelect: () => copyImage(value(), sniffed, copyText()),
});
}
items.push({
label: sniffed?.encoding === "base64" ? "Copy Base64" : "Copy",
// Second of a pair reads as one thing with two options, so it keeps the indent without
// repeating the icon above it
leftSlot: createElement(Icon, { icon: image ? "empty" : "copy" }),
onSelect: () => copyToClipboard(copyText()),
});
items.push({
label: "Save to File",
leftSlot: createElement(Icon, { icon: "download" }),
onSelect: () =>
fireAndForget(saveValue(value(), sniffed, sniffed?.label.toLowerCase() ?? "value")),
});
return items;
}
/** The payload, with the `data:` header taken off. */
function payloadOf(text: string, sniffed: SniffedValue): string {
return text.slice(sniffed.offset);
}
/**
* A payload every strict base64 decoder will accept.
*
* The url-safe alphabet stands for the same bytes, and a ragged final group is dropped rather
* than padded — it can only ever be worth a single byte, and both `atob` and the Rust decoder
* behind the save command reject one outright.
*/
function normalizeBase64(payload: string): string {
const clean = payload.replace(/\s+/g, "").replaceAll("-", "+").replaceAll("_", "/");
return clean.slice(0, clean.length - (clean.length % 4));
}
/**
* The bytes a collapsed value stands for.
*
* `atob` rather than `Uint8Array.fromBase64`, which is too new to rely on across the webviews we
* ship on. Only ever called for something the user asked to see, never during layout.
*/
export function decodeValue(text: string, sniffed: SniffedValue): Uint8Array<ArrayBuffer> {
const payload = payloadOf(text, sniffed);
if (sniffed.encoding === "percent") {
return new TextEncoder().encode(decodeURIComponent(payload));
}
const binary = atob(normalizeBase64(payload));
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
/**
* Whether the clipboard can take this as a picture rather than as text.
*
* SVG is left out on purpose: it is markup, so it pastes usefully as text, and rasterising it
* would throw away the thing that makes it worth having.
*/
function isCopyableImage(sniffed: SniffedValue | null): sniffed is SniffedValue {
return (
sniffed != null && sniffed.mime.startsWith("image/") && !sniffed.mime.startsWith("image/svg")
);
}
/**
* The value as a PNG.
*
* Not a preference: PNG is the only image format the Clipboard API requires a browser to accept
* on write — `text/plain`, `text/html` and `image/png` are the spec's mandatory data types — and
* engines reject `image/jpeg` outright. So anything else is rasterised through a canvas, which
* costs a decode and an encode but means a JPEG or a WEBP pastes just as readily as a PNG.
*
* `ClipboardItem.supports()` exists to ask whether a format could be written as it stands, and
* skipping the conversion would be worth real time on a large photo. It was tried: WebKit says
* no to `image/jpeg`, so the check only ever chose PNG and was removed again.
*
* The re-encode is lossless, so nothing is degraded, but a photograph lands on the clipboard far
* larger than its JPEG. That matters less than it looks: the system pasteboard holds images
* uncompressed regardless of what we hand it.
*/
async function toPngBlob(text: string, sniffed: SniffedValue): Promise<Blob> {
const blob = new Blob([decodeValue(text, sniffed)], { type: sniffed.mime });
if (sniffed.mime === "image/png") {
return blob;
}
const bitmap = await createImageBitmap(blob);
const canvas = document.createElement("canvas");
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const ctx = canvas.getContext("2d");
if (ctx == null) {
throw new Error("Could not get a canvas to convert the image");
}
ctx.drawImage(bitmap, 0, 0);
bitmap.close();
return await new Promise((resolve, reject) => {
canvas.toBlob(
(png) => (png == null ? reject(new Error("Could not encode the image")) : resolve(png)),
"image/png",
);
});
}
/**
* Puts the picture on the clipboard, so it can be pasted anywhere that takes an image.
*
* Not awaited, and deliberately so. The webview only allows a clipboard write that starts inside
* the click that asked for it, and decoding a few megabytes takes longer than that lasts — so
* the item is handed the still-pending promise rather than a finished blob.
*/
export function copyImage(text: string, sniffed: SniffedValue, fallback: string) {
if (typeof ClipboardItem === "undefined") {
copyToClipboard(fallback);
return;
}
const png = toPngBlob(text, sniffed);
navigator.clipboard
.write([new ClipboardItem({ "image/png": png })])
.then(() =>
showToast({
id: "copied",
color: "success",
icon: "copy",
// Says PNG because that is what lands on the clipboard, whatever the value held
message: "Copied as PNG",
}),
)
.catch((err: unknown) => {
// Anything from a webview that won't take an image to a file we couldn't decode. The
// encoded text is always there to fall back on.
console.error("Failed to copy image, copying text instead", err);
copyToClipboard(fallback);
});
}
/** Base64 of a byte array, in chunks so a few megabytes don't blow the argument limit. */
function toBase64(bytes: Uint8Array): string {
let binary = "";
for (let i = 0; i < bytes.length; i += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
}
return btoa(binary);
}
/**
* Writes a collapsed value to a file the user picks.
*
* A value that is already base64 goes straight to the backend as-is — decoding it here only to
* encode it again would walk megabytes twice for nothing.
*/
export async function saveValue(text: string, sniffed: SniffedValue | null, name: string) {
const ext = sniffed == null ? "txt" : (mime.getExtension(sniffed.mime) ?? "bin");
const filepath = await save({ defaultPath: `${name}.${ext}`, title: "Save Value" });
if (filepath == null) {
return; // Cancelled
}
const data =
sniffed == null
? toBase64(new TextEncoder().encode(text))
: sniffed.encoding === "base64"
? normalizeBase64(payloadOf(text, sniffed))
: toBase64(decodeValue(text, sniffed));
await invokeCmd("cmd_save_base64_to_binary", { filepath, data });
showToast({ message: `Saved to ${filepath}` });
}
+187 -1
View File
@@ -1,5 +1,9 @@
import { describe, expect, test } from "vite-plus/test";
import { extractPathPlaceholders } from "./pathPlaceholders";
import {
derivePathPlaceholderPairs,
extractPathPlaceholders,
renamePathPlaceholder,
} from "./pathPlaceholders";
describe("extractPathPlaceholders", () => {
test("extracts a single placeholder", () => {
@@ -26,3 +30,185 @@ describe("extractPathPlaceholders", () => {
expect(extractPathPlaceholders("https://example.com/foo/bar?q=1#hash")).toEqual([]);
});
});
describe("derivePathPlaceholderPairs", () => {
const neverRename = () => false;
test("adds a row for a placeholder with no parameter", () => {
const { urlParameterPairs } = derivePathPlaceholderPairs("/users/:id", [], neverRename);
expect(urlParameterPairs).toMatchObject([{ name: ":id", value: "", enabled: true }]);
expect(urlParameterPairs[0]?.commitName).toBeTypeOf("function");
});
test("gives the existing parameter for a placeholder a commitName, without mutating it", () => {
const parameter = { name: ":id", value: "123", enabled: true, id: "p1" };
const { urlParameterPairs } = derivePathPlaceholderPairs(
"/users/:id",
[parameter],
neverRename,
);
expect(urlParameterPairs[0]).toMatchObject({ name: ":id", value: "123", id: "p1" });
expect(urlParameterPairs[0]?.commitName).toBeTypeOf("function");
expect(parameter).toEqual({ name: ":id", value: "123", enabled: true, id: "p1" });
});
test("leaves query parameters alone", () => {
const { urlParameterPairs } = derivePathPlaceholderPairs(
"/users/:id",
[{ name: "q", value: "hi", enabled: true, id: "p1" }],
neverRename,
);
expect(urlParameterPairs[0]).toEqual({ name: "q", value: "hi", enabled: true, id: "p1" });
expect(urlParameterPairs[1]?.commitName).toBeTypeOf("function");
});
test("commitName renames this row's placeholder", () => {
const renames: [string, string][] = [];
const { urlParameterPairs } = derivePathPlaceholderPairs(
"/a/:x/b/:y",
[],
(oldName, newName) => {
renames.push([oldName, newName]);
return true;
},
);
urlParameterPairs[1]?.commitName?.(":z");
expect(renames).toEqual([[":y", ":z"]]);
});
test("drops empty parameters", () => {
const { urlParameterPairs } = derivePathPlaceholderPairs(
"/users",
[
{ name: "", value: "", enabled: true, id: "p1" },
{ name: "q", value: "", enabled: true, id: "p2" },
],
neverRename,
);
expect(urlParameterPairs).toMatchObject([{ name: "q", id: "p2" }]);
});
test("collapses a placeholder that appears twice into one row", () => {
const { urlParameterPairs } = derivePathPlaceholderPairs("/a/:id/b/:id", [], neverRename);
expect(urlParameterPairs).toMatchObject([{ name: ":id" }]);
});
test("gives a derived row the same id every time, so re-deriving is stable", () => {
const first = derivePathPlaceholderPairs("/users/:id", [], neverRename);
const second = derivePathPlaceholderPairs("/users/:id", [], neverRename);
expect(first.urlParameterPairs[0]?.id).toEqual(second.urlParameterPairs[0]?.id);
});
test("derived row ids avoid colliding with a persisted derived id", () => {
// A derived id sticks to the parameter once the user gives the row a value. If its placeholder
// is then renamed away in the URL bar, the parameter survives as a stray still holding the id,
// and the replacement placeholder's row must not collide with it.
const stray = { name: ":old", value: "42", enabled: true, id: "path-placeholder:0" };
const { urlParameterPairs } = derivePathPlaceholderPairs("/pets/:new", [stray], neverRename);
const ids = urlParameterPairs.map((p) => p.id);
expect(new Set(ids).size).toEqual(ids.length);
});
test("keeps a derived row's id stable across a rename", () => {
const before = derivePathPlaceholderPairs("/a/:x/b/:y", [], neverRename);
const after = derivePathPlaceholderPairs("/a/:x2/b/:y", [], neverRename);
expect(after.urlParameterPairs.map((p) => p.id)).toEqual(
before.urlParameterPairs.map((p) => p.id),
);
});
test("keys off the placeholder names", () => {
expect(derivePathPlaceholderPairs("/a/:x/b/:y", [], neverRename).urlParametersKey).toEqual(
":x,:y",
);
expect(derivePathPlaceholderPairs("/a/b", [], neverRename).urlParametersKey).toEqual("");
});
});
describe("renamePathPlaceholder", () => {
const model = (url: string, urlParameters: { name: string; value: string }[] = []) => ({
url,
urlParameters,
});
test("renames the placeholder in the URL", () => {
expect(
renamePathPlaceholder(model("https://x.com/pets/:petId/info"), ":petId", ":animalId"),
).toEqual({ url: "https://x.com/pets/:animalId/info", urlParameters: [] });
});
test("carries the parameter value over to the new name", () => {
const patch = renamePathPlaceholder(
model("/pets/:petId", [
{ name: "q", value: "1" },
{ name: ":petId", value: "42" },
]),
":petId",
":animalId",
);
expect(patch).toEqual({
url: "/pets/:animalId",
urlParameters: [
{ name: "q", value: "1" },
{ name: ":animalId", value: "42" },
],
});
});
test("renames every occurrence of a repeated placeholder", () => {
expect(renamePathPlaceholder(model("/a/:id/b/:id"), ":id", ":key")?.url).toEqual(
"/a/:key/b/:key",
);
});
test("adds a missing leading colon", () => {
expect(renamePathPlaceholder(model("/pets/:petId"), ":petId", "animalId")?.url).toEqual(
"/pets/:animalId",
);
});
test("renames a placeholder followed by a literal colon", () => {
expect(renamePathPlaceholder(model("/tasks/:id:cancel"), ":id", ":taskId")?.url).toEqual(
"/tasks/:taskId:cancel",
);
});
test("does not rename a placeholder the new name is a prefix of", () => {
expect(renamePathPlaceholder(model("/a/:id/b/:idx"), ":id", ":key")?.url).toEqual(
"/a/:key/b/:idx",
);
});
test("does not touch a same-named segment that isn't a placeholder", () => {
expect(renamePathPlaceholder(model("/id/:id?x=:id"), ":id", ":key")?.url).toEqual(
"/id/:key?x=:id",
);
});
test("treats regex characters in the old name literally", () => {
expect(renamePathPlaceholder(model("/a/:i.d/b/:iXd"), ":i.d", ":key")?.url).toEqual(
"/a/:key/b/:iXd",
);
});
test.each([[""], [":"], [":a/b"], [":a?b"], [":a#b"], [":a:b"], [":a b"], [":a\tb"]])(
"rejects the unusable name %j",
(name) => {
expect(renamePathPlaceholder(model("/pets/:petId"), ":petId", name)).toBeNull();
},
);
test("rejects a name already used by another placeholder", () => {
expect(renamePathPlaceholder(model("/pets/:petId/:ownerId"), ":petId", ":ownerId")).toBeNull();
});
test("allows renaming a placeholder to itself", () => {
expect(renamePathPlaceholder(model("/pets/:petId"), ":petId", ":petId")?.url).toEqual(
"/pets/:petId",
);
});
test("rejects renaming a placeholder that isn't in the URL", () => {
expect(renamePathPlaceholder(model("/pets/:petId"), ":other", ":animalId")).toBeNull();
});
});
+88
View File
@@ -1,3 +1,6 @@
import type { HttpUrlParameter } from "@yaakapp-internal/models";
import type { EditablePair } from "../components/core/PairEditor";
/**
* Extract `:name`-style path placeholders from a URL string.
*
@@ -12,3 +15,88 @@
export function extractPathPlaceholders(url: string): string[] {
return Array.from(url.matchAll(/\/(:[^/?#:]+)/g)).map((m) => m[1] ?? "");
}
/**
* Build the rows for the Params tab: the request's URL parameters, plus a row for each path
* placeholder in the URL that doesn't have one yet. A placeholder that appears more than once
* in the URL still gets a single row.
*
* Only placeholder rows get a `commitName`, which makes the editor hold name edits until blur and
* hand them to `renamePlaceholder` instead of writing on every keystroke — renaming has to rewrite
* the URL too. `renamePlaceholder` returns false to reject the new name, which reverts the field.
*
* `urlParametersKey` changes whenever the URL's placeholders do, and is used to reset the pair
* editor so derived rows appear and disappear along with the URL.
*/
export function derivePathPlaceholderPairs(
url: string,
urlParameters: HttpUrlParameter[],
renamePlaceholder: (oldName: string, newName: string) => boolean,
): { urlParameterPairs: EditablePair[]; urlParametersKey: string } {
const placeholderNames = extractPathPlaceholders(url);
const commitNameFor = (oldName: string) => (newName: string) =>
renamePlaceholder(oldName, newName);
// NOTE: Copy each parameter because `commitName` is UI-only. Adding it in place would mutate the
// persisted model.
const urlParameterPairs: EditablePair[] = urlParameters
.filter((p) => p.name || p.value)
.map((p) =>
placeholderNames.includes(p.name) ? { ...p, commitName: commitNameFor(p.name) } : { ...p },
);
// NOTE: Ids are derived from the placeholder's position instead of generated, so neither
// re-deriving nor renaming hands a row a new identity. The pair editor keys rows by id, so a
// changed id remounts the row and drops the user's focus.
//
// A derived id sticks to the parameter once the user gives the row a value, so a parameter that
// outlives its placeholder (renamed away in the URL bar) still holds one. Skip past taken ids
// so a new placeholder at that position can't collide with it.
const takenIds = new Set(urlParameterPairs.map((p) => p.id));
const uniquePlaceholderNames = [...new Set(placeholderNames)];
for (const [index, name] of uniquePlaceholderNames.entries()) {
if (urlParameterPairs.some((p) => p.name === name)) continue;
let id = `path-placeholder:${index}`;
for (let bump = index + 1; takenIds.has(id); bump++) id = `path-placeholder:${bump}`;
takenIds.add(id);
urlParameterPairs.push({ name, value: "", enabled: true, commitName: commitNameFor(name), id });
}
return { urlParameterPairs, urlParametersKey: placeholderNames.join(",") };
}
/**
* Compute the patch for renaming a path placeholder: every occurrence replaced in the URL, and
* the matching URL parameter renamed so the user's value follows along. Both have to be applied
* together, or the value detaches from the placeholder.
*
* Returns `null` when the rename can't be applied, meaning the caller should leave the model
* alone. That's the case when the new name wouldn't parse as a placeholder anymore (empty, or
* containing `/`, `?`, `#`, `:`, or whitespace) or when it's already used by another placeholder
* in the URL. A missing leading `:` is added rather than rejected, since focusing the name field
* selects all of its text and typing over it is the natural way to rename.
*/
export function renamePathPlaceholder(
model: { url: string; urlParameters: HttpUrlParameter[] },
oldName: string,
newName: string,
): { url: string; urlParameters: HttpUrlParameter[] } | null {
const name = newName.startsWith(":") ? newName : `:${newName}`;
if (!/^:[^/?#:\s]+$/.test(name)) return null;
const placeholderNames = extractPathPlaceholders(model.url);
if (!placeholderNames.includes(oldName)) return null;
if (name !== oldName && placeholderNames.includes(name)) return null;
const pattern = new RegExp(`(/)${escapeRegExp(oldName)}(?=[/?#:]|$)`, "g");
return {
url: model.url.replace(pattern, (_match, slash: string) => `${slash}${name}`),
urlParameters: model.urlParameters.map((p) => (p.name === oldName ? { ...p, name } : p)),
};
}
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
@@ -0,0 +1,152 @@
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({});
});
});
@@ -0,0 +1,199 @@
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);
}
+2
View File
@@ -45,9 +45,11 @@ type TauriCmd =
| "cmd_plugin_init_errors"
| "cmd_reload_plugins"
| "cmd_render_template"
| "cmd_save_base64_to_binary"
| "cmd_save_response"
| "cmd_secure_template"
| "cmd_send_ephemeral_request"
| "cmd_send_feedback"
| "cmd_send_http_request"
| "cmd_template_function_summaries"
| "cmd_template_function_config"
+5 -3
View File
@@ -28,15 +28,17 @@ export function showToast({
setTimeout(() => {
const newToast: ToastInstance = { id, uniqueKey, timeout, ...props };
if (timeout != null) {
setTimeout(() => hideToast(newToast), timeout);
}
jotaiStore.set(toastsAtom, (prev) => [...prev, newToast]);
}, delay);
return id;
}
export function hideToastById(id: string) {
const toast = jotaiStore.get(toastsAtom).find((t) => t.id === id);
if (toast) hideToast(toast);
}
export function hideToast(toHide: ToastInstance) {
jotaiStore.set(toastsAtom, (all) => {
const t = all.find((t) => t.uniqueKey === toHide.uniqueKey);
+2 -2
View File
@@ -30,7 +30,7 @@
"@shopify/lang-jsonc": "^1.0.1",
"@tanstack/react-query": "^5.90.5",
"@tanstack/react-router": "^1.133.13",
"@tanstack/react-virtual": "^3.13.12",
"@tanstack/react-virtual": "^3.14.9",
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2.7.1",
@@ -57,7 +57,7 @@
"lucide-react": "^0.525.0",
"mime": "^4.0.4",
"motion": "^12.4.7",
"nanoid": "^5.0.9",
"nanoid": "^5.1.16",
"papaparse": "^5.4.1",
"parse-color": "^1.0.0",
"react": "^19.2.0",
+4
View File
@@ -12,6 +12,9 @@ import { queryClient } from "../lib/queryClient";
const Toasts = lazy(() => import("../components/Toasts").then((m) => ({ default: m.Toasts })));
const Dialogs = lazy(() => import("../components/Dialogs").then((m) => ({ default: m.Dialogs })));
const ContextMenus = lazy(() =>
import("../components/ContextMenus").then((m) => ({ default: m.ContextMenus })),
);
export const Route = createRootRoute({
component: RouteComponent,
@@ -29,6 +32,7 @@ function RouteComponent() {
<Suspense>
<Toasts />
<Dialogs />
<ContextMenus />
</Suspense>
<Layout />
<GlobalHooks />
+7 -5
View File
@@ -46,11 +46,13 @@ async function configureThemeAndShow() {
}
// 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;
listen<ModelPayload[]>("model_writes", async (event) => {
const relevant = event.payload.some(
(p) =>
p.change.type === "upsert" &&
(p.model.model === "settings" || p.model.model === "plugin"),
);
if (!relevant) return;
await configureThemeAndShow();
}).catch(console.error);
@@ -0,0 +1,172 @@
---
name: use-yaak
description: >
Build and run HTTP API requests with the Yaak CLI (`yaak`): create workspaces,
folders, environments and variables, author HTTP requests, configure
authentication (OAuth 2.0, bearer tokens, API keys, basic, JWT, AWS SigV4),
send them individually or a whole folder/workspace at once, chain one
request's response into the next, and import existing APIs from OpenAPI,
Postman, Insomnia, or cURL. Use this skill whenever the user mentions Yaak, a
Yaak workspace, or the `yaak` command, and also when they ask to try, hit,
call, exercise, or smoke test an HTTP or REST endpoint, to save or organize
API requests for reuse, to set up API requests for manual testing, to add auth
to a saved request, to turn an OpenAPI or Postman collection into runnable
requests, or to run a saved request suite against staging versus production.
Prefer this over one-off `curl` commands whenever the requests should be
saved, reused, shared, or run as a set.
allowed-tools: Bash(yaak:*), Bash(which:*), Bash(command:*), Bash(npm:*), Bash(npx:*)
---
# Use Yaak
<!--
Managed by the Yaak CLI. `yaak agent install` replaces this file wholesale on
every run, so local edits are lost. To add your own guidance, write a separate
skill or use your tool's project instructions instead.
yaak-cli-version: __YAAK_CLI_VERSION__
-->
Yaak is a desktop API client. The `yaak` CLI reads and writes the **same local
database as the desktop app**, so anything you create shows up in the app
immediately, and vice versa. There is no server and no sign-in: `yaak auth` is
only for publishing plugins to the Yaak registry.
Two consequences worth holding onto. Requests you create are permanent user data
in an app they use, not scratch files, so name them the way the user would and
clean up anything created just to test. And because the app is right there, the
CLI is usually the wrong place to *read* a response in detail; it is the right
place to build, organize, and run requests.
## The CLI describes itself
**This skill deliberately does not list fields, body types, auth strategies, or
template functions.** The user's CLI version and installed plugins decide what
exists, so any list written here would eventually be wrong. Ask the CLI:
```bash
yaak --help # commands, plus agent hints at the bottom
yaak <command> --help # flags for one command
yaak request schema http --pretty # full request model, with guidance per field
yaak template-function list [filter] # template functions from installed plugins
yaak template-function show <name> # one function's arguments
```
`request schema http` is generated from the real model and merges in the auth
strategies contributed by plugins, so it is the authoritative answer for what a
request payload may contain and what each auth strategy needs. `workspace`,
`environment`, and `folder` have `schema` subcommands too.
Read the relevant schema before writing a JSON payload you are not certain of.
That is faster than a failed send, and it stays correct as Yaak changes.
## Resource model
- **Workspace** (`wk_…`) is the top-level container.
- **Folder** (`fl_…`) groups requests and can nest. Folders carry headers and
authentication that child requests inherit, which is the usual way to apply
one token to a whole group.
- **Request** (`rq_…`) is a single HTTP, gRPC, or WebSocket request. The CLI can
currently only create and send HTTP ones.
- **Environment** (`ev_…`) holds variables. Each workspace has a base
environment plus any number of sub-environments; a sub-environment overrides
base variables of the same name and is chosen per send with `-e`.
- **Cookie jar** (`cj_…`) stores cookies per workspace. The oldest is used by
default, so this normally needs no attention.
IDs are prefix-typed, so you can always tell what an ID refers to. Commands that
take a workspace ID infer it when exactly one workspace exists.
## Getting oriented
```bash
yaak --version || npm install -g @yaakapp/cli
yaak workspace list
```
Pick the workspace matching the user's project before changing anything, and
create one only when nothing fits.
### Skill freshness
The CLI writes this skill, so an upgraded CLI can leave it behind. That failure
is silent: nothing errors, the skill just stops mentioning things the CLI can
now do. Check once per session, alongside the commands above:
```bash
yaak --help 2>&1 | grep -A2 "Agent tooling:"
```
If it reports the skill is out of date, run `yaak agent install` and tell the
user to restart their coding tool. This session keeps running on the old copy
until they do, so finish the current request either way. Check once and do not
re-run it after acting.
**When the CLI and this skill disagree, the CLI is right.**
## Core workflows
**Start from a spec when one exists.** `yaak import <file>` auto-detects OpenAPI,
Swagger, Postman, Insomnia, cURL, and Yaak exports, and beats authoring requests
by hand every time.
**Make the host swappable.** Put the base URL in a base-environment variable,
reference it as `${[ base_url ]}`, then add a sub-environment per deployment
target. Now `yaak -e ev_staging send <wk_id>` runs everything against staging.
**Chain instead of shell-plumbing.** A request can read another request's
response directly, and Yaak sends the dependency first if it needs to:
```
${[ response.body.path(request='rq_login', path='$.token') ]}
```
Run `yaak template-function show response.body.path` for its arguments,
including how to control when the upstream request re-sends. Chain when a
request genuinely depends on another's response; to merely run requests in
order, `yaak send <fl_id>` already does that.
**Run a set.** `yaak send` accepts a folder or workspace ID, with `--fail-fast`
and `--parallel`. Workspace and request IDs survive an export/import, so a
committed `yaak export` plus `--data-dir ./.yaak` gives a runnable suite in CI.
## Reading results
A plain send writes only the response body to stdout. Yaak also stores every
response, so the reliable way to see what happened is to ask afterwards rather
than to parse the send output:
```bash
yaak response show rq_abc123 # latest response for a request, as JSON
yaak response list rq_abc123 # its history, newest first
yaak response body rq_abc123 # just the body
```
`response show` gives status, reason, timing, headers, the final URL, and any
transport error. Pass a response ID for a specific one.
`-v` on a send prints the same information prefixed `*`, `>`, and `<`, with the
body after the last `<` header line:
```bash
yaak -v request send rq_abc123 2>&1 | grep '^< HTTP'
```
That works, but `response show` is still better when you need to act on the
result, since it gives you fields rather than text to parse.
Exit code 1 means the send did not complete: an unresolved template variable, an
unreachable host, a TLS failure. **HTTP error statuses are not failures.** Like
`curl`, a 404 or 500 exits 0, and a folder of requests that all return 500
reports success. Never tell the user an API is healthy based on a clean exit;
check the status.
## Execution rules
1. Resolve the workspace before mutating, and prefer an existing one.
2. Read the schema rather than guessing field names, auth fields, or body shapes.
3. `update` takes a JSON merge patch keyed by `id`: send only what changes, and
note that arrays are replaced wholesale, not merged.
4. Deletes need `--yes` in a non-interactive shell. Confirm with the user first.
5. Never write a real secret into an environment variable on the user's behalf.
Reference one and let them fill in the value.
6. Verify what you built by sending it, and report the real HTTP status.
+115 -6
View File
@@ -1,17 +1,17 @@
use clap::{Args, Parser, Subcommand, ValueEnum};
use std::path::PathBuf;
pub const AGENT_HINTS: &str = r#"Agent Hints:
- Template variable syntax is ${[ my_var ]}, not {{ ... }}
- Template function syntax is ${[ namespace.my_func(a='aaa',b='bbb') ]}
- View JSONSchema for models before creating or updating (eg. `yaak request schema http`)
- Deletion requires confirmation (--yes for non-interactive environments)"#;
#[derive(Parser)]
#[command(name = "yaak")]
#[command(about = "Yaak CLI - API client from the command line")]
#[command(version = crate::version::cli_version())]
#[command(disable_help_subcommand = true)]
#[command(after_help = r#"Agent Hints:
- Template variable syntax is ${[ my_var ]}, not {{ ... }}
- Template function syntax is ${[ namespace.my_func(a='aaa',b='bbb') ]}
- View JSONSchema for models before creating or updating (eg. `yaak request schema http`)
- Deletion requires confirmation (--yes for non-interactive environments)
"#)]
pub struct Cli {
/// Use a custom data directory
#[arg(long, global = true)]
@@ -39,6 +39,9 @@ pub struct Cli {
#[derive(Subcommand)]
pub enum Commands {
/// Install Yaak skills for AI coding agents
Agent(AgentArgs),
/// Authentication commands
Auth(AuthArgs),
@@ -82,6 +85,105 @@ pub enum Commands {
/// Environment commands
Environment(EnvironmentArgs),
/// Template function commands
#[command(alias = "func")]
TemplateFunction(TemplateFunctionArgs),
/// Response commands
Response(ResponseArgs),
}
#[derive(Args)]
pub struct ResponseArgs {
#[command(subcommand)]
pub command: ResponseCommands,
}
#[derive(Subcommand)]
pub enum ResponseCommands {
/// List stored responses for a request or workspace
List {
/// Request or workspace ID (optional when exactly one workspace exists)
id: Option<String>,
/// Maximum number of responses to return, newest first
#[arg(long)]
limit: Option<u64>,
},
/// Show a response as JSON, including status, timing, and headers
Show {
/// Response ID, or a request ID to use its most recent response
id: String,
},
/// Write a stored response body to stdout
Body {
/// Response ID, or a request ID to use its most recent response
id: String,
},
/// Delete stored responses for a request or workspace
Delete {
/// Request or workspace ID
id: String,
/// Skip confirmation prompt
#[arg(short, long)]
yes: bool,
},
}
#[derive(Args)]
pub struct TemplateFunctionArgs {
#[command(subcommand)]
pub command: TemplateFunctionCommands,
}
#[derive(Subcommand)]
pub enum TemplateFunctionCommands {
/// List template functions provided by installed plugins
List {
/// Only show functions whose name contains this text
#[arg(value_name = "FILTER")]
filter: Option<String>,
},
/// Show a template function's arguments as JSON
Show {
/// Template function name (for example: response.body.path)
name: String,
/// Pretty-print JSON output
#[arg(long)]
pretty: bool,
},
}
#[derive(Args)]
pub struct AgentArgs {
#[command(subcommand)]
pub command: AgentCommands,
}
#[derive(Subcommand)]
pub enum AgentCommands {
/// Install the Yaak skill so coding agents know how to drive the CLI
#[command(alias = "update", alias = "add")]
Install {
/// Install for specific agents instead of all detected ones
#[arg(long = "agent", value_name = "AGENT")]
agent: Option<Vec<String>>,
},
/// Remove the Yaak skill
#[command(alias = "uninstall", alias = "rm")]
Remove {
/// Remove for specific agents instead of all detected ones
#[arg(long = "agent", value_name = "AGENT")]
agent: Option<Vec<String>>,
},
}
#[derive(Args)]
@@ -328,6 +430,13 @@ pub enum FolderCommands {
workspace_id: Option<String>,
},
/// Output JSON schema for folder create/update payloads
Schema {
/// Pretty-print schema JSON output
#[arg(long)]
pretty: bool,
},
/// Show a folder as JSON
Show {
/// Folder ID
+269
View File
@@ -0,0 +1,269 @@
use crate::cli::{AgentArgs, AgentCommands};
use crate::ui;
use crate::version;
use include_dir::{Dir, include_dir};
use std::fs;
use std::path::{Path, PathBuf};
static SKILL_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/skills/use-yaak");
const SKILL_NAME: &str = "use-yaak";
/// Substituted at install time so the installed skill records which CLI wrote it,
/// letting `--help` tell the user when an upgrade has left their copy behind.
const VERSION_PLACEHOLDER: &str = "__YAAK_CLI_VERSION__";
const VERSION_MARKER: &str = "yaak-cli-version:";
type CommandResult<T = ()> = std::result::Result<T, String>;
/// A coding tool that reads skills from a directory in the user's home.
struct Target {
/// Display name used in output.
label: &'static str,
/// Directory holding all skills for this tool (`…/skills`).
skills_dir: PathBuf,
}
pub fn run(args: AgentArgs) -> i32 {
let result = match args.command {
AgentCommands::Install { agent } => install(agent),
AgentCommands::Remove { agent } => remove(agent),
};
match result {
Ok(()) => 0,
Err(error) => {
ui::error(&error);
1
}
}
}
fn install(agent: Option<Vec<String>>) -> CommandResult {
let targets = resolve_targets(agent)?;
let mut installed = 0usize;
for target in &targets {
let dir = target.skills_dir.join(SKILL_NAME);
match write_skill(&dir) {
Ok(()) => {
installed += 1;
ui::success(&format!("{} -> {}", target.label, dir.display()));
}
Err(error) => ui::warning_stderr(&format!("{}: {}", target.label, error)),
}
}
if installed == 0 {
return Err("Failed to install the Yaak skill anywhere".to_string());
}
ui::info("Restart your coding tool to pick up the skill");
Ok(())
}
fn remove(agent: Option<Vec<String>>) -> CommandResult {
let targets = resolve_targets(agent)?;
let mut removed = 0usize;
for target in &targets {
let dir = target.skills_dir.join(SKILL_NAME);
if !dir.exists() {
continue;
}
match fs::remove_dir_all(&dir) {
Ok(()) => {
removed += 1;
ui::success(&format!("Removed {}", dir.display()));
}
Err(error) => {
ui::warning_stderr(&format!("Failed to remove {}: {error}", dir.display()))
}
}
}
if removed == 0 {
ui::info("No Yaak skill was installed");
}
Ok(())
}
/// The skill directory belongs to the CLI, so every install overwrites what is there
/// and prunes what this version no longer ships. Preserving local edits would be worse
/// than losing them: an edited file would be skipped by every future install and go
/// stale forever, against a CLI that keeps changing.
///
/// NOTE: atomicity is per file, not for the install as a whole. That is total today
/// because the skill is a single file, but adding a second one would mean a reader
/// could catch a mix of old and new files. Replacing the directory as a unit is not an
/// option (`rename` refuses a non-empty destination), so a multi-file skill would need
/// the published path to become a symlink swapped between versioned directories.
fn write_skill(dir: &Path) -> CommandResult {
fs::create_dir_all(dir).map_err(|e| format!("Failed to create {}: {e}", dir.display()))?;
let mut written = Vec::new();
for file in walk(&SKILL_DIR) {
let relative = file.path().to_path_buf();
let destination = dir.join(&relative);
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create {}: {e}", parent.display()))?;
}
let contents = stamp_version(file.contents());
write_atomic(&destination, &contents)?;
written.push(relative);
}
prune_unshipped(dir, &written);
Ok(())
}
/// Write via a sibling temp file and rename over the destination. `rename` replaces an
/// existing file atomically, so a reader sees either the old copy or the new one, and a
/// crash part way through cannot leave a half-written skill behind.
fn write_atomic(destination: &Path, contents: &[u8]) -> CommandResult {
let parent = destination
.parent()
.ok_or_else(|| format!("Invalid skill path {}", destination.display()))?;
let name = destination
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| format!("Invalid skill path {}", destination.display()))?;
let temp = parent.join(format!(".{name}.tmp-{}", std::process::id()));
fs::write(&temp, contents)
.map_err(|e| format!("Failed to write {}: {e}", destination.display()))?;
match fs::rename(&temp, destination) {
Ok(()) => Ok(()),
Err(error) => {
let _ = fs::remove_file(&temp);
Err(format!("Failed to write {}: {error}", destination.display()))
}
}
}
/// Stamp the running version into the skill so it carries its own provenance.
fn stamp_version(contents: &[u8]) -> Vec<u8> {
match std::str::from_utf8(contents) {
Ok(text) => text.replace(VERSION_PLACEHOLDER, version::cli_version()).into_bytes(),
Err(_) => contents.to_vec(),
}
}
/// Drop anything this version does not ship, so files an older version wrote (or a
/// leftover temp file) cannot linger beside the refreshed skill.
fn prune_unshipped(dir: &Path, written: &[PathBuf]) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let Ok(relative) = path.strip_prefix(dir) else {
continue;
};
if written.iter().any(|w| w == relative || w.starts_with(relative)) {
continue;
}
if path.is_dir() {
let _ = fs::remove_dir_all(&path);
} else {
let _ = fs::remove_file(&path);
}
}
}
/// Read the version an installed skill was stamped with.
fn installed_version(dir: &Path) -> Option<String> {
let text = fs::read_to_string(dir.join("SKILL.md")).ok()?;
text.lines()
.find_map(|line| line.trim().strip_prefix(VERSION_MARKER))
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty() && v != VERSION_PLACEHOLDER)
}
/// Health summary appended to root `--help`, so an agent can notice in one command
/// that an upgraded CLI has left the installed skill behind. Silent when nothing is
/// installed anywhere, to avoid nagging users who do not use agent tooling.
pub fn help_section() -> Option<String> {
let targets = resolve_targets(None).ok()?;
let current = version::cli_version();
let mut stale = Vec::new();
let mut installed = 0usize;
for target in &targets {
let dir = target.skills_dir.join(SKILL_NAME);
if !dir.join("SKILL.md").exists() {
continue;
}
installed += 1;
let found = installed_version(&dir).unwrap_or_default();
if found != current {
let found = if found.is_empty() { "unknown".to_string() } else { found };
stale.push(format!("{} ({found})", target.label));
}
}
if installed == 0 {
return None;
}
if stale.is_empty() {
return Some(format!(
"Agent tooling:\n Yaak skill is installed and up to date ({current})"
));
}
Some(format!(
"Agent tooling:\n Yaak skill is out of date for {} — CLI is {current}\n Run `yaak agent install`, then restart your coding tool",
stale.join(", ")
))
}
/// Flatten the embedded skill directory into its files, recursing into subdirectories.
fn walk<'a>(dir: &'a Dir<'a>) -> Vec<&'a include_dir::File<'a>> {
let mut files: Vec<_> = dir.files().collect();
for child in dir.dirs() {
files.extend(walk(child));
}
files
}
/// `~/.agents/skills` is the cross-tool location and is always written. Tool-specific
/// directories are written only when that tool is already set up on this machine, so
/// installing never creates a config directory for a tool the user does not use.
fn resolve_targets(requested: Option<Vec<String>>) -> CommandResult<Vec<Target>> {
let home = dirs::home_dir().ok_or("Could not determine home directory")?;
let known: Vec<(&str, PathBuf, PathBuf)> = vec![
("agents", home.join(".agents"), home.join(".agents").join("skills")),
("claude-code", home.join(".claude"), home.join(".claude").join("skills")),
("cursor", home.join(".cursor"), home.join(".cursor").join("skills")),
("codex", home.join(".codex"), home.join(".codex").join("skills")),
("opencode", home.join(".opencode"), home.join(".opencode").join("skills")),
];
if let Some(requested) = requested {
let mut targets = Vec::new();
for name in requested {
let found =
known.iter().find(|(label, _, _)| *label == name.as_str()).ok_or_else(|| {
let names: Vec<_> = known.iter().map(|(l, _, _)| *l).collect();
format!("Unknown agent '{name}'. Known agents: {}", names.join(", "))
})?;
targets.push(Target { label: found.0, skills_dir: found.2.clone() });
}
return Ok(targets);
}
let targets: Vec<Target> = known
.into_iter()
.filter(|(label, marker, _)| *label == "agents" || marker.exists())
.map(|(label, _, skills_dir)| Target { label, skills_dir })
.collect();
Ok(targets)
}
+89 -21
View File
@@ -16,6 +16,7 @@ use tokio::net::{TcpListener, TcpStream};
const OAUTH_CLIENT_ID: &str = "a1fe44800c2d7e803cad1b4bf07a291c";
const KEYRING_USER: &str = "yaak";
const AUTH_TIMEOUT: Duration = Duration::from_secs(300);
const CALLBACK_READ_TIMEOUT: Duration = Duration::from_secs(10);
const MAX_REQUEST_BYTES: usize = 16 * 1024;
type CommandResult<T = ()> = std::result::Result<T, String>;
@@ -209,35 +210,71 @@ async fn receive_oauth_code(
expected_state: &str,
app_base_url: &str,
) -> CommandResult<String> {
// Browsers speculatively open extra connections that may never carry a
// request. Handle each connection concurrently so an idle socket can't
// block the one carrying the real callback.
let (tx, mut rx) = tokio::sync::mpsc::channel::<CommandResult<String>>(1);
loop {
let (mut stream, _) = listener
.accept()
.await
.map_err(|e| format!("OAuth callback server accept error: {e}"))?;
match parse_callback_request(&mut stream).await {
Ok((state, code)) => {
if state != expected_state {
let _ = write_bad_request(&mut stream, "Invalid OAuth state").await;
continue;
}
let success_redirect = format!("{app_base_url}/login/oauth/success");
write_redirect(&mut stream, &success_redirect)
.await
.map_err(|e| format!("Failed responding to OAuth callback: {e}"))?;
return Ok(code);
tokio::select! {
accepted = listener.accept() => {
let (stream, _) = accepted
.map_err(|e| format!("OAuth callback server accept error: {e}"))?;
tokio::spawn(handle_callback_connection(
stream,
expected_state.to_string(),
app_base_url.to_string(),
tx.clone(),
));
}
Err(error) => {
let _ = write_bad_request(&mut stream, &error).await;
if error.starts_with("OAuth provider returned error:") {
return Err(error);
result = rx.recv() => {
if let Some(result) = result {
return result;
}
}
}
}
}
async fn handle_callback_connection(
mut stream: TcpStream,
expected_state: String,
app_base_url: String,
tx: tokio::sync::mpsc::Sender<CommandResult<String>>,
) {
let parsed = match tokio::time::timeout(
CALLBACK_READ_TIMEOUT,
parse_callback_request(&mut stream),
)
.await
{
Ok(parsed) => parsed,
Err(_) => return, // Idle speculative connection; drop it
};
match parsed {
Ok((state, code)) => {
if state != expected_state {
let _ = write_bad_request(&mut stream, "Invalid OAuth state").await;
return;
}
let success_redirect = format!("{app_base_url}/login/oauth/success");
let result = match write_redirect(&mut stream, &success_redirect).await {
Ok(()) => Ok(code),
Err(e) => Err(format!("Failed responding to OAuth callback: {e}")),
};
let _ = tx.send(result).await;
}
Err(error) => {
let _ = write_bad_request(&mut stream, &error).await;
if error.starts_with("OAuth provider returned error:") {
let _ = tx.send(Err(error)).await;
}
}
}
}
async fn parse_callback_request(stream: &mut TcpStream) -> CommandResult<(String, String)> {
let target = read_http_target(stream).await?;
if !target.starts_with("/oauth/callback") {
@@ -488,6 +525,37 @@ mod tests {
assert!(err.contains("User denied"));
}
#[tokio::test]
async fn receive_oauth_code_ignores_idle_speculative_connections() {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let addr = listener.local_addr().expect("local addr");
let server = tokio::spawn(async move {
receive_oauth_code(listener, "expected-state", "http://localhost:9444").await
});
// Browsers preconnect sockets that never carry a request; these must
// not block the connection carrying the real callback.
let _idle1 = TcpStream::connect(addr).await.expect("connect idle 1");
let _idle2 = TcpStream::connect(addr).await.expect("connect idle 2");
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let mut client = TcpStream::connect(addr).await.expect("connect");
client
.write_all(
b"GET /oauth/callback?code=abc123&state=expected-state HTTP/1.1\r\nHost: localhost\r\n\r\n",
)
.await
.expect("write");
let code = tokio::time::timeout(std::time::Duration::from_secs(2), server)
.await
.expect("idle connections must not block the real callback")
.expect("join")
.expect("should return code");
assert_eq!(code, "abc123");
}
#[tokio::test]
async fn receive_oauth_code_fails_fast_on_provider_error() {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
@@ -5,7 +5,9 @@ use crate::utils::json::{
apply_merge_patch, is_json_shorthand, merge_workspace_id_arg, parse_optional_json,
parse_required_json, require_id, validate_create_id,
};
use crate::utils::schema::append_agent_hints;
use crate::utils::workspace::resolve_workspace_id;
use schemars::schema_for;
use yaak_models::models::Folder;
use yaak_models::util::UpdateSource;
@@ -14,6 +16,7 @@ type CommandResult<T = ()> = std::result::Result<T, String>;
pub fn run(ctx: &CliContext, args: FolderArgs) -> i32 {
let result = match args.command {
FolderCommands::List { workspace_id } => list(ctx, workspace_id.as_deref()),
FolderCommands::Schema { pretty } => schema(pretty),
FolderCommands::Show { folder_id } => show(ctx, &folder_id),
FolderCommands::Create { workspace_id, name, json } => {
create(ctx, workspace_id, name, json)
@@ -31,6 +34,18 @@ pub fn run(ctx: &CliContext, args: FolderArgs) -> i32 {
}
}
fn schema(pretty: bool) -> CommandResult {
let mut schema = serde_json::to_value(schema_for!(Folder))
.map_err(|e| format!("Failed to serialize folder schema: {e}"))?;
append_agent_hints(&mut schema);
let output =
if pretty { serde_json::to_string_pretty(&schema) } else { serde_json::to_string(&schema) }
.map_err(|e| format!("Failed to format folder schema JSON: {e}"))?;
println!("{output}");
Ok(())
}
fn list(ctx: &CliContext, workspace_id: Option<&str>) -> CommandResult {
let workspace_id = resolve_workspace_id(ctx, workspace_id, "folder list")?;
let folders =
+3
View File
@@ -1,3 +1,4 @@
pub mod agent;
pub mod auth;
pub mod cookie_jar;
pub mod environment;
@@ -5,5 +6,7 @@ pub mod folder;
pub mod import_export;
pub mod plugin;
pub mod request;
pub mod response;
pub mod send;
pub mod template_function;
pub mod workspace;
+77 -2
View File
@@ -107,6 +107,47 @@ async fn schema(ctx: &CliContext, request_type: RequestSchemaType, pretty: bool)
Ok(())
}
/// `request create` only builds HTTP requests, but `request schema` will happily hand
/// out gRPC and WebSocket schemas. Without this, a gRPC payload deserializes into an
/// HttpRequest with its unknown fields dropped, quietly producing a broken request
/// (the gRPC method name lands in `method`, and `service` disappears).
fn reject_non_http_payload(payload: &Value, context: &str) -> CommandResult {
let Some(object) = payload.as_object() else {
return Ok(());
};
if let Some(model) = object.get("model").and_then(Value::as_str)
&& !model.is_empty()
&& model != "http_request"
{
return Err(format!(
"{context} only supports HTTP requests, but the payload has \"model\": \"{model}\". The CLI cannot work with {model} requests yet; use the Yaak app instead."
));
}
let known: std::collections::BTreeSet<String> = serde_json::to_value(schema_for!(HttpRequest))
.ok()
.and_then(|schema| schema.get("properties").and_then(Value::as_object).cloned())
.map(|properties| properties.keys().cloned().collect())
.unwrap_or_default();
if known.is_empty() {
return Ok(());
}
let unknown: Vec<&str> =
object.keys().filter(|key| !known.contains(*key)).map(String::as_str).collect();
if !unknown.is_empty() {
return Err(format!(
"{context} got fields that are not part of an HTTP request: {}. If this is a gRPC or WebSocket request, the CLI cannot work with it yet. Run `yaak request schema http` for the valid fields.",
unknown.join(", ")
));
}
Ok(())
}
fn enrich_schema_guidance(schema: &mut Value, request_type: RequestSchemaType) {
if !matches!(request_type, RequestSchemaType::Http) {
return;
@@ -119,7 +160,21 @@ fn enrich_schema_guidance(schema: &mut Value, request_type: RequestSchemaType) {
if let Some(url_schema) = properties.get_mut("url").and_then(Value::as_object_mut) {
append_description(
url_schema,
"For path segments like `/foo/:id/comments/:commentId`, put concrete values in `urlParameters` using names without `:` (for example `id`, `commentId`).",
"For path segments like `/foo/:id/comments/:commentId`, put concrete values in `urlParameters` using names that keep the leading `:` (for example `:id`, `:commentId`). A name without the `:` is sent as a query string parameter instead, leaving the placeholder in the path.",
);
}
if let Some(body_type_schema) = properties.get_mut("bodyType").and_then(Value::as_object_mut) {
append_description(
body_type_schema,
"Known values: `application/json`, `text/xml`, `application/x-www-form-urlencoded`, `multipart/form-data`, `graphql`, `binary`, `other`, or null for no body. This selects how `body` is encoded; it does NOT add a `Content-Type` header. Add that header yourself, matching the body type (`other` pairs with `text/plain` and `graphql` with `application/json`). Multipart is the exception: its header is generated at send time to carry the boundary.",
);
}
if let Some(body_schema) = properties.get_mut("body").and_then(Value::as_object_mut) {
append_description(
body_schema,
"Shape depends on `bodyType`. Text-ish types (`application/json`, `text/xml`, `other`) use `{\"text\": \"...\"}` where the value is a string, so JSON bodies are a JSON string containing JSON. Form types use `{\"form\": [{\"name\": \"a\", \"value\": \"1\", \"enabled\": true}]}`, and a multipart entry may use `file` (an absolute path) and `contentType` instead of `value`. `binary` uses `{\"filePath\": \"/abs/path\"}`. `graphql` uses `{\"query\": \"...\", \"variables\": \"{}\", \"operationName\": \"\"}` where `variables` is a string of JSON.",
);
}
}
@@ -353,6 +408,7 @@ fn create(
}
validate_create_id(&payload, "request")?;
reject_non_http_payload(&payload, "request create")?;
let mut request: HttpRequest = serde_json::from_value(payload)
.map_err(|e| format!("Failed to parse request create JSON: {e}"))?;
let fallback_workspace_id = if workspace_id_arg.is_none() && request.workspace_id.is_empty()
@@ -401,6 +457,7 @@ fn create(
fn update(ctx: &CliContext, json: Option<String>, json_input: Option<String>) -> CommandResult {
let patch = parse_required_json(json, json_input, "request update")?;
let id = require_id(&patch, "request update")?;
reject_non_http_payload(&patch, "request update")?;
let existing = ctx
.db()
@@ -493,14 +550,24 @@ async fn send_http_request_by_id(
}
}
});
// Verbose mode has a second writer on stdout (the event task above), and the two
// race: the body could land in the middle of the headers, or run onto the same
// line as the status. So buffer the body while verbose and write it once the
// events are done. Without -v nothing else writes, so stream it straight through.
let body_handle = tokio::task::spawn_blocking(move || {
let mut buffered = Vec::new();
let mut stdout = std::io::stdout();
while let Some(chunk) = body_chunk_rx.blocking_recv() {
if verbose {
buffered.extend_from_slice(&chunk);
continue;
}
if stdout.write_all(&chunk).is_err() {
break;
}
let _ = stdout.flush();
}
buffered
});
let response_dir = ctx.data_dir().join("responses");
@@ -522,8 +589,16 @@ async fn send_http_request_by_id(
})
.await;
// Await the events first so every `*`, `>`, and `<` line is out before the body.
let _ = event_handle.await;
let _ = body_handle.await;
if let Ok(buffered) = body_handle.await
&& !buffered.is_empty()
{
let mut stdout = std::io::stdout();
let _ = stdout.write_all(&buffered);
let _ = stdout.flush();
}
result.map_err(|e| e.to_string())?;
Ok(())
}
@@ -0,0 +1,139 @@
use crate::cli::{ResponseArgs, ResponseCommands};
use crate::context::CliContext;
use crate::utils::confirm::confirm_delete;
use crate::utils::workspace::resolve_workspace_id;
use std::io::Write;
use yaak_models::models::HttpResponse;
use yaak_models::util::UpdateSource;
type CommandResult<T = ()> = std::result::Result<T, String>;
pub fn run(ctx: &CliContext, args: ResponseArgs) -> i32 {
let result = match args.command {
ResponseCommands::List { id, limit } => list(ctx, id.as_deref(), limit),
ResponseCommands::Show { id } => show(ctx, &id),
ResponseCommands::Body { id } => body(ctx, &id),
ResponseCommands::Delete { id, yes } => delete(ctx, &id, yes),
};
match result {
Ok(()) => 0,
Err(error) => {
eprintln!("Error: {error}");
1
}
}
}
/// Accepts a response ID, or a request ID meaning "the latest response for that request".
fn resolve_response(ctx: &CliContext, id: &str) -> CommandResult<HttpResponse> {
if let Ok(response) = ctx.db().get_http_response(id) {
return Ok(response);
}
if ctx.db().get_http_request(id).is_ok() {
return ctx
.db()
.list_http_responses_for_request(id, Some(1))
.map_err(|e| format!("Failed to list responses: {e}"))?
.into_iter()
.next()
.ok_or_else(|| format!("Request {id} has no stored responses yet"));
}
Err(format!("Could not resolve ID '{id}' as a response or request"))
}
fn list(ctx: &CliContext, id: Option<&str>, limit: Option<u64>) -> CommandResult {
// An explicit request ID scopes to that request; anything else lists the workspace.
let responses = match id {
Some(id) if ctx.db().get_http_request(id).is_ok() => ctx
.db()
.list_http_responses_for_request(id, limit)
.map_err(|e| format!("Failed to list responses: {e}"))?,
other => {
let workspace_id = resolve_workspace_id(ctx, other, "response list")?;
ctx.db()
.list_http_responses(&workspace_id, limit)
.map_err(|e| format!("Failed to list responses: {e}"))?
}
};
if responses.is_empty() {
println!("No responses found");
return Ok(());
}
for response in responses {
let status = match response.error {
// Errors can be a paragraph of nested debug output; `show` has the full text.
Some(error) => {
let first_line = error.lines().next().unwrap_or_default();
let summary = match first_line.char_indices().nth(80) {
Some((cut, _)) => format!("{}...", &first_line[..cut]),
None => first_line.to_string(),
};
format!("ERROR {summary}")
}
None => {
let reason = response.status_reason.unwrap_or_default();
format!("{} {}", response.status, reason).trim_end().to_string()
}
};
println!("{} - {} {} ({}ms)", response.id, status, response.url, response.elapsed);
}
Ok(())
}
fn show(ctx: &CliContext, id: &str) -> CommandResult {
let response = resolve_response(ctx, id)?;
let output = serde_json::to_string_pretty(&response)
.map_err(|e| format!("Failed to serialize response: {e}"))?;
println!("{output}");
Ok(())
}
fn body(ctx: &CliContext, id: &str) -> CommandResult {
let response = resolve_response(ctx, id)?;
let Some(body_path) = response.body_path else {
return Err(format!("Response {} has no stored body", response.id));
};
let bytes = std::fs::read(&body_path)
.map_err(|e| format!("Failed to read response body at {body_path}: {e}"))?;
let mut stdout = std::io::stdout();
stdout.write_all(&bytes).map_err(|e| format!("Failed to write response body: {e}"))?;
stdout.flush().map_err(|e| format!("Failed to flush response body: {e}"))?;
Ok(())
}
fn delete(ctx: &CliContext, id: &str, yes: bool) -> CommandResult {
// Deleting is scoped to a whole request or workspace, since a single stored
// response is rarely the thing someone wants to remove.
if ctx.db().get_http_request(id).is_ok() {
if !yes && !confirm_delete("responses for request", id) {
println!("Aborted");
return Ok(());
}
let count = ctx
.db()
.delete_all_http_responses_for_request(id, &UpdateSource::Sync)
.map_err(|e| format!("Failed to delete responses: {e}"))?;
println!("Deleted {count} responses for request {id}");
return Ok(());
}
let workspace_id = resolve_workspace_id(ctx, Some(id), "response delete")?;
if !yes && !confirm_delete("responses for workspace", &workspace_id) {
println!("Aborted");
return Ok(());
}
let count = ctx
.db()
.delete_all_http_responses_for_workspace(&workspace_id, &UpdateSource::Sync)
.map_err(|e| format!("Failed to delete responses: {e}"))?;
println!("Deleted {count} responses for workspace {workspace_id}");
Ok(())
}
@@ -0,0 +1,111 @@
use crate::cli::{TemplateFunctionArgs, TemplateFunctionCommands};
use crate::context::CliContext;
use yaak_plugins::events::{PluginContext, TemplateFunction};
type CommandResult<T = ()> = std::result::Result<T, String>;
pub async fn run(ctx: &CliContext, args: TemplateFunctionArgs) -> i32 {
let result = match args.command {
TemplateFunctionCommands::List { filter } => list(ctx, filter.as_deref()).await,
TemplateFunctionCommands::Show { name, pretty } => show(ctx, &name, pretty).await,
};
match result {
Ok(()) => 0,
Err(error) => {
eprintln!("Error: {error}");
1
}
}
}
/// Template functions come from plugins, so the only accurate list is the one the
/// installed plugins report right now.
async fn all(ctx: &CliContext) -> CommandResult<Vec<TemplateFunction>> {
let plugin_context = PluginContext::new_empty();
let summaries = ctx
.plugin_manager()
.get_template_function_summaries(&plugin_context)
.await
.map_err(|e| format!("Failed to list template functions: {e}"))?;
let mut functions: Vec<TemplateFunction> =
summaries.into_iter().flat_map(|summary| summary.functions).collect();
functions.sort_by(|a, b| a.name.cmp(&b.name));
Ok(functions)
}
async fn list(ctx: &CliContext, filter: Option<&str>) -> CommandResult {
let mut functions = all(ctx).await?;
if let Some(filter) = filter {
let needle = filter.to_lowercase();
functions.retain(|f| f.name.to_lowercase().contains(&needle));
}
if functions.is_empty() {
match filter {
Some(filter) => println!("No template functions matching '{filter}'"),
None => println!("No template functions found"),
}
return Ok(());
}
for function in functions {
let args = function.args.iter().filter_map(arg_name).collect::<Vec<_>>().join(", ");
match function.description {
Some(description) if !description.is_empty() => {
println!("{}({}) - {}", function.name, args, description)
}
_ => println!("{}({})", function.name, args),
}
}
Ok(())
}
async fn show(ctx: &CliContext, name: &str, pretty: bool) -> CommandResult {
let functions = all(ctx).await?;
let function = functions
.iter()
.find(|f| {
f.name == name
|| f.aliases.as_ref().is_some_and(|aliases| aliases.iter().any(|a| a == name))
})
.ok_or_else(|| {
let names = functions.iter().map(|f| f.name.as_str()).collect::<Vec<_>>();
format!("No template function named '{name}'. Available: {}", names.join(", "))
})?;
let output = if pretty {
serde_json::to_string_pretty(function)
} else {
serde_json::to_string(function)
}
.map_err(|e| format!("Failed to serialize template function: {e}"))?;
println!("{output}");
Ok(())
}
fn arg_name(arg: &yaak_plugins::events::TemplateFunctionArg) -> Option<String> {
use yaak_plugins::events::{FormInput, TemplateFunctionArg};
let TemplateFunctionArg::FormInput(input) = arg;
let base = match input {
FormInput::Text(v) => &v.base,
FormInput::Editor(v) => &v.base,
FormInput::Select(v) => &v.base,
FormInput::Checkbox(v) => &v.base,
FormInput::File(v) => &v.base,
FormInput::HttpRequest(v) => &v.base,
FormInput::KeyValue(v) => &v.base,
// Layout-only inputs have no value of their own
FormInput::Accordion(_)
| FormInput::HStack(_)
| FormInput::Banner(_)
| FormInput::Markdown(_) => return None,
};
if base.name.trim().is_empty() { None } else { Some(base.name.clone()) }
}
@@ -131,7 +131,7 @@ fn delete(ctx: &CliContext, workspace_id: &str, yes: bool) -> CommandResult {
let deleted = ctx
.db()
.delete_workspace_by_id(workspace_id, &UpdateSource::Sync)
.delete_workspace_by_id(workspace_id, &UpdateSource::Sync, ctx.blob_manager())
.map_err(|e| format!("Failed to delete workspace: {e}"))?;
println!("Deleted workspace: {}", deleted.id);
Ok(())
+33 -3
View File
@@ -7,15 +7,31 @@ mod utils;
mod version;
mod version_check;
use clap::Parser;
use cli::{Cli, Commands, PluginCommands, RequestCommands};
use clap::{CommandFactory, FromArgMatches};
use cli::{AGENT_HINTS, Cli, Commands, PluginCommands, RequestCommands};
use context::{CliContext, CliExecutionContext};
use std::path::PathBuf;
use yaak_models::queries::any_request::AnyRequest;
/// Built at runtime so root `--help` can report whether the installed agent skill is
/// still in step with this CLI version.
fn help_footer() -> String {
match commands::agent::help_section() {
Some(section) => format!("{AGENT_HINTS}\n\n{section}"),
None => format!(
"{AGENT_HINTS}\n - Run `yaak agent install` to install the Yaak skill for AI coding agents"
),
}
}
#[tokio::main]
async fn main() {
let Cli { data_dir, environment, cookie_jar, verbose, log, command } = Cli::parse();
let matches = Cli::command().after_help(help_footer()).get_matches();
let Cli { data_dir, environment, cookie_jar, verbose, log, command } =
match Cli::from_arg_matches(&matches) {
Ok(cli) => cli,
Err(error) => error.exit(),
};
if let Some(log_level) = log {
match log_level {
@@ -36,6 +52,20 @@ async fn main() {
version_check::maybe_check_for_updates().await;
let exit_code = match command {
Commands::Agent(args) => commands::agent::run(args),
Commands::Response(args) => {
let context = CliContext::new(data_dir.clone(), app_id);
let exit_code = commands::response::run(&context, args);
context.shutdown().await;
exit_code
}
Commands::TemplateFunction(args) => {
let mut context = CliContext::new(data_dir.clone(), app_id);
context.init_plugins(CliExecutionContext::default()).await;
let exit_code = commands::template_function::run(&context, args).await;
context.shutdown().await;
exit_code
}
Commands::Auth(args) => commands::auth::run(args).await,
Commands::Import(args) => {
let mut context = CliContext::new(data_dir.clone(), app_id);
+4
View File
@@ -24,7 +24,11 @@ tauri-build = { version = "2.6.1", features = [] }
[target.'cfg(target_os = "linux")'.dependencies]
openssl-sys = { version = "0.9.105", features = ["vendored"] } # For Ubuntu installation to work
[target.'cfg(any(target_os = "macos", target_os = "linux"))'.dependencies]
rlimit = "0.11" # Raise the launchd 256 open-file soft limit at startup
[dependencies]
base64 = "0.22.1" # For writing values the editor only ever held encoded
charset = "0.1.5"
chrono = { workspace = true, features = ["serde"] }
cookie = "0.18.1"
@@ -0,0 +1,67 @@
use log::{debug, warn};
use serde::Serialize;
use tauri::{AppHandle, Runtime, is_dev};
use yaak_api::{ApiClientKind, yaak_api_client};
use yaak_common::platform::get_os_str;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct FeedbackPayload {
feature: String,
text: String,
app_version: String,
os: String,
}
/// Send explicit user feedback for a feature. Fire-and-forget: errors are
/// logged and swallowed so a failed send never surfaces to the user.
pub async fn send_feedback<R: Runtime>(app_handle: &AppHandle<R>, feature: String, text: String) {
let app_version = app_handle.package_info().version.to_string();
let payload = FeedbackPayload {
feature,
text,
app_version: app_version.clone(),
os: get_os_str().to_string(),
};
let client = match yaak_api_client(ApiClientKind::App, &app_version) {
Ok(c) => c,
Err(e) => {
debug!("Failed to build feedback client: {e:?}");
return;
}
};
let url = build_url("/app-feedback");
debug!(
"Sending feature feedback to {url}: feature={}, app_version={}, os={}, text_len={}",
payload.feature,
payload.app_version,
payload.os,
payload.text.len()
);
match client.post(&url).json(&payload).send().await {
Ok(resp) => {
let status = resp.status();
if status.is_success() {
debug!("Sent feature feedback with status {status}");
} else {
let body = resp
.text()
.await
.unwrap_or_else(|e| format!("<failed to read response body: {e:?}>"));
warn!("Failed to send feature feedback with status {status}: {body}");
}
}
Err(e) => warn!("Failed to send feature feedback: {e:?}"),
}
}
fn build_url(path: &str) -> String {
if is_dev() {
format!("http://localhost:9444/api/v1{path}")
} else {
format!("https://api.yaak.app/api/v1{path}")
}
}
@@ -32,18 +32,37 @@ pub(crate) async fn watch_git_worktree_status<R: Runtime>(
let repo_dir = dir.to_path_buf();
let workdir = paths.workdir;
let gitdir = paths.gitdir;
let commondir = paths.commondir;
let (tx, rx) = mpsc::channel::<notify::Result<notify::Event>>();
let mut watcher = notify::recommended_watcher(tx)
.map_err(|e| Error::GenericError(format!("Failed to watch Git repository: {e}")))?;
// Watch only the directory Yaak syncs to, not the whole worktree — the
// containing repo may be huge and busy (e.g. a sync dir inside a monorepo),
// and watching it all burns CPU re-checking status for unrelated changes
watcher
.watch(&workdir, notify::RecursiveMode::Recursive)
.map_err(|e| Error::GenericError(format!("Failed to watch Git worktree: {e}")))?;
if gitdir != workdir {
.watch(&repo_dir, notify::RecursiveMode::Recursive)
.map_err(|e| Error::GenericError(format!("Failed to watch Git sync directory: {e}")))?;
// Watch the git metadata that affects branch/status info: the top-level
// gitdir files (HEAD, index) and refs. Not the whole gitdir, since
// .git/objects churns constantly during fetches and gc. Refs and
// packed-refs live in the common dir, which only differs from the gitdir
// for linked worktrees
watcher
.watch(&gitdir, notify::RecursiveMode::NonRecursive)
.map_err(|e| Error::GenericError(format!("Failed to watch Git metadata: {e}")))?;
if commondir != gitdir {
watcher
.watch(&gitdir, notify::RecursiveMode::Recursive)
.map_err(|e| Error::GenericError(format!("Failed to watch Git metadata: {e}")))?;
.watch(&commondir, notify::RecursiveMode::NonRecursive)
.map_err(|e| Error::GenericError(format!("Failed to watch Git common dir: {e}")))?;
}
let refs_dir = commondir.join("refs");
if refs_dir.exists() {
watcher
.watch(&refs_dir, notify::RecursiveMode::Recursive)
.map_err(|e| Error::GenericError(format!("Failed to watch Git refs: {e}")))?;
}
let (async_tx, mut async_rx) = tokio::sync::mpsc::channel::<notify::Result<notify::Event>>(100);
@@ -70,6 +89,7 @@ pub(crate) async fn watch_git_worktree_status<R: Runtime>(
&repo_dir,
&workdir,
&gitdir,
&commondir,
&channel,
).await;
}
@@ -98,9 +118,10 @@ async fn handle_git_watch_event(
repo_dir: &Path,
workdir: &Path,
gitdir: &Path,
commondir: &Path,
channel: &Channel<GitWorktreeStatus>,
) {
if !is_relevant_git_watch_event(event_res, repo_dir, workdir, gitdir) {
if !is_relevant_git_watch_event(event_res, repo_dir, workdir, gitdir, commondir) {
return;
}
@@ -111,7 +132,7 @@ async fn handle_git_watch_event(
loop {
select! {
Some(event_res) = async_rx.recv() => {
let _ = is_relevant_git_watch_event(event_res, repo_dir, workdir, gitdir);
let _ = is_relevant_git_watch_event(event_res, repo_dir, workdir, gitdir, commondir);
}
_ = &mut settle_window => {
break;
@@ -127,6 +148,7 @@ fn is_relevant_git_watch_event(
repo_dir: &Path,
workdir: &Path,
gitdir: &Path,
commondir: &Path,
) -> bool {
let event = match event_res {
Ok(event) => event,
@@ -137,7 +159,7 @@ fn is_relevant_git_watch_event(
};
for path in event.paths {
if path.strip_prefix(gitdir).is_ok() {
if path.strip_prefix(gitdir).is_ok() || path.strip_prefix(commondir).is_ok() {
return true;
}
+48 -2
View File
@@ -65,6 +65,7 @@ use yaak_tls::find_client_certificate;
mod commands;
mod encoding;
mod error;
mod feedback;
mod git_ext;
mod git_watcher;
mod grpc;
@@ -292,6 +293,16 @@ async fn cmd_render_template<R: Runtime>(
Ok(result)
}
#[tauri::command]
async fn cmd_send_feedback<R: Runtime>(
app_handle: AppHandle<R>,
feature: String,
text: String,
) -> YaakResult<()> {
feedback::send_feedback(&app_handle, feature, text).await;
Ok(())
}
#[tauri::command]
async fn cmd_dismiss_notification<R: Runtime>(
window: WebviewWindow<R>,
@@ -1423,6 +1434,30 @@ async fn cmd_export_data<R: Runtime>(
})?)
}
/// Decodes base64 and writes the bytes to a file the user picked.
///
/// The webview can't do this itself: its `fs` permissions are read-only and scoped to the app
/// data directory, and widening them so it could write anywhere would be a poor trade in an app
/// whose whole job is rendering responses from servers it doesn't control.
///
/// Base64 in rather than bytes for two reasons. A `Vec<u8>` crosses the IPC boundary as a JSON
/// array of numbers, several times the size of the thing being saved. And the callers that need
/// this — values the editor collapsed — are holding base64 already, so passing it through
/// untouched means the save never decodes megabytes on the main thread.
#[tauri::command]
async fn cmd_save_base64_to_binary<R: Runtime>(
_app_handle: AppHandle<R>,
filepath: &str,
data: &str,
) -> YaakResult<()> {
use base64::Engine;
let bytes = base64::engine::general_purpose::STANDARD
.decode(data)
.map_err(|e| GenericError(format!("Data is not valid base64: {e}")))?;
fs::write(filepath, bytes).map_err(|e| GenericError(e.to_string()))?;
Ok(())
}
#[tauri::command]
async fn cmd_save_response<R: Runtime>(
app_handle: AppHandle<R>,
@@ -1595,10 +1630,11 @@ async fn cmd_delete_all_http_responses<R: Runtime>(
app_handle: AppHandle<R>,
window: WebviewWindow<R>,
) -> YaakResult<()> {
Ok(app_handle.db().delete_all_http_responses_for_request(
app_handle.db().delete_all_http_responses_for_request(
request_id,
&UpdateSource::from_window_label(window.label()),
)?)
)?;
Ok(())
}
#[tauri::command]
@@ -1665,6 +1701,14 @@ async fn cmd_check_for_updates<R: Runtime>(
#[cfg_attr(mobile, tauri::mobile_entry_point)]
#[cfg_attr(feature = "cef", tauri::cef_entry_point)]
pub fn run() {
// GUI apps launched via Finder/launchd inherit a 256 open-file soft limit on macOS
// (1024 on most Linux desktops). SQLite WAL connections hold ~3 fds each, so raise
// the limit toward the hard cap before opening any DB pools.
#[cfg(any(target_os = "macos", target_os = "linux"))]
if let Err(e) = rlimit::increase_nofile_limit(10240) {
eprintln!("Failed to raise open-file limit: {e}");
}
let mut builder = tauri::Builder::<TauriRuntime>::default().plugin(
Builder::default()
.targets([
@@ -1819,6 +1863,7 @@ pub fn run() {
cmd_delete_send_history,
cmd_dismiss_notification,
cmd_export_data,
cmd_send_feedback,
cmd_http_request_body,
cmd_http_response_body,
cmd_format_json,
@@ -1843,6 +1888,7 @@ pub fn run() {
cmd_reload_plugins,
cmd_render_template,
cmd_restart,
cmd_save_base64_to_binary,
cmd_save_response,
cmd_send_ephemeral_request,
cmd_send_http_request,
+12
View File
@@ -1,5 +1,17 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
// On Nvidia + Wayland, WebKit2GTK's DMA-BUF renderer triggers a protocol error (71) due to
// an explicit sync bug (https://bugs.webkit.org/show_bug.cgi?id=280210). Disabling explicit
// sync via the Nvidia driver avoids the crash without disabling hardware acceleration.
#[cfg(target_os = "linux")]
if std::env::var("__NV_DISABLE_EXPLICIT_SYNC").is_err()
&& std::env::var("WAYLAND_DISPLAY").is_ok()
&& std::path::Path::new("/sys/module/nvidia").exists()
{
// SAFETY: called before any threads are spawned.
unsafe { std::env::set_var("__NV_DISABLE_EXPLICIT_SYNC", "1") };
}
tauri_app_client_lib::run();
}
+83 -35
View File
@@ -14,7 +14,7 @@ use yaak_models::client_db::ClientDb;
use yaak_models::error::Result;
use yaak_models::models::{AnyModel, GraphQlIntrospection, GrpcEvent, Settings, WebsocketEvent};
use yaak_models::query_manager::QueryManager;
use yaak_models::util::UpdateSource;
use yaak_models::util::{ModelPayload, UpdateSource};
use yaak_plugins::manager::PluginManager;
const MODEL_CHANGES_RETENTION_HOURS: i64 = 1;
@@ -57,6 +57,7 @@ fn drain_model_changes_batch<R: Runtime>(
}
let fetched_count = changes.len();
let mut batch: Vec<ModelPayload> = Vec::with_capacity(fetched_count);
for change in changes {
cursor.created_at = change.created_at;
cursor.id = change.id;
@@ -66,8 +67,14 @@ fn drain_model_changes_batch<R: Runtime>(
if matches!(change.payload.update_source, UpdateSource::Window { .. }) {
continue;
}
if let Err(err) = app_handle.emit("model_write", change.payload) {
error!("Failed to emit model_write event: {err:?}");
batch.push(change.payload);
}
// Emit as a single batch so bulk writes (imports, sync, CLI) don't flood the
// frontend with per-model events.
if !batch.is_empty() {
if let Err(err) = app_handle.emit("model_writes", batch) {
error!("Failed to emit model_writes event: {err:?}");
}
}
@@ -162,52 +169,69 @@ pub(crate) fn models_upsert<R: Runtime>(
Ok(id)
}
// Async so cascading deletes (e.g. a workspace with thousands of requests) run on a
// blocking thread instead of stalling the main thread and all other IPC.
#[tauri::command]
pub(crate) fn models_delete<R: Runtime>(
pub(crate) async fn models_delete<R: Runtime>(
window: WebviewWindow<R>,
model: AnyModel,
) -> Result<String> {
use yaak_models::error::Error::GenericError;
let blobs = window.blob_manager();
// Use transaction for deletions because it might recurse
window.with_tx(|tx| {
let source = &UpdateSource::from_window_label(window.label());
let id = match model {
AnyModel::CookieJar(m) => tx.delete_cookie_jar(&m, source)?.id,
AnyModel::Environment(m) => tx.delete_environment(&m, source)?.id,
AnyModel::Folder(m) => tx.delete_folder(&m, source)?.id,
AnyModel::GrpcConnection(m) => tx.delete_grpc_connection(&m, source)?.id,
AnyModel::GrpcRequest(m) => tx.delete_grpc_request(&m, source)?.id,
AnyModel::HttpRequest(m) => tx.delete_http_request(&m, source)?.id,
AnyModel::HttpResponse(m) => tx.delete_http_response(&m, source, &blobs)?.id,
AnyModel::Plugin(m) => tx.delete_plugin(&m, source)?.id,
AnyModel::WebsocketConnection(m) => tx.delete_websocket_connection(&m, source)?.id,
AnyModel::WebsocketRequest(m) => tx.delete_websocket_request(&m, source)?.id,
AnyModel::Workspace(m) => tx.delete_workspace(&m, source)?.id,
a => return Err(GenericError(format!("Cannot delete AnyModel {a:?})"))),
};
Ok(id)
tauri::async_runtime::spawn_blocking(move || {
let blobs = window.blob_manager();
// Use transaction for deletions because it might recurse
window.with_tx(|tx| {
let source = &UpdateSource::from_window_label(window.label());
let id = match model {
AnyModel::CookieJar(m) => tx.delete_cookie_jar(&m, source)?.id,
AnyModel::Environment(m) => tx.delete_environment(&m, source)?.id,
AnyModel::Folder(m) => tx.delete_folder(&m, source)?.id,
AnyModel::GrpcConnection(m) => tx.delete_grpc_connection(&m, source)?.id,
AnyModel::GrpcRequest(m) => tx.delete_grpc_request(&m, source)?.id,
AnyModel::HttpRequest(m) => tx.delete_http_request(&m, source)?.id,
AnyModel::HttpResponse(m) => tx.delete_http_response(&m, source, &blobs)?.id,
AnyModel::Plugin(m) => tx.delete_plugin(&m, source)?.id,
AnyModel::WebsocketConnection(m) => tx.delete_websocket_connection(&m, source)?.id,
AnyModel::WebsocketRequest(m) => tx.delete_websocket_request(&m, source)?.id,
AnyModel::Workspace(m) => tx.delete_workspace(&m, source, &blobs)?.id,
a => return Err(GenericError(format!("Cannot delete AnyModel {a:?})"))),
};
Ok(id)
})
})
.await
.map_err(|e| GenericError(format!("Delete task failed: {e}")))?
}
#[tauri::command]
pub(crate) fn models_duplicate<R: Runtime>(
window: WebviewWindow<R>,
model: AnyModel,
model_type: String,
model_id: String,
) -> Result<String> {
use yaak_models::error::Error::GenericError;
// Use transaction for duplications because it might recurse
window.with_tx(|tx| {
let source = &UpdateSource::from_window_label(window.label());
let id = match model {
AnyModel::Environment(m) => tx.duplicate_environment(&m, source)?.id,
AnyModel::Folder(m) => tx.duplicate_folder(&m, source)?.id,
AnyModel::GrpcRequest(m) => tx.duplicate_grpc_request(&m, source)?.id,
AnyModel::HttpRequest(m) => tx.duplicate_http_request(&m, source)?.id,
AnyModel::WebsocketRequest(m) => tx.duplicate_websocket_request(&m, source)?.id,
a => return Err(GenericError(format!("Cannot duplicate AnyModel {a:?})"))),
// Fetch the model fresh from the DB so the duplicate doesn't come from
// a stale frontend snapshot
let id = match model_type.as_str() {
"environment" => {
tx.duplicate_environment(&tx.get_environment(&model_id)?, source)?.id
}
"folder" => tx.duplicate_folder(&tx.get_folder(&model_id)?, source)?.id,
"grpc_request" => {
tx.duplicate_grpc_request(&tx.get_grpc_request(&model_id)?, source)?.id
}
"http_request" => {
tx.duplicate_http_request(&tx.get_http_request(&model_id)?, source)?.id
}
"websocket_request" => {
tx.duplicate_websocket_request(&tx.get_websocket_request(&model_id)?, source)?.id
}
t => return Err(GenericError(format!("Cannot duplicate model type {t}"))),
};
Ok(id)
@@ -353,6 +377,20 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
let poll_query_manager = query_manager.clone();
// GC response bodies orphaned by cascade deletes, which historically
// didn't clean the blob DB or responses directory
let gc_query_manager = query_manager.clone();
let gc_blob_manager = blob_manager.clone();
let gc_responses_dir = app_path.join("responses");
tauri::async_runtime::spawn_blocking(move || {
let db = gc_query_manager.connect();
match db.delete_orphaned_response_bodies(&gc_blob_manager, &gc_responses_dir) {
Ok(0) => {}
Ok(n) => log::info!("Deleted {n} orphaned response bodies"),
Err(e) => error!("Failed to delete orphaned response bodies: {e:?}"),
}
});
app_handle.manage(query_manager);
app_handle.manage(blob_manager);
@@ -367,12 +405,22 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
// current sync-model UX snappy, while DB polling handles external writers (CLI).
let app_handle_local = app_handle.clone();
tauri::async_runtime::spawn(async move {
for payload in rx {
if !matches!(payload.update_source, UpdateSource::Window { .. }) {
while let Ok(payload) = rx.recv() {
let mut batch: Vec<ModelPayload> = Vec::new();
if matches!(payload.update_source, UpdateSource::Window { .. }) {
batch.push(payload);
}
// Coalesce any writes already queued into the same emit
while let Ok(next) = rx.try_recv() {
if matches!(next.update_source, UpdateSource::Window { .. }) {
batch.push(next);
}
}
if batch.is_empty() {
continue;
}
if let Err(err) = app_handle_local.emit("model_write", payload) {
error!("Failed to emit local model_write event: {err:?}");
if let Err(err) = app_handle_local.emit("model_writes", batch) {
error!("Failed to emit local model_writes event: {err:?}");
}
}
});
@@ -28,7 +28,7 @@ use yaak_plugins::api::{
PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse, check_plugin_updates,
search_plugins,
};
use yaak_plugins::events::PluginContext;
use yaak_plugins::events::{Color, PluginContext, ShowToastRequest};
use yaak_plugins::install::{delete_and_uninstall, download_and_install};
use yaak_plugins::manager::PluginManager;
use yaak_plugins::plugin_meta::get_plugin_meta;
@@ -285,6 +285,19 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
#[cfg(not(target_os = "windows"))]
let node_bin_name = "yaaknode";
// In dev, spawn yaaknode from the source vendored dir, not the copy under
// target/. tauri-build rewrites the target copy in place when the source
// changes (e.g. a Node version bump between branches), and on macOS an
// in-place write permanently taints the inode's cached code signature —
// every later spawn of it dies with SIGKILL (Code Signature Invalid).
// vendor-node.cjs always recreates the source file on a fresh inode.
#[cfg(debug_assertions)]
let node_bin_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("vendored")
.join("node")
.join(node_bin_name);
#[cfg(not(debug_assertions))]
let node_bin_path = app_handle
.path()
.resolve(format!("vendored/node/{}", node_bin_name), BaseDirectory::Resource)
@@ -315,6 +328,30 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
.await
.expect("Failed to start plugin runtime");
// Surface unexpected runtime crashes to the user
let mut crash_rx = manager.runtime_crash_rx();
let app_handle_crash = app_handle_clone.clone();
tauri::async_runtime::spawn(async move {
if crash_rx.wait_for(|status| status.is_some()).await.is_ok() {
let status = crash_rx.borrow().clone().unwrap_or_default();
// The crash may happen during startup, before any window or
// frontend listener exists — wait so the toast isn't lost
while app_handle_crash.webview_windows().is_empty() {
tokio::time::sleep(Duration::from_millis(500)).await;
}
tokio::time::sleep(Duration::from_secs(3)).await;
let _ = app_handle_crash.emit(
"show_toast",
ShowToastRequest {
message: format!("Plugin runtime crashed ({status})"),
color: Some(Color::Danger),
icon: None,
timeout: None,
},
);
}
});
app_handle_clone.manage(manager);
});
+3 -2
View File
@@ -3,7 +3,7 @@
//! This module provides the Tauri commands for sync functionality.
use crate::error::Result;
use crate::models_ext::QueryManagerExt;
use crate::models_ext::{BlobManagerExt, QueryManagerExt};
use chrono::Utc;
use log::warn;
use serde::{Deserialize, Serialize};
@@ -55,7 +55,8 @@ pub(crate) async fn cmd_sync_apply<R: Runtime>(
workspace_id: &str,
) -> Result<()> {
let db = app_handle.db();
let sync_state_ops = apply_sync_ops(&db, workspace_id, sync_dir, sync_ops)?;
let blobs = app_handle.blob_manager();
let sync_state_ops = apply_sync_ops(&db, &blobs, workspace_id, sync_dir, sync_ops)?;
apply_sync_state_ops(&db, workspace_id, sync_dir, sync_state_ops)?;
Ok(())
}
@@ -1,4 +1,7 @@
{
"build": {
"features": ["updater", "license", "wry"]
},
"app": {
"security": {
"capabilities": [
@@ -1,13 +1,18 @@
use std::sync::{Arc, Mutex};
#[cfg(target_os = "linux")]
use std::time::Duration;
#[cfg(target_os = "linux")]
use log::{debug, warn};
use tauri::{AppHandle, Emitter, Runtime};
#[cfg(target_os = "linux")]
use tauri::Emitter;
use tauri::{AppHandle, Runtime};
pub const INITIAL_APPEARANCE_GLOBAL: &str = "__YAAK_INITIAL_APPEARANCE__";
pub const INITIAL_APPEARANCE_SOURCE_GLOBAL: &str = "__YAAK_INITIAL_APPEARANCE_SOURCE__";
pub const SYSTEM_APPEARANCE_CHANGE_EVENT: &str = "system_appearance_change";
#[cfg(target_os = "linux")]
const SYSTEM_APPEARANCE_POLL_INTERVAL: Duration = Duration::from_secs(1);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -42,6 +47,8 @@ impl InitialAppearanceSource {
#[derive(Clone)]
pub struct SystemAppearanceState {
// Only read by the Linux polling thread
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
last_appearance: Arc<Mutex<Option<Appearance>>>,
}

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