mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-10 05:48:52 +02:00
Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f8fc00e7c | ||
|
|
077bfa87b8 | ||
|
|
733f2e5929 | ||
|
|
0f994f89ac | ||
|
|
24f76398f9 | ||
|
|
14475915df | ||
|
|
a50e04f565 | ||
|
|
32fbd66912 | ||
|
|
3503a9da8e | ||
|
|
cef6abf5d0 | ||
|
|
a2a6cb17ca | ||
|
|
3f098f95fe | ||
|
|
195f89337f | ||
|
|
57fffe5a41 | ||
|
|
0c24d6562a | ||
|
|
b1ea3e3560 | ||
|
|
d72b7d7d30 | ||
|
|
b40e2cdc1b | ||
|
|
19cc6ee6d4 | ||
|
|
e59ecce886 | ||
|
|
c30f5b767b | ||
|
|
42b22d4c07 | ||
|
|
e05feba708 | ||
|
|
6b9b3660de | ||
|
|
e20a184c70 | ||
|
|
155260521e | ||
|
|
2fee5ba413 | ||
|
|
2c1cf5a13c | ||
|
|
b332a0eba9 | ||
|
|
f2972ee534 | ||
|
|
4cad671305 | ||
|
|
ea33261e08 | ||
|
|
4ee080fa49 | ||
|
|
c833aeba78 | ||
|
|
eb2a2dd775 | ||
|
|
e52853cc2d | ||
|
|
851d0a26f0 | ||
|
|
78de83c754 | ||
|
|
9f3fd46d49 | ||
|
|
6c42b27edb |
@@ -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
|
|
||||||
[](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,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):
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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
@@ -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",
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https://github.com/MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a> <a href="https://github.com/dharsanb"><img src="https://github.com/dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a> <a href="https://github.com/railwayapp"><img src="https://github.com/railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a> <a href="https://github.com/caseyamcl"><img src="https://github.com/caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a> <a href="https://github.com/bytebase"><img src="https://github.com/bytebase.png" width="80px" alt="User avatar: bytebase" /></a> <a href="https://github.com/"><img src="https://raw.githubusercontent.com/JamesIves/github-sponsors-readme-action/dev/.github/assets/placeholder.png" width="80px" alt="User avatar: " /></a> <!-- sponsors-premium -->
|
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https://github.com/MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a> <a href="https://github.com/dharsanb"><img src="https://github.com/dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a> <a href="https://github.com/railwayapp"><img src="https://github.com/railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a> <a href="https://github.com/caseyamcl"><img src="https://github.com/caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a> <a href="https://github.com/bytebase"><img src="https://github.com/bytebase.png" width="80px" alt="User avatar: bytebase" /></a> <a href="https://github.com/"><img src="https://raw.githubusercontent.com/JamesIves/github-sponsors-readme-action/dev/.github/assets/placeholder.png" width="80px" alt="User avatar: " /></a> <!-- sponsors-premium -->
|
||||||
</p>
|
</p>
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https://github.com/seanwash.png" width="50px" alt="User avatar: seanwash" /></a> <a href="https://github.com/jerath"><img src="https://github.com/jerath.png" width="50px" alt="User avatar: jerath" /></a> <a href="https://github.com/itsa-sh"><img src="https://github.com/itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a> <a href="https://github.com/dmmulroy"><img src="https://github.com/dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a> <a href="https://github.com/timcole"><img src="https://github.com/timcole.png" width="50px" alt="User avatar: timcole" /></a> <a href="https://github.com/VLZH"><img src="https://github.com/VLZH.png" width="50px" alt="User avatar: VLZH" /></a> <a href="https://github.com/terasaka2k"><img src="https://github.com/terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a> <a href="https://github.com/andriyor"><img src="https://github.com/andriyor.png" width="50px" alt="User avatar: andriyor" /></a> <a href="https://github.com/majudhu"><img src="https://github.com/majudhu.png" width="50px" alt="User avatar: majudhu" /></a> <a href="https://github.com/axelrindle"><img src="https://github.com/axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a> <a href="https://github.com/jirizverina"><img src="https://github.com/jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a> <a href="https://github.com/chip-well"><img src="https://github.com/chip-well.png" width="50px" alt="User avatar: chip-well" /></a> <a href="https://github.com/GRAYAH"><img src="https://github.com/GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a> <a href="https://github.com/flashblaze"><img src="https://github.com/flashblaze.png" width="50px" alt="User avatar: flashblaze" /></a> <a href="https://github.com/Frostist"><img src="https://github.com/Frostist.png" width="50px" alt="User avatar: Frostist" /></a> <!-- sponsors-base -->
|
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https://github.com/seanwash.png" width="50px" alt="User avatar: seanwash" /></a> <a href="https://github.com/jerath"><img src="https://github.com/jerath.png" width="50px" alt="User avatar: jerath" /></a> <a href="https://github.com/itsa-sh"><img src="https://github.com/itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a> <a href="https://github.com/dmmulroy"><img src="https://github.com/dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a> <a href="https://github.com/timcole"><img src="https://github.com/timcole.png" width="50px" alt="User avatar: timcole" /></a> <a href="https://github.com/VLZH"><img src="https://github.com/VLZH.png" width="50px" alt="User avatar: VLZH" /></a> <a href="https://github.com/terasaka2k"><img src="https://github.com/terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a> <a href="https://github.com/andriyor"><img src="https://github.com/andriyor.png" width="50px" alt="User avatar: andriyor" /></a> <a href="https://github.com/majudhu"><img src="https://github.com/majudhu.png" width="50px" alt="User avatar: majudhu" /></a> <a href="https://github.com/axelrindle"><img src="https://github.com/axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a> <a href="https://github.com/jirizverina"><img src="https://github.com/jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a> <a href="https://github.com/chip-well"><img src="https://github.com/chip-well.png" width="50px" alt="User avatar: chip-well" /></a> <a href="https://github.com/GRAYAH"><img src="https://github.com/GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a> <a href="https://github.com/flashblaze"><img src="https://github.com/flashblaze.png" width="50px" alt="User avatar: flashblaze" /></a> <a href="https://github.com/Frostist"><img src="https://github.com/Frostist.png" width="50px" alt="User avatar: Frostist" /></a> <a href="https://github.com/PurplProto"><img src="https://github.com/PurplProto.png" width="50px" alt="User avatar: PurplProto" /></a> <!-- sponsors-base -->
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||

|

|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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="pl-3 py-3 pr-10 flex items-start gap-2 w-full max-h-44 overflow-auto">
|
<div
|
||||||
|
className={classNames(
|
||||||
|
"pl-3 py-3 flex items-start gap-2 w-full overflow-auto",
|
||||||
|
hideDismiss ? "pr-3" : "pr-10",
|
||||||
|
dynamicHeight ? "max-h-[80vh]" : "max-h-44",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{toastIcon && <Icon icon={toastIcon} color={color} className="mt-1 shrink-0" />}
|
{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,16 +121,18 @@ export function Toast({ children, open, onClose, timeout, action, icon, color }:
|
|||||||
</VStack>
|
</VStack>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<IconButton
|
{!hideDismiss && (
|
||||||
color={color}
|
<IconButton
|
||||||
variant="border"
|
color={color}
|
||||||
className="opacity-60 border-0 absolute! top-2 right-2"
|
variant="border"
|
||||||
title="Dismiss"
|
className="opacity-60 border-0 absolute! top-2 right-2"
|
||||||
icon="x"
|
title="Dismiss"
|
||||||
onClick={onClose}
|
icon="x"
|
||||||
/>
|
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,125 +81,196 @@ 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="introspection" className="opacity-100!">
|
<div key="operation" className="opacity-100!">
|
||||||
{schema === undefined ? null /* Initializing */ : (
|
<RadioDropdown
|
||||||
<Dropdown
|
value={currentBody.operationName ?? operationNames[0] ?? OPERATION_NAME_NOT_SPECIFIED}
|
||||||
items={[
|
onChange={handleChangeOperationName}
|
||||||
...((schema != null
|
items={[
|
||||||
? [
|
{ type: "separator", label: "Operation Name" },
|
||||||
{
|
{
|
||||||
label: "Clear",
|
label: <span className="text-text-subtle italic">Not specified</span>,
|
||||||
onSelect: clear,
|
value: OPERATION_NAME_NOT_SPECIFIED,
|
||||||
color: "danger",
|
},
|
||||||
leftSlot: <Icon icon="trash" />,
|
...operationNames.map((operationName) => ({
|
||||||
},
|
label: operationName,
|
||||||
{ type: "separator" },
|
value: operationName,
|
||||||
]
|
})),
|
||||||
: []) satisfies DropdownItem[]),
|
] satisfies RadioDropdownItem<string>[]}
|
||||||
{
|
>
|
||||||
hidden: !error,
|
<Button size="sm" variant="border" title="Select Operation" forDropdown>
|
||||||
label: (
|
{currentBody.operationName === OPERATION_NAME_NOT_SPECIFIED ? (
|
||||||
<Banner color="danger">
|
<span className="text-text-subtle italic">Not specified</span>
|
||||||
<p className="mb-1">Schema introspection failed</p>
|
) : (
|
||||||
<Button
|
currentBody.operationName ?? operationNames[0]
|
||||||
size="xs"
|
)}
|
||||||
color="danger"
|
</Button>
|
||||||
variant="border"
|
</RadioDropdown>
|
||||||
onClick={() => {
|
|
||||||
showDialog({
|
|
||||||
title: "Introspection Failed",
|
|
||||||
size: "sm",
|
|
||||||
id: "introspection-failed",
|
|
||||||
render: ({ hide }) => (
|
|
||||||
<>
|
|
||||||
<FormattedError>{error ?? "unknown"}</FormattedError>
|
|
||||||
<div className="w-full my-4">
|
|
||||||
<Button
|
|
||||||
onClick={async () => {
|
|
||||||
hide();
|
|
||||||
await refetch();
|
|
||||||
}}
|
|
||||||
className="ml-auto"
|
|
||||||
color="primary"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
Retry Request
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
View Error
|
|
||||||
</Button>
|
|
||||||
</Banner>
|
|
||||||
),
|
|
||||||
type: "content",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
hidden: schema == null,
|
|
||||||
label: `${isDocOpen ? "Hide" : "Show"} Documentation`,
|
|
||||||
leftSlot: <Icon icon="book_open_text" />,
|
|
||||||
onSelect: () => {
|
|
||||||
setGraphqlDocStateAtomValue((v) => ({
|
|
||||||
...v,
|
|
||||||
[request.id]: isDocOpen ? undefined : null,
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Introspect Schema",
|
|
||||||
leftSlot: <Icon icon="refresh" spin={isLoading} />,
|
|
||||||
keepOpenOnSelect: true,
|
|
||||||
onSelect: refetch,
|
|
||||||
},
|
|
||||||
{ type: "separator", label: "Setting" },
|
|
||||||
{
|
|
||||||
label: "Automatic Introspection",
|
|
||||||
keepOpenOnSelect: true,
|
|
||||||
onSelect: () => {
|
|
||||||
setAutoIntrospectDisabled({
|
|
||||||
...autoIntrospectDisabled,
|
|
||||||
[baseRequest.id]: !autoIntrospectDisabled?.[baseRequest.id],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
leftSlot: (
|
|
||||||
<Icon
|
|
||||||
icon={
|
|
||||||
autoIntrospectDisabled?.[baseRequest.id]
|
|
||||||
? "check_square_unchecked"
|
|
||||||
: "check_square_checked"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="border"
|
|
||||||
title="Refetch Schema"
|
|
||||||
isLoading={isLoading}
|
|
||||||
color={error ? "danger" : "default"}
|
|
||||||
forDropdown
|
|
||||||
>
|
|
||||||
{error ? "Introspection Failed" : schema ? "Schema" : "No Schema"}
|
|
||||||
</Button>
|
|
||||||
</Dropdown>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
) : null,
|
||||||
|
<div key="introspection" className="opacity-100!">
|
||||||
|
{schema === undefined ? null /* Initializing */ : (
|
||||||
|
<Dropdown
|
||||||
|
items={[
|
||||||
|
...((schema != null
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: "Clear",
|
||||||
|
onSelect: clear,
|
||||||
|
color: "danger",
|
||||||
|
leftSlot: <Icon icon="trash" />,
|
||||||
|
},
|
||||||
|
{ type: "separator" },
|
||||||
|
]
|
||||||
|
: []) satisfies DropdownItem[]),
|
||||||
|
{
|
||||||
|
hidden: !error,
|
||||||
|
label: (
|
||||||
|
<Banner color="danger">
|
||||||
|
<p className="mb-1">Schema introspection failed</p>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
color="danger"
|
||||||
|
variant="border"
|
||||||
|
onClick={() => {
|
||||||
|
showDialog({
|
||||||
|
title: "Introspection Failed",
|
||||||
|
size: "sm",
|
||||||
|
id: "introspection-failed",
|
||||||
|
render: ({ hide }) => (
|
||||||
|
<>
|
||||||
|
<FormattedError>{error ?? "unknown"}</FormattedError>
|
||||||
|
<div className="w-full my-4">
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
hide();
|
||||||
|
await refetch();
|
||||||
|
}}
|
||||||
|
className="ml-auto"
|
||||||
|
color="primary"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Retry Request
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
View Error
|
||||||
|
</Button>
|
||||||
|
</Banner>
|
||||||
|
),
|
||||||
|
type: "content",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hidden: schema == null,
|
||||||
|
label: `${isDocOpen ? "Hide" : "Show"} Documentation`,
|
||||||
|
leftSlot: <Icon icon="book_open_text" />,
|
||||||
|
onSelect: () => {
|
||||||
|
setGraphqlDocStateAtomValue((v) => ({
|
||||||
|
...v,
|
||||||
|
[request.id]: isDocOpen ? undefined : null,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Introspect Schema",
|
||||||
|
leftSlot: <Icon icon="refresh" spin={isLoading} />,
|
||||||
|
keepOpenOnSelect: true,
|
||||||
|
onSelect: refetch,
|
||||||
|
},
|
||||||
|
{ type: "separator", label: "Setting" },
|
||||||
|
{
|
||||||
|
label: "Automatic Introspection",
|
||||||
|
keepOpenOnSelect: true,
|
||||||
|
onSelect: () => {
|
||||||
|
setAutoIntrospectDisabled({
|
||||||
|
...autoIntrospectDisabled,
|
||||||
|
[baseRequest.id]: !autoIntrospectDisabled?.[baseRequest.id],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
leftSlot: (
|
||||||
|
<Icon
|
||||||
|
icon={
|
||||||
|
autoIntrospectDisabled?.[baseRequest.id]
|
||||||
|
? "check_square_unchecked"
|
||||||
|
: "check_square_checked"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="border"
|
||||||
|
title="Refetch Schema"
|
||||||
|
isLoading={isLoading}
|
||||||
|
color={error ? "danger" : "default"}
|
||||||
|
forDropdown
|
||||||
|
>
|
||||||
|
{error ? "Introspection Failed" : schema ? "Schema" : "No Schema"}
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
)}
|
||||||
</div>,
|
</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 ?? ""}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,35 +210,71 @@ async fn receive_oauth_code(
|
|||||||
expected_state: &str,
|
expected_state: &str,
|
||||||
app_base_url: &str,
|
app_base_url: &str,
|
||||||
) -> CommandResult<String> {
|
) -> CommandResult<String> {
|
||||||
|
// Browsers speculatively open extra connections that may never carry a
|
||||||
|
// request. Handle each connection concurrently so an idle socket can't
|
||||||
|
// block the one carrying the real callback.
|
||||||
|
let (tx, mut rx) = tokio::sync::mpsc::channel::<CommandResult<String>>(1);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let (mut stream, _) = listener
|
tokio::select! {
|
||||||
.accept()
|
accepted = listener.accept() => {
|
||||||
.await
|
let (stream, _) = accepted
|
||||||
.map_err(|e| format!("OAuth callback server accept error: {e}"))?;
|
.map_err(|e| format!("OAuth callback server accept error: {e}"))?;
|
||||||
|
tokio::spawn(handle_callback_connection(
|
||||||
match parse_callback_request(&mut stream).await {
|
stream,
|
||||||
Ok((state, code)) => {
|
expected_state.to_string(),
|
||||||
if state != expected_state {
|
app_base_url.to_string(),
|
||||||
let _ = write_bad_request(&mut stream, "Invalid OAuth state").await;
|
tx.clone(),
|
||||||
continue;
|
));
|
||||||
}
|
|
||||||
|
|
||||||
let success_redirect = format!("{app_base_url}/login/oauth/success");
|
|
||||||
write_redirect(&mut stream, &success_redirect)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed responding to OAuth callback: {e}"))?;
|
|
||||||
return Ok(code);
|
|
||||||
}
|
}
|
||||||
Err(error) => {
|
result = rx.recv() => {
|
||||||
let _ = write_bad_request(&mut stream, &error).await;
|
if let Some(result) = result {
|
||||||
if error.starts_with("OAuth provider returned error:") {
|
return result;
|
||||||
return Err(error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn handle_callback_connection(
|
||||||
|
mut stream: TcpStream,
|
||||||
|
expected_state: String,
|
||||||
|
app_base_url: String,
|
||||||
|
tx: tokio::sync::mpsc::Sender<CommandResult<String>>,
|
||||||
|
) {
|
||||||
|
let parsed = match tokio::time::timeout(
|
||||||
|
CALLBACK_READ_TIMEOUT,
|
||||||
|
parse_callback_request(&mut stream),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(parsed) => parsed,
|
||||||
|
Err(_) => return, // Idle speculative connection; drop it
|
||||||
|
};
|
||||||
|
|
||||||
|
match parsed {
|
||||||
|
Ok((state, code)) => {
|
||||||
|
if state != expected_state {
|
||||||
|
let _ = write_bad_request(&mut stream, "Invalid OAuth state").await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let success_redirect = format!("{app_base_url}/login/oauth/success");
|
||||||
|
let result = match write_redirect(&mut stream, &success_redirect).await {
|
||||||
|
Ok(()) => Ok(code),
|
||||||
|
Err(e) => Err(format!("Failed responding to OAuth callback: {e}")),
|
||||||
|
};
|
||||||
|
let _ = tx.send(result).await;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
let _ = write_bad_request(&mut stream, &error).await;
|
||||||
|
if error.starts_with("OAuth provider returned error:") {
|
||||||
|
let _ = tx.send(Err(error)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn parse_callback_request(stream: &mut TcpStream) -> CommandResult<(String, String)> {
|
async fn parse_callback_request(stream: &mut TcpStream) -> CommandResult<(String, String)> {
|
||||||
let target = read_http_target(stream).await?;
|
let target = read_http_target(stream).await?;
|
||||||
if !target.starts_with("/oauth/callback") {
|
if !target.starts_with("/oauth/callback") {
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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
|
||||||
|
.watch(&gitdir, notify::RecursiveMode::NonRecursive)
|
||||||
|
.map_err(|e| Error::GenericError(format!("Failed to watch Git metadata: {e}")))?;
|
||||||
|
if commondir != gitdir {
|
||||||
watcher
|
watcher
|
||||||
.watch(&gitdir, notify::RecursiveMode::Recursive)
|
.watch(&commondir, 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 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"] }
|
||||||
|
|||||||
Generated
+6
-1
@@ -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>, };
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()))?;
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
};
|
||||||
},
|
let sibling_names = self
|
||||||
source,
|
.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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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,16 +142,22 @@ 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! {
|
||||||
Ok(_) => {
|
result = client_connect_rx.changed() => match result {
|
||||||
info!("Plugin runtime client connected!");
|
Ok(_) => {
|
||||||
// Note: initialize_all_plugins is now called separately by the app
|
info!("Plugin runtime client connected!");
|
||||||
// after setting up the plugin list
|
// Note: initialize_all_plugins is now called separately by the app
|
||||||
}
|
// after setting up the plugin list
|
||||||
Err(e) => {
|
}
|
||||||
warn!("Failed to receive from client connection rx {e:?}");
|
Err(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?;
|
||||||
|
|
||||||
|
|||||||
@@ -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,18 +69,29 @@ 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! {
|
||||||
warn!("Kill channel closed before explicit shutdown; terminating plugin runtime");
|
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");
|
||||||
|
}
|
||||||
|
info!("Killing plugin runtime");
|
||||||
|
if let Err(e) = child.kill().await {
|
||||||
|
warn!("Failed to kill plugin runtime: {e}");
|
||||||
|
}
|
||||||
|
info!("Killed plugin runtime");
|
||||||
|
let _ = killed_tx.send(());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
info!("Killing plugin runtime");
|
|
||||||
if let Err(e) = child.kill().await {
|
|
||||||
warn!("Failed to kill plugin runtime: {e}");
|
|
||||||
}
|
|
||||||
info!("Killed plugin runtime");
|
|
||||||
let _ = killed_tx.send(());
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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));
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|||||||
BIN
Binary file not shown.
+1
-1
@@ -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;
|
||||||
|
|||||||
+71
-19
@@ -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, ¶ms.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,14 +949,24 @@ 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);
|
||||||
|
blob_manager.connect().insert_chunk(&chunk).map_err(|e| e.to_string())?;
|
||||||
|
chunk_index += 1;
|
||||||
}
|
}
|
||||||
let chunk = BodyChunk::new(&body_id, chunk_index, data);
|
}
|
||||||
|
|
||||||
|
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())?;
|
blob_manager.connect().insert_chunk(&chunk).map_err(|e| e.to_string())?;
|
||||||
chunk_index += 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(total_bytes)
|
Ok(total_bytes)
|
||||||
|
|||||||
Generated
+79
-37
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
// preventScroll so scrolling stays single-sourced (focus() implicitly
|
||||||
|
// scrolls, which fights the virtualizer's scrollToIndex)
|
||||||
|
$el.focus({ preventScroll: true });
|
||||||
|
$el.scrollIntoView({ block: "nearest" });
|
||||||
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
$el.focus();
|
virtualizerRef.current?.scrollToIndex(index, { align: "auto" });
|
||||||
$el.scrollIntoView({ block: "nearest" });
|
requestAnimationFrame(() => find()?.focus({ preventScroll: true }));
|
||||||
return 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;
|
||||||
|
}
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user