Compare commits

..
Author SHA1 Message Date
Gregory SchierandGitHub 1f8fc00e7c Merge branch 'main' into worktree-perf 2026-08-02 08:07:00 -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 a2a6cb17ca Improve typing and sidebar performance in large workspaces 2026-07-25 07:14:46 -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
Gregory Schier 6c42b27edb Build CEF Linux tarballs from debs 2026-07-03 17:35:56 -07:00
107 changed files with 3301 additions and 857 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 ## 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. - [ ] 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. - [ ] If this PR is not a bug fix, I linked the feedback item where @gschier explicitly gave me permission to work on it.
- [ ] I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md). - [ ] I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).
- [ ] I tested this change locally. - [ ] I tested this change locally.
- [ ] I added or updated tests when reasonable. - [ ] I added or updated tests, or tests are not reasonable for this change.
- [ ] I added screenshots or recordings for UI changes when reasonable. - [ ] I added screenshots or recordings, or this change does not affect the UI.
Explicit permission feedback item (required if not a bug fix): 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)), ...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 = { const CHECKBOXES = {
bugFix: "This PR is a bug fix.", bugFix: ["This PR is a bug fix."],
explicitPermission: explicitPermission: [
"If this PR is not a bug fix, I linked the feedback item where @gschier explicitly gave me permission to work on it.", "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).", "I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).",
testedLocally: "I tested this change locally.", ],
testsUpdated: "I added or updated tests when reasonable.", testedLocally: ["I tested this change locally."],
screenshotsAdded: 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.", "I added screenshots or recordings for UI changes when reasonable.",
],
}; };
function escapeRegExp(value) { function escapeRegExp(value) {
@@ -102,8 +111,8 @@ function normalizeCheckboxLabel(label) {
.trim(); .trim();
} }
function checkboxState(body, label) { function checkboxState(body, labels) {
const expectedLabel = normalizeCheckboxLabel(label); const expectedLabels = new Set(labels.map(normalizeCheckboxLabel));
for (const line of body.split("\n")) { for (const line of body.split("\n")) {
const match = line.match(/^\s*[-*]\s*\[([ xX])\]\s*(.*?)\s*$/i); const match = line.match(/^\s*[-*]\s*\[([ xX])\]\s*(.*?)\s*$/i);
@@ -112,7 +121,7 @@ function checkboxState(body, label) {
continue; continue;
} }
if (normalizeCheckboxLabel(match[2]) === expectedLabel) { if (expectedLabels.has(normalizeCheckboxLabel(match[2]))) {
return match[1].toLowerCase() === "x"; return match[1].toLowerCase() === "x";
} }
} }
@@ -255,7 +264,8 @@ function analyzePullRequest(pr) {
if (states.testsUpdated !== true) { if (states.testsUpdated !== true) {
blockers.push({ blockers.push({
label: LABELS.policyUnmet.name, 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({ blockers.push({
label: LABELS.policyUnmet.name, label: LABELS.policyUnmet.name,
message: 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 pr = response.data;
const issueNumber = pr.number; 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 ( if (
minimumAutomaticPullNumber != null && minimumAutomaticPullNumber != null &&
pr.number < minimumAutomaticPullNumber 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: jobs:
check: check:
if: github.repository == 'mountain-loop/yaak'
name: Check contribution policy name: Check contribution policy
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
+1
View File
@@ -13,6 +13,7 @@ permissions:
jobs: jobs:
update-flathub: update-flathub:
if: github.repository == 'mountain-loop/yaak'
name: Update Flathub manifest name: Update Flathub manifest
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
+1
View File
@@ -15,6 +15,7 @@ permissions:
jobs: jobs:
publish-npm: publish-npm:
if: github.repository == 'mountain-loop/yaak'
name: Publish @yaakapp/api name: Publish @yaakapp/api
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
+22 -5
View File
@@ -5,6 +5,7 @@ on:
jobs: jobs:
build-artifacts: build-artifacts:
if: github.repository == 'mountain-loop/yaak'
permissions: permissions:
contents: write contents: write
@@ -39,18 +40,18 @@ jobs:
targets: "" targets: ""
- platform: "ubuntu-22.04" - platform: "ubuntu-22.04"
args: >- args: >-
--bundles appimage --bundles deb
--config ./tauri.release.conf.json --config ./tauri.release.conf.json
--config '{"productName":"yaak-cef","mainBinaryName":"yaak-cef","identifier":"app.yaak.desktop.cef","build":{"features":["license","cef"]},"bundle":{"createUpdaterArtifacts":false,"linux":{"appimage":{"files":{"/usr/lib/libfreebl3.chk":"/usr/lib/x86_64-linux-gnu/libfreebl3.chk","/usr/lib/libfreebl3.so":"/usr/lib/x86_64-linux-gnu/libfreebl3.so","/usr/lib/libfreeblpriv3.chk":"/usr/lib/x86_64-linux-gnu/libfreeblpriv3.chk","/usr/lib/libfreeblpriv3.so":"/usr/lib/x86_64-linux-gnu/libfreeblpriv3.so","/usr/lib/libsoftokn3.chk":"/usr/lib/x86_64-linux-gnu/nss/libsoftokn3.chk","/usr/lib/libsoftokn3.so":"/usr/lib/x86_64-linux-gnu/nss/libsoftokn3.so","/usr/lib/nss/libfreebl3.chk":"/usr/lib/x86_64-linux-gnu/nss/libfreebl3.chk","/usr/lib/nss/libfreebl3.so":"/usr/lib/x86_64-linux-gnu/nss/libfreebl3.so","/usr/lib/nss/libfreeblpriv3.chk":"/usr/lib/x86_64-linux-gnu/nss/libfreeblpriv3.chk","/usr/lib/nss/libfreeblpriv3.so":"/usr/lib/x86_64-linux-gnu/nss/libfreeblpriv3.so","/usr/lib/nss/libnssckbi.so":"/usr/lib/x86_64-linux-gnu/nss/libnssckbi.so","/usr/lib/nss/libnssdbm3.chk":"/usr/lib/x86_64-linux-gnu/nss/libnssdbm3.chk","/usr/lib/nss/libnssdbm3.so":"/usr/lib/x86_64-linux-gnu/nss/libnssdbm3.so","/usr/lib/nss/libsoftokn3.chk":"/usr/lib/x86_64-linux-gnu/nss/libsoftokn3.chk","/usr/lib/nss/libsoftokn3.so":"/usr/lib/x86_64-linux-gnu/nss/libsoftokn3.so"}}}}}' --config '{"productName":"yaak-cef","mainBinaryName":"yaak-cef","identifier":"app.yaak.desktop.cef","build":{"features":["license","cef"]},"bundle":{"createUpdaterArtifacts":false}}'
yaak_arch: "x64" yaak_arch: "x64"
os: "ubuntu" os: "ubuntu"
runtime: "cef" runtime: "cef"
targets: "" targets: ""
- platform: "ubuntu-22.04-arm" - platform: "ubuntu-22.04-arm"
args: >- args: >-
--bundles appimage --bundles deb
--config ./tauri.release.conf.json --config ./tauri.release.conf.json
--config '{"productName":"yaak-cef","mainBinaryName":"yaak-cef","identifier":"app.yaak.desktop.cef","build":{"features":["license","cef"]},"bundle":{"createUpdaterArtifacts":false,"linux":{"appimage":{"files":{"/usr/lib/libfreebl3.chk":"/usr/lib/aarch64-linux-gnu/libfreebl3.chk","/usr/lib/libfreebl3.so":"/usr/lib/aarch64-linux-gnu/libfreebl3.so","/usr/lib/libfreeblpriv3.chk":"/usr/lib/aarch64-linux-gnu/libfreeblpriv3.chk","/usr/lib/libfreeblpriv3.so":"/usr/lib/aarch64-linux-gnu/libfreeblpriv3.so","/usr/lib/libsoftokn3.chk":"/usr/lib/aarch64-linux-gnu/nss/libsoftokn3.chk","/usr/lib/libsoftokn3.so":"/usr/lib/aarch64-linux-gnu/nss/libsoftokn3.so","/usr/lib/nss/libfreebl3.chk":"/usr/lib/aarch64-linux-gnu/nss/libfreebl3.chk","/usr/lib/nss/libfreebl3.so":"/usr/lib/aarch64-linux-gnu/nss/libfreebl3.so","/usr/lib/nss/libfreeblpriv3.chk":"/usr/lib/aarch64-linux-gnu/nss/libfreeblpriv3.chk","/usr/lib/nss/libfreeblpriv3.so":"/usr/lib/aarch64-linux-gnu/nss/libfreeblpriv3.so","/usr/lib/nss/libnssckbi.so":"/usr/lib/aarch64-linux-gnu/nss/libnssckbi.so","/usr/lib/nss/libnssdbm3.chk":"/usr/lib/aarch64-linux-gnu/nss/libnssdbm3.chk","/usr/lib/nss/libnssdbm3.so":"/usr/lib/aarch64-linux-gnu/nss/libnssdbm3.so","/usr/lib/nss/libsoftokn3.chk":"/usr/lib/aarch64-linux-gnu/nss/libsoftokn3.chk","/usr/lib/nss/libsoftokn3.so":"/usr/lib/aarch64-linux-gnu/nss/libsoftokn3.so"}}}}}' --config '{"productName":"yaak-cef","mainBinaryName":"yaak-cef","identifier":"app.yaak.desktop.cef","build":{"features":["license","cef"]},"bundle":{"createUpdaterArtifacts":false}}'
yaak_arch: "arm64" yaak_arch: "arm64"
os: "ubuntu" os: "ubuntu"
runtime: "cef" runtime: "cef"
@@ -184,12 +185,28 @@ jobs:
tauriScript: "node ../../node_modules/@tauri-apps/cli/tauri.js" tauriScript: "node ../../node_modules/@tauri-apps/cli/tauri.js"
tagName: "v__VERSION__" tagName: "v__VERSION__"
releaseName: "Release __VERSION__" releaseName: "Release __VERSION__"
releaseBody: "[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)" releaseBody: "<!-- generated-by-yaak-releases -->"
releaseDraft: true releaseDraft: true
prerelease: true prerelease: true
projectPath: ./crates-tauri/yaak-app-client projectPath: ./crates-tauri/yaak-app-client
args: "${{ matrix.args }}" args: "${{ matrix.args }}"
- name: Build and upload CEF tarball from deb (Linux only)
if: matrix.os == 'ubuntu' && matrix.runtime == 'cef'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
deb=$(find target/release/bundle/deb -maxdepth 1 -type f -name '*.deb' | head -n 1)
version="${GITHUB_REF_NAME#v}"
extract_dir="target/release/bundle/deb/yaak-cef-linux-${{ matrix.yaak_arch }}"
tarball="target/release/bundle/deb/yaak-cef_${version}_linux_${{ matrix.yaak_arch }}.tar.gz"
rm -rf "$extract_dir"
mkdir -p "$extract_dir"
dpkg-deb -x "$deb" "$extract_dir"
tar -C "$extract_dir" -czf "$tarball" .
gh release upload "${{ github.ref_name }}" "$tarball" --clobber
# Build a per-machine NSIS installer for enterprise deployment (PDQ, SCCM, Intune) # Build a per-machine NSIS installer for enterprise deployment (PDQ, SCCM, Intune)
- name: Build and upload machine-wide installer (Windows only) - name: Build and upload machine-wide installer (Windows only)
if: matrix.os == 'windows' if: matrix.os == 'windows'
+4 -3
View File
@@ -2,7 +2,7 @@ name: Release CLI to NPM
on: on:
push: push:
tags: [yaak-cli-*] tags: [v*]
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version: version:
@@ -15,6 +15,7 @@ permissions:
jobs: jobs:
prepare-vendored-assets: prepare-vendored-assets:
if: github.repository == 'mountain-loop/yaak'
name: Prepare vendored plugin assets name: Prepare vendored plugin assets
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -118,7 +119,7 @@ jobs:
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="$WORKFLOW_VERSION" VERSION="$WORKFLOW_VERSION"
else else
VERSION="${GITHUB_REF_NAME#yaak-cli-}" VERSION="${GITHUB_REF_NAME}"
fi fi
VERSION="${VERSION#v}" VERSION="${VERSION#v}"
echo "Building yaak version: $VERSION" echo "Building yaak version: $VERSION"
@@ -175,7 +176,7 @@ jobs:
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="$WORKFLOW_VERSION" VERSION="$WORKFLOW_VERSION"
else else
VERSION="${GITHUB_REF_NAME#yaak-cli-}" VERSION="${GITHUB_REF_NAME}"
fi fi
VERSION="${VERSION#v}" VERSION="${VERSION#v}"
if [[ "$VERSION" == *-* ]]; then if [[ "$VERSION" == *-* ]]; then
+1
View File
@@ -7,6 +7,7 @@ permissions:
contents: write contents: write
jobs: jobs:
deploy: deploy:
if: github.repository == 'mountain-loop/yaak'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout 🛎️ - name: Checkout 🛎️
+1 -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 - Do not commit, push, or tag without explicit approval
Generated
+80 -13
View File
@@ -862,6 +862,15 @@ dependencies = [
"alloc-stdlib", "alloc-stdlib",
] ]
[[package]]
name = "bs58"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
dependencies = [
"tinyvec",
]
[[package]] [[package]]
name = "bstr" name = "bstr"
version = "1.12.1" version = "1.12.1"
@@ -1752,8 +1761,18 @@ version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
dependencies = [ dependencies = [
"darling_core", "darling_core 0.20.11",
"darling_macro", "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]] [[package]]
@@ -1763,6 +1782,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
dependencies = [ dependencies = [
"fnv", "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", "ident_case",
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -1776,7 +1807,18 @@ version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [ 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", "quote",
"syn 2.0.101", "syn 2.0.101",
] ]
@@ -3885,9 +3927,9 @@ checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.172" version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]] [[package]]
name = "libdbus-sys" name = "libdbus-sys"
@@ -6649,6 +6691,15 @@ dependencies = [
"syn 1.0.109", "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]] [[package]]
name = "rolldown" name = "rolldown"
version = "0.1.0" version = "0.1.0"
@@ -7307,6 +7358,18 @@ dependencies = [
"uuid", "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]] [[package]]
name = "schemars" name = "schemars"
version = "1.2.1" version = "1.2.1"
@@ -7375,7 +7438,7 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bae0cbad6ab996955664982739354128c58d16e126114fe88c2a493642502aab" checksum = "bae0cbad6ab996955664982739354128c58d16e126114fe88c2a493642502aab"
dependencies = [ dependencies = [
"darling", "darling 0.20.11",
"heck 0.4.1", "heck 0.4.1",
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -7644,17 +7707,19 @@ dependencies = [
[[package]] [[package]]
name = "serde_with" name = "serde_with"
version = "3.12.0" version = "3.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6b6f7f2fcb69f747921f79f3926bd1e203fce4fef62c268dd3abfb6d86029aa" checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bs58",
"chrono", "chrono",
"hex", "hex",
"indexmap 1.9.3", "indexmap 1.9.3",
"indexmap 2.14.0", "indexmap 2.14.0",
"serde", "schemars 0.9.0",
"serde_derive", "schemars 1.2.1",
"serde_core",
"serde_json", "serde_json",
"serde_with_macros", "serde_with_macros",
"time", "time",
@@ -7662,11 +7727,11 @@ dependencies = [
[[package]] [[package]]
name = "serde_with_macros" name = "serde_with_macros"
version = "3.12.0" version = "3.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d00caa5193a3c8362ac2b73be6b9e768aa5a4b2f721d8f4b339600c3cb51f8e" checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
dependencies = [ dependencies = [
"darling", "darling 0.23.0",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.101", "syn 2.0.101",
@@ -10932,6 +10997,7 @@ dependencies = [
"r2d2_sqlite", "r2d2_sqlite",
"rand 0.9.1", "rand 0.9.1",
"reqwest 0.12.20", "reqwest 0.12.20",
"rlimit",
"serde", "serde",
"serde_json", "serde_json",
"tauri", "tauri",
@@ -11103,6 +11169,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml", "serde_yaml",
"tempfile",
"thiserror 2.0.17", "thiserror 2.0.17",
"tokio", "tokio",
"ts-rs", "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 --> <!-- 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>
<p align="center"> <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> </p>
![Yaak API Client](https://yaak.app/static/screenshot.png) ![Yaak API Client](https://yaak.app/static/screenshot.png)
@@ -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,4 @@
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 { HStack, Icon, useContainerSize, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
@@ -75,11 +75,13 @@ export function GrpcRequestPane({
const { width: paneWidth } = useContainerSize(urlContainerEl); const { width: paneWidth } = useContainerSize(urlContainerEl);
const handleChangeUrl = useCallback( const handleChangeUrl = useCallback(
(url: string) => patchModel(activeRequest, { url }), (url: string) => patchModelDebounced(activeRequest, { url }),
[activeRequest], [activeRequest],
); );
const handleChangeMessage = useCallback( 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 }), (message: string) => patchModel(activeRequest, { message }),
[activeRequest], [activeRequest],
); );
@@ -146,12 +148,12 @@ export function GrpcRequestPane({
); );
const handleMetadataChange = useCallback( const handleMetadataChange = useCallback(
(metadata: HttpRequestHeader[]) => patchModel(activeRequest, { metadata }), (metadata: HttpRequestHeader[]) => patchModelDebounced(activeRequest, { metadata }),
[activeRequest], [activeRequest],
); );
const handleDescriptionChange = useCallback( const handleDescriptionChange = useCallback(
(description: string) => patchModel(activeRequest, { description }), (description: string) => patchModelDebounced(activeRequest, { description }),
[activeRequest], [activeRequest],
); );
@@ -299,7 +301,7 @@ export function GrpcRequestPane({
className="font-sans text-xl! px-0!" className="font-sans text-xl! px-0!"
containerClassName="border-0" containerClassName="border-0"
placeholder={resolvedModelName(activeRequest)} placeholder={resolvedModelName(activeRequest)}
onChange={(name) => patchModel(activeRequest, { name })} onChange={(name) => patchModelDebounced(activeRequest, { name })}
/> />
<MarkdownEditor <MarkdownEditor
name="request-description" name="request-description"
+26 -31
View File
@@ -1,12 +1,11 @@
import type { HttpRequest } from "@yaakapp-internal/models"; import type { HttpRequest } from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models"; import { patchModel, patchModelDebounced } from "@yaakapp-internal/models";
import type { GenericCompletionOption } from "@yaakapp-internal/plugins"; import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
import classNames from "classnames"; import classNames from "classnames";
import { atom, useAtomValue } from "jotai"; import { atom, useAtomValue } from "jotai";
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import { lazy, Suspense, useCallback, useMemo, useRef, useState } from "react"; import { lazy, Suspense, useCallback, useMemo, useRef, useState } from "react";
import { activeRequestIdAtom } from "../hooks/useActiveRequestId"; import { allRequestUrlsAtom } from "../hooks/useAllRequests";
import { allRequestsAtom } from "../hooks/useAllRequests";
import { useAuthTab } from "../hooks/useAuthTab"; import { useAuthTab } from "../hooks/useAuthTab";
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse"; import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
import { useHeadersTab } from "../hooks/useHeadersTab"; import { useHeadersTab } from "../hooks/useHeadersTab";
@@ -16,10 +15,10 @@ import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor"; import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey"; import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest"; import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
import { deepEqualAtom } from "../lib/atoms";
import { languageFromContentType } from "../lib/contentType"; import { languageFromContentType } from "../lib/contentType";
import { generateId } from "../lib/generateId"; import { generateId } from "../lib/generateId";
import { extractPathPlaceholders } from "../lib/pathPlaceholders"; import { extractPathPlaceholders } from "../lib/pathPlaceholders";
import { convertRequestBody } from "../lib/requestBodyConversion";
import { import {
BODY_TYPE_BINARY, BODY_TYPE_BINARY,
BODY_TYPE_FORM_MULTIPART, BODY_TYPE_FORM_MULTIPART,
@@ -38,6 +37,7 @@ import { BinaryFileEditor } from "./BinaryFileEditor";
import { ConfirmLargeRequestBody } from "./ConfirmLargeRequestBody"; import { ConfirmLargeRequestBody } from "./ConfirmLargeRequestBody";
import { CountBadge } from "./core/CountBadge"; import { CountBadge } from "./core/CountBadge";
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion"; import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
import { getUrlCompletionConfig } from "./core/Editor/url/completion";
import { Editor } from "./core/Editor/LazyEditor"; import { Editor } from "./core/Editor/LazyEditor";
import { InlineCode } from "@yaakapp-internal/ui"; import { InlineCode } from "@yaakapp-internal/ui";
import type { Pair } from "./core/PairEditor"; import type { Pair } from "./core/PairEditor";
@@ -75,15 +75,12 @@ const TAB_SETTINGS = "settings";
const TAB_DESCRIPTION = "description"; const TAB_DESCRIPTION = "description";
const TABS_STORAGE_KEY = "http_request_tabs"; const TABS_STORAGE_KEY = "http_request_tabs";
const nonActiveRequestUrlsAtom = atom((get) => { // Derived from the identity-stable URL list so this only recomputes when a URL
const activeRequestId = get(activeRequestIdAtom); // actually changes. The active request's own URL is included, but exact matches
const requests = get(allRequestsAtom); // are filtered out at completion time by genericCompletion.
return requests const requestUrlOptionsAtom = atom((get): GenericCompletionOption[] =>
.filter((r) => r.id !== activeRequestId) get(allRequestUrlsAtom).map((url) => ({ type: "constant", label: url })),
.map((r): GenericCompletionOption => ({ type: "constant", label: r.url })); );
});
const memoNotActiveRequestUrlsAtom = deepEqualAtom(nonActiveRequestUrlsAtom);
export function HttpRequestPane({ style, fullHeight, className, activeRequest }: Props) { export function HttpRequestPane({ style, fullHeight, className, activeRequest }: Props) {
const activeRequestId = activeRequest.id; const activeRequestId = activeRequest.id;
@@ -195,7 +192,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; let newContentType: string | null | undefined;
if (bodyType === BODY_TYPE_NONE) { if (bodyType === BODY_TYPE_NONE) {
newContentType = null; newContentType = null;
@@ -265,28 +269,19 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
const { mutate: importCurl } = useImportCurl(); const { mutate: importCurl } = useImportCurl();
const handleBodyChange = useCallback( const handleBodyChange = useCallback(
(body: HttpRequest["body"]) => patchModel(activeRequest, { body }), (body: HttpRequest["body"]) => patchModelDebounced(activeRequest, { body }),
[activeRequest], [activeRequest],
); );
const handleBodyTextChange = useCallback( const handleBodyTextChange = useCallback(
(text: string) => patchModel(activeRequest, { body: { ...activeRequest.body, text } }), (text: string) => patchModelDebounced(activeRequest, { body: { ...activeRequest.body, text } }),
[activeRequest], [activeRequest],
); );
const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom); const autocompleteUrls = useAtomValue(requestUrlOptionsAtom);
const autocomplete: GenericCompletionConfig = useMemo( const autocomplete: GenericCompletionConfig = useMemo(
() => ({ () => getUrlCompletionConfig(autocompleteUrls),
minMatch: 3,
options:
autocompleteUrls.length > 0
? autocompleteUrls
: [
{ label: "http://", type: "constant" },
{ label: "https://", type: "constant" },
],
}),
[autocompleteUrls], [autocompleteUrls],
); );
@@ -323,7 +318,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
); );
const handleUrlChange = useCallback( const handleUrlChange = useCallback(
(url: string) => patchModel(activeRequest, { url }), (url: string) => patchModelDebounced(activeRequest, { url }),
[activeRequest], [activeRequest],
); );
@@ -369,7 +364,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
forceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`} forceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`}
headers={activeRequest.headers} headers={activeRequest.headers}
stateKey={`headers.${activeRequest.id}`} stateKey={`headers.${activeRequest.id}`}
onChange={(headers) => patchModel(activeRequest, { headers })} onChange={(headers) => patchModelDebounced(activeRequest, { headers })}
/> />
</TabContent> </TabContent>
<TabContent value={TAB_PARAMS}> <TabContent value={TAB_PARAMS}>
@@ -377,7 +372,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
stateKey={`params.${activeRequest.id}`} stateKey={`params.${activeRequest.id}`}
forceUpdateKey={forceUpdateKey + urlParametersKey} forceUpdateKey={forceUpdateKey + urlParametersKey}
pairs={urlParameterPairs} pairs={urlParameterPairs}
onChange={(urlParameters) => patchModel(activeRequest, { urlParameters })} onChange={(urlParameters) => patchModelDebounced(activeRequest, { urlParameters })}
/> />
</TabContent> </TabContent>
<TabContent value={TAB_SETTINGS}> <TabContent value={TAB_SETTINGS}>
@@ -429,7 +424,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
requestId={activeRequest.id} requestId={activeRequest.id}
contentType={contentType} contentType={contentType}
body={activeRequest.body} body={activeRequest.body}
onChange={(body) => patchModel(activeRequest, { body })} onChange={(body) => patchModelDebounced(activeRequest, { body })}
onChangeContentType={handleContentTypeChange} onChangeContentType={handleContentTypeChange}
/> />
) : typeof activeRequest.bodyType === "string" ? ( ) : typeof activeRequest.bodyType === "string" ? (
@@ -92,7 +92,9 @@ export const RecentHttpResponsesDropdown = function ResponsePane({
</HStack> </HStack>
), ),
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <Icon icon="empty" />, 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> </SettingsSection>
</CargoFeature> </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 && ( {showWorkspaceSettingsMovedBanner && (
<DismissibleBanner <DismissibleBanner
id="workspace-settings-moved-2026-06-30" id="workspace-settings-moved-2026-06-30"
+6 -1
View File
@@ -112,6 +112,7 @@ function Sidebar({ className }: { className?: string }) {
const treeId = `tree.${activeWorkspaceId ?? "unknown"}`; const treeId = `tree.${activeWorkspaceId ?? "unknown"}`;
const filterText = useAtomValue(sidebarFilterAtom); const filterText = useAtomValue(sidebarFilterAtom);
const [tree, allFields, emptyFilterSuggestions] = useAtomValue(sidebarTreeAtom) ?? []; const [tree, allFields, emptyFilterSuggestions] = useAtomValue(sidebarTreeAtom) ?? [];
const wrapperRef = useRef<HTMLElement>(null); const wrapperRef = useRef<HTMLElement>(null);
const treeRef = useRef<TreeHandle>(null); const treeRef = useRef<TreeHandle>(null);
const filterRef = useRef<InputHandle>(null); const filterRef = useRef<InputHandle>(null);
@@ -724,7 +725,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({ function getGitContextMenuItems({
items, items,
@@ -1,5 +1,5 @@
import type { WebsocketRequest } from "@yaakapp-internal/models"; import type { WebsocketRequest } from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models"; import { flushAllModelWrites, patchModel, patchModelDebounced } from "@yaakapp-internal/models";
import type { GenericCompletionOption } from "@yaakapp-internal/plugins"; import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
import { closeWebsocket, connectWebsocket, sendWebsocket } from "@yaakapp-internal/ws"; import { closeWebsocket, connectWebsocket, sendWebsocket } from "@yaakapp-internal/ws";
import classNames from "classnames"; import classNames from "classnames";
@@ -8,8 +8,7 @@ import type { CSSProperties } from "react";
import { useCallback, useMemo, useRef } from "react"; import { useCallback, useMemo, useRef } from "react";
import { getActiveCookieJar } from "../hooks/useActiveCookieJar"; import { getActiveCookieJar } from "../hooks/useActiveCookieJar";
import { getActiveEnvironment } from "../hooks/useActiveEnvironment"; import { getActiveEnvironment } from "../hooks/useActiveEnvironment";
import { activeRequestIdAtom } from "../hooks/useActiveRequestId"; import { allRequestUrlsAtom } from "../hooks/useAllRequests";
import { allRequestsAtom } from "../hooks/useAllRequests";
import { useAuthTab } from "../hooks/useAuthTab"; import { useAuthTab } from "../hooks/useAuthTab";
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse"; import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
import { useHeadersTab } from "../hooks/useHeadersTab"; import { useHeadersTab } from "../hooks/useHeadersTab";
@@ -18,7 +17,6 @@ import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { activeWebsocketConnectionAtom } from "../hooks/usePinnedWebsocketConnection"; import { activeWebsocketConnectionAtom } from "../hooks/usePinnedWebsocketConnection";
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor"; import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey"; import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
import { deepEqualAtom } from "../lib/atoms";
import { languageFromContentType } from "../lib/contentType"; import { languageFromContentType } from "../lib/contentType";
import { generateId } from "../lib/generateId"; import { generateId } from "../lib/generateId";
import { extractPathPlaceholders } from "../lib/pathPlaceholders"; import { extractPathPlaceholders } from "../lib/pathPlaceholders";
@@ -26,6 +24,7 @@ import { prepareImportQuerystring } from "../lib/prepareImportQuerystring";
import { resolvedModelName } from "../lib/resolvedModelName"; import { resolvedModelName } from "../lib/resolvedModelName";
import { CountBadge } from "./core/CountBadge"; import { CountBadge } from "./core/CountBadge";
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion"; import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
import { getUrlCompletionConfig } from "./core/Editor/url/completion";
import { Editor } from "./core/Editor/LazyEditor"; import { Editor } from "./core/Editor/LazyEditor";
import { IconButton } from "./core/IconButton"; import { IconButton } from "./core/IconButton";
import type { Pair } from "./core/PairEditor"; import type { Pair } from "./core/PairEditor";
@@ -54,15 +53,12 @@ const TAB_SETTINGS = "settings";
const TAB_DESCRIPTION = "description"; const TAB_DESCRIPTION = "description";
const TABS_STORAGE_KEY = "websocket_request_tabs"; const TABS_STORAGE_KEY = "websocket_request_tabs";
const nonActiveRequestUrlsAtom = atom((get) => { // Derived from the identity-stable URL list so this only recomputes when a URL
const activeRequestId = get(activeRequestIdAtom); // actually changes. The active request's own URL is included, but exact matches
const requests = get(allRequestsAtom); // are filtered out at completion time by genericCompletion.
return requests const requestUrlOptionsAtom = atom((get): GenericCompletionOption[] =>
.filter((r) => r.id !== activeRequestId) get(allRequestUrlsAtom).map((url) => ({ type: "constant", label: url })),
.map((r): GenericCompletionOption => ({ type: "constant", label: r.url })); );
});
const memoNotActiveRequestUrlsAtom = deepEqualAtom(nonActiveRequestUrlsAtom);
export function WebsocketRequestPane({ style, fullHeight, className, activeRequest }: Props) { export function WebsocketRequestPane({ style, fullHeight, className, activeRequest }: Props) {
const activeRequestId = activeRequest.id; const activeRequestId = activeRequest.id;
@@ -127,23 +123,15 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null); const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
const connection = useAtomValue(activeWebsocketConnectionAtom); const connection = useAtomValue(activeWebsocketConnectionAtom);
const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom); const autocompleteUrls = useAtomValue(requestUrlOptionsAtom);
const autocomplete: GenericCompletionConfig = useMemo( const autocomplete: GenericCompletionConfig = useMemo(
() => ({ () => getUrlCompletionConfig(autocompleteUrls),
minMatch: 3,
options:
autocompleteUrls.length > 0
? autocompleteUrls
: [
{ label: "http://", type: "constant" },
{ label: "https://", type: "constant" },
],
}),
[autocompleteUrls], [autocompleteUrls],
); );
const handleConnect = useCallback(async () => { const handleConnect = useCallback(async () => {
await flushAllModelWrites(); // The backend reads the request from the DB
await connectWebsocket({ await connectWebsocket({
requestId: activeRequest.id, requestId: activeRequest.id,
environmentId: getActiveEnvironment()?.id ?? null, environmentId: getActiveEnvironment()?.id ?? null,
@@ -153,6 +141,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
const handleSend = useCallback(async () => { const handleSend = useCallback(async () => {
if (connection == null) return; if (connection == null) return;
await flushAllModelWrites(); // The backend reads the message from the DB
await sendWebsocket({ await sendWebsocket({
connectionId: connection?.id, connectionId: connection?.id,
environmentId: getActiveEnvironment()?.id ?? null, environmentId: getActiveEnvironment()?.id ?? null,
@@ -165,7 +154,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
}, [connection]); }, [connection]);
const handleUrlChange = useCallback( const handleUrlChange = useCallback(
(url: string) => patchModel(activeRequest, { url }), (url: string) => patchModelDebounced(activeRequest, { url }),
[activeRequest], [activeRequest],
); );
@@ -249,7 +238,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
forceUpdateKey={forceUpdateKey} forceUpdateKey={forceUpdateKey}
headers={activeRequest.headers} headers={activeRequest.headers}
stateKey={`headers.${activeRequest.id}`} stateKey={`headers.${activeRequest.id}`}
onChange={(headers) => patchModel(activeRequest, { headers })} onChange={(headers) => patchModelDebounced(activeRequest, { headers })}
/> />
</TabContent> </TabContent>
<TabContent value={TAB_PARAMS}> <TabContent value={TAB_PARAMS}>
@@ -257,7 +246,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
stateKey={`params.${activeRequest.id}`} stateKey={`params.${activeRequest.id}`}
forceUpdateKey={forceUpdateKey + urlParametersKey} forceUpdateKey={forceUpdateKey + urlParametersKey}
pairs={urlParameterPairs} pairs={urlParameterPairs}
onChange={(urlParameters) => patchModel(activeRequest, { urlParameters })} onChange={(urlParameters) => patchModelDebounced(activeRequest, { urlParameters })}
/> />
</TabContent> </TabContent>
<TabContent value={TAB_MESSAGE}> <TabContent value={TAB_MESSAGE}>
@@ -269,7 +258,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
heightMode={fullHeight ? "full" : "auto"} heightMode={fullHeight ? "full" : "auto"}
defaultValue={activeRequest.message} defaultValue={activeRequest.message}
language={messageLanguage} language={messageLanguage}
onChange={(message) => patchModel(activeRequest, { message })} onChange={(message) => patchModelDebounced(activeRequest, { message })}
stateKey={`json.${activeRequest.id}`} stateKey={`json.${activeRequest.id}`}
/> />
</TabContent> </TabContent>
@@ -286,7 +275,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
className="font-sans text-xl! px-0!" className="font-sans text-xl! px-0!"
containerClassName="border-0" containerClassName="border-0"
placeholder={resolvedModelName(activeRequest)} placeholder={resolvedModelName(activeRequest)}
onChange={(name) => patchModel(activeRequest, { name })} onChange={(name) => patchModelDebounced(activeRequest, { name })}
/> />
<MarkdownEditor <MarkdownEditor
name="request-description" name="request-description"
@@ -294,7 +283,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
defaultValue={activeRequest.description} defaultValue={activeRequest.description}
stateKey={`description.${activeRequest.id}`} stateKey={`description.${activeRequest.id}`}
forceUpdateKey={forceUpdateKey} forceUpdateKey={forceUpdateKey}
onChange={(description) => patchModel(activeRequest, { description })} onChange={(description) => patchModelDebounced(activeRequest, { description })}
/> />
</div> </div>
</TabContent> </TabContent>
@@ -1,4 +1,5 @@
import { startCompletion } from "@codemirror/autocomplete"; import { startCompletion } from "@codemirror/autocomplete";
import { debounce } from "@yaakapp-internal/lib";
import { defaultKeymap, historyField, indentWithTab } from "@codemirror/commands"; import { defaultKeymap, historyField, indentWithTab } from "@codemirror/commands";
import { foldState, forceParsing } from "@codemirror/language"; import { foldState, forceParsing } from "@codemirror/language";
import type { EditorStateConfig, Extension } from "@codemirror/state"; import type { EditorStateConfig, Extension } from "@codemirror/state";
@@ -381,6 +382,7 @@ function EditorInner({
const initEditorRef = useCallback( const initEditorRef = useCallback(
function initEditorRef(container: HTMLDivElement | null) { function initEditorRef(container: HTMLDivElement | null) {
if (container === null) { if (container === null) {
flushCachedEditorState(stateKey);
cm.current?.view.destroy(); cm.current?.view.destroy();
cm.current = null; cm.current = null;
return; return;
@@ -639,7 +641,7 @@ function getExtensions({
onChange.current?.(update.state.doc.toString()); onChange.current?.(update.state.doc.toString());
} }
saveCachedEditorState(stateKey, update.state); saveCachedEditorStateDebounced(stateKey, update.state);
}), }),
]; ];
} }
@@ -652,6 +654,27 @@ const placeholderElFromText = (text: string | undefined) => {
return el; return el;
}; };
// Serializing the state (full doc + history) and md5-ing the doc is too
// expensive to do on every update (each keystroke and cursor move), so
// debounce it per state key and flush when the editor unmounts.
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);
}
function flushCachedEditorState(stateKey: string | null) {
if (!stateKey) return;
stateSavers.get(stateKey)?.flush();
}
function saveCachedEditorState(stateKey: string | null, state: EditorState | null) { function saveCachedEditorState(stateKey: string | null, state: EditorState | null) {
if (!stateKey || state == null) return; if (!stateKey || state == null) return;
const stateObj = state.toJSON(stateFields); const stateObj = state.toJSON(stateFields);
@@ -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 type { GenericCompletionOption } from "@yaakapp-internal/plugins";
import { defaultBoost } from "./twig/completion"; import { defaultBoost } from "./twig/completion";
export type GenericCompletion = GenericCompletionOption & {
apply?: Completion["apply"];
};
export interface GenericCompletionConfig { export interface GenericCompletionConfig {
minMatch?: number; minMatch?: number;
options: GenericCompletionOption[]; options: GenericCompletion[];
} }
/** /**
@@ -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({ const protocolOptions: GenericCompletionOption[] = [
options: [
{ label: "http://", type: "constant" }, { label: "http://", type: "constant" },
{ label: "https://", type: "constant" }, { label: "https://", type: "constant" },
], ];
minMatch: 1,
}); 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) => ( {hotkeys.map((hotkey) => (
<Fragment key={hotkey}> <Fragment key={hotkey}>
<HotkeyLabel className="truncate" action={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> </Fragment>
))} ))}
{bottomSlot} {bottomSlot}
@@ -318,6 +318,7 @@ function BaseInput({
editorClassName, editorClassName,
multiLine && size === "md" && "py-1.5", multiLine && size === "md" && "py-1.5",
multiLine && size === "sm" && "py-1", multiLine && size === "sm" && "py-1",
multiLine && (size === "xs" || size === "2xs") && "py-0.5",
)} )}
onFocus={handleFocus} onFocus={handleFocus}
onBlur={handleBlur} onBlur={handleBlur}
+61 -3
View File
@@ -3,6 +3,7 @@ import { Icon, type IconProps, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import * as m from "motion/react-m"; import * as m from "motion/react-m";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useKey } from "react-use"; import { useKey } from "react-use";
import { IconButton } from "./IconButton"; import { IconButton } from "./IconButton";
@@ -15,6 +16,12 @@ export interface ToastProps {
action?: (args: { hide: () => void }) => ReactNode; action?: (args: { hide: () => void }) => ReactNode;
icon?: ShowToastRequest["icon"] | null; icon?: ShowToastRequest["icon"] | null;
color?: ShowToastRequest["color"]; 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> = { 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", 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( useKey(
"Escape", "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", "relative pointer-events-auto bg-surface text-text rounded-lg",
"border border-border shadow-lg w-100", "border border-border shadow-lg w-100",
)} )}
onFocusCapture={cancelAutoHide}
onKeyDownCapture={cancelAutoHide}
onPointerDownCapture={cancelAutoHide}
>
<div
className={classNames(
"pl-3 py-3 flex items-start gap-2 w-full overflow-auto",
hideDismiss ? "pr-3" : "pr-10",
dynamicHeight ? "max-h-[80vh]" : "max-h-44",
)}
> >
<div className="pl-3 py-3 pr-10 flex items-start gap-2 w-full max-h-44 overflow-auto">
{toastIcon && <Icon icon={toastIcon} color={color} className="mt-1 shrink-0" />} {toastIcon && <Icon icon={toastIcon} color={color} className="mt-1 shrink-0" />}
<VStack space={2} className="w-full min-w-0"> <VStack space={2} className="w-full min-w-0">
<div className="select-auto">{children}</div> <div className="select-auto">{children}</div>
@@ -65,6 +121,7 @@ export function Toast({ children, open, onClose, timeout, action, icon, color }:
</VStack> </VStack>
</div> </div>
{!hideDismiss && (
<IconButton <IconButton
color={color} color={color}
variant="border" variant="border"
@@ -73,8 +130,9 @@ export function Toast({ children, open, onClose, timeout, action, icon, color }:
icon="x" icon="x"
onClick={onClose} onClick={onClose}
/> />
)}
{timeout != null && ( {timeout != null && !autoHideCanceled && (
<div className="w-full absolute bottom-0 left-0 right-0"> <div className="w-full absolute bottom-0 left-0 right-0">
<m.div <m.div
className="bg-surface-highlight h-[3px]" className="bg-surface-highlight h-[3px]"
@@ -12,6 +12,7 @@ import { Banner, HStack, Icon, InlineCode, SplitLayout } from "@yaakapp-internal
import classNames from "classnames"; import classNames from "classnames";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { modelToYaml } from "../../lib/diffYaml"; import { modelToYaml } from "../../lib/diffYaml";
import { trackFeatureUsage } from "../../lib/featureFeedback";
import { resolvedModelName } from "../../lib/resolvedModelName"; import { resolvedModelName } from "../../lib/resolvedModelName";
import { showConfirm } from "../../lib/confirm"; import { showConfirm } from "../../lib/confirm";
import { showErrorToast } from "../../lib/toast"; import { showErrorToast } from "../../lib/toast";
@@ -55,6 +56,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
setCommitError(null); setCommitError(null);
try { try {
await commit.mutateAsync({ message }); await commit.mutateAsync({ message });
trackFeatureUsage("git-sync");
onDone(); onDone();
} catch (err) { } catch (err) {
setCommitError(String(err)); setCommitError(String(err));
@@ -66,6 +68,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
try { try {
const r = await commitAndPush.mutateAsync({ message }); const r = await commitAndPush.mutateAsync({ message });
handlePushResult(r); handlePushResult(r);
trackFeatureUsage("git-sync");
onDone(); onDone();
} catch (err) { } catch (err) {
showErrorToast({ showErrorToast({
@@ -206,9 +209,10 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
layout="horizontal" layout="horizontal"
defaultRatio={0.6} defaultRatio={0.6}
firstSlot={({ style }) => ( 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?" /> <CommercialUseBanner source="git-commit" title="Using Git for work?" />
<SplitLayout <SplitLayout
className="min-h-0 flex-1"
storageKey="commit-vertical" storageKey="commit-vertical"
layout="vertical" layout="vertical"
defaultRatio={0.35} defaultRatio={0.35}
@@ -226,11 +230,12 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
/> />
{externalEntries.find((e) => e.status !== "current") && ( {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) => ( {externalEntries.map((entry) => (
<ExternalTreeNode <ExternalTreeNode
key={entry.relaPath + entry.status} key={entry.relaPath + entry.status}
entry={entry} entry={entry}
relaDir={status.data?.relaDir ?? ""}
onCheck={checkEntry} onCheck={checkEntry}
/> />
))} ))}
@@ -239,7 +244,10 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
</div> </div>
)} )}
secondSlot={({ style: innerStyle }) => ( 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 <Input
className="text-base! font-sans rounded-md" className="text-base! font-sans rounded-md"
placeholder="Commit message..." placeholder="Commit message..."
@@ -388,15 +396,22 @@ function TreeNodeChildren({
function ExternalTreeNode({ function ExternalTreeNode({
entry, entry,
relaDir,
onCheck, onCheck,
}: { }: {
entry: GitStatusEntry; entry: GitStatusEntry;
relaDir: string;
onCheck: (entry: GitStatusEntry) => void; onCheck: (entry: GitStatusEntry) => void;
}) { }) {
if (entry.status === "current") { if (entry.status === "current") {
return null; 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 ( return (
<Checkbox <Checkbox
fullWidth fullWidth
@@ -406,7 +421,7 @@ function ExternalTreeNode({
title={ title={
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] gap-1 w-full items-center"> <div className="grid grid-cols-[auto_minmax(0,1fr)_auto] gap-1 w-full items-center">
<Icon color="secondary" icon="file_code" /> <Icon color="secondary" icon="file_code" />
<div className="truncate">{entry.relaPath}</div> <div className="truncate">{displayPath}</div>
<InlineCode <InlineCode
className={classNames( className={classNames(
"py-0 ml-auto bg-transparent w-24 text-center", "py-0 ml-auto bg-transparent w-24 text-center",
@@ -1,7 +1,7 @@
import type { HttpRequest } from "@yaakapp-internal/models"; import type { HttpRequest } from "@yaakapp-internal/models";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useMemo } from "react"; import { useCallback, useEffect, useMemo } from "react";
import { useLocalStorage } from "react-use"; import { useLocalStorage } from "react-use";
import { useIntrospectGraphQL } from "../../hooks/useIntrospectGraphQL"; import { useIntrospectGraphQL } from "../../hooks/useIntrospectGraphQL";
import { useStateWithDeps } from "../../hooks/useStateWithDeps"; import { useStateWithDeps } from "../../hooks/useStateWithDeps";
@@ -11,9 +11,13 @@ import type { DropdownItem } from "../core/Dropdown";
import { Dropdown } from "../core/Dropdown"; import { Dropdown } from "../core/Dropdown";
import type { EditorProps } from "../core/Editor/Editor"; import type { EditorProps } from "../core/Editor/Editor";
import { Editor } from "../core/Editor/LazyEditor"; 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 { Banner, FormattedError, Icon } from "@yaakapp-internal/ui";
import { Separator } from "../core/Separator"; import { Separator } from "../core/Separator";
import { tryFormatGraphql } from "../../lib/formatters"; import { tryFormatGraphql } from "../../lib/formatters";
import { parseGraphQLOperationNames } from "../../lib/graphqlOperationNames";
import { normalizeGraphQLBody } from "../../lib/requestBodyConversion";
import { showGraphQLDocExplorerAtom } from "./graphqlAtoms"; import { showGraphQLDocExplorerAtom } from "./graphqlAtoms";
type Props = Pick<EditorProps, "heightMode" | "className" | "forceUpdateKey"> & { type Props = Pick<EditorProps, "heightMode" | "className" | "forceUpdateKey"> & {
@@ -22,6 +26,8 @@ type Props = Pick<EditorProps, "heightMode" | "className" | "forceUpdateKey"> &
request: HttpRequest; request: HttpRequest;
}; };
const OPERATION_NAME_NOT_SPECIFIED = "";
export function GraphQLEditor(props: Props) { export function GraphQLEditor(props: Props) {
// There's some weirdness with stale onChange being called when switching requests, so we'll // 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. // key on the request ID as a workaround for now.
@@ -38,25 +44,25 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
const [currentBody, setCurrentBody] = useStateWithDeps<{ const [currentBody, setCurrentBody] = useStateWithDeps<{
query: string; query: string;
variables: string | undefined; variables: string | undefined;
operationName?: string;
}>(() => { }>(() => {
// Migrate text bodies to GraphQL format // Migrate text bodies to GraphQL format
// NOTE: This is how GraphQL used to be stored // NOTE: This is how GraphQL used to be stored
if ("text" in request.body) { return normalizeGraphQLBody(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 ?? "" };
}, [extraEditorProps.forceUpdateKey]); }, [extraEditorProps.forceUpdateKey]);
const [isDocOpenRecord, setGraphqlDocStateAtomValue] = useAtom(showGraphQLDocExplorerAtom); const [isDocOpenRecord, setGraphqlDocStateAtomValue] = useAtom(showGraphQLDocExplorerAtom);
const isDocOpen = isDocOpenRecord[request.id] !== undefined; const isDocOpen = isDocOpenRecord[request.id] !== undefined;
const parsedOperationNames = useMemo(
() => parseGraphQLOperationNames(currentBody.query),
[currentBody.query],
);
const operationNames = useMemo(() => parsedOperationNames ?? [], [parsedOperationNames]);
const handleChangeQuery = useCallback( const handleChangeQuery = useCallback(
(query: string) => { (query: string) => {
setCurrentBody(({ variables }) => { setCurrentBody(({ variables, operationName }) => {
const newBody = { query, variables }; const newBody = buildGraphQLBody({ query, variables, operationName });
onChange(newBody); onChange(newBody);
return newBody; return newBody;
}); });
@@ -66,8 +72,8 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
const handleChangeVariables = useCallback( const handleChangeVariables = useCallback(
(variables: string) => { (variables: string) => {
setCurrentBody(({ query }) => { setCurrentBody(({ query, operationName }) => {
const newBody = { query, variables: variables || undefined }; const newBody = buildGraphQLBody({ query, variables, operationName });
onChange(newBody); onChange(newBody);
return newBody; return newBody;
}); });
@@ -75,9 +81,78 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
[onChange, setCurrentBody], [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"]>( const actions = useMemo<EditorProps["actions"]>(
() => [ () => [
<div key="actions" className="flex flex-row opacity-100! shadow!"> 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!"> <div key="introspection" className="opacity-100!">
{schema === undefined ? null /* Initializing */ : ( {schema === undefined ? null /* Initializing */ : (
<Dropdown <Dropdown
@@ -185,15 +260,17 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
</Button> </Button>
</Dropdown> </Dropdown>
)} )}
</div>
</div>, </div>,
], ],
[ [
schema, schema,
clear, clear,
error, error,
currentBody.operationName,
handleChangeOperationName,
isDocOpen, isDocOpen,
isLoading, isLoading,
operationNames,
refetch, refetch,
autoIntrospectDisabled, autoIntrospectDisabled,
baseRequest.id, baseRequest.id,
@@ -237,10 +314,23 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
); );
} }
function tryParseJson(text: string, fallback: unknown) { function buildGraphQLBody(body: {
try { query: string;
return JSON.parse(text); variables: string | undefined;
} catch { operationName?: string;
return fallback; }) {
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;
} }
@@ -71,6 +71,7 @@ function ActualEventStreamViewer({ response }: Props) {
summary.data.fragmentCount === 0 && summary.data.fragmentCount === 0 &&
!summary.isFetching && !summary.isFetching &&
summary.error == null; summary.error == null;
const filterEventPreviews = showExtractedText && filterEventPreviewsSetting.value === true; const filterEventPreviews = showExtractedText && filterEventPreviewsSetting.value === true;
const applyToDetails = showExtractedText && applyToDetailsSetting.value === true; const applyToDetails = showExtractedText && applyToDetailsSetting.value === true;
const renderMarkdown = showExtractedText && renderMarkdownSetting.value === true; const renderMarkdown = showExtractedText && renderMarkdownSetting.value === true;
@@ -218,7 +219,7 @@ function ActualEventStreamViewer({ response }: Props) {
<SseSummaryFooter <SseSummaryFooter
style={style} style={style}
error={summary.error ? String(summary.error) : null} error={summary.error ? String(summary.error) : null}
isLoading={summary.isLoading} isLoading={summary.isLoading && summary.data == null}
onRenderMarkdownChange={renderMarkdownSetting.set} onRenderMarkdownChange={renderMarkdownSetting.set}
renderMarkdown={renderMarkdown} renderMarkdown={renderMarkdown}
resultKeyPath={summarySettings.resultKeyPath ?? ""} resultKeyPath={summarySettings.resultKeyPath ?? ""}
+24
View File
@@ -4,6 +4,7 @@ import {
websocketRequestsAtom, websocketRequestsAtom,
} from "@yaakapp-internal/models"; } from "@yaakapp-internal/models";
import { atom, useAtomValue } from "jotai"; import { atom, useAtomValue } from "jotai";
import { selectAtom } from "jotai/utils";
export const allRequestsAtom = atom((get) => [ export const allRequestsAtom = atom((get) => [
...get(httpRequestsAtom), ...get(httpRequestsAtom),
@@ -14,3 +15,26 @@ export const allRequestsAtom = atom((get) => [
export function useAllRequests() { export function useAllRequests() {
return useAtomValue(allRequestsAtom); 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 { useMutation, useQuery } from "@tanstack/react-query";
import { emit } from "@tauri-apps/api/event"; import { emit } from "@tauri-apps/api/event";
import type { GrpcConnection, GrpcRequest } from "@yaakapp-internal/models"; import type { GrpcConnection, GrpcRequest } from "@yaakapp-internal/models";
import { flushAllModelWrites } from "@yaakapp-internal/models";
import { jotaiStore } from "../lib/jotai"; import { jotaiStore } from "../lib/jotai";
import { minPromiseMillis } from "../lib/minPromiseMillis"; import { minPromiseMillis } from "../lib/minPromiseMillis";
import { invokeCmd } from "../lib/tauri"; import { invokeCmd } from "../lib/tauri";
@@ -22,8 +23,14 @@ export function useGrpc(
const go = useMutation<void, string>({ const go = useMutation<void, string>({
mutationKey: ["grpc_go", conn?.id], mutationKey: ["grpc_go", conn?.id],
mutationFn: () => mutationFn: async () => {
invokeCmd<void>("cmd_grpc_go", { requestId, environmentId: environment?.id, protoFiles }), await flushAllModelWrites(); // The backend reads the request from the DB
return invokeCmd<void>("cmd_grpc_go", {
requestId,
environmentId: environment?.id,
protoFiles,
});
},
}); });
const send = useMutation({ const send = useMutation({
+8 -10
View File
@@ -6,21 +6,19 @@ import { useMemo } from "react";
export function useParentFolders(m: Folder | HttpRequest | GrpcRequest | WebsocketRequest | null) { export function useParentFolders(m: Folder | HttpRequest | GrpcRequest | WebsocketRequest | null) {
const folders = useAtomValue(foldersAtom); 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( function getParentFolders(folders: Folder[], folderId: string | null): Folder[] {
folders: Folder[], if (folderId == null) return [];
currentModel: Folder | HttpRequest | GrpcRequest | WebsocketRequest | null,
): Folder[] {
if (currentModel == null) return [];
const parentFolder = currentModel.folderId const parentFolder = folders.find((f) => f.id === folderId);
? folders.find((f) => f.id === currentModel.folderId)
: null;
if (parentFolder == null) { if (parentFolder == null) {
return []; return [];
} }
return [parentFolder, ...getParentFolders(folders, parentFolder)]; return [parentFolder, ...getParentFolders(folders, parentFolder.folderId ?? null)];
} }
@@ -6,6 +6,7 @@ import { getResponseBodySseSummary } from "../lib/responseBody";
export function useResponseBodySseSummary(response: HttpResponse, resultKeyPath: string | null) { export function useResponseBodySseSummary(response: HttpResponse, resultKeyPath: string | null) {
return useQuery<SseSummary>({ return useQuery<SseSummary>({
enabled: resultKeyPath != null, enabled: resultKeyPath != null,
placeholderData: (prev) => prev, // Keep previous data on refetch
queryKey: [ queryKey: [
"response-body-sse-summary", "response-body-sse-summary",
response.id, response.id,
+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;
}
}
@@ -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);
}
+1
View File
@@ -48,6 +48,7 @@ type TauriCmd =
| "cmd_save_response" | "cmd_save_response"
| "cmd_secure_template" | "cmd_secure_template"
| "cmd_send_ephemeral_request" | "cmd_send_ephemeral_request"
| "cmd_send_feedback"
| "cmd_send_http_request" | "cmd_send_http_request"
| "cmd_template_function_summaries" | "cmd_template_function_summaries"
| "cmd_template_function_config" | "cmd_template_function_config"
+5 -3
View File
@@ -28,15 +28,17 @@ export function showToast({
setTimeout(() => { setTimeout(() => {
const newToast: ToastInstance = { id, uniqueKey, timeout, ...props }; const newToast: ToastInstance = { id, uniqueKey, timeout, ...props };
if (timeout != null) {
setTimeout(() => hideToast(newToast), timeout);
}
jotaiStore.set(toastsAtom, (prev) => [...prev, newToast]); jotaiStore.set(toastsAtom, (prev) => [...prev, newToast]);
}, delay); }, delay);
return id; 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) { export function hideToast(toHide: ToastInstance) {
jotaiStore.set(toastsAtom, (all) => { jotaiStore.set(toastsAtom, (all) => {
const t = all.find((t) => t.uniqueKey === toHide.uniqueKey); const t = all.find((t) => t.uniqueKey === toHide.uniqueKey);
+81 -13
View File
@@ -16,6 +16,7 @@ use tokio::net::{TcpListener, TcpStream};
const OAUTH_CLIENT_ID: &str = "a1fe44800c2d7e803cad1b4bf07a291c"; const OAUTH_CLIENT_ID: &str = "a1fe44800c2d7e803cad1b4bf07a291c";
const KEYRING_USER: &str = "yaak"; const KEYRING_USER: &str = "yaak";
const AUTH_TIMEOUT: Duration = Duration::from_secs(300); const AUTH_TIMEOUT: Duration = Duration::from_secs(300);
const CALLBACK_READ_TIMEOUT: Duration = Duration::from_secs(10);
const MAX_REQUEST_BYTES: usize = 16 * 1024; const MAX_REQUEST_BYTES: usize = 16 * 1024;
type CommandResult<T = ()> = std::result::Result<T, String>; type CommandResult<T = ()> = std::result::Result<T, String>;
@@ -209,30 +210,66 @@ async fn receive_oauth_code(
expected_state: &str, expected_state: &str,
app_base_url: &str, app_base_url: &str,
) -> CommandResult<String> { ) -> CommandResult<String> {
loop { // Browsers speculatively open extra connections that may never carry a
let (mut stream, _) = listener // request. Handle each connection concurrently so an idle socket can't
.accept() // block the one carrying the real callback.
.await let (tx, mut rx) = tokio::sync::mpsc::channel::<CommandResult<String>>(1);
.map_err(|e| format!("OAuth callback server accept error: {e}"))?;
match parse_callback_request(&mut stream).await { loop {
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(),
));
}
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)) => { Ok((state, code)) => {
if state != expected_state { if state != expected_state {
let _ = write_bad_request(&mut stream, "Invalid OAuth state").await; let _ = write_bad_request(&mut stream, "Invalid OAuth state").await;
continue; return;
} }
let success_redirect = format!("{app_base_url}/login/oauth/success"); let success_redirect = format!("{app_base_url}/login/oauth/success");
write_redirect(&mut stream, &success_redirect) let result = match write_redirect(&mut stream, &success_redirect).await {
.await Ok(()) => Ok(code),
.map_err(|e| format!("Failed responding to OAuth callback: {e}"))?; Err(e) => Err(format!("Failed responding to OAuth callback: {e}")),
return Ok(code); };
let _ = tx.send(result).await;
} }
Err(error) => { Err(error) => {
let _ = write_bad_request(&mut stream, &error).await; let _ = write_bad_request(&mut stream, &error).await;
if error.starts_with("OAuth provider returned error:") { if error.starts_with("OAuth provider returned error:") {
return Err(error); let _ = tx.send(Err(error)).await;
}
} }
} }
} }
@@ -488,6 +525,37 @@ mod tests {
assert!(err.contains("User denied")); 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] #[tokio::test]
async fn receive_oauth_code_fails_fast_on_provider_error() { async fn receive_oauth_code_fails_fast_on_provider_error() {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
+3
View File
@@ -24,6 +24,9 @@ tauri-build = { version = "2.6.1", features = [] }
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
openssl-sys = { version = "0.9.105", features = ["vendored"] } # For Ubuntu installation to work 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] [dependencies]
charset = "0.1.5" charset = "0.1.5"
chrono = { workspace = true, features = ["serde"] } chrono = { workspace = true, features = ["serde"] }
@@ -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 repo_dir = dir.to_path_buf();
let workdir = paths.workdir; let workdir = paths.workdir;
let gitdir = paths.gitdir; let gitdir = paths.gitdir;
let commondir = paths.commondir;
let (tx, rx) = mpsc::channel::<notify::Result<notify::Event>>(); let (tx, rx) = mpsc::channel::<notify::Result<notify::Event>>();
let mut watcher = notify::recommended_watcher(tx) let mut watcher = notify::recommended_watcher(tx)
.map_err(|e| Error::GenericError(format!("Failed to watch Git repository: {e}")))?; .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 watcher
.watch(&workdir, notify::RecursiveMode::Recursive) .watch(&repo_dir, notify::RecursiveMode::Recursive)
.map_err(|e| Error::GenericError(format!("Failed to watch Git worktree: {e}")))?; .map_err(|e| Error::GenericError(format!("Failed to watch Git sync directory: {e}")))?;
if gitdir != workdir {
// 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 watcher
.watch(&gitdir, notify::RecursiveMode::Recursive) .watch(&gitdir, notify::RecursiveMode::NonRecursive)
.map_err(|e| Error::GenericError(format!("Failed to watch Git metadata: {e}")))?; .map_err(|e| Error::GenericError(format!("Failed to watch Git metadata: {e}")))?;
if commondir != gitdir {
watcher
.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); 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, &repo_dir,
&workdir, &workdir,
&gitdir, &gitdir,
&commondir,
&channel, &channel,
).await; ).await;
} }
@@ -98,9 +118,10 @@ async fn handle_git_watch_event(
repo_dir: &Path, repo_dir: &Path,
workdir: &Path, workdir: &Path,
gitdir: &Path, gitdir: &Path,
commondir: &Path,
channel: &Channel<GitWorktreeStatus>, 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; return;
} }
@@ -111,7 +132,7 @@ async fn handle_git_watch_event(
loop { loop {
select! { select! {
Some(event_res) = async_rx.recv() => { 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 => { _ = &mut settle_window => {
break; break;
@@ -127,6 +148,7 @@ fn is_relevant_git_watch_event(
repo_dir: &Path, repo_dir: &Path,
workdir: &Path, workdir: &Path,
gitdir: &Path, gitdir: &Path,
commondir: &Path,
) -> bool { ) -> bool {
let event = match event_res { let event = match event_res {
Ok(event) => event, Ok(event) => event,
@@ -137,7 +159,7 @@ fn is_relevant_git_watch_event(
}; };
for path in event.paths { 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; return true;
} }
+20
View File
@@ -65,6 +65,7 @@ use yaak_tls::find_client_certificate;
mod commands; mod commands;
mod encoding; mod encoding;
mod error; mod error;
mod feedback;
mod git_ext; mod git_ext;
mod git_watcher; mod git_watcher;
mod grpc; mod grpc;
@@ -292,6 +293,16 @@ async fn cmd_render_template<R: Runtime>(
Ok(result) 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] #[tauri::command]
async fn cmd_dismiss_notification<R: Runtime>( async fn cmd_dismiss_notification<R: Runtime>(
window: WebviewWindow<R>, window: WebviewWindow<R>,
@@ -1665,6 +1676,14 @@ async fn cmd_check_for_updates<R: Runtime>(
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
#[cfg_attr(feature = "cef", tauri::cef_entry_point)] #[cfg_attr(feature = "cef", tauri::cef_entry_point)]
pub fn run() { 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( let mut builder = tauri::Builder::<TauriRuntime>::default().plugin(
Builder::default() Builder::default()
.targets([ .targets([
@@ -1819,6 +1838,7 @@ pub fn run() {
cmd_delete_send_history, cmd_delete_send_history,
cmd_dismiss_notification, cmd_dismiss_notification,
cmd_export_data, cmd_export_data,
cmd_send_feedback,
cmd_http_request_body, cmd_http_request_body,
cmd_http_response_body, cmd_http_response_body,
cmd_format_json, cmd_format_json,
+19 -8
View File
@@ -194,20 +194,31 @@ pub(crate) fn models_delete<R: Runtime>(
#[tauri::command] #[tauri::command]
pub(crate) fn models_duplicate<R: Runtime>( pub(crate) fn models_duplicate<R: Runtime>(
window: WebviewWindow<R>, window: WebviewWindow<R>,
model: AnyModel, model_type: String,
model_id: String,
) -> Result<String> { ) -> Result<String> {
use yaak_models::error::Error::GenericError; use yaak_models::error::Error::GenericError;
// Use transaction for duplications because it might recurse // Use transaction for duplications because it might recurse
window.with_tx(|tx| { window.with_tx(|tx| {
let source = &UpdateSource::from_window_label(window.label()); let source = &UpdateSource::from_window_label(window.label());
let id = match model { // Fetch the model fresh from the DB so the duplicate doesn't come from
AnyModel::Environment(m) => tx.duplicate_environment(&m, source)?.id, // a stale frontend snapshot
AnyModel::Folder(m) => tx.duplicate_folder(&m, source)?.id, let id = match model_type.as_str() {
AnyModel::GrpcRequest(m) => tx.duplicate_grpc_request(&m, source)?.id, "environment" => {
AnyModel::HttpRequest(m) => tx.duplicate_http_request(&m, source)?.id, tx.duplicate_environment(&tx.get_environment(&model_id)?, source)?.id
AnyModel::WebsocketRequest(m) => tx.duplicate_websocket_request(&m, source)?.id, }
a => return Err(GenericError(format!("Cannot duplicate AnyModel {a:?})"))), "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) Ok(id)
@@ -28,7 +28,7 @@ use yaak_plugins::api::{
PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse, check_plugin_updates, PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse, check_plugin_updates,
search_plugins, 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::install::{delete_and_uninstall, download_and_install};
use yaak_plugins::manager::PluginManager; use yaak_plugins::manager::PluginManager;
use yaak_plugins::plugin_meta::get_plugin_meta; use yaak_plugins::plugin_meta::get_plugin_meta;
@@ -315,6 +315,30 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
.await .await
.expect("Failed to start plugin runtime"); .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); app_handle_clone.manage(manager);
}); });
@@ -1,4 +1,7 @@
{ {
"build": {
"features": ["updater", "license", "wry"]
},
"app": { "app": {
"security": { "security": {
"capabilities": [ "capabilities": [
@@ -1,13 +1,18 @@
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
#[cfg(target_os = "linux")]
use std::time::Duration; use std::time::Duration;
#[cfg(target_os = "linux")]
use log::{debug, warn}; 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_GLOBAL: &str = "__YAAK_INITIAL_APPEARANCE__";
pub const INITIAL_APPEARANCE_SOURCE_GLOBAL: &str = "__YAAK_INITIAL_APPEARANCE_SOURCE__"; pub const INITIAL_APPEARANCE_SOURCE_GLOBAL: &str = "__YAAK_INITIAL_APPEARANCE_SOURCE__";
pub const SYSTEM_APPEARANCE_CHANGE_EVENT: &str = "system_appearance_change"; pub const SYSTEM_APPEARANCE_CHANGE_EVENT: &str = "system_appearance_change";
#[cfg(target_os = "linux")]
const SYSTEM_APPEARANCE_POLL_INTERVAL: Duration = Duration::from_secs(1); const SYSTEM_APPEARANCE_POLL_INTERVAL: Duration = Duration::from_secs(1);
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -42,6 +47,8 @@ impl InitialAppearanceSource {
#[derive(Clone)] #[derive(Clone)]
pub struct SystemAppearanceState { 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>>>, last_appearance: Arc<Mutex<Option<Appearance>>>,
} }
+4
View File
@@ -18,3 +18,7 @@ url = "2"
yaak-common = { workspace = true } yaak-common = { workspace = true }
yaak-models = { workspace = true } yaak-models = { workspace = true }
yaak-sync = { workspace = true } yaak-sync = { workspace = true }
[dev-dependencies]
tempfile = "3"
tokio = { workspace = true, features = ["macros", "rt"] }
+6 -1
View File
@@ -19,7 +19,12 @@ export type GitStatus = "untracked" | "conflict" | "current" | "modified" | "rem
export type GitStatusEntry = { relaPath: string, status: GitStatus, staged: boolean, prev: SyncModel | null, next: SyncModel | null, }; export type GitStatusEntry = { relaPath: string, status: GitStatus, staged: boolean, prev: SyncModel | null, next: SyncModel | null, };
export type GitStatusSummary = { path: string, headRef: string | null, headRefShorthand: string | null, entries: Array<GitStatusEntry>, origins: Array<string>, localBranches: Array<string>, remoteBranches: Array<string>, ahead: number, behind: number, }; export type GitStatusSummary = { path: string,
/**
* The status directory relative to the repo root ("" when it IS the root).
* Useful for displaying entry paths relative to the sync directory
*/
relaDir: string, headRef: string | null, headRefShorthand: string | null, entries: Array<GitStatusEntry>, origins: Array<string>, localBranches: Array<string>, remoteBranches: Array<string>, ahead: number, behind: number, };
export type GitWorktreeStatus = { entries: Array<GitWorktreeStatusEntry>, }; export type GitWorktreeStatus = { entries: Array<GitWorktreeStatusEntry>, };
+87 -2
View File
@@ -1,11 +1,39 @@
use crate::binary::new_binary_command; use crate::binary::new_binary_command;
use crate::error::Error::GenericError; use crate::error::Error::GenericError;
use crate::repository::open_repo;
use crate::status::repo_relative_dir;
use log::info; use log::info;
use std::path::Path; use std::path::Path;
/// Commit the staged changes within `dir` (their current worktree content,
/// matching what the commit dialog displays). Scoping the commit to the sync
/// directory means files staged outside of it (e.g. elsewhere in a containing
/// monorepo) are never swept into a Yaak commit — they stay staged for the
/// user's own next commit.
pub async fn git_commit(dir: &Path, message: &str) -> crate::error::Result<()> { pub async fn git_commit(dir: &Path, message: &str) -> crate::error::Result<()> {
let out = // Run git from the repo root: command-line pathspecs resolve relative to
new_binary_command(dir).await?.args(["commit", "--message", message]).output().await?; // the working directory, and staged paths are repo-root-relative
let (workdir, rela_dir) = {
let repo = open_repo(dir)?;
let workdir = repo
.workdir()
.ok_or_else(|| GenericError("Repository has no worktree".to_string()))?
.to_path_buf();
(workdir, repo_relative_dir(&repo, dir))
};
let staged = staged_files(&workdir, rela_dir.as_deref()).await?;
if staged.is_empty() {
return Err(GenericError("No staged changes to commit".to_string()));
}
let mut cmd = new_binary_command(&workdir).await?;
// --literal-pathspecs: the staged paths are exact files, never patterns
cmd.arg("--literal-pathspecs");
cmd.args(["commit", "--message", message, "--"]);
cmd.args(staged);
let out = cmd.output().await?;
let stdout = String::from_utf8_lossy(&out.stdout); let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr); let stderr = String::from_utf8_lossy(&out.stderr);
@@ -19,3 +47,60 @@ pub async fn git_commit(dir: &Path, message: &str) -> crate::error::Result<()> {
Ok(()) Ok(())
} }
/// Repo-relative paths of staged changes, limited to `rela_dir` when given.
/// Must be run from the repo root so the pathspec resolves correctly. Uses
/// -z for NUL separation so paths with special characters come through
/// unquoted, --no-renames so staged renames list both sides, and
/// --literal-pathspecs so a scope dir with glob characters isn't a pattern
async fn staged_files(workdir: &Path, rela_dir: Option<&str>) -> crate::error::Result<Vec<String>> {
let mut cmd = new_binary_command(workdir).await?;
cmd.arg("--literal-pathspecs");
cmd.args(["diff", "--cached", "--name-only", "--no-renames", "-z"]);
if let Some(rela_dir) = rela_dir {
cmd.args(["--", rela_dir]);
}
let out = cmd.output().await?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
return Err(GenericError(format!("Failed to list staged files: {}", stderr)));
}
Ok(String::from_utf8_lossy(&out.stdout)
.split('\0')
.filter(|p| !p.is_empty())
.map(String::from)
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_staged_files_scoped() {
let tmp = tempfile::tempdir().unwrap();
let repo = git2::Repository::init(tmp.path()).unwrap();
// Scope dir name doubles as a glob pattern; sync1 is its glob decoy
let sync_dir = tmp.path().join("sync[1]");
std::fs::create_dir(&sync_dir).unwrap();
std::fs::write(sync_dir.join("yaak.req_1.yaml"), "inside").unwrap();
std::fs::create_dir(tmp.path().join("sync1")).unwrap();
std::fs::write(tmp.path().join("sync1").join("decoy.txt"), "decoy").unwrap();
std::fs::write(tmp.path().join("outside.txt"), "outside").unwrap();
let mut index = repo.index().unwrap();
index.add_path(Path::new("sync[1]/yaak.req_1.yaml")).unwrap();
index.add_path(Path::new("sync1/decoy.txt")).unwrap();
index.add_path(Path::new("outside.txt")).unwrap();
index.write().unwrap();
let scoped = staged_files(tmp.path(), Some("sync[1]")).await.unwrap();
assert_eq!(scoped, vec!["sync[1]/yaak.req_1.yaml"]);
let all = staged_files(tmp.path(), None).await.unwrap();
assert_eq!(all.len(), 3);
}
}
+5 -2
View File
@@ -21,8 +21,11 @@ pub enum PullResult {
fn has_uncommitted_changes(dir: &Path) -> Result<bool> { fn has_uncommitted_changes(dir: &Path) -> Result<bool> {
let repo = open_repo(dir)?; let repo = open_repo(dir)?;
let mut opts = git2::StatusOptions::new(); // Scoped to the sync directory: uncommitted changes elsewhere in a
opts.include_ignored(false).include_untracked(false); // containing monorepo shouldn't block pulling. Git itself still refuses
// a merge that would clobber uncommitted files outside the sync dir
let mut opts = crate::status::scoped_status_options(&repo, dir);
opts.include_untracked(false);
let statuses = repo.statuses(Some(&mut opts))?; let statuses = repo.statuses(Some(&mut opts))?;
Ok(statuses.iter().any(|e| e.status() != git2::Status::CURRENT)) Ok(statuses.iter().any(|e| e.status() != git2::Status::CURRENT))
} }
+8 -1
View File
@@ -6,6 +6,7 @@ use std::path::{Path, PathBuf};
pub struct GitRepositoryPaths { pub struct GitRepositoryPaths {
pub workdir: PathBuf, pub workdir: PathBuf,
pub gitdir: PathBuf, pub gitdir: PathBuf,
pub commondir: PathBuf,
} }
pub(crate) fn open_repo(dir: &Path) -> crate::error::Result<git2::Repository> { pub(crate) fn open_repo(dir: &Path) -> crate::error::Result<git2::Repository> {
@@ -22,7 +23,13 @@ pub fn git_repository_paths(dir: &Path) -> Result<GitRepositoryPaths> {
.workdir() .workdir()
.ok_or_else(|| Error::GenericError("Git repository does not have a worktree".into()))? .ok_or_else(|| Error::GenericError("Git repository does not have a worktree".into()))?
.to_path_buf(); .to_path_buf();
Ok(GitRepositoryPaths { workdir, gitdir: repo.path().to_path_buf() }) Ok(GitRepositoryPaths {
workdir,
gitdir: repo.path().to_path_buf(),
// Same as gitdir except for linked worktrees, where shared refs and
// packed-refs live in the main repository's git directory
commondir: repo.commondir().to_path_buf(),
})
} }
pub fn git_path_is_ignored(dir: &Path, rela_path: &Path) -> Result<bool> { pub fn git_path_is_ignored(dir: &Path, rela_path: &Path) -> Result<bool> {
+109 -10
View File
@@ -12,6 +12,9 @@ use yaak_sync::models::SyncModel;
#[ts(export, export_to = "gen_git.ts")] #[ts(export, export_to = "gen_git.ts")]
pub struct GitStatusSummary { pub struct GitStatusSummary {
pub path: String, pub path: String,
/// The status directory relative to the repo root ("" when it IS the root).
/// Useful for displaying entry paths relative to the sync directory
pub rela_dir: String,
pub head_ref: Option<String>, pub head_ref: Option<String>,
pub head_ref_shorthand: Option<String>, pub head_ref_shorthand: Option<String>,
pub entries: Vec<GitStatusEntry>, pub entries: Vec<GitStatusEntry>,
@@ -79,11 +82,8 @@ pub enum GitStatus {
pub fn git_worktree_status(dir: &Path) -> crate::error::Result<GitWorktreeStatus> { pub fn git_worktree_status(dir: &Path) -> crate::error::Result<GitWorktreeStatus> {
let repo = open_repo(dir)?; let repo = open_repo(dir)?;
let mut opts = git2::StatusOptions::new(); let mut opts = scoped_status_options(&repo, dir);
opts.include_ignored(false) opts.include_unmodified(false);
.include_untracked(true)
.recurse_untracked_dirs(true)
.include_unmodified(false);
let mut entries = Vec::new(); let mut entries = Vec::new();
for entry in repo.statuses(Some(&mut opts))?.into_iter() { for entry in repo.statuses(Some(&mut opts))?.into_iter() {
@@ -115,11 +115,8 @@ pub fn git_status(dir: &Path) -> crate::error::Result<GitStatusSummary> {
let branch_info = git_branch_info_for_repo(&repo, dir)?; let branch_info = git_branch_info_for_repo(&repo, dir)?;
let head_tree = repo.head().ok().and_then(|head| head.peel_to_tree().ok()); let head_tree = repo.head().ok().and_then(|head| head.peel_to_tree().ok());
let mut opts = git2::StatusOptions::new(); let mut opts = scoped_status_options(&repo, dir);
opts.include_ignored(false) opts.include_unmodified(true); // Include unchanged
.include_untracked(true) // Include untracked
.recurse_untracked_dirs(true) // Show all untracked
.include_unmodified(true); // Include unchanged
// TODO: Support renames // TODO: Support renames
@@ -160,6 +157,7 @@ pub fn git_status(dir: &Path) -> crate::error::Result<GitStatusSummary> {
Ok(GitStatusSummary { Ok(GitStatusSummary {
entries, entries,
rela_dir: repo_relative_dir(&repo, dir).unwrap_or_default(),
path: branch_info.path, path: branch_info.path,
head_ref: branch_info.head_ref, head_ref: branch_info.head_ref,
head_ref_shorthand: branch_info.head_ref_shorthand, head_ref_shorthand: branch_info.head_ref_shorthand,
@@ -266,6 +264,40 @@ fn git_status_from_raw(status: git2::Status) -> Option<(GitStatus, bool)> {
Some((status, staged)) Some((status, staged))
} }
/// Construct StatusOptions for a walk scoped to `dir`. Yaak only cares about
/// the sync directory, and a full walk is expensive when the containing repo
/// is large (e.g. a sync dir inside a monorepo); scoping is a no-op when
/// `dir` is the repo root. Always build status walks through this so a new
/// call site can't forget the scoping.
pub(crate) fn scoped_status_options(repo: &git2::Repository, dir: &Path) -> git2::StatusOptions {
let mut opts = git2::StatusOptions::new();
opts.include_ignored(false).include_untracked(true).recurse_untracked_dirs(true);
if let Some(rela) = repo_relative_dir(repo, dir) {
opts.pathspec(rela);
// Match the path literally (exact or directory prefix) instead of as
// a glob — directory names can contain pattern characters like [ or *
opts.disable_pathspec_match(true);
}
opts
}
/// The path of `dir` relative to the repo root as a forward-slash string
/// (Git pathspecs use forward slashes even on Windows), or None when `dir`
/// is the root itself (or outside the repo). Both sides are canonicalized so
/// symlinked paths compare consistently.
pub(crate) fn repo_relative_dir(repo: &git2::Repository, dir: &Path) -> Option<String> {
let workdir = repo.workdir()?;
let workdir = workdir.canonicalize().unwrap_or_else(|_| workdir.to_path_buf());
let canonical_dir = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
let rela = canonical_dir.strip_prefix(&workdir).ok()?;
if rela.as_os_str().is_empty() {
return None;
}
let parts: Vec<String> =
rela.components().map(|c| c.as_os_str().to_string_lossy().into_owned()).collect();
Some(parts.join("/"))
}
fn model_id_from_rela_path(path: &Path) -> Option<String> { fn model_id_from_rela_path(path: &Path) -> Option<String> {
let ext = path.extension()?.to_str()?; let ext = path.extension()?.to_str()?;
if ext != "yaml" && ext != "yml" && ext != "json" { if ext != "yaml" && ext != "yml" && ext != "json" {
@@ -274,3 +306,70 @@ fn model_id_from_rela_path(path: &Path) -> Option<String> {
path.file_stem()?.to_str()?.strip_prefix("yaak.").map(String::from) path.file_stem()?.to_str()?.strip_prefix("yaak.").map(String::from)
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_worktree_status_scoped_to_subdir() {
let tmp = tempfile::tempdir().unwrap();
git2::Repository::init(tmp.path()).unwrap();
let sync_dir = tmp.path().join("sync");
std::fs::create_dir(&sync_dir).unwrap();
std::fs::write(sync_dir.join("yaak.req_1.yaml"), "inside").unwrap();
std::fs::write(tmp.path().join("outside.txt"), "outside").unwrap();
// Status on a subdirectory only reports that subdirectory
let status = git_worktree_status(&sync_dir).unwrap();
let paths: Vec<&str> = status.entries.iter().map(|e| e.rela_path.as_str()).collect();
assert_eq!(paths, vec!["sync/yaak.req_1.yaml"]);
assert_eq!(status.entries[0].model_id.as_deref(), Some("req_1"));
// Status on the repo root reports everything
let status = git_worktree_status(tmp.path()).unwrap();
assert_eq!(status.entries.len(), 2);
}
#[test]
fn test_worktree_status_scoped_literal_dir_name() {
let tmp = tempfile::tempdir().unwrap();
git2::Repository::init(tmp.path()).unwrap();
// A directory name that is also a valid glob pattern ([1] matches "1")
let sync_dir = tmp.path().join("sync[1]");
std::fs::create_dir(&sync_dir).unwrap();
std::fs::write(sync_dir.join("yaak.req_1.yaml"), "inside").unwrap();
std::fs::create_dir(tmp.path().join("sync1")).unwrap();
std::fs::write(tmp.path().join("sync1").join("decoy.txt"), "glob match").unwrap();
let status = git_worktree_status(&sync_dir).unwrap();
let paths: Vec<&str> = status.entries.iter().map(|e| e.rela_path.as_str()).collect();
assert_eq!(paths, vec!["sync[1]/yaak.req_1.yaml"]);
}
#[test]
fn test_status_scoped_to_subdir() {
let tmp = tempfile::tempdir().unwrap();
git2::Repository::init(tmp.path()).unwrap();
let sync_dir = tmp.path().join("sync");
std::fs::create_dir(&sync_dir).unwrap();
std::fs::write(sync_dir.join("yaak.req_1.yaml"), "inside").unwrap();
std::fs::write(sync_dir.join("README.md"), "external, but in sync dir").unwrap();
std::fs::write(tmp.path().join("outside.txt"), "outside").unwrap();
// The commit dialog's status only reports the sync directory
let status = git_status(&sync_dir).unwrap();
assert_eq!(status.rela_dir, "sync");
let mut paths: Vec<&str> = status.entries.iter().map(|e| e.rela_path.as_str()).collect();
paths.sort();
assert_eq!(paths, vec!["sync/README.md", "sync/yaak.req_1.yaml"]);
// Status on the repo root reports everything
let status = git_status(tmp.path()).unwrap();
assert_eq!(status.rela_dir, "");
assert_eq!(status.entries.len(), 3);
}
}
+77 -14
View File
@@ -191,12 +191,16 @@ fn build_url(r: &HttpRequest) -> String {
fn append_graphql_query_params(url: &str, body: &BTreeMap<String, serde_json::Value>) -> String { fn append_graphql_query_params(url: &str, body: &BTreeMap<String, serde_json::Value>) -> String {
let query = get_str_map(body, "query").to_string(); let query = get_str_map(body, "query").to_string();
let variables = strip_json_comments(&get_str_map(body, "variables")); let variables = strip_json_comments(&get_str_map(body, "variables"));
let operation_name = get_str_map(body, "operationName").to_string();
let mut params = vec![("query".to_string(), query)]; let mut params = vec![("query".to_string(), query)];
if !variables.trim().is_empty() { if !variables.trim().is_empty() {
params.push(("variables".to_string(), variables)); params.push(("variables".to_string(), variables));
} }
if !operation_name.trim().is_empty() {
params.push(("operationName".to_string(), operation_name));
}
// Strip existing query/variables params to avoid duplicates // Strip existing query/variables params to avoid duplicates
let url = strip_query_params(url, &["query", "variables"]); let url = strip_query_params(url, &["query", "variables", "operationName"]);
append_query_params(&url, params) append_query_params(&url, params)
} }
@@ -329,23 +333,30 @@ fn build_graphql_body(
) -> Option<SendableBodyWithMeta> { ) -> Option<SendableBodyWithMeta> {
let query = get_str_map(body, "query"); let query = get_str_map(body, "query");
let variables = strip_json_comments(&get_str_map(body, "variables")); let variables = strip_json_comments(&get_str_map(body, "variables"));
let operation_name = get_str_map(body, "operationName");
if method.to_lowercase() == "get" { if method.to_lowercase() == "get" {
// GraphQL GET requests use query parameters, not a body // GraphQL GET requests use query parameters, not a body
return None; return None;
} }
let body = if variables.trim().is_empty() { let mut body = serde_json::Map::new();
format!(r#"{{"query":{}}}"#, serde_json::to_string(&query).unwrap_or_default()) body.insert("query".to_string(), serde_json::Value::String(query.to_string()));
} else { if !variables.trim().is_empty() {
format!( body.insert(
r#"{{"query":{},"variables":{}}}"#, "variables".to_string(),
serde_json::to_string(&query).unwrap_or_default(), serde_json::from_str(&variables)
variables .unwrap_or_else(|_| serde_json::Value::String(variables)),
) );
}; }
if !operation_name.trim().is_empty() {
body.insert(
"operationName".to_string(),
serde_json::Value::String(operation_name.to_string()),
);
}
Some(SendableBodyWithMeta::Bytes(Bytes::from(body))) Some(SendableBodyWithMeta::Bytes(Bytes::from(serde_json::to_string(&body).unwrap_or_default())))
} }
async fn build_multipart_body( async fn build_multipart_body(
@@ -522,6 +533,33 @@ mod tests {
assert_eq!(result, "https://example.com/api?foo=bar&baz=qux"); assert_eq!(result, "https://example.com/api?foo=bar&baz=qux");
} }
#[test]
fn test_build_url_replaces_graphql_operation_name_from_body() {
let mut body = BTreeMap::new();
body.insert("query".to_string(), json!("query Foo { foo } query Bar { bar }"));
body.insert("operationName".to_string(), json!("Bar"));
let r = HttpRequest {
method: "GET".to_string(),
body_type: Some("graphql".to_string()),
body,
url: "https://example.com/graphql".to_string(),
url_parameters: vec![HttpUrlParameter {
enabled: true,
name: "operationName".to_string(),
value: "Foo".to_string(),
id: None,
}],
..Default::default()
};
let result = build_url(&r);
assert_eq!(
result,
"https://example.com/graphql?query=query%20Foo%20%7B%20foo%20%7D%20query%20Bar%20%7B%20bar%20%7D&operationName=Bar",
);
}
#[test] #[test]
fn test_build_url_with_disabled_params() { fn test_build_url_with_disabled_params() {
let r = HttpRequest { let r = HttpRequest {
@@ -880,9 +918,34 @@ mod tests {
let result = build_graphql_body("POST", &body); let result = build_graphql_body("POST", &body);
match result { match result {
Some(SendableBodyWithMeta::Bytes(bytes)) => { Some(SendableBodyWithMeta::Bytes(bytes)) => {
let expected = assert_eq!(
r#"{"query":"{ user(id: $id) { name } }","variables":{"id": "123"}}"#; serde_json::from_slice::<serde_json::Value>(&bytes).unwrap(),
assert_eq!(bytes, Bytes::from(expected)); json!({
"query": "{ user(id: $id) { name } }",
"variables": { "id": "123" },
}),
);
}
_ => panic!("Expected Some(SendableBody::Bytes)"),
}
}
#[tokio::test]
async fn test_graphql_body_with_operation_name() {
let mut body = BTreeMap::new();
body.insert("query".to_string(), json!("query Search { viewer { id } }"));
body.insert("operationName".to_string(), json!("Search"));
let result = build_graphql_body("POST", &body);
match result {
Some(SendableBodyWithMeta::Bytes(bytes)) => {
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&bytes).unwrap(),
json!({
"query": "query Search { viewer { id } }",
"operationName": "Search",
}),
);
} }
_ => panic!("Expected Some(SendableBody::Bytes)"), _ => panic!("Expected Some(SendableBody::Bytes)"),
} }
+1
View File
@@ -402,6 +402,7 @@ export type Settings = {
themeLight: string; themeLight: string;
updateChannel: string; updateChannel: string;
hideLicenseBadge: boolean; hideLicenseBadge: boolean;
promptFeedback: boolean;
autoupdate: boolean; autoupdate: boolean;
autoDownloadUpdates: boolean; autoDownloadUpdates: boolean;
checkNotifications: boolean; checkNotifications: boolean;
+71 -34
View File
@@ -1,6 +1,6 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { resolvedModelName } from "@yaakapp/yaak-client/lib/resolvedModelName"; import { debounce } from "@yaakapp-internal/lib";
import { AnyModel, ModelPayload } from "../bindings/gen_models"; import { AnyModel, ModelPayload } from "../bindings/gen_models";
import { modelStoreDataAtom } from "./atoms"; import { modelStoreDataAtom } from "./atoms";
import { ExtractModel, JotaiStore, ModelStoreData } from "./types"; import { ExtractModel, JotaiStore, ModelStoreData } from "./types";
@@ -13,6 +13,9 @@ const pendingModelWrites = new Set<Promise<unknown>>();
export function initModelStore(store: JotaiStore) { export function initModelStore(store: JotaiStore) {
_store = store; _store = store;
// Don't lose debounced patches if the window closes while one is pending
window.addEventListener("beforeunload", flushAllPendingPatches);
getCurrentWebviewWindow() getCurrentWebviewWindow()
.listen<ModelPayload>("model_write", ({ payload }) => { .listen<ModelPayload>("model_write", ({ payload }) => {
if (shouldIgnoreModel(payload)) return; if (shouldIgnoreModel(payload)) return;
@@ -54,6 +57,7 @@ function trackModelWrite<T>(write: Promise<T>): Promise<T> {
} }
export async function flushAllModelWrites(): Promise<void> { export async function flushAllModelWrites(): Promise<void> {
flushAllPendingPatches();
const results = await Promise.allSettled(pendingModelWrites); const results = await Promise.allSettled(pendingModelWrites);
const rejected = results.find((result) => result.status === "rejected"); const rejected = results.find((result) => result.status === "rejected");
if (rejected?.status === "rejected") { if (rejected?.status === "rejected") {
@@ -61,6 +65,61 @@ export async function flushAllModelWrites(): Promise<void> {
} }
} }
const PATCH_DEBOUNCE_MS = 400;
interface PendingPatch {
model: AnyModel["model"];
id: string;
patch: Record<string, unknown>;
write: ReturnType<typeof debounce>;
}
const pendingPatches = new Map<string, PendingPatch>();
/**
* Like patchModel, but coalesces rapid patches to the same model (eg. one per
* keystroke) into a single write. Later fields overwrite earlier ones, so it's
* only safe for whole-value fields like url, body, or headers. Pending patches
* flush after a short delay, and flushAllModelWrites() (called before sends and
* duplicates) flushes them immediately.
*/
export function patchModelDebounced<
M extends AnyModel["model"],
T extends ExtractModel<AnyModel, M>,
>(base: Pick<T, "id" | "model">, patch: Partial<T>): void {
const key = `${base.model}.${base.id}`;
let pending = pendingPatches.get(key);
if (pending == null) {
pending = {
model: base.model,
id: base.id,
patch: {},
write: debounce(() => writePendingPatch(key), PATCH_DEBOUNCE_MS),
};
pendingPatches.set(key, pending);
}
pending.patch = { ...pending.patch, ...patch };
pending.write();
}
function writePendingPatch(key: string) {
const pending = pendingPatches.get(key);
if (pending == null) return;
pendingPatches.delete(key);
try {
void patchModelById(pending.model, pending.id, pending.patch);
} catch (err) {
// Model may have been deleted while the patch was pending
console.warn("Failed to flush pending patch", key, err);
}
}
export function flushAllPendingPatches() {
for (const pending of Array.from(pendingPatches.values())) {
pending.write.flush();
}
}
let _activeWorkspaceId: string | null = null; let _activeWorkspaceId: string | null = null;
export async function changeModelStoreWorkspace(workspaceId: string | null) { export async function changeModelStoreWorkspace(workspaceId: string | null) {
@@ -156,44 +215,22 @@ export async function deleteModel<M extends AnyModel["model"], T extends Extract
await trackModelWrite(invoke<string>("models_delete", { model })); await trackModelWrite(invoke<string>("models_delete", { model }));
} }
export function duplicateModel<M extends AnyModel["model"], T extends ExtractModel<AnyModel, M>>( export async function duplicateModel<
model: T | null, M extends AnyModel["model"],
) { T extends ExtractModel<AnyModel, M>,
>(model: T | null): Promise<string> {
if (model == null) { if (model == null) {
throw new Error("Failed to duplicate null model"); throw new Error("Failed to duplicate null model");
} }
// If the model has an explicit (non-empty) name, try to duplicate it with a name that doesn't conflict. // Flush pending writes first, since the backend duplicates from the DB (the passed-in
// When the name is empty, keep it empty so the display falls back to the URL. // model may be a stale snapshot, eg. from the memoized sidebar tree). Conflict-free
let name = "name" in model ? model.name : undefined; // naming ("Foo Copy 2") is also handled by the backend.
if (name) { await flushAllModelWrites();
const existingModels = listModels(model.model);
for (let i = 0; i < 100; i++) {
const hasConflict = existingModels.some((m) => {
if ("folderId" in m && "folderId" in model && model.folderId !== m.folderId) {
return false;
} else if (resolvedModelName(m) !== name) {
return false;
}
return true;
});
if (!hasConflict) {
break;
}
// Name conflict. Try another one return trackModelWrite(
const m: RegExpMatchArray | null = name.match(/ Copy( (?<n>\d+))?$/); invoke<string>("models_duplicate", { modelType: model.model, modelId: model.id }),
if (m != null && m.groups?.n == null) { );
name = name.substring(0, m.index) + " Copy 2";
} else if (m != null && m.groups?.n != null) {
name = name.substring(0, m.index) + ` Copy ${parseInt(m.groups.n) + 1}`;
} else {
name = `${name} Copy`;
}
}
}
return trackModelWrite(invoke<string>("models_duplicate", { model: { ...model, name } }));
} }
export async function createGlobalModel<T extends Exclude<AnyModel, { workspaceId: string }>>( export async function createGlobalModel<T extends Exclude<AnyModel, { workspaceId: string }>>(
@@ -0,0 +1,3 @@
-- Add a setting to enable in-app feature feedback prompts
ALTER TABLE settings
ADD COLUMN prompt_feedback BOOLEAN DEFAULT TRUE NOT NULL;
+6 -9
View File
@@ -5,7 +5,6 @@ use log::{debug, info};
use r2d2::Pool; use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager; use r2d2_sqlite::SqliteConnectionManager;
use rusqlite::{OptionalExtension, params}; use rusqlite::{OptionalExtension, params};
use std::sync::{Arc, Mutex};
static BLOB_MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/blob_migrations"); static BLOB_MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/blob_migrations");
@@ -25,23 +24,21 @@ impl BodyChunk {
} }
/// Manages the blob database connection pool. /// Manages the blob database connection pool.
// Pool is internally synchronized — don't wrap it in a Mutex. A Mutex held across the
// blocking `get()` serializes every blob access behind the slowest waiter, freezing the
// whole app whenever the pool is exhausted.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct BlobManager { pub struct BlobManager {
pool: Arc<Mutex<Pool<SqliteConnectionManager>>>, pool: Pool<SqliteConnectionManager>,
} }
impl BlobManager { impl BlobManager {
pub fn new(pool: Pool<SqliteConnectionManager>) -> Self { pub fn new(pool: Pool<SqliteConnectionManager>) -> Self {
Self { pool: Arc::new(Mutex::new(pool)) } Self { pool }
} }
pub fn connect(&self) -> BlobContext { pub fn connect(&self) -> BlobContext {
let conn = self let conn = self.pool.get().expect("Failed to get blob DB connection from pool");
.pool
.lock()
.expect("Failed to gain lock on blob DB")
.get()
.expect("Failed to get blob DB connection from pool");
BlobContext { conn } BlobContext { conn }
} }
} }
+8 -3
View File
@@ -54,11 +54,15 @@ pub fn init_standalone(
create_dir_all(parent)?; create_dir_all(parent)?;
} }
// Main database pool // Main database pool. Sized for concurrent in-flight queries, not concurrent app
// features — connections are held per-statement, so even heavy fan-out (e.g. many
// gRPC streams) only needs a handful at once. Keep max_size modest: WAL connections
// hold ~3 file descriptors each, and macOS GUI apps get a 256 fd soft limit.
info!("Initializing app database {db_path:?}"); info!("Initializing app database {db_path:?}");
let manager = sqlite_file_manager(db_path); let manager = sqlite_file_manager(db_path);
let pool = Pool::builder() let pool = Pool::builder()
.max_size(100) .max_size(20)
.min_idle(Some(2))
.connection_timeout(Duration::from_secs(10)) .connection_timeout(Duration::from_secs(10))
.build(manager) .build(manager)
.map_err(|e| Error::Database(e.to_string()))?; .map_err(|e| Error::Database(e.to_string()))?;
@@ -70,7 +74,8 @@ pub fn init_standalone(
// Blob database pool // Blob database pool
let blob_manager = sqlite_file_manager(blob_path); let blob_manager = sqlite_file_manager(blob_path);
let blob_pool = Pool::builder() let blob_pool = Pool::builder()
.max_size(50) .max_size(10)
.min_idle(Some(1))
.connection_timeout(Duration::from_secs(10)) .connection_timeout(Duration::from_secs(10))
.build(blob_manager) .build(blob_manager)
.map_err(|e| Error::Database(e.to_string()))?; .map_err(|e| Error::Database(e.to_string()))?;
+4
View File
@@ -246,6 +246,7 @@ pub struct Settings {
pub theme_light: String, pub theme_light: String,
pub update_channel: String, pub update_channel: String,
pub hide_license_badge: bool, pub hide_license_badge: bool,
pub prompt_feedback: bool,
pub autoupdate: bool, pub autoupdate: bool,
pub auto_download_updates: bool, pub auto_download_updates: bool,
pub check_notifications: bool, pub check_notifications: bool,
@@ -303,6 +304,7 @@ impl UpsertModelInfo for Settings {
(ThemeLight, self.theme_light.as_str().into()), (ThemeLight, self.theme_light.as_str().into()),
(UpdateChannel, self.update_channel.into()), (UpdateChannel, self.update_channel.into()),
(HideLicenseBadge, self.hide_license_badge.into()), (HideLicenseBadge, self.hide_license_badge.into()),
(PromptFeedback, self.prompt_feedback.into()),
(Autoupdate, self.autoupdate.into()), (Autoupdate, self.autoupdate.into()),
(AutoDownloadUpdates, self.auto_download_updates.into()), (AutoDownloadUpdates, self.auto_download_updates.into()),
(ColoredMethods, self.colored_methods.into()), (ColoredMethods, self.colored_methods.into()),
@@ -332,6 +334,7 @@ impl UpsertModelInfo for Settings {
SettingsIden::ThemeLight, SettingsIden::ThemeLight,
SettingsIden::UpdateChannel, SettingsIden::UpdateChannel,
SettingsIden::HideLicenseBadge, SettingsIden::HideLicenseBadge,
SettingsIden::PromptFeedback,
SettingsIden::Autoupdate, SettingsIden::Autoupdate,
SettingsIden::AutoDownloadUpdates, SettingsIden::AutoDownloadUpdates,
SettingsIden::ColoredMethods, SettingsIden::ColoredMethods,
@@ -372,6 +375,7 @@ impl UpsertModelInfo for Settings {
autoupdate: row.get("autoupdate")?, autoupdate: row.get("autoupdate")?,
auto_download_updates: row.get("auto_download_updates")?, auto_download_updates: row.get("auto_download_updates")?,
hide_license_badge: row.get("hide_license_badge")?, hide_license_badge: row.get("hide_license_badge")?,
prompt_feedback: row.get("prompt_feedback")?,
colored_methods: row.get("colored_methods")?, colored_methods: row.get("colored_methods")?,
check_notifications: row.get("check_notifications")?, check_notifications: row.get("check_notifications")?,
hotkeys: serde_json::from_str(&hotkeys).unwrap_or_default(), hotkeys: serde_json::from_str(&hotkeys).unwrap_or_default(),
@@ -0,0 +1,56 @@
/// Compute a name for a duplicated model that doesn't conflict with any sibling
/// name, following the " Copy N" convention. Empty names are kept empty so the
/// display falls back to the URL.
pub(crate) fn conflict_free_name(name: &str, sibling_names: &[String]) -> String {
if name.is_empty() {
return String::new();
}
let mut name = name.to_string();
for _ in 0..100 {
if !sibling_names.contains(&name) {
break;
}
name = next_copy_name(&name);
}
name
}
fn next_copy_name(name: &str) -> String {
if let Some(base) = name.strip_suffix(" Copy") {
return format!("{base} Copy 2");
}
if let Some(idx) = name.rfind(" Copy ") {
let n = &name[idx + " Copy ".len()..];
if !n.is_empty() && n.chars().all(|c| c.is_ascii_digit()) {
if let Ok(n) = n.parse::<u64>() {
return format!("{} Copy {}", &name[..idx], n + 1);
}
}
}
format!("{name} Copy")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_next_copy_name() {
assert_eq!(next_copy_name("Foo"), "Foo Copy");
assert_eq!(next_copy_name("Foo Copy"), "Foo Copy 2");
assert_eq!(next_copy_name("Foo Copy 2"), "Foo Copy 3");
assert_eq!(next_copy_name("Foo Copy 99"), "Foo Copy 100");
assert_eq!(next_copy_name("Copy"), "Copy Copy");
}
#[test]
fn test_conflict_free_name() {
let siblings = vec!["Foo".to_string(), "Foo Copy".to_string(), "".to_string()];
assert_eq!(conflict_free_name("Foo", &siblings), "Foo Copy 2");
assert_eq!(conflict_free_name("Bar", &siblings), "Bar");
assert_eq!(conflict_free_name("", &siblings), "");
}
}
@@ -1,3 +1,4 @@
use super::conflict_free_name;
use crate::client_db::ClientDb; use crate::client_db::ClientDb;
use crate::error::Error::{MissingBaseEnvironment, MultipleBaseEnvironments}; use crate::error::Error::{MissingBaseEnvironment, MultipleBaseEnvironments};
use crate::error::Result; use crate::error::Result;
@@ -88,6 +89,12 @@ impl<'a> ClientDb<'a> {
) -> Result<Environment> { ) -> Result<Environment> {
let mut environment = environment.clone(); let mut environment = environment.clone();
environment.id = "".to_string(); environment.id = "".to_string();
let sibling_names = self
.list_environments_dangerous(&environment.workspace_id)?
.into_iter()
.map(|e| e.name)
.collect::<Vec<_>>();
environment.name = conflict_free_name(&environment.name, &sibling_names);
self.upsert_environment(&environment, source) self.upsert_environment(&environment, source)
} }
+11 -5
View File
@@ -1,3 +1,4 @@
use super::conflict_free_name;
use crate::client_db::ClientDb; use crate::client_db::ClientDb;
use crate::connection_or_tx::ConnectionOrTx; use crate::connection_or_tx::ConnectionOrTx;
use crate::error::Result; use crate::error::Result;
@@ -62,14 +63,19 @@ impl<'a> ClientDb<'a> {
pub fn duplicate_folder(&self, src_folder: &Folder, source: &UpdateSource) -> Result<Folder> { pub fn duplicate_folder(&self, src_folder: &Folder, source: &UpdateSource) -> Result<Folder> {
let fid = &src_folder.id; let fid = &src_folder.id;
let new_folder = self.upsert_folder( let mut folder = Folder {
&Folder {
id: "".into(), id: "".into(),
sort_priority: src_folder.sort_priority + 0.001, sort_priority: src_folder.sort_priority + 0.001,
..src_folder.clone() ..src_folder.clone()
}, };
source, let sibling_names = self
)?; .list_folders(&folder.workspace_id)?
.into_iter()
.filter(|f| f.folder_id == folder.folder_id)
.map(|f| f.name)
.collect::<Vec<_>>();
folder.name = conflict_free_name(&folder.name, &sibling_names);
let new_folder = self.upsert_folder(&folder, source)?;
for m in self.find_many::<HttpRequest>(HttpRequestIden::FolderId, fid, None)? { for m in self.find_many::<HttpRequest>(HttpRequestIden::FolderId, fid, None)? {
self.upsert_http_request( self.upsert_http_request(
@@ -1,4 +1,4 @@
use super::dedupe_headers; use super::{conflict_free_name, dedupe_headers};
use crate::client_db::ClientDb; use crate::client_db::ClientDb;
use crate::error::Result; use crate::error::Result;
use crate::models::{ use crate::models::{
@@ -58,6 +58,13 @@ impl<'a> ClientDb<'a> {
let mut request = grpc_request.clone(); let mut request = grpc_request.clone();
request.id = "".to_string(); request.id = "".to_string();
request.sort_priority = request.sort_priority + 0.001; request.sort_priority = request.sort_priority + 0.001;
let sibling_names = self
.list_grpc_requests(&request.workspace_id)?
.into_iter()
.filter(|m| m.folder_id == request.folder_id)
.map(|m| m.name)
.collect::<Vec<_>>();
request.name = conflict_free_name(&request.name, &sibling_names);
self.upsert(&request, source) self.upsert(&request, source)
} }
@@ -1,4 +1,4 @@
use super::dedupe_headers; use super::{conflict_free_name, dedupe_headers};
use crate::client_db::ClientDb; use crate::client_db::ClientDb;
use crate::error::Result; use crate::error::Result;
use crate::models::{ use crate::models::{
@@ -44,6 +44,13 @@ impl<'a> ClientDb<'a> {
let mut http_request = http_request.clone(); let mut http_request = http_request.clone();
http_request.id = "".to_string(); http_request.id = "".to_string();
http_request.sort_priority = http_request.sort_priority + 0.001; http_request.sort_priority = http_request.sort_priority + 0.001;
let sibling_names = self
.list_http_requests(&http_request.workspace_id)?
.into_iter()
.filter(|m| m.folder_id == http_request.folder_id)
.map(|m| m.name)
.collect::<Vec<_>>();
http_request.name = conflict_free_name(&http_request.name, &sibling_names);
self.upsert(&http_request, source) self.upsert(&http_request, source)
} }
+2
View File
@@ -1,6 +1,7 @@
pub mod any_request; pub mod any_request;
mod batch; mod batch;
mod cookie_jars; mod cookie_jars;
mod duplicate_name;
mod environments; mod environments;
mod folders; mod folders;
mod graphql_introspections; mod graphql_introspections;
@@ -22,6 +23,7 @@ mod websocket_requests;
mod workspace_metas; mod workspace_metas;
pub mod workspaces; pub mod workspaces;
pub use model_changes::PersistedModelChange; pub use model_changes::PersistedModelChange;
pub(crate) use duplicate_name::conflict_free_name;
const MAX_HISTORY_ITEMS: usize = 20; const MAX_HISTORY_ITEMS: usize = 20;
@@ -38,6 +38,7 @@ impl<'a> ClientDb<'a> {
autoupdate: true, autoupdate: true,
colored_methods: false, colored_methods: false,
hide_license_badge: false, hide_license_badge: false,
prompt_feedback: true,
auto_download_updates: true, auto_download_updates: true,
check_notifications: true, check_notifications: true,
hotkeys: HashMap::new(), hotkeys: HashMap::new(),
@@ -1,4 +1,4 @@
use super::dedupe_headers; use super::{conflict_free_name, dedupe_headers};
use crate::client_db::ClientDb; use crate::client_db::ClientDb;
use crate::error::Result; use crate::error::Result;
use crate::models::{ use crate::models::{
@@ -60,6 +60,13 @@ impl<'a> ClientDb<'a> {
let mut websocket_request = websocket_request.clone(); let mut websocket_request = websocket_request.clone();
websocket_request.id = "".to_string(); websocket_request.id = "".to_string();
websocket_request.sort_priority = websocket_request.sort_priority + 0.001; websocket_request.sort_priority = websocket_request.sort_priority + 0.001;
let sibling_names = self
.list_websocket_requests(&websocket_request.workspace_id)?
.into_iter()
.filter(|m| m.folder_id == websocket_request.folder_id)
.map(|m| m.name)
.collect::<Vec<_>>();
websocket_request.name = conflict_free_name(&websocket_request.name, &sibling_names);
self.upsert(&websocket_request, source) self.upsert(&websocket_request, source)
} }
+9 -21
View File
@@ -4,27 +4,25 @@ use crate::util::ModelPayload;
use r2d2::Pool; use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager; use r2d2_sqlite::SqliteConnectionManager;
use rusqlite::TransactionBehavior; use rusqlite::TransactionBehavior;
use std::sync::{Arc, Mutex, mpsc}; use std::sync::mpsc;
use yaak_database::{ConnectionOrTx, DbContext}; use yaak_database::{ConnectionOrTx, DbContext};
// Pool is internally synchronized — don't wrap it in a Mutex. A Mutex held across the
// blocking `get()` serializes every DB access behind the slowest waiter, freezing the
// whole app whenever the pool is exhausted.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct QueryManager { pub struct QueryManager {
pool: Arc<Mutex<Pool<SqliteConnectionManager>>>, pool: Pool<SqliteConnectionManager>,
events_tx: mpsc::Sender<ModelPayload>, events_tx: mpsc::Sender<ModelPayload>,
} }
impl QueryManager { impl QueryManager {
pub fn new(pool: Pool<SqliteConnectionManager>, events_tx: mpsc::Sender<ModelPayload>) -> Self { pub fn new(pool: Pool<SqliteConnectionManager>, events_tx: mpsc::Sender<ModelPayload>) -> Self {
QueryManager { pool: Arc::new(Mutex::new(pool)), events_tx } QueryManager { pool, events_tx }
} }
pub fn connect(&self) -> ClientDb<'_> { pub fn connect(&self) -> ClientDb<'_> {
let conn = self let conn = self.pool.get().expect("Failed to get a new DB connection from the pool");
.pool
.lock()
.expect("Failed to gain lock on DB")
.get()
.expect("Failed to get a new DB connection from the pool");
let ctx = DbContext::new(ConnectionOrTx::Connection(conn)); let ctx = DbContext::new(ConnectionOrTx::Connection(conn));
ClientDb::new(ctx, self.events_tx.clone()) ClientDb::new(ctx, self.events_tx.clone())
} }
@@ -33,12 +31,7 @@ impl QueryManager {
where where
F: FnOnce(&ClientDb) -> T, F: FnOnce(&ClientDb) -> T,
{ {
let conn = self let conn = self.pool.get().expect("Failed to get new DB connection from the pool");
.pool
.lock()
.expect("Failed to gain lock on DB for transaction")
.get()
.expect("Failed to get new DB connection from the pool");
let ctx = DbContext::new(ConnectionOrTx::Connection(conn)); let ctx = DbContext::new(ConnectionOrTx::Connection(conn));
let db = ClientDb::new(ctx, self.events_tx.clone()); let db = ClientDb::new(ctx, self.events_tx.clone());
@@ -53,12 +46,7 @@ impl QueryManager {
where where
E: From<crate::error::Error>, E: From<crate::error::Error>,
{ {
let mut conn = self let mut conn = self.pool.get().expect("Failed to get new DB connection from the pool");
.pool
.lock()
.expect("Failed to gain lock on DB for transaction")
.get()
.expect("Failed to get new DB connection from the pool");
let tx = conn let tx = conn
.transaction_with_behavior(TransactionBehavior::Immediate) .transaction_with_behavior(TransactionBehavior::Immediate)
.expect("Failed to start DB transaction"); .expect("Failed to start DB transaction");
+31 -3
View File
@@ -52,6 +52,9 @@ pub struct PluginManager {
dev_mode: bool, dev_mode: bool,
/// Errors from plugin initialization, retrievable once via `take_init_errors`. /// Errors from plugin initialization, retrievable once via `take_init_errors`.
init_errors: Arc<Mutex<Vec<(String, String)>>>, init_errors: Arc<Mutex<Vec<(String, String)>>>,
/// Set to the exit status if the runtime dies unexpectedly, observable
/// via `runtime_crash_rx`.
runtime_crash_rx: tokio::sync::watch::Receiver<Option<String>>,
} }
/// Callback for plugin initialization events (e.g., toast notifications) /// Callback for plugin initialization events (e.g., toast notifications)
@@ -83,6 +86,12 @@ impl PluginManager {
let (client_disconnect_tx, mut client_disconnect_rx) = mpsc::channel(128); let (client_disconnect_tx, mut client_disconnect_rx) = mpsc::channel(128);
let (client_connect_tx, mut client_connect_rx) = tokio::sync::watch::channel(false); let (client_connect_tx, mut client_connect_rx) = tokio::sync::watch::channel(false);
// Set to the exit status if the runtime dies unexpectedly. The app is
// still usable without the plugin runtime (just missing features), so
// this unblocks startup and is surfaced to the user rather than
// taking the app down.
let (unexpected_exit_tx, unexpected_exit_rx) =
tokio::sync::watch::channel::<Option<String>>(None);
let ws_service = let ws_service =
PluginRuntimeServerWebsocket::new(events_tx, client_disconnect_tx, client_connect_tx); PluginRuntimeServerWebsocket::new(events_tx, client_disconnect_tx, client_connect_tx);
@@ -96,6 +105,7 @@ impl PluginManager {
installed_plugin_dir, installed_plugin_dir,
dev_mode, dev_mode,
init_errors: Default::default(), init_errors: Default::default(),
runtime_crash_rx: unexpected_exit_rx.clone(),
}; };
// Forward events to subscribers // Forward events to subscribers
@@ -132,9 +142,11 @@ impl PluginManager {
let listener = TcpListener::bind(listen_addr).await.expect("Failed to bind TCP listener"); let listener = TcpListener::bind(listen_addr).await.expect("Failed to bind TCP listener");
let addr = listener.local_addr().expect("Failed to get local address"); let addr = listener.local_addr().expect("Failed to get local address");
// 1. Wait for Node.js runtime to connect // 1. Wait for the Node.js runtime to connect, or for it to die trying
let mut init_dead_rx = unexpected_exit_rx.clone();
let init_plugins_task = tokio::spawn(async move { let init_plugins_task = tokio::spawn(async move {
match client_connect_rx.changed().await { tokio::select! {
result = client_connect_rx.changed() => match result {
Ok(_) => { Ok(_) => {
info!("Plugin runtime client connected!"); info!("Plugin runtime client connected!");
// Note: initialize_all_plugins is now called separately by the app // Note: initialize_all_plugins is now called separately by the app
@@ -143,6 +155,10 @@ impl PluginManager {
Err(e) => { Err(e) => {
warn!("Failed to receive from client connection rx {e:?}"); warn!("Failed to receive from client connection rx {e:?}");
} }
},
_ = init_dead_rx.wait_for(|status| status.is_some()) => {
warn!("Plugin runtime exited before connecting; continuing without plugins");
}
} }
}); });
@@ -159,11 +175,17 @@ impl PluginManager {
addr, addr,
&kill_server_rx, &kill_server_rx,
killed_tx, killed_tx,
unexpected_exit_tx,
) )
.await?; .await?;
info!("Waiting for plugins to initialize"); info!("Waiting for plugins to initialize");
init_plugins_task.await.map_err(|e| PluginErr(e.to_string()))?; init_plugins_task.await.map_err(|e| PluginErr(e.to_string()))?;
if unexpected_exit_rx.borrow().is_some() {
warn!("Skipping plugin initialization because the runtime is not running");
return Ok(plugin_manager);
}
let bundled_dirs = plugin_manager.list_bundled_plugin_dirs().await?; let bundled_dirs = plugin_manager.list_bundled_plugin_dirs().await?;
let db = query_manager.connect(); let db = query_manager.connect();
for dir in &bundled_dirs { for dir in &bundled_dirs {
@@ -201,6 +223,12 @@ impl PluginManager {
std::mem::take(&mut *self.init_errors.lock().await) std::mem::take(&mut *self.init_errors.lock().await)
} }
/// A receiver that is set to the exit status if the plugin runtime dies
/// unexpectedly.
pub fn runtime_crash_rx(&self) -> tokio::sync::watch::Receiver<Option<String>> {
self.runtime_crash_rx.clone()
}
/// Get the vendored plugin directory path (resolves dev mode path if applicable) /// Get the vendored plugin directory path (resolves dev mode path if applicable)
pub fn get_plugins_dir(&self) -> PathBuf { pub fn get_plugins_dir(&self) -> PathBuf {
if self.dev_mode { if self.dev_mode {
@@ -1070,7 +1098,7 @@ impl PluginManager {
&InternalEventPayload::ImportRequest(ImportRequest { &InternalEventPayload::ImportRequest(ImportRequest {
content: content.to_string(), content: content.to_string(),
}), }),
Duration::from_secs(5), Duration::from_secs(60),
) )
.await?; .await?;
+18 -3
View File
@@ -1,5 +1,5 @@
use crate::error::Result; use crate::error::Result;
use log::{info, warn}; use log::{error, info, warn};
use std::net::SocketAddr; use std::net::SocketAddr;
use std::path::Path; use std::path::Path;
use std::process::Stdio; use std::process::Stdio;
@@ -15,12 +15,16 @@ use yaak_common::command::new_xplatform_command;
/// * `plugin_runtime_main` - Path to the plugin runtime index.cjs /// * `plugin_runtime_main` - Path to the plugin runtime index.cjs
/// * `addr` - Socket address for the plugin runtime to connect to /// * `addr` - Socket address for the plugin runtime to connect to
/// * `kill_rx` - Channel to signal shutdown /// * `kill_rx` - Channel to signal shutdown
/// * `killed_tx` - Notified once the runtime is killed after a shutdown signal
/// * `unexpected_exit_tx` - Set to the exit status if the runtime exits
/// without being asked to
pub async fn start_nodejs_plugin_runtime( pub async fn start_nodejs_plugin_runtime(
node_bin_path: &Path, node_bin_path: &Path,
plugin_runtime_main: &Path, plugin_runtime_main: &Path,
addr: SocketAddr, addr: SocketAddr,
kill_rx: &Receiver<bool>, kill_rx: &Receiver<bool>,
killed_tx: oneshot::Sender<()>, killed_tx: oneshot::Sender<()>,
unexpected_exit_tx: tokio::sync::watch::Sender<Option<String>>,
) -> Result<()> { ) -> Result<()> {
// HACK: Remove UNC prefix for Windows paths to pass to sidecar // HACK: Remove UNC prefix for Windows paths to pass to sidecar
let plugin_runtime_main_str = let plugin_runtime_main_str =
@@ -65,10 +69,19 @@ pub async fn start_nodejs_plugin_runtime(
}); });
} }
// Handle kill signal // Wait for either an explicit kill signal or the child exiting on its own.
// An unexpected exit is reported to the caller, which decides whether to
// abort startup or surface a user-facing error.
let mut kill_rx = kill_rx.clone(); let mut kill_rx = kill_rx.clone();
tokio::spawn(async move { tokio::spawn(async move {
if kill_rx.wait_for(|b| *b == true).await.is_err() { tokio::select! {
status = child.wait() => {
let status = status.map(|s| s.to_string()).unwrap_or_else(|e| e.to_string());
error!("Plugin runtime exited unexpectedly ({status})");
let _ = unexpected_exit_tx.send(Some(status));
}
closed = async { kill_rx.wait_for(|b| *b == true).await.is_err() } => {
if closed {
warn!("Kill channel closed before explicit shutdown; terminating plugin runtime"); warn!("Kill channel closed before explicit shutdown; terminating plugin runtime");
} }
info!("Killing plugin runtime"); info!("Killing plugin runtime");
@@ -77,6 +90,8 @@ pub async fn start_nodejs_plugin_runtime(
} }
info!("Killed plugin runtime"); info!("Killed plugin runtime");
let _ = killed_tx.send(()); let _ = killed_tx.send(());
}
}
}); });
Ok(()) Ok(())
+13 -1
View File
@@ -1,5 +1,6 @@
const { execSync } = require("node:child_process"); const { execSync } = require("node:child_process");
const fs = require("node:fs"); const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path"); const path = require("node:path");
if (process.env.SKIP_WASM_BUILD === "1") { if (process.env.SKIP_WASM_BUILD === "1") {
@@ -7,7 +8,18 @@ if (process.env.SKIP_WASM_BUILD === "1") {
return; return;
} }
execSync("wasm-pack build --target bundler", { stdio: "inherit" }); // Remap machine-specific paths that rustc embeds into the binary (panic
// location strings), so builds are reproducible across machines
const sysroot = execSync("rustc --print sysroot").toString().trim();
const cargoHome = process.env.CARGO_HOME ?? path.join(os.homedir(), ".cargo");
execSync("wasm-pack build --target bundler", {
stdio: "inherit",
env: {
...process.env,
RUSTFLAGS: `--remap-path-prefix=${cargoHome}=/cargo --remap-path-prefix=${sysroot}=/rustc`,
},
});
// Rewrite the generated entry to use Vite's ?init import style instead of // Rewrite the generated entry to use Vite's ?init import style instead of
// the ES Module Integration style that wasm-pack generates, which Vite/rolldown // the ES Module Integration style that wasm-pack generates, which Vite/rolldown
+4 -1
View File
@@ -1,5 +1,8 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export function unescape_template(template: string): any;
export function escape_template(template: string): any; export function escape_template(template: string): any;
export function parse_template(template: string): any; export function parse_template(template: string): any;
export function unescape_template(template: string): any;
+118 -128
View File
@@ -1,9 +1,82 @@
let wasm; /**
export function __wbg_set_wasm(val) { * @param {string} template
wasm = val; * @returns {any}
*/
export function escape_template(template) {
const ptr0 = passStringToWasm0(template, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.escape_template(ptr0, len0);
if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}
return takeFromExternrefTable0(ret[0]);
} }
/**
* @param {string} template
* @returns {any}
*/
export function parse_template(template) {
const ptr0 = passStringToWasm0(template, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.parse_template(ptr0, len0);
if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}
return takeFromExternrefTable0(ret[0]);
}
/**
* @param {string} template
* @returns {any}
*/
export function unescape_template(template) {
const ptr0 = passStringToWasm0(template, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.unescape_template(ptr0, len0);
if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}
return takeFromExternrefTable0(ret[0]);
}
export function __wbg___wbindgen_debug_string_edece8177ad01481(arg0, arg1) {
const ret = debugString(arg1);
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
}
export function __wbg___wbindgen_throw_9c31b086c2b26051(arg0, arg1) {
throw new Error(getStringFromWasm0(arg0, arg1));
}
export function __wbg_new_02d162bc6cf02f60() {
const ret = new Object();
return ret;
}
export function __wbg_new_310879b66b6e95e1() {
const ret = new Array();
return ret;
}
export function __wbg_set_6be42768c690e380(arg0, arg1, arg2) {
arg0[arg1] = arg2;
}
export function __wbg_set_78ea6a19f4818587(arg0, arg1, arg2) {
arg0[arg1 >>> 0] = arg2;
}
export function __wbindgen_cast_0000000000000001(arg0, arg1) {
// Cast intrinsic for `Ref(String) -> Externref`.
const ret = getStringFromWasm0(arg0, arg1);
return ret;
}
export function __wbindgen_init_externref_table() {
const table = wasm.__wbindgen_externrefs;
const offset = table.grow(4);
table.set(0, undefined);
table.set(offset + 0, undefined);
table.set(offset + 1, null);
table.set(offset + 2, true);
table.set(offset + 3, false);
}
function debugString(val) { function debugString(val) {
// primitive types // primitive types
const type = typeof val; const type = typeof val;
@@ -69,10 +142,19 @@ function debugString(val) {
return className; return className;
} }
let WASM_VECTOR_LEN = 0; let cachedDataViewMemory0 = null;
function getDataViewMemory0() {
if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) {
cachedDataViewMemory0 = new DataView(wasm.memory.buffer);
}
return cachedDataViewMemory0;
}
function getStringFromWasm0(ptr, len) {
return decodeText(ptr >>> 0, len);
}
let cachedUint8ArrayMemory0 = null; let cachedUint8ArrayMemory0 = null;
function getUint8ArrayMemory0() { function getUint8ArrayMemory0() {
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) { if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer); cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
@@ -80,25 +162,7 @@ function getUint8ArrayMemory0() {
return cachedUint8ArrayMemory0; return cachedUint8ArrayMemory0;
} }
const lTextEncoder = typeof TextEncoder === 'undefined' ? (0, module.require)('util').TextEncoder : TextEncoder;
let cachedTextEncoder = new lTextEncoder('utf-8');
const encodeString = (typeof cachedTextEncoder.encodeInto === 'function'
? function (arg, view) {
return cachedTextEncoder.encodeInto(arg, view);
}
: function (arg, view) {
const buf = cachedTextEncoder.encode(arg);
view.set(buf);
return {
read: arg.length,
written: buf.length
};
});
function passStringToWasm0(arg, malloc, realloc) { function passStringToWasm0(arg, malloc, realloc) {
if (realloc === undefined) { if (realloc === undefined) {
const buf = cachedTextEncoder.encode(arg); const buf = cachedTextEncoder.encode(arg);
const ptr = malloc(buf.length, 1) >>> 0; const ptr = malloc(buf.length, 1) >>> 0;
@@ -119,14 +183,13 @@ function passStringToWasm0(arg, malloc, realloc) {
if (code > 0x7F) break; if (code > 0x7F) break;
mem[ptr + offset] = code; mem[ptr + offset] = code;
} }
if (offset !== len) { if (offset !== len) {
if (offset !== 0) { if (offset !== 0) {
arg = arg.slice(offset); arg = arg.slice(offset);
} }
ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0; ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len); const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
const ret = encodeString(arg, view); const ret = cachedTextEncoder.encodeInto(arg, view);
offset += ret.written; offset += ret.written;
ptr = realloc(ptr, len, offset, 1) >>> 0; ptr = realloc(ptr, len, offset, 1) >>> 0;
@@ -136,116 +199,43 @@ function passStringToWasm0(arg, malloc, realloc) {
return ptr; return ptr;
} }
let cachedDataViewMemory0 = null;
function getDataViewMemory0() {
if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) {
cachedDataViewMemory0 = new DataView(wasm.memory.buffer);
}
return cachedDataViewMemory0;
}
const lTextDecoder = typeof TextDecoder === 'undefined' ? (0, module.require)('util').TextDecoder : TextDecoder;
let cachedTextDecoder = new lTextDecoder('utf-8', { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
function getStringFromWasm0(ptr, len) {
ptr = ptr >>> 0;
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
}
function takeFromExternrefTable0(idx) { function takeFromExternrefTable0(idx) {
const value = wasm.__wbindgen_export_2.get(idx); const value = wasm.__wbindgen_externrefs.get(idx);
wasm.__externref_table_dealloc(idx); wasm.__externref_table_dealloc(idx);
return value; return value;
} }
/**
* @param {string} template let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
* @returns {any} cachedTextDecoder.decode();
*/ const MAX_SAFARI_DECODE_BYTES = 2146435072;
export function unescape_template(template) { let numBytesDecoded = 0;
const ptr0 = passStringToWasm0(template, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); function decodeText(ptr, len) {
const len0 = WASM_VECTOR_LEN; numBytesDecoded += len;
const ret = wasm.unescape_template(ptr0, len0); if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
if (ret[2]) { cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
throw takeFromExternrefTable0(ret[1]); cachedTextDecoder.decode();
numBytesDecoded = len;
} }
return takeFromExternrefTable0(ret[0]); return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
} }
/** const cachedTextEncoder = new TextEncoder();
* @param {string} template
* @returns {any} if (!('encodeInto' in cachedTextEncoder)) {
*/ cachedTextEncoder.encodeInto = function (arg, view) {
export function escape_template(template) { const buf = cachedTextEncoder.encode(arg);
const ptr0 = passStringToWasm0(template, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); view.set(buf);
const len0 = WASM_VECTOR_LEN; return {
const ret = wasm.escape_template(ptr0, len0); read: arg.length,
if (ret[2]) { written: buf.length
throw takeFromExternrefTable0(ret[1]); };
} };
return takeFromExternrefTable0(ret[0]);
} }
/** let WASM_VECTOR_LEN = 0;
* @param {string} template
* @returns {any}
*/ let wasm;
export function parse_template(template) { export function __wbg_set_wasm(val) {
const ptr0 = passStringToWasm0(template, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); wasm = val;
const len0 = WASM_VECTOR_LEN;
const ret = wasm.parse_template(ptr0, len0);
if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}
return takeFromExternrefTable0(ret[0]);
} }
export function __wbg_new_405e22f390576ce2() {
const ret = new Object();
return ret;
};
export function __wbg_new_78feb108b6472713() {
const ret = new Array();
return ret;
};
export function __wbg_set_37837023f3d740e8(arg0, arg1, arg2) {
arg0[arg1 >>> 0] = arg2;
};
export function __wbg_set_3f1d0b984ed272ed(arg0, arg1, arg2) {
arg0[arg1] = arg2;
};
export function __wbindgen_debug_string(arg0, arg1) {
const ret = debugString(arg1);
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
};
export function __wbindgen_init_externref_table() {
const table = wasm.__wbindgen_export_2;
const offset = table.grow(4);
table.set(0, undefined);
table.set(offset + 0, undefined);
table.set(offset + 1, null);
table.set(offset + 2, true);
table.set(offset + 3, false);
;
};
export function __wbindgen_string_new(arg0, arg1) {
const ret = getStringFromWasm0(arg0, arg1);
return ret;
};
export function __wbindgen_throw(arg0, arg1) {
throw new Error(getStringFromWasm0(arg0, arg1));
};
Binary file not shown.
+1 -1
View File
@@ -6,6 +6,6 @@ export const parse_template: (a: number, b: number) => [number, number, number];
export const unescape_template: (a: number, b: number) => [number, number, number]; export const unescape_template: (a: number, b: number) => [number, number, number];
export const __wbindgen_malloc: (a: number, b: number) => number; export const __wbindgen_malloc: (a: number, b: number) => number;
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
export const __wbindgen_export_2: WebAssembly.Table; export const __wbindgen_externrefs: WebAssembly.Table;
export const __externref_table_dealloc: (a: number) => void; export const __externref_table_dealloc: (a: number) => void;
export const __wbindgen_start: () => void; export const __wbindgen_start: () => void;
+70 -18
View File
@@ -511,7 +511,10 @@ pub async fn send_http_request<T: TemplateCallback>(
.map_err(SendHttpRequestError::PrepareSendableRequest)?; .map_err(SendHttpRequestError::PrepareSendableRequest)?;
} }
let request_content_length = sendable_body_length(sendable_request.body.as_ref()); let request_content_length = match sendable_request.body.as_ref() {
Some(SendableBody::Bytes(_)) => sendable_body_length(sendable_request.body.as_ref()),
Some(SendableBody::Stream { .. }) | None => None,
};
let mut response = params.existing_response.unwrap_or_default(); let mut response = params.existing_response.unwrap_or_default();
response.request_id = params.request.id.clone(); response.request_id = params.request.id.clone();
response.workspace_id = params.request.workspace_id.clone(); response.workspace_id = params.request.workspace_id.clone();
@@ -811,16 +814,6 @@ pub async fn send_http_request<T: TemplateCallback>(
})?; })?;
drop(body_stream); drop(body_stream);
if let Some(task) = request_body_capture_task.take() {
match task.await {
Ok(Ok(total)) => {
response.request_content_length = Some(usize_to_i32(total));
}
Ok(Err(err)) => request_body_capture_error = Some(err),
Err(err) => request_body_capture_error = Some(err.to_string()),
}
}
if let Some(err) = request_body_capture_error.take() { if let Some(err) = request_body_capture_error.take() {
response.error = Some(append_error_message( response.error = Some(append_error_message(
response.error.take(), response.error.take(),
@@ -828,10 +821,6 @@ pub async fn send_http_request<T: TemplateCallback>(
)); ));
} }
if let Err(join_err) = event_handle.await {
warn!("Failed to join response event task: {}", join_err);
}
if let Some(err) = body_read_error { if let Some(err) = body_read_error {
if persist_response { if persist_response {
let _ = persist_response_error( let _ = persist_response_error(
@@ -849,6 +838,16 @@ pub async fn send_http_request<T: TemplateCallback>(
cookie_jar.as_mut(), cookie_jar.as_mut(),
cookie_behavior.store.as_ref(), cookie_behavior.store.as_ref(),
)?; )?;
if let Some(task) = request_body_capture_task.take() {
match task.await {
Ok(Ok(_)) => {}
Ok(Err(err)) => warn!("Failed to store request body after response error: {err}"),
Err(err) => warn!("Failed to join request body capture task: {err}"),
}
}
if let Err(join_err) = event_handle.await {
warn!("Failed to join response event task: {}", join_err);
}
return Err(err); return Err(err);
} }
@@ -875,6 +874,49 @@ pub async fn send_http_request<T: TemplateCallback>(
persist_cookie_jar(params.query_manager, cookie_jar.as_mut(), cookie_behavior.store.as_ref())?; persist_cookie_jar(params.query_manager, cookie_jar.as_mut(), cookie_behavior.store.as_ref())?;
// Request-body history can be much larger than the response. It should not keep the
// response in a loading state after the network/response-body work has completed.
if let Some(task) = request_body_capture_task.take() {
let mut update_response = false;
match task.await {
Ok(Ok(total)) => {
let total = Some(usize_to_i32(total));
if response.request_content_length != total {
response.request_content_length = total;
update_response = true;
}
}
Ok(Err(err)) => {
response.error = Some(append_error_message(
response.error.take(),
format!("Request succeeded but failed to store request body: {err}"),
));
update_response = true;
}
Err(err) => {
response.error = Some(append_error_message(
response.error.take(),
format!("Request succeeded but failed to store request body: {err}"),
));
update_response = true;
}
}
if update_response && persist_response {
response = params
.query_manager
.connect()
.upsert_http_response(&response, &params.update_source, params.blob_manager)
.map_err(SendHttpRequestError::PersistResponse)?;
}
}
// Timeline events are useful history, but they should not keep the response in a loading state
// after the network/response-body work has completed.
if let Err(join_err) = event_handle.await {
warn!("Failed to join response event task: {}", join_err);
}
Ok(SendHttpRequestResult { rendered_request, response, response_body }) Ok(SendHttpRequestResult { rendered_request, response, response_body })
} }
@@ -907,15 +949,25 @@ async fn persist_request_body_stream(
) -> std::result::Result<usize, String> { ) -> std::result::Result<usize, String> {
let mut chunk_index: i32 = 0; let mut chunk_index: i32 = 0;
let mut total_bytes = 0usize; let mut total_bytes = 0usize;
// Stream reads arrive in small (eg. 8-16 KiB) pieces, so accumulate them into
// full-size chunks to avoid thousands of tiny inserts for large bodies
let mut buf: Vec<u8> = Vec::with_capacity(REQUEST_BODY_CHUNK_SIZE);
while let Some(data) = rx.recv().await { while let Some(data) = rx.recv().await {
total_bytes += data.len(); total_bytes += data.len();
if data.is_empty() { buf.extend_from_slice(&data);
continue; while buf.len() >= REQUEST_BODY_CHUNK_SIZE {
} let data = buf.drain(..REQUEST_BODY_CHUNK_SIZE).collect();
let chunk = BodyChunk::new(&body_id, chunk_index, data); let chunk = BodyChunk::new(&body_id, chunk_index, data);
blob_manager.connect().insert_chunk(&chunk).map_err(|e| e.to_string())?; blob_manager.connect().insert_chunk(&chunk).map_err(|e| e.to_string())?;
chunk_index += 1; chunk_index += 1;
} }
}
if !buf.is_empty() {
let chunk = BodyChunk::new(&body_id, chunk_index, buf);
blob_manager.connect().insert_chunk(&chunk).map_err(|e| e.to_string())?;
}
Ok(total_bytes) Ok(total_bytes)
} }
+79 -37
View File
@@ -83,12 +83,12 @@
"@tauri-apps/cli": "npm:@tauri-apps/cli-cef@3.0.0-alpha.6", "@tauri-apps/cli": "npm:@tauri-apps/cli-cef@3.0.0-alpha.6",
"@types/babel__core": "^7.20.5", "@types/babel__core": "^7.20.5",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"@yaakapp/cli": "^0.5.1", "@yaakapp/cli": "*",
"babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-compiler": "^1.0.0",
"dotenv-cli": "^11.0.0", "dotenv-cli": "^11.0.0",
"nodejs-file-downloader": "^4.13.0", "nodejs-file-downloader": "^4.13.0",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"postcss": "^8.5.16", "postcss": "^8.5.25",
"tailwindcss": "^4.3.2", "tailwindcss": "^4.3.2",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1", "vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1",
@@ -6331,20 +6331,20 @@
} }
}, },
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "2.2.2", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bytes": "^3.1.2", "bytes": "^3.1.2",
"content-type": "^1.0.5", "content-type": "^2.0.0",
"debug": "^4.4.3", "debug": "^4.4.3",
"http-errors": "^2.0.0", "http-errors": "^2.0.1",
"iconv-lite": "^0.7.0", "iconv-lite": "^0.7.2",
"on-finished": "^2.4.1", "on-finished": "^2.4.1",
"qs": "^6.14.1", "qs": "^6.15.2",
"raw-body": "^3.0.1", "raw-body": "^3.0.2",
"type-is": "^2.0.1" "type-is": "^2.1.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -6354,6 +6354,19 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/body-parser/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.12", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -8386,9 +8399,9 @@
"integrity": "sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw==" "integrity": "sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw=="
}, },
"node_modules/fast-uri": { "node_modules/fast-uri": {
"version": "3.1.2", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -9249,9 +9262,9 @@
} }
}, },
"node_modules/hono": { "node_modules/hono": {
"version": "4.12.25", "version": "4.12.27",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz",
"integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", "integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=16.9.0" "node": ">=16.9.0"
@@ -12107,9 +12120,9 @@
} }
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.15", "version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -13483,9 +13496,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.16", "version": "8.5.25",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -13503,7 +13516,7 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"nanoid": "^3.3.12", "nanoid": "^3.3.16",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
}, },
@@ -14616,9 +14629,9 @@
} }
}, },
"node_modules/seroval": { "node_modules/seroval": {
"version": "1.4.2", "version": "1.5.6",
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.4.2.tgz", "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.6.tgz",
"integrity": "sha512-N3HEHRCZYn3cQbsC4B5ldj9j+tHdf4JZoYPlcI4rRYu0Xy4qN8MQf1Z08EibzB0WpgRG5BGK08FTrmM66eSzKQ==", "integrity": "sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10" "node": ">=10"
@@ -14739,9 +14752,9 @@
} }
}, },
"node_modules/shell-quote": { "node_modules/shell-quote": {
"version": "1.8.4", "version": "1.10.0",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz",
"integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -15916,17 +15929,34 @@
} }
}, },
"node_modules/type-is": { "node_modules/type-is": {
"version": "2.0.1", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"content-type": "^1.0.5", "content-type": "^2.0.0",
"media-typer": "^1.1.0", "media-typer": "^1.1.0",
"mime-types": "^3.0.0" "mime-types": "^3.0.0"
}, },
"engines": { "engines": {
"node": ">= 0.6" "node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/type-is/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
} }
}, },
"node_modules/type-is/node_modules/mime-db": { "node_modules/type-is/node_modules/mime-db": {
@@ -17266,9 +17296,9 @@
"version": "0.2.1", "version": "0.2.1",
"dependencies": { "dependencies": {
"@hono/mcp": "^0.2.3", "@hono/mcp": "^0.2.3",
"@hono/node-server": "^1.19.13", "@hono/node-server": "^2.0.10",
"@modelcontextprotocol/sdk": "^1.26.0", "@modelcontextprotocol/sdk": "^1.26.0",
"hono": "^4.12.25", "hono": "^4.12.27",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
@@ -17276,6 +17306,18 @@
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
}, },
"plugins-external/mcp-server/node_modules/@hono/node-server": {
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.10.tgz",
"integrity": "sha512-ZcnNVhKTmyDJeg0UlnZjvM73JBsTAuhrH/J4fjwGOw59PwOW51r4J+p6CsKZWXdKSme4MFqU62CZMOsdDrU4CA==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"peerDependencies": {
"hono": "^4"
}
},
"plugins/action-copy-curl": { "plugins/action-copy-curl": {
"name": "@yaak/action-copy-curl", "name": "@yaak/action-copy-curl",
"version": "0.1.0" "version": "0.1.0"
+2 -2
View File
@@ -116,12 +116,12 @@
"@tauri-apps/cli": "npm:@tauri-apps/cli-cef@3.0.0-alpha.6", "@tauri-apps/cli": "npm:@tauri-apps/cli-cef@3.0.0-alpha.6",
"@types/babel__core": "^7.20.5", "@types/babel__core": "^7.20.5",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"@yaakapp/cli": "^0.5.1", "@yaakapp/cli": "latest",
"babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-compiler": "^1.0.0",
"dotenv-cli": "^11.0.0", "dotenv-cli": "^11.0.0",
"nodejs-file-downloader": "^4.13.0", "nodejs-file-downloader": "^4.13.0",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"postcss": "^8.5.16", "postcss": "^8.5.25",
"tailwindcss": "^4.3.2", "tailwindcss": "^4.3.2",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1", "vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1",
+22 -3
View File
@@ -1,13 +1,32 @@
// oxlint-disable-next-line no-explicit-any // oxlint-disable-next-line no-explicit-any
export function debounce(fn: (...args: any[]) => void, delay = 500) { export function debounce(fn: (...args: any[]) => void, delay = 500) {
let timer: ReturnType<typeof setTimeout>; let timer: ReturnType<typeof setTimeout> | null = null;
// oxlint-disable-next-line no-explicit-any
let lastArgs: any[] | null = null;
// oxlint-disable-next-line no-explicit-any // oxlint-disable-next-line no-explicit-any
const result = (...args: any[]) => { const result = (...args: any[]) => {
clearTimeout(timer); lastArgs = args;
timer = setTimeout(() => fn(...args), delay); if (timer != null) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
const argsToUse = lastArgs ?? [];
lastArgs = null;
fn(...argsToUse);
}, delay);
}; };
result.cancel = () => { result.cancel = () => {
if (timer != null) clearTimeout(timer);
timer = null;
lastArgs = null;
};
// Invoke a pending call immediately instead of waiting out the delay
result.flush = () => {
if (timer == null) return;
clearTimeout(timer); clearTimeout(timer);
timer = null;
const argsToUse = lastArgs ?? [];
lastArgs = null;
fn(...argsToUse);
}; };
return result; return result;
} }
+54 -15
View File
@@ -1,4 +1,5 @@
import type { DragEndEvent, DragMoveEvent, DragStartEvent } from "@dnd-kit/core"; import type { DragEndEvent, DragMoveEvent, DragStartEvent } from "@dnd-kit/core";
import type { Virtualizer } from "@tanstack/react-virtual";
import { import {
DndContext, DndContext,
MeasuringStrategy, MeasuringStrategy,
@@ -23,7 +24,7 @@ import {
} from "react"; } from "react";
import { useKey, useKeyPressEvent } from "react-use"; import { useKey, useKeyPressEvent } from "react-use";
import { computeSideForDragMove } from "../../lib/dnd"; import { computeSideForDragMove } from "../../lib/dnd";
import { useStore } from "jotai"; import { useAtomValue, useStore } from "jotai";
import { draggingIdsFamily, focusIdsFamily, hoveredParentFamily, selectedIdsFamily } from "./atoms"; import { draggingIdsFamily, focusIdsFamily, hoveredParentFamily, selectedIdsFamily } from "./atoms";
import { type CollapsedAtom, CollapsedAtomContext } from "./context"; import { type CollapsedAtom, CollapsedAtomContext } from "./context";
import type { ContextMenuRenderer, JotaiStore, SelectableTreeNode, TreeNode } from "./common"; import type { ContextMenuRenderer, JotaiStore, SelectableTreeNode, TreeNode } from "./common";
@@ -87,7 +88,27 @@ function TreeInner<T extends { id: string }>(
) { ) {
const store = useStore(); const store = useStore();
const treeRef = useRef<HTMLDivElement>(null); const treeRef = useRef<HTMLDivElement>(null);
const virtualizerRef = useRef<Virtualizer<HTMLElement, Element> | null>(null);
const getScrollElement = useCallback(() => treeRef.current, []);
const handleVirtualizerReady = useCallback((v: Virtualizer<HTMLElement, Element>) => {
virtualizerRef.current = v;
}, []);
const selectableItems = useSelectableItems(root); const selectableItems = useSelectableItems(root);
// Only render nodes that are actually visible (not filtered out, and not
// inside a collapsed folder). Mounting every node regardless of visibility
// makes large workspaces unusable: thousands of hidden TreeItems each run
// their dnd/context hooks on every tree commit just to return null.
const collapsedMap = useAtomValue(collapsedAtom);
const visibleItems = useMemo(() => {
return selectableItems.filter((i) => {
if (i.node.hidden) return false;
for (let p = i.node.parent; p != null; p = p.parent) {
if (collapsedMap[p.item.id]) return false;
}
return true;
});
}, [selectableItems, collapsedMap]);
const [showContextMenu, setShowContextMenu] = useState<{ const [showContextMenu, setShowContextMenu] = useState<{
items: unknown[]; items: unknown[];
x: number; x: number;
@@ -125,16 +146,28 @@ function TreeInner<T extends { id: string }>(
}, []); }, []);
const tryFocus = useCallback(() => { const tryFocus = useCallback(() => {
const $el = treeRef.current?.querySelector<HTMLButtonElement>( const find = () =>
'.tree-item button[tabindex="0"]', treeRef.current?.querySelector<HTMLButtonElement>('.tree-item button[tabindex="0"]');
); const $el = find();
if ($el == null) { if ($el != null) {
return false; // preventScroll so scrolling stays single-sourced (focus() implicitly
} // scrolls, which fights the virtualizer's scrollToIndex)
$el.focus(); $el.focus({ preventScroll: true });
$el.scrollIntoView({ block: "nearest" }); $el.scrollIntoView({ block: "nearest" });
return true; return true;
}, []); }
// The focused row may be virtualized out of range. Scroll it into range,
// then focus it once it has mounted.
const lastFocusedId = store.get(focusIdsFamily(treeId)).lastId;
const index = visibleItems.findIndex((i) => i.node.item.id === lastFocusedId);
if (index < 0) {
return false;
}
virtualizerRef.current?.scrollToIndex(index, { align: "auto" });
requestAnimationFrame(() => find()?.focus({ preventScroll: true }));
return true;
}, [store, treeId, visibleItems]);
const ensureTabbableItem = useCallback(() => { const ensureTabbableItem = useCallback(() => {
const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId; const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
@@ -448,8 +481,8 @@ function TreeInner<T extends { id: string }>(
store.set(hoveredParentFamily(treeId), { store.set(hoveredParentFamily(treeId), {
parentId: root.item.id, parentId: root.item.id,
parentDepth: root.depth, parentDepth: root.depth,
index: selectableItems.length, index: visibleItems.length,
childIndex: selectableItems.length, childIndex: visibleItems.length,
}); });
return; return;
} }
@@ -477,8 +510,8 @@ function TreeInner<T extends { id: string }>(
const item = node.item; const item = node.item;
let hoveredParent = node.parent; let hoveredParent = node.parent;
const dragIndex = selectableItems.findIndex((n) => n.node.item.id === item.id) ?? -1; const dragIndex = visibleItems.findIndex((n) => n.node.item.id === item.id) ?? -1;
const hovered = selectableItems[dragIndex]?.node ?? null; const hovered = visibleItems[dragIndex]?.node ?? null;
const hoveredIndex = dragIndex + (side === "before" ? 0 : 1); const hoveredIndex = dragIndex + (side === "before" ? 0 : 1);
let hoveredChildIndex = overSelectableItem.index + (side === "before" ? 0 : 1); let hoveredChildIndex = overSelectableItem.index + (side === "before" ? 0 : 1);
@@ -509,7 +542,7 @@ function TreeInner<T extends { id: string }>(
}); });
} }
}, },
[root.depth, root.item.id, selectableItems, treeId], [root.depth, root.item.id, selectableItems, treeId, visibleItems],
); );
const handleDragStart = useCallback( const handleDragStart = useCallback(
@@ -680,12 +713,18 @@ function TreeInner<T extends { id: string }>(
"[&_.tree-item.selected+.drop-marker+.tree-item.selected]:rounded-t-none", "[&_.tree-item.selected+.drop-marker+.tree-item.selected]:rounded-t-none",
"[&_.tree-item.selected:has(+.tree-item.selected)]:rounded-b-none", "[&_.tree-item.selected:has(+.tree-item.selected)]:rounded-b-none",
"[&_.tree-item.selected:has(+.drop-marker+.tree-item.selected)]:rounded-b-none", "[&_.tree-item.selected:has(+.drop-marker+.tree-item.selected)]:rounded-b-none",
// Virtualized rows are wrapped in .tree-row divs, so the sibling
// relationships above need wrapper-aware equivalents
"[&_.tree-row:has(.tree-item.selected)+.tree-row_.tree-item.selected]:rounded-t-none",
"[&_.tree-row:has(.tree-item.selected):has(+.tree-row_.tree-item.selected)_.tree-item.selected]:rounded-b-none",
)} )}
> >
<TreeItemList <TreeItemList
addTreeItemRef={handleAddTreeItemRef} addTreeItemRef={handleAddTreeItemRef}
nodes={selectableItems} nodes={visibleItems}
treeId={treeId} treeId={treeId}
getScrollElement={getScrollElement}
onVirtualizerReady={handleVirtualizerReady}
{...treeItemListProps} {...treeItemListProps}
/> />
</div> </div>
@@ -20,6 +20,7 @@ import {
} from "./context"; } from "./context";
import type { TreeNode } from "./common"; import type { TreeNode } from "./common";
import { getNodeKey } from "./common"; import { getNodeKey } from "./common";
import { isImeCompositionEvent } from "./keyboard";
import type { TreeProps } from "./Tree"; import type { TreeProps } from "./Tree";
import { TreeIndentGuide } from "./TreeIndentGuide"; import { TreeIndentGuide } from "./TreeIndentGuide";
@@ -170,6 +171,8 @@ function TreeItem_<T extends { id: string }>({
const handleEditKeyDown = useCallback( const handleEditKeyDown = useCallback(
async (e: ReactKeyboardEvent<HTMLInputElement>) => { async (e: ReactKeyboardEvent<HTMLInputElement>) => {
e.stopPropagation(); // Don't trigger other tree keys (like arrows) e.stopPropagation(); // Don't trigger other tree keys (like arrows)
if (isImeCompositionEvent(e.nativeEvent)) return;
switch (e.key) { switch (e.key) {
case "Enter": case "Enter":
if (editing) { if (editing) {
@@ -1,5 +1,7 @@
import type { Virtualizer } from "@tanstack/react-virtual";
import { useVirtualizer } from "@tanstack/react-virtual";
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import { Fragment } from "react"; import { Fragment, useLayoutEffect, useRef, useState } from "react";
import type { SelectableTreeNode } from "./common"; import type { SelectableTreeNode } from "./common";
import type { TreeProps } from "./Tree"; import type { TreeProps } from "./Tree";
import { TreeDropMarker } from "./TreeDropMarker"; import { TreeDropMarker } from "./TreeDropMarker";
@@ -22,9 +24,22 @@ export type TreeItemListProps<T extends { id: string }> = Pick<
className?: string; className?: string;
forceDepth?: number; forceDepth?: number;
addTreeItemRef?: (item: T, n: TreeItemHandle | null) => void; addTreeItemRef?: (item: T, n: TreeItemHandle | null) => void;
/**
* Enable virtualization by providing the scroll container. Rows are then
* windowed with @tanstack/react-virtual and only visible rows mount.
*/
getScrollElement?: () => HTMLElement | null;
onVirtualizerReady?: (v: Virtualizer<HTMLElement, Element>) => void;
}; };
export function TreeItemList<T extends { id: string }>({ export function TreeItemList<T extends { id: string }>(props: TreeItemListProps<T>) {
if (props.getScrollElement != null) {
return <VirtualTreeItemList {...props} getScrollElement={props.getScrollElement} />;
}
return <StaticTreeItemList {...props} />;
}
function StaticTreeItemList<T extends { id: string }>({
className, className,
getItemKey, getItemKey,
nodes, nodes,
@@ -32,6 +47,8 @@ export function TreeItemList<T extends { id: string }>({
treeId, treeId,
forceDepth, forceDepth,
addTreeItemRef, addTreeItemRef,
getScrollElement: _getScrollElement,
onVirtualizerReady: _onVirtualizerReady,
...props ...props
}: TreeItemListProps<T>) { }: TreeItemListProps<T>) {
return ( return (
@@ -53,3 +70,89 @@ export function TreeItemList<T extends { id: string }>({
</ul> </ul>
); );
} }
// Rows are --height-sm (2rem). Derive the pixel estimate from the actual root
// font size so scroll math stays accurate under interface scaling.
function estimateRowHeightPx() {
const rem = Number.parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
return 2 * rem;
}
function VirtualTreeItemList<T extends { id: string }>({
className,
getItemKey,
nodes,
style,
treeId,
forceDepth,
addTreeItemRef,
getScrollElement,
onVirtualizerReady,
...props
}: TreeItemListProps<T> & { getScrollElement: () => HTMLElement | null }) {
const listRef = useRef<HTMLUListElement>(null);
// Offset of the list within the scroll container (eg. container padding),
// so windowing and scrollToIndex targets aren't shifted by it
const [scrollMargin, setScrollMargin] = useState(0);
useLayoutEffect(() => {
const list = listRef.current;
const scroller = getScrollElement();
if (list == null || scroller == null) return;
const offset =
list.getBoundingClientRect().top - scroller.getBoundingClientRect().top + scroller.scrollTop;
setScrollMargin(offset);
}, [getScrollElement]);
const virtualizer = useVirtualizer({
count: nodes.length,
getScrollElement,
estimateSize: estimateRowHeightPx,
overscan: 10,
scrollMargin,
});
useLayoutEffect(() => {
onVirtualizerReady?.(virtualizer);
}, [virtualizer, onVirtualizerReady]);
return (
<ul
ref={listRef}
style={{ ...style, height: `${virtualizer.getTotalSize()}px`, position: "relative" }}
className={className}
>
<TreeDropMarker node={null} treeId={treeId} index={0} />
{virtualizer.getVirtualItems().map((virtualItem) => {
const child = nodes[virtualItem.index];
if (child == null) return null;
return (
<div
// Key by item so window shifts don't remount rows unnecessarily
key={getItemKey(child.node.item)}
ref={virtualizer.measureElement}
data-index={virtualItem.index}
className="tree-row"
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualItem.start - scrollMargin}px)`,
}}
>
<TreeItem
treeId={treeId}
setRef={addTreeItemRef}
node={child.node}
getItemKey={getItemKey}
depth={forceDepth == null ? child.depth : forceDepth}
{...props}
/>
<TreeDropMarker node={child.node} treeId={treeId} index={virtualItem.index + 1} />
</div>
);
})}
</ul>
);
}
@@ -0,0 +1,20 @@
import { describe, expect, test } from "vite-plus/test";
import { isImeCompositionEvent } from "./keyboard";
describe("isImeCompositionEvent", () => {
test("detects an active standards-based composition", () => {
expect(isImeCompositionEvent({ isComposing: true, keyCode: 13 })).toBe(true);
});
test("detects the Safari/WebKit key code fallback", () => {
expect(isImeCompositionEvent({ isComposing: false, keyCode: 229 })).toBe(true);
});
test("does not classify an ordinary Enter keydown as composition", () => {
expect(isImeCompositionEvent({ isComposing: false, keyCode: 13 })).toBe(false);
});
test("does not classify an ordinary Escape keydown as composition", () => {
expect(isImeCompositionEvent({ isComposing: false, keyCode: 27 })).toBe(false);
});
});
@@ -0,0 +1,7 @@
export type ImeKeyboardEvent = Pick<KeyboardEvent, "isComposing" | "keyCode">;
export function isImeCompositionEvent(event: ImeKeyboardEvent): boolean {
// Safari can clear `isComposing` on the keydown that finishes composition.
// `229` is retained as the compatibility signal that an IME is processing it.
return event.isComposing || event.keyCode === 229;
}
+2 -2
View File
@@ -15,9 +15,9 @@
}, },
"dependencies": { "dependencies": {
"@hono/mcp": "^0.2.3", "@hono/mcp": "^0.2.3",
"@hono/node-server": "^1.19.13", "@hono/node-server": "^2.0.10",
"@modelcontextprotocol/sdk": "^1.26.0", "@modelcontextprotocol/sdk": "^1.26.0",
"hono": "^4.12.25", "hono": "^4.12.27",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
+1
View File
@@ -93,6 +93,7 @@ export async function convertToCurl(request: Partial<HttpRequest>) {
const body = { const body = {
query: request.body.query || "", query: request.body.query || "",
variables: maybeParseJSON(request.body.variables, undefined), variables: maybeParseJSON(request.body.variables, undefined),
operationName: request.body.operationName || undefined,
}; };
xs.push("--data", quote(JSON.stringify(body))); xs.push("--data", quote(JSON.stringify(body)));
xs.push(NEWLINE); xs.push(NEWLINE);
@@ -66,6 +66,25 @@ describe("exporter-curl", () => {
); );
}); });
test("Exports POST with GraphQL operation name", async () => {
expect(
await convertToCurl({
url: "https://yaak.app",
method: "POST",
bodyType: "graphql",
body: {
query: "query Foo { foo } query Bar { bar }",
operationName: "Foo",
},
}),
).toEqual(
[
`curl -X POST 'https://yaak.app'`,
`--data '{"query":"query Foo { foo } query Bar { bar }","operationName":"Foo"}'`,
].join(" \\\n "),
);
});
test("Exports POST with GraphQL data no variables", async () => { test("Exports POST with GraphQL data no variables", async () => {
expect( expect(
await convertToCurl({ await convertToCurl({
@@ -13,6 +13,7 @@ export async function getOrRefreshAccessToken(
credentialsInBody, credentialsInBody,
clientId, clientId,
clientSecret, clientSecret,
tokenName,
forceRefresh, forceRefresh,
}: { }: {
scope: string | null; scope: string | null;
@@ -20,6 +21,7 @@ export async function getOrRefreshAccessToken(
credentialsInBody: boolean; credentialsInBody: boolean;
clientId: string; clientId: string;
clientSecret: string; clientSecret: string;
tokenName?: "access_token" | "id_token";
forceRefresh?: boolean; forceRefresh?: boolean;
}, },
): Promise<AccessToken | null> { ): Promise<AccessToken | null> {
@@ -28,7 +30,7 @@ export async function getOrRefreshAccessToken(
return null; return null;
} }
const isExpired = isTokenExpired(token); const isExpired = isTokenExpired(token, tokenName);
// Return the current access token if it's still valid // Return the current access token if it's still valid
if (!isExpired && !forceRefresh) { if (!isExpired && !forceRefresh) {
@@ -111,5 +113,5 @@ export async function getOrRefreshAccessToken(
refresh_token: response.refresh_token ?? token.response.refresh_token, refresh_token: response.refresh_token ?? token.response.refresh_token,
}; };
return storeToken(ctx, tokenArgs, newResponse); return storeToken(ctx, tokenArgs, newResponse, tokenName);
} }
@@ -67,6 +67,7 @@ export async function getAuthorizationCode(
clientId, clientId,
clientSecret, clientSecret,
credentialsInBody, credentialsInBody,
tokenName,
}); });
if (token != null) { if (token != null) {
return token; return token;
+3 -3
View File
@@ -37,7 +37,7 @@ export async function getImplicit(
authorizationUrl: authorizationUrlRaw, authorizationUrl: authorizationUrlRaw,
}; };
const token = await getToken(ctx, tokenArgs); const token = await getToken(ctx, tokenArgs);
if (token != null && !isTokenExpired(token)) { if (token != null && !isTokenExpired(token, tokenName)) {
return token; return token;
} }
@@ -137,7 +137,7 @@ async function getTokenViaEmbeddedBrowser(
const response = Object.fromEntries(params) as unknown as AccessTokenRawResponse; const response = Object.fromEntries(params) as unknown as AccessTokenRawResponse;
try { try {
resolve(storeToken(ctx, tokenArgs, response)); resolve(storeToken(ctx, tokenArgs, response, tokenName));
} catch (err) { } catch (err) {
reject(err); reject(err);
} }
@@ -195,5 +195,5 @@ async function extractImplicitToken(
response.id_token = idToken; response.id_token = idToken;
} }
return storeToken(ctx, tokenArgs, response); return storeToken(ctx, tokenArgs, response, tokenName);
} }
+5 -1
View File
@@ -1,5 +1,6 @@
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import type { Context } from "@yaakapp/api"; import type { Context } from "@yaakapp/api";
import { jwtExpiresAt } from "./util";
export async function storeToken( export async function storeToken(
ctx: Context, ctx: Context,
@@ -11,7 +12,10 @@ export async function storeToken(
throw new Error(`${tokenName} not found in response ${Object.keys(response).join(", ")}`); throw new Error(`${tokenName} not found in response ${Object.keys(response).join(", ")}`);
} }
const expiresAt = response.expires_in ? Date.now() + response.expires_in * 1000 : null; // Prefer expires_in from the response, falling back to the JWT's own exp claim
const expiresAt = response.expires_in
? Date.now() + response.expires_in * 1000
: jwtExpiresAt(response[tokenName]);
const token: AccessToken = { const token: AccessToken = {
response, response,
expiresAt, expiresAt,
+29 -2
View File
@@ -1,7 +1,34 @@
import jwt from "jsonwebtoken";
import type { AccessToken } from "./store"; import type { AccessToken } from "./store";
export function isTokenExpired(token: AccessToken) { export function isTokenExpired(
return token.expiresAt && Date.now() > token.expiresAt; token: AccessToken,
tokenName: "access_token" | "id_token" = "access_token",
) {
// Fall back to the JWT's own exp claim for tokens stored without an expiry
// (eg. from a token response that had no expires_in). Decode the same token
// that gets sent as the credential.
const expiresAt = token.expiresAt ?? jwtExpiresAt(token.response[tokenName]);
return expiresAt != null && Date.now() > expiresAt;
}
/**
* Get the expiry timestamp (ms) from a JWT's `exp` claim, or null if the token
* is not a JWT or has no `exp`.
*/
export function jwtExpiresAt(token: string | undefined): number | null {
if (!token) return null;
try {
const payload = jwt.decode(token);
if (payload != null && typeof payload === "object" && typeof payload.exp === "number") {
return payload.exp * 1000;
}
} catch {
// Opaque (non-JWT) token
}
return null;
} }
export function extractCode(urlStr: string, redirectUri: string | null): string | null { export function extractCode(urlStr: string, redirectUri: string | null): string | null {
+54 -1
View File
@@ -1,5 +1,6 @@
import jwt from "jsonwebtoken";
import { describe, expect, test } from "vite-plus/test"; import { describe, expect, test } from "vite-plus/test";
import { extractCode } from "../src/util"; import { extractCode, isTokenExpired, jwtExpiresAt } from "../src/util";
describe("extractCode", () => { describe("extractCode", () => {
test("extracts code from query when same origin + path", () => { test("extracts code from query when same origin + path", () => {
@@ -107,3 +108,55 @@ describe("extractCode", () => {
expect(extractCode(url, redirect)).toBe("abc"); expect(extractCode(url, redirect)).toBe("abc");
}); });
}); });
describe("isTokenExpired", () => {
const jwtWithExp = (expSecondsFromNow: number) =>
jwt.sign({ exp: Math.floor(Date.now() / 1000) + expSecondsFromNow }, "test-secret");
test("uses stored expiresAt when present", () => {
expect(isTokenExpired({ response: { access_token: "x" }, expiresAt: Date.now() - 1000 })).toBe(
true,
);
expect(isTokenExpired({ response: { access_token: "x" }, expiresAt: Date.now() + 10000 })).toBe(
false,
);
});
test("falls back to JWT exp claim when expiresAt is null", () => {
expect(
isTokenExpired({ response: { access_token: jwtWithExp(-60) }, expiresAt: null }),
).toBe(true);
expect(
isTokenExpired({ response: { access_token: jwtWithExp(60) }, expiresAt: null }),
).toBe(false);
});
test("treats opaque tokens without expiresAt as non-expiring", () => {
expect(isTokenExpired({ response: { access_token: "opaque-token" }, expiresAt: null })).toBe(
false,
);
});
test("checks the token that is used as the credential", () => {
// Expired id_token credential is not masked by an opaque access_token
const token = {
response: { access_token: "opaque-token", id_token: jwtWithExp(-60) },
expiresAt: null,
};
expect(isTokenExpired(token, "id_token")).toBe(true);
expect(isTokenExpired(token, "access_token")).toBe(false);
});
});
describe("jwtExpiresAt", () => {
test("extracts exp claim in milliseconds", () => {
const exp = Math.floor(Date.now() / 1000) + 300;
expect(jwtExpiresAt(jwt.sign({ exp }, "test-secret"))).toBe(exp * 1000);
});
test("returns null for non-JWT or missing tokens", () => {
expect(jwtExpiresAt("not-a-jwt")).toBeNull();
expect(jwtExpiresAt(undefined)).toBeNull();
expect(jwtExpiresAt(jwt.sign({ foo: "bar" }, "test-secret"))).toBeNull();
});
});
+117
View File
@@ -0,0 +1,117 @@
type GraphQLDetectionSignal = {
score: number;
requiresGraphQLDocument?: boolean;
};
export type GraphQLJsonBody = {
query: string;
variables?: string;
operationName?: string;
};
type GraphQLJsonBodyArgs = {
mimeType: string | null;
text: string;
url: string;
};
export function isGraphQLJsonBody(args: GraphQLJsonBodyArgs): boolean {
return parseGraphQLJsonBody(args) != null;
}
export function parseGraphQLJsonBody({
mimeType,
text,
url,
}: GraphQLJsonBodyArgs): GraphQLJsonBody | null {
if (mimeType !== "application/json") {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
return null;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return null;
}
const body = parsed as Record<string, unknown>;
if (typeof body.query !== "string") {
return null;
}
if (hasExtraGraphQLEnvelopeFields(body)) {
return null;
}
const signals = getGraphQLDetectionSignals(body, url);
const score = signals.reduce((total, signal) => total + signal.score, 0);
const hasGraphQLDocument = signals.some((signal) => signal.requiresGraphQLDocument);
if (!hasGraphQLDocument || score < 4) {
return null;
}
const result: GraphQLJsonBody = { query: body.query };
if (body.variables != null) {
result.variables =
typeof body.variables === "string" ? body.variables : JSON.stringify(body.variables, null, 2);
}
if (typeof body.operationName === "string") {
result.operationName = body.operationName;
}
return result;
}
function hasExtraGraphQLEnvelopeFields(body: Record<string, unknown>): boolean {
const allowedKeys = new Set(["query", "variables", "operationName"]);
return Object.keys(body).some((key) => !allowedKeys.has(key));
}
function getGraphQLDetectionSignals(
body: Record<string, unknown>,
url: string,
): GraphQLDetectionSignal[] {
const signals: GraphQLDetectionSignal[] = [];
const query = body.query as string;
const urlPath = getUrlPath(url).toLowerCase();
if (/\b(graphql|gql)\b/.test(urlPath)) {
signals.push({ score: 2 });
}
if (/^(query|mutation|subscription|fragment)\b/.test(query.trim())) {
signals.push({ score: 3 });
} else if (/^\{[\s\S]*\}$/.test(query.trim())) {
signals.push({ score: 3, requiresGraphQLDocument: true });
}
if (/\{[\s\S]*\}/.test(query)) {
signals.push({ score: 1, requiresGraphQLDocument: true });
}
if (typeof body.operationName === "string" && body.operationName.trim() !== "") {
signals.push({ score: 1 });
}
if (
body.variables != null &&
(typeof body.variables === "object" || typeof body.variables === "string")
) {
signals.push({ score: 1 });
}
return signals;
}
function getUrlPath(url: string): string {
try {
return new URL(url).pathname;
} catch {
return url;
}
}

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